一个 Python 项目从零开始,工具链通常是这么长出来的:先用 venv 建虚拟环境,pip 装依赖,某天发现版本乱了,于是加上 requirements.txt 手动锁版本;再后来觉得手动锁不靠谱,引入 pip-tools 生成 requirements.lock;团队里有人受不了这套流程,换成 poetry,结果 poetry 解依赖慢得让人想砸键盘。每一层都是为了补上一层的坑。
uv 的思路是把这些层全砍掉。它是一个用 Rust 写的 Python 包与项目管理器,同时扮演解释器安装器、虚拟环境管理器、依赖解析器和锁文件生成器的角色。这篇文章不走官方文档的流水账,而是拿一个真实点的小项目从头走一遍,把每条命令背后的行为讲清楚,最后补上 CI 和 Docker 的配置,以及几个我第一次用时踩到的坑。
先把 uv 装上
macOS 和 Linux 一条命令搞定:
curl -LsSf https://astral.sh/uv/install.sh | sh
Windows 用 PowerShell:
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
不喜欢脚本安装的话,Homebrew 和 PyPI 也都能装:brew install uv,或者 pipx install uv。装完开个新终端敲 uv --version 确认一下。
有一点值得先说明:uv 自己可以管理 Python 解释器版本,所以机器上装没装 Python 3.12 其实无所谓,它会把需要的版本下载到 ~/.local/share/uv/python 里(这些是 python-build-standalone 构建的独立版本)。
建一个项目,而不是一堆散文件
假设要写一个日志分析小工具,叫 logscan,功能很简单:读一个 Nginx 的 access.log,按 HTTP 状态码统计次数。
uv init logscan
cd logscan
生成的结构是这样:
logscan/
├── .gitignore
├── .python-version
├── README.md
├── main.py
└── pyproject.toml
打开 pyproject.toml,内容是标准 PEP 621 格式:
[project]
name = "logscan"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.11"
dependencies = []
这里有个关键点:uv 没有发明新的配置文件格式。它读的就是 pyproject.toml,和 hatch、pdm、setuptools 用的是同一套标准。这意味着迁移到 uv 不代表被绑定,哪天想换回去,文件还是通用的。
.python-version 里存着项目要用的解释器版本,这个文件会被 uv 读取,也会被 pyenv、asdf 这类工具识别,算是行业惯例了。
加依赖:注意它和 pip 的行为差异
项目要用 rich 来输出表格:
uv add rich
这条命令做了四件事:解析 rich 及其全部传递依赖、下载 wheel 到全局缓存、把 rich>=13.9.4 写进 pyproject.toml 的 dependencies、生成 uv.lock 锁定完整依赖树。同时在项目目录下建好了 .venv,rich 就装在里面。
加开发依赖要用 --dev:
uv add --dev pytest
它会写到一个独立的表中,不污染运行时依赖:
[dependency-groups]
dev = [
"pytest>=8.3.4",
]
[dependency-groups] 是 PEP 735 定义的规范,uv 是较早采用它的工具之一。相比以前塞在 [tool.uv] dev-dependencies 里的做法,这种写法其他工具也能读。
删依赖用 uv remove rich,同样会同步更新 pyproject、lock 文件和虚拟环境。
uv.lock 长什么样,为什么它比 requirements.txt 强
lock 文件是跨平台通用的,不需要像 pip-tools 那样为不同系统生成多份。它是一个纯文本的 TOML:
[[package]]
name = "rich"
version = "13.9.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "...", hash = "sha256:..." }
wheels = [
{ url = "...", hash = "sha256:..." },
]
每个包都带 hash,每个 wheel 的平台信息也都记着。所以 uv sync 在任何机器上跑都能得到完全一致的结果,包括校验和。
想看看当前依赖树:
uv tree
uv tree --depth 1
升级某个包,不动其他依赖:
uv lock --upgrade-package rich
全部升到最新:
uv lock --upgrade
注意这两条命令只改 lock 文件,不动虚拟环境。uv sync 才是把环境对齐到 lock 的操作。
跑代码:uv run 会顺手帮你同步
uv run 是个很省心的东西。执行前它会检查 lock 和环境是否一致,不一致就自动同步,然后才跑命令:
uv run python main.py access.log
uv run pytest -q
好处是不需要 source .venv/bin/activate,也不需要考虑当前 shell 激活的是哪个环境。uv run 会显式使用项目自己的解释器。
不过这个「自动同步」在 CI 里要关掉。后面会讲怎么关。
把功能写出来
编辑 main.py:
from __future__ import annotations
import re
import sys
from collections import Counter
from pathlib import Path
from rich.console import Console
from rich.table import Table
# Nginx 默认 combined 格式里,状态码跟在请求行的引号之后
LINE_RE = re.compile(r'"s+(?P<code>[1-5]d{2})s+')
console = Console()
def scan(path: Path) -> Counter[str]:
codes: Counter[str] = Counter()
with path.open(encoding="utf-8", errors="replace") as fh:
for line in fh:
match = LINE_RE.search(line)
if match:
codes[match.group("code")] += 1
return codes
def render(codes: Counter[str]) -> None:
total = sum(codes.values())
if total == 0:
console.print("[yellow]没有解析到任何请求行[/yellow]")
return
table = Table(title=f"状态码统计(共 {total} 条)")
table.add_column("状态码")
table.add_column("次数", justify="right")
table.add_column("占比", justify="right")
for code, hits in codes.most_common():
table.add_row(code, str(hits), f"{hits / total:.1%}")
console.print(table)
def main() -> None:
if len(sys.argv) != 2:
raise SystemExit("用法: uv run python main.py <access.log>")
render(scan(Path(sys.argv[1])))
if __name__ == "__main__":
main()
然后加一个测试文件 tests/test_main.py:
from pathlib import Path
from main import scan
SAMPLE = """
10.0.0.7 - - [12/May/2025:09:14:02 +0800] "GET /api/v1/orders HTTP/1.1" 200 512
10.0.0.7 - - [12/May/2025:09:14:03 +0800] "POST /api/v1/orders HTTP/1.1" 201 128
10.0.0.9 - - [12/May/2025:09:14:05 +0800] "GET /healthz HTTP/1.1" 200 3
10.0.0.9 - - [12/May/2025:09:14:06 +0800] "GET /api/v1/orders/9 HTTP/1.1" 500 64
"""
def test_scan_counts_status_codes(tmp_path: Path) -> None:
log = tmp_path / "access.log"
log.write_text(SAMPLE, encoding="utf-8")
codes = scan(log)
assert codes == {"200": 2, "201": 1, "500": 1}
def test_scan_ignores_malformed_lines(tmp_path: Path) -> None:
log = tmp_path / "broken.log"
log.write_text("这一行根本不是日志nn", encoding="utf-8")
assert scan(log) == {}
测试跑不起来,因为 pytest 需要知道去哪儿找 main 模块。往 pyproject.toml 里补一段配置:
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
再跑:
uv run pytest -q
两个测试应该都是绿的。
切换 Python 版本不用重装系统
看看 uv 能装哪些版本:
uv python list
装一个 3.13:
uv python install 3.13
把项目切过去:
uv python pin 3.13
这会改掉 .python-version,然后 uv sync 会用新解释器重建虚拟环境。整个过程不用 sudo,不碰系统 Python。
临时试一下别的版本也很快:
uv run --python 3.11 python main.py access.log
这条命令会按需下载 3.11、临时建一个环境、跑完就丢。想验证代码在多版本下的兼容性,比自己折腾 pyenv 省事得多。
命令行工具不要装进项目
项目依赖和日常工具要分开。像 ruff、httpie 这类全局命令行工具,用 uv tool 管理:
uv tool install ruff
uv tool list
uv tool upgrade ruff
uv tool uninstall ruff
它们装在独立的环境里,不会跑到任何项目的 .venv 中。想临时用一次、不留痕迹的话,有 uvx:
uvx ruff check .
uvx --from httpie http GET https://example.com
uvx 相当于一次性的 pipx,用完即弃,缓存还在,下次启动非常快。
从已有项目迁移
假设手上有个用 requirements.txt 的老项目,不想手写 pyproject:
cd old-project
uv init --bare
uv add -r requirements.txt
--bare 表示只生成一个最小的 pyproject.toml,不创建 main.py 和 README。
也可以只让 uv 读 requirements 文件、不接管项目:
uv venv
uv pip install -r requirements.txt
uv pip 是 pip 的直接替代品,参数几乎一样,但速度快得多,而且 uv pip install 不会写 pyproject.toml——它只是操作环境。这两种用法别混着来,容易搞不清依赖到底记录在哪。
反过来,要把锁文件导出成 requirements.txt 交给别人的构建流程:
uv export --format requirements-txt --no-dev --output-file requirements.txt
CI 里怎么用
GitHub Actions 有个官方 action,能顺便把缓存也配好:
name: test
on: [push, pull_request]
jobs:
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 安装 uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: 按锁文件安装
run: uv sync --frozen --no-dev
- name: 跑测试
run: uv run --frozen pytest -q
--frozen 的含义是:完全按 uv.lock 装,不检查它是否过期,也不去改它。CI 里必须加,否则 uv sync 可能悄悄重解一遍依赖,把 lock 文件改了却没人发现,最后线上环境和仓库对不上。
如果想让 CI 顺便校验锁文件是否跟 pyproject.toml 保持一致,把 --frozen 换成 --locked:lock 过期时它会直接报错。这一条建议加上,等于给依赖变更加了一道门槛。
Docker 镜像里的两段式安装
核心技巧是先只拷依赖描述文件、装依赖,再拷源码。这样改业务代码不会让依赖层缓存失效:
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1
UV_LINK_MODE=copy
# 第一段:只装依赖,不装项目本身
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
# 第二段:拷源码,再装项目
COPY . .
RUN uv sync --frozen --no-dev
ENV PATH="/app/.venv/bin:$PATH"
CMD ["python", "main.py", "/var/log/nginx/access.log"]
两个环境变量值得解释一下。UV_COMPILE_BYTECODE=1 让 uv 在安装时就编译 .pyc,容器启动时少一次编译开销。UV_LINK_MODE=copy 是因为 Docker 的 overlayfs 上硬链接可能跨层失效,用 copy 更稳,代价是多占一点镜像空间。
另外 --no-install-project 这一句不能省。不加的话,第一段会因为找不到项目源码而失败,缓存分层也就白搭了。
几个真踩过的坑
uv run 会偷偷改 lock 文件。 本地开发无所谓,CI 里就很危险。养成在 CI 命令后面统一加 --frozen 或 --locked 的习惯。
别和系统 pip 混用。 项目目录下 .venv 由 uv 全权管理,如果手贱跑一次系统的 pip install xxx,装进去的东西不会出现在 pyproject 里,下次 uv sync 时可能被清掉,然后你就开始怀疑人生。要装临时包,用 uv run --with xxx:
uv run --with ipython python -c "import IPython; IPython.start_ipython()"
私有源要显式声明。 公司内网 PyPI 镜像不能光靠环境变量,写进配置更可靠:
[[tool.uv.index]]
name = "corp"
url = "https://pypi.corp.internal/simple"
default = true
需要认证的话,用 UV_INDEX_CORP_USERNAME 和 UV_INDEX_CORP_PASSWORD 两个环境变量传凭据,别把密码写进文件。
缓存别乱删。 uv 的全局缓存在 ~/.cache/uv,硬链接到各个项目的 .venv 里。所以项目虚拟环境看着很小,实际依赖都在缓存里。想清理用 uv cache prune,它只删不再被任何环境引用的部分。直接 rm -rf ~/.cache/uv 会让所有项目的 venv 失效,虽然不会坏,但下次每个项目都要重新装一遍。
系统级依赖仍然是系统级。 uv 装的 Python 是独立构建版,某些需要链接系统库的包(比如部分数据库驱动、图形库)可能找不到头文件。遇到这类情况,用 uv python install 3.12 --python-preference only-system 或者直接把项目指到系统解释器上。
收尾
整套流程走下来,项目里多出来的文件就这么几个:pyproject.toml 写意图,uv.lock 记事实,.python-version 定解释器。没有额外的元数据目录,没有自定义的配置文件,也没有需要记的第二套命令语法。
从 pyproject.toml 到锁文件到虚拟环境到解释器版本,这一整条链路由同一条命令贯通——这大概就是它比「再包一层」的方案更让人愿意长期用下去的原因。真要迁移的话,建议先从个人项目试起,把 uv run 和 CI 里的 --frozen 用顺了,再推给团队。

