返回技术文章

uv:Python 的环境、版本与依赖管理

  • Python
  • uv
  • 包管理
  • 环境管理

Python 的环境管理长期是五件事:装解释器用 pyenv,建虚拟环境用 virtualenv,装包用 pip,锁版本用 pip-tools,跑全局命令行工具用 pipx。uv 把五件都做了,而且它们共用同一套缓存、同一份锁文件、同一个”项目”的概念。

uv 是 Rust 写的,快是它最常被提到的优点,但快不是它值得换掉旧流程的理由。真正的差别在于它逼你建立一个明确的项目模型:解释器、项目、虚拟环境各归各的层,谁声明、谁锁定、谁是可抛弃的产物,分得很清楚。这篇文章按这个模型来讲。

三条命令先跑起来

uv init my-app
cd my-app
uv add httpx
uv run python main.py

对照 pip 时代的做法:

mkdir my-app && cd my-app
python3 -m venv .venv
source .venv/bin/activate
pip install httpx
python main.py

省掉的不只是几行命令。注意 uv 那三行里没有 activate——uv run 会自己找到项目、自己确认环境是最新的、然后用正确的解释器执行你的命令。这件事的价值在于它让”忘记 activate 导致装到系统 Python 里”这一类事故从根上消失了。

uv init 生成了什么

my-app/
├── .python-version      # 这个项目钉在哪个解释器上
├── README.md
├── pyproject.toml       # 依赖声明
├── src/
│   └── my_app/
│       └── __init__.py
└── .venv/               # 第一次 uv run 时出现,不在 init 的输出里

⚠️ uv init 的默认行为在 v0.12 变过。 现在它默认按”可打包项目”处理,会在 pyproject.toml 里写上 [build-system](默认后端是 uv_build),并把源码放进 src/<包名>/ 布局。v0.12 之前不这样。

你想要命令结果
可打包的应用(默认)uv init带 [build-system],src/ 布局
库uv init --lib额外带 py.typed,不含脚本入口
扁平脚本项目uv init --no-packagemain.py 放根目录,无 [build-system]
只要一个清单uv init --bare只建 pyproject.toml
单文件脚本uv init --script foo.py带 PEP 723 内联元数据

三层模型

后面每一节都挂在这张图的某一层上,先把层次理清:

由 uv 安装与维护 声明意图,锁定结果 由锁文件物化出来 解释器 项目 环境 uv python install ~/.local/share/uv/python pyproject.toml uv.lock .venv 删掉可重建 .python-version uv sync 从这里挑一个用 uv add 改的是这里 uv run 从这里执行 三层里只有 .venv 可以随手删掉

三层的分工是这篇文章的骨架:

  • 解释器层是机器级的,一个版本装一次,所有项目共用。它跟你的项目没有绑定关系。
  • 项目层是仓库级的,跟着代码走、进版本控制。pyproject.toml 写你想要什么,uv.lock 记实际解析出了什么。
  • 环境层是本地级的,可以随时删。它不是手工艺品,是从锁文件物化出来的产物——这是 uv 与 virtualenv 时代最大的观念差别。以前 .venv 是”我精心配好的那个环境”,现在它等价于构建产物。

版本管理

装解释器

uv python install 3.12          # 下载并安装,不需要 sudo
uv python list                  # 本机有哪些,哪些是 uv 装的
uv python dir                   # uv 的安装目录
uv python find 3.12             # 找一个可用的 3.12,打印路径
uv python uninstall 3.11
uv python upgrade               # 把已装的版本升到最新补丁

安装目录默认是数据目录下的 python/,在 macOS 上是 ~/.local/share/uv/python:

$ uv python dir
/Users/you/.local/share/uv/python

⚠️ 这里不是 pyenv 那一套 shims。uv 装完不会去改你的 PATH,也不会在全局偷偷切换版本——它装的东西只有 uv 自己和它启动的进程会用。(要让 python3.12 直接可执行,用 uv python update-shell。)

钉住项目用哪个版本

uv python pin 3.12

这会在当前目录写一个 .python-version,内容就是 3.12。它有两个作用:把项目钉死在这个版本上;这个文件应当提交进版本控制,这样同事和 CI 拉下来用的是同一个。

uv python pin --global 3.12

--global 写的是用户配置目录下的全局 .python-version,作为所有没有自己 pin 的项目的兜底。查找顺序是:从当前工作目录逐级向上找 .python-version,都没找到才用全局的。

requires-python 不是”我要用哪个版本”

这是最容易搞混的一处。pyproject.toml 里的

[project]
requires-python = ">=3.10"

是一句兼容性声明——“这个项目在 3.10 及以上都能跑”,而不是”请用 3.10”。它决定了解析依赖时允许选哪些包版本(比如依赖里有个包只支持 3.11+,那 >=3.10 就会把 3.10 排除掉)。

真正决定”跑起来用哪个解释器”的是显式请求:

优先级来源说明
1(最高)--python / -p、UV_PYTHON 环境变量一次性覆盖,不改文件
2.python-version 文件项目级钉住,应提交
3(最低)requires-python在其中挑第一个兼容的版本

第三档是问题所在:它是”挑第一个兼容的”,意味着装了新版本 Python 之后,同一个项目的行为可能就变了。文档的原话是”会使用第一个满足要求的版本,除非另有显式请求”。要在各处行为一致,就 pin 一个。

uv run --python 3.11 python -V      # 临时用 3.11 跑
UV_PYTHON=3.10 uv run python -V     # 同上,环境变量形式

需要多个版本的项目

如果测试矩阵要同时跑 3.10 / 3.11 / 3.12,写一个 .python-versions(复数):

3.10
3.11
3.12

uv python install 会把这里列出的全部装上。它和单数的 .python-version 是并存的两件事:单数决定”默认用哪个”,复数决定”要把哪些装上”。

相关的环境变量

变量作用
UV_PYTHON等价于 --python,指定用哪个解释器
UV_PYTHON_INSTALL_DIR改受管解释器的安装位置
UV_PYTHON_DOWNLOADS=never禁止自动下载 Python,只能用系统里已有的
UV_MANAGED_PYTHON强制只用 uv 管理的版本(在 CI 里保证确定性很有用)

CI 的常见做法就是 UV_PYTHON_DOWNLOADS=never 配一个预先装好的解释器,避免每次构建都去下载。

开发环境管理

uv run 才是入口

很多人从 pip 迁过来时,第一反应是把 pip install 换成 uv pip install,然后继续手动 source .venv/bin/activate。这是把 uv 当成了”更快的 pip”,丢掉了它最有价值的部分。

uv run 的执行链是:

第 1 步 第 2 步 第 3 步 第 4 步 读清单 检查锁文件 同步环境 执行命令 pyproject.toml 与 uv.lock 过时了就要 重新解析 物化到 本地 .venv 在你敲的命令上 用这个环境 默认四步全走完;三种档位让它在中途停下

关键在于第 2、3 步是自动的。你改了 pyproject.toml 之后直接 uv run,它会先把环境同步好再执行——不需要记得先跑 uv sync。这份自动同步是 uv run 存在的理由,也是它比”activate 之后跑 python”更不容易出错的根本原因。

三种让链条停下的档位

这三个参数最容易被混着用,其实语义差别很明确:

参数停在哪一步锁文件缺失时锁文件过时(与 pyproject.toml 不符)时典型场景
(默认)不停自动生成自动更新锁文件并同步日常开发
--locked跳过第 3 步的写入,但校验报错报错,不修改任何文件CI:断言锁文件是最新的
--frozen跳过第 2 步的核对报错不报错——按锁文件原样同步,pyproject.toml 的改动被无视构建镜像:锁文件是唯一事实来源
--no-sync直接跳到第 4 步报错不检查确定环境已经对了,只想省时间

⚠️ --no-sync 隐含了 --frozen,不是”什么都不校验的裸跑”。这两个不要互相替代:CI 里要的是 --locked(它会告诉你锁文件过期了),--frozen 只会安静地用旧锁文件——在 CI 里这等于把问题藏起来。

另外还有一个只检查不改动的:

uv sync --check        # 环境与项目是否同步,不同步则以非零码退出
uv lock --check        # 锁文件是否最新(等价于 --locked)
uv lock --check-exists # 只断言 uv.lock 存在(等价于 --frozen)

临时依赖:—with

想试一个库,又不想污染项目的依赖清单:

uv run --with rich python -c "from rich import print; print('[bold]hi[/bold]')"
uv run --with 'pandas[excel]' python analyze.py

--with 装出来的东西在项目环境之上叠一层临时环境,允许与项目依赖冲突——这正是它适合”试一下”的原因:为了跑通一次临时命令,它可以在那一层里用另一个版本的某个包,而不动项目本身。

单文件脚本

不想为一个小工具建整个项目,用 PEP 723 的内联元数据:

# /// script
# requires-python = ">=3.11"
# dependencies = ["httpx", "rich"]
# ///
import httpx
from rich import print

print(httpx.get("https://example.com").status_code)
uv run fetch.py     # 依赖自动装好、跑完即走,不留 .venv
uv add --script fetch.py rich    # 往脚本里加依赖(会改写那段注释)

对”一次性数据脚本”来说这是最省事的形态:脚本自己带着依赖声明,发给同事能直接跑。

全局命令行工具交给 uv tool

uv tool install ruff           # 装成全局可执行
uv tool list
uv tool upgrade ruff
uv tool uninstall ruff

uvx ruff check .               # 不安装,直接跑一次(等价 uv tool run)
uvx --from httpie http GET https://example.com

对应 pipx。「装一次常用」用 uv tool install,「偶尔用一次」用 uvx。两者的环境都与你的项目隔离,不会和项目依赖打架。

什么时候还需要手敲 uv venv

大部分情况不需要。剩下的场景是:要指定一个非 .venv 的路径、要在已有目录上建环境、要 --system 装到系统环境。

uv venv                          # 在当前目录建 .venv
uv venv --python 3.11 .venv311   # 指定版本与路径

uv sync 内部就是干这件事,所以日常用不到它。

让编辑器用对解释器

在项目里把解释器指到 .venv/bin/python 即可(VS Code 会自动发现 .venv)。因为是 uv sync 在管这个目录,不要手工往 .venv 里 pip install——下次 sync 会把它按锁文件抹平,你装的东西会消失,而且消失得很安静。

依赖与锁文件

增删依赖

uv add httpx                    # 加运行依赖
uv add --dev pytest ruff        # 加开发依赖
uv add --optional network httpx # 加可选依赖(extras)
uv add 'pandas[excel]'          # 连同它的 extra 一起要
uv remove httpx
uv tree                         # 看看依赖树

⚠️ uv add httpx 是声明式的:它同时更新 pyproject.toml、uv.lock 和当前的 .venv。而 uv pip install httpx 是命令式的,只动当前环境,不改任何文件。新项目统一用前者,后者只在对接遗留流程时用。

开发依赖写在哪

写在 pyproject.toml 的 [dependency-groups] 里(PEP 735):

[dependency-groups]
dev = ["pytest", "ruff"]
lint = ["ruff"]

[tool.uv]
default-groups = ["dev"]

⚠️ 有一个容易踩的陷阱:uv 早期把开发依赖放在 [tool.uv] dev-dependencies。这个字段目前仍然有效(它的内容会和 dependency-groups.dev 合并),但文档已经明确说它最终会被移除。麻烦之处在于——如果你的 pyproject.toml 里已经有 dev-dependencies,uv add --dev 会继续往那个旧字段里写,而不是新建 dependency-groups.dev。迁移时把旧字段手动搬过去,不然后面看清单会找不到东西去哪了。

组可以选择性地装:

uv sync --no-dev              # 不装 dev 组(部署时用)
uv sync --only-group lint     # 只装 lint 组
uv sync --all-groups          # 全装
uv sync --no-default-groups   # 一个默认组都不装

--dev / --only-dev / --no-dev 分别是 --group dev / --only-group dev / --no-group dev 的简写。

uv.lock 的性质

uv lock                # 只更新锁文件,不动环境
uv lock --upgrade      # 在允许范围内升到最新
uv lock --upgrade-package httpx

几点要记住:

  • uv.lock 是跨平台的。 uv 做的是 universal resolution——同一份锁文件在 macOS、Linux、Windows 上都成立,各平台需要的差异(比如某个包的平台专属依赖)都在同一份文件里表达。这一点和 uv pip compile 不同,后者默认输出平台特定的结果(行为对标 pip-tools),要跨平台得加 --universal。
  • 它要进版本控制。 锁文件的意义就是让所有人装到同一套版本。
  • 不要手改。 它在下次 uv lock 时会被重写。

导出成别的格式

uv export --format requirements.txt -o requirements.txt
uv export --format pylock.toml -o pylock.toml
uv export --format cyclonedx1.5     # CycloneDX v1.5 JSON,用于 SBOM

--format 支持 requirements.txt(默认)、pylock.toml(PEP 751,标准化后的锁文件格式)与 cyclonedx1.5。不写 --format 时按输出文件的扩展名推断。需要把依赖交给一个还只认 requirements.txt 的环境(或者出一个软件物料清单做合规)时用得上。

workspace:一个仓库多个包

代码多起来之后常见两种组织方式:建多个独立项目,各自一套 pyproject.toml 和锁文件;或者用一个 workspace 把它们串起来。后者的收益是依赖版本统一——所有成员共用一份锁文件,不会出现 A 包用 httpx 0.27、B 包用 0.28 的情况。

workspace 根 packages/core packages/api pyproject.toml uv.lock(只有一份) 自己的 pyproject.toml 自己的 pyproject.toml { workspace = true } uv lock 与 uv sync 默认作用于整个 workspace

根的 pyproject.toml:

[project]
name = "my-workspace"
requires-python = ">=3.11"

[tool.uv.workspace]
members = ["packages/*"]
exclude = ["packages/experiments"]

成员(packages/api/pyproject.toml)引用另一个成员:

[project]
name = "api"
dependencies = ["core"]

[tool.uv.sources]
core = { workspace = true }

几个要点:

  • members 是必填的,exclude 可选,都是 glob。被 members 命中且没被 exclude 的目录必须有自己的 pyproject.toml,否则报错。根目录自身也算一个成员。
  • 整个 workspace 只有一份锁文件,在根目录。uv lock 一次作用于全部成员,成员之间不会有版本分裂——这正是用 workspace 的理由。
  • 成员间依赖建议用可编辑(editable)方式,改 core 的代码立刻在 api 里生效,不需要重装。
  • 只操作某一个成员:
uv sync --package api
uv run --package api python -m api
uv export --package api

不加 --package 时,命令作用于 workspace 根。

  • ⚠️ workspace 强制单一 requires-python,取所有成员要求的交集。如果某个成员只能跑在 3.10 而另一个要 3.12,是配不到一起的——这种就得拆成独立项目。

判断标准很简单:这些包要不要一起发版、能不能接受同一套依赖版本? 能,就用 workspace;不能(比如一个是给别人的独立库、另一个是内部服务),就别硬凑。

CI 与 Docker 里的 uv

GitHub Actions

- uses: astral-sh/setup-uv@v9.0.0
  with:
    version: "0.12.18"   # 钉死 uv 自身的版本
    enable-cache: true   # 缓存基于 uv.lock

- run: uv sync --locked --all-extras --dev
- run: uv run pytest

两点值得说:

  • version 要钉死。 uv 迭代很快,uv init 的默认行为这类东西在小版本之间都变过(见开头)。CI 里跟着 latest 走,某天会莫名其妙地构建出不一样的东西。
  • 同步用 --locked,锁文件一旦和 pyproject.toml 对不上就让 CI 红掉。这正是 --locked 存在的意义,别用 --frozen 糊过去。

Docker 多阶段构建

官方推荐的做法是把 uv 二进制从官方镜像里拷过来,而不是在镜像里 pip install uv:

FROM ghcr.io/astral-sh/uv:0.12.18 AS uv

FROM python:3.12-slim

COPY --from=uv /uv /uvx /bin/

ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy \
    UV_NO_DEV=1 \
    UV_PYTHON_DOWNLOADS=0

WORKDIR /app

# 第一层:只装依赖,不装项目自己
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --locked --no-install-project

COPY . /app

# 第二层:这时才把项目代码装进去
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked

ENV PATH="/app/.venv/bin:$PATH"
CMD ["python", "-m", "my_app"]

逐条解释:

做法为什么
COPY --from=... /uv /uvx /bin/拷一个静态二进制,不引入安装步骤;官方镜像本身是 distroless 的
--mount=type=bind,source=uv.lock,...只把清单挂进去,不 COPY 整个项目——否则改一行代码就让依赖层缓存失效
--no-install-project这个中间层只装依赖。代码天天变、依赖很少变,把两者分开缓存,构建时间能差一个数量级
--mount=type=cache,target=/root/.cache/uvuv 的下载缓存跨构建复用(需要 BuildKit)
UV_COMPILE_BYTECODE=1构建期就编译好 .pyc,容器启动更快
UV_LINK_MODE=copy缓存与目标在不同文件系统上时,避免 uv 打印 link 警告
UV_NO_DEV=1等价于 --no-dev,镜像里不要装测试工具
UV_PYTHON_DOWNLOADS=0用镜像自带的系统 Python,不要让 uv 在构建时去下载解释器

⚠️ workspace 项目在中间层要用 --frozen 而不是 --locked。原因很实际:那一层只挂了根的 uv.lock 和 pyproject.toml,各成员的 pyproject.toml 还没进来,uv 没法校验锁文件,--locked 会直接失败。官方文档的写法是:

uv sync --frozen --no-install-workspace

--no-install-workspace 比 --no-install-project 更进一步,把项目和所有 workspace 成员都排除掉。等代码全部 COPY 进来之后,再跑正常的 uv sync --locked。

完整的容器化上线流程——镜像怎么构建、compose 怎么编排、更新与回滚怎么做——写在另一篇里。镜像分层的缓存机制本身,这篇讲得更细。

和 pip / pip-tools / poetry 的对照

你以前做的事从前现在
装解释器pyenv install 3.12uv python install 3.12
建虚拟环境python -m venv .venvuv venv(通常不用手敲)
激活环境source .venv/bin/activate不需要,用 uv run
装依赖pip install httpxuv add httpx
锁版本pip freeze > requirements.txtuv lock(自动维护 uv.lock)
按锁文件装pip install -r requirements.txtuv sync
生成 requirementspip-compileuv export
装命令行工具pipx install ruffuv tool install ruff
跑一次性工具pipx run ruffuvx ruff

已经有一套 requirements.txt 的项目,迁移路径是先建项目骨架再导入:

uv init
uv add -r requirements.txt

⚠️ 官方目前只有 pip → uv 项目这一篇迁移指南;Poetry 与 Pipenv 的迁移指南还没有,uv pip 兼容接口与”已有 pyproject.toml 该怎么迁”也都标注为待写。所以从 Poetry 过来主要得手工搬——重点是 [tool.poetry.dependencies] 要拆成 [project] dependencies 与 [dependency-groups] 两处。

踩坑清单

  • requires-python 不等于”用哪个解释器”。 它是兼容性约束,实际用哪个由 .python-version / --python 决定,两者都没有时才会去挑”第一个兼容的”——那是个会随环境变化的答案。要确定性就 uv python pin。
  • --no-sync 隐含 --frozen,不是”什么都不检查”。CI 里要断言锁文件最新,用 --locked;--frozen 是”我知道锁文件旧了,但我就要按它来”。
  • uv pip install 不改任何文件。 新项目里用它,pyproject.toml 和 uv.lock 都不会更新,下次 uv sync 会把装的东西抹掉。
  • 别手工往 .venv 里 pip install。 同上,它会被下次 sync 按锁文件抹平,而且不报错。
  • uv add --dev 会顺着旧字段走。 pyproject.toml 里若已有 [tool.uv] dev-dependencies,新加的开发依赖会继续写进那里,而不是新建 [dependency-groups]。先手工迁移旧字段。
  • uv init 的默认行为随版本变过。 v0.12 起默认按可打包项目处理(带 [build-system])。本机若还是旧版本,生成的文件会与官方文档不符——uv self update 之后再照文档做。
  • .venv 不要进版本控制,.python-version 和 uv.lock 要进。前者是可重建的产物,后两者是复现环境的依据。
  • workspace 只认一份锁文件、一个 requires-python。 成员之间要求冲突时配不到一起,这种情况该拆成独立项目而不是硬凑。