pyproject.toml · uv.lock · .venv · 全局缓存 · 硬链接 · uv run · Python 版本
uv:一个命令管好 Python 版本、依赖和虚拟环境
Python 的 import 只认一个装包的目录,所以每个项目都得有自己的一份目录,叫虚拟环境。uv 的精华:你在 pyproject.toml 里写想要什么,uv 把整棵依赖树钉死在 uv.lock 里,再按锁文件把 .venv “算”出来;包从全局缓存硬链接过去,重建一次只要几十毫秒,所以 .venv 随时可以删掉重建,它和锁文件永远对得上。不懂这一点的典型用法,是钻进 .venv 手动 pip install:环境变了,锁文件没变,同事那里就跑不起来,下一次 uv sync 还会把它删掉。
全景:一个 FastAPI 请求的一生 · 第 1 站地图:站 01 · uv 管环境和依赖(对照 Maven)以 uv 0.13.7、Python 3.13 为例
①为什么学:curl 打进来之前,得先有一个对的进程在等着
在全景第 1 站,这一步要解决的是:20:15 你敲下 curl /gc/summary 之前,8000 端口上得有一个 uvicorn 进程。它要跑在 Python 3.13 上,带着版本对得上的 28 个包,其中 uvloop、pydantic-core 这几个是编译好的二进制,要和机器的 CPU、系统对得上。而且你的笔记本、同事的电脑、CI、值班机,装出来的东西要一模一样。
mvn 按 pom.xml 去 ~/.m2 找 jar,启动时拼成 classpath。Python 这边换成了 uv。②老办法:系统 Python 加 pip,能跑,但换台机器就不一样
Java 程序员第一次写 Python,最自然的做法是用系统自带的 python3,缺什么 pip install 什么。在本机上第一步就碰壁了:
$ python3 --version Python 3.10.12 # Ubuntu 22.04 自带 $ python3 -c 'import asyncio; asyncio.timeout' AttributeError: module 'asyncio' has no attribute 'timeout' # 3.11 才有,main.py 的超时就靠它 $ python3 -c 'import fastapi' ModuleNotFoundError: No module named 'fastapi'
Python 版本 pip 管不了,得另外装(pyenv、系统包、官网安装包)。新一点的 Debian、Ubuntu 还会直接拒绝往系统 Python 里 pip install,报 externally-managed-environment(PEP 668),逼你用虚拟环境。
于是老办法升级成 python -m venv .venv + pip install + pip freeze > requirements.txt。要看清它疼在哪,先对照 Maven 为什么没有这个问题:
Mavenjar 只存一份,classpath 是启动参数
- 一台机器上,不同项目各用各的版本
- 项目目录里没有依赖的拷贝
pip + venvimport 只认 site-packages,每个项目拷一份
- Python 版本本身不归 pip 管
pip freeze只记下这台机器此刻装了什么:没有哈希,也分不清哪些是你要的、哪些是被带进来的- 加了包忘记 freeze,同事那里就跑不起来
差距在一处:Java 的 classpath 是启动参数,Python 的包要装进一个目录。所以 Python 必须给每个项目一份目录,问题只剩“这份目录怎么又快又准地造出来”。造得准,靠锁文件;造得快,靠缓存加链接。uv 两样都做了。
实测数字的前提:本机 2 核 ARM 云主机,Python 3.13.18,pip 26.2.2,同样装 fastapi、uvicorn[standard]、httpx、pytest。pip 缓存冷时一共 9.3 s,缓存热时建环境 2.5 s 加安装 4.7 s。
③选谁:pip-tools、Poetry、Conda、uv
这几样都能做到“锁住版本、每个项目一个环境”,差在管多宽、多快、跟不跟标准走。
| pip + venv + pip-tools | Poetry | Conda | uv | |
|---|---|---|---|---|
| 管 Python 版本 | 不管 | 不管,要配 pyenv | 管 | 管,按需自动下载 |
| 锁文件 | pip-compile 生成带哈希的 requirements.txt | poetry.lock | 要另装 conda-lock | uv.lock,一份覆盖所有平台 |
| 依赖写在哪 | requirements.in | pyproject.toml(2.0 起支持标准的 [project] 表) | environment.yml | pyproject.toml 标准 [project] 表 |
| 能装非 Python 的东西 | 不能 | 不能 | 能:CUDA、MKL、C 库 | 不能 |
| 速度 | 慢 | 中 | 慢 | 快:Rust 写的,全局缓存加硬链接 |
| 一个工具包办 | 三个工具拼起来 | 依赖、打包、发布 | 环境和包 | Python 版本、环境、锁、运行、打包、临时跑工具(uvx) |
选择规则:新的 Python 服务、脚本、Agent 项目用 uv;要装 CUDA 这类非 Python 二进制、做数据科学的用 Conda;已经在用 Poetry 的老项目不用急着迁。值班助手是一个普通的 Web 服务,没有 GPU 依赖。
本页学 uv:它把 Maven(依赖)、SDKMAN(JDK 版本)和 mvn exec(运行)三样合成了一个命令。
④原理:三个文件、两个仓库,一条单向的路
uv 的命令很多,背后只有一条路:你写的意图 → 钉死的结果 → 本机的环境。看懂这条路,后面的命令、坑和规范都能推出来。
先知道这些四个 Python 基本概念,各用一句 Java 话对上
import 怎么找包
按 sys.path 列表的顺序,找名叫 fastapi 的目录,第一个找到的算数。约等于 classpath,但同一个目录里同名包只能有一个版本。
wheel(.whl)
预先打好的 zip 包,约等于 jar。文件名带平台标签,比如 cp313-manylinux_aarch64,因为里面可能有编译好的 C 或 Rust 代码。
虚拟环境(venv)
就是一个目录:bin/python 链接到真正的解释器,pyvenv.cfg 告诉解释器“我是一个环境”,包装在 lib/python3.13/site-packages。
pyproject.toml
Python 项目的标准描述文件(PEP 621),约等于 pom.xml:项目名、要求的 Python 版本、依赖列表。
核心机制:意图 → 结果 → 环境
uv add 改左边,uv lock 算中间,uv sync 造右边;uv run 每次启动前把这三步都检查一遍,对得上就直接跑。- 1两个文件分工。pyproject.toml 写范围,比如
fastapi>=0.162.2,表示“我能接受哪些版本”;uv.lock 写结果,表示“这一次实际用哪个版本、文件的哈希是多少”。两个都提交到 git,就和提交 pom.xml 一样。 - 2锁文件覆盖所有平台。uv 解析时不只为这台机器算,而是为 Linux、macOS、Windows 和
requires-python允许的所有 Python 版本一起算,用标记(marker)写明哪个包在哪个平台才要。本机实测的 uv.lock 里有 20 个 Windows wheel 和 56 个 macOS wheel 的哈希,虽然这台是 Linux。 - 3环境是算出来的。
uv sync默认是“精确同步”:lock 里没有的包,环境里有就删掉。所以 .venv 只是锁文件在这台机器上的投影,可以随手删,几十毫秒就能重建。 - 4uv run 自带检查。它先确认 lock 和 pyproject 对得上,再确认 .venv 和 lock 对得上,最后才在 .venv 里起子进程。什么都没变时,这一套检查本机实测 17 ms。
为什么这样设计三个约束,逼出三个选择
约束 1:import 只认目录,每个项目必须有一份自己的包目录。复制太慢,那就只复制一次。
被放弃:每个项目解压一份pip 的做法
每次都要解压和写盘,环境越多越慢、越占地方。
选中:缓存解压一次,项目里只建链接uv 的做法
建链接只改目录项,不读写文件内容,所以几乎不花时间。Linux 上默认硬链接,macOS 上默认用写时复制的克隆(按文档)。
uv sync 是 6.6 s。约束 2:一个环境里一个包只能有一个版本,传递依赖总会撞车。撞车时,要么悄悄挑一个,要么当场说清楚。
Maven:最近者优先悄悄挑一个
uv:所有约束同时满足满足不了就报错
uv 的求解器基于 PubGrub 算法,找不到解时会把冲突链一条条列出来。Maven 也是一个类只能有一个版本,区别只在冲突时它按规则挑一个,不报错。
约束 3:环境和锁文件会悄悄走散。人会忘记 sync,会手动装包,会改了 pyproject 忘记 lock。uv 的回答是让“运行”这个入口顺手把三样对齐:uv run 每次都检查,代价是每次多十几毫秒。
比方失效的地方:.venv 像 Maven 的 target/(可删、不进 git),但 target 放的是编译产物,.venv 放的是运行环境,Java 没有对应物,因为 classpath 不需要落地成目录。uv.lock 也不完全等于 pom.xml:pom 只写死直接依赖,传递依赖靠规则推,没有哈希;uv.lock 记的是整棵树加哈希,更像 Gradle 的 dependency locking 或 npm 的 package-lock.json。
基本语法十个命令,对着 Maven 记
| uv | Maven / Java 里 | 做什么 |
|---|---|---|
uv init gc-helper --no-package | mvn archetype:generate | 建项目:pyproject.toml、.python-version、.gitignore、main.py,并 git init |
uv python pin 3.13 | sdk use java 17 | 写 .python-version;没装就在第一次用时下载 |
uv add httpx | 往 pom 里加一个 <dependency> | 改 pyproject,重新 lock,同步 .venv,一步到位 |
uv add --dev pytest | <scope>test</scope> | 放进 [dependency-groups] dev,生产可以不装 |
uv remove httpx | 删掉那个 <dependency> | 同样三步一起做 |
uv sync | mvn dependency:resolve | 按 lock 造 .venv,多的删、缺的补 |
uv run uvicorn main:app | mvn spring-boot:run | 检查、同步,再在 .venv 里运行命令 |
uv tree | mvn dependency:tree | 看依赖树 |
uv lock --upgrade-package httpx | 改 pom 里的版本号 | 只升级这一个包,其余不动 |
uvx ruff check | 下载一个 jar 用 java -jar 跑一次 | 在临时环境里跑一个工具,不进项目依赖 |
参数、私服、Docker、CI 写法这些细节放到 第⑥步。
实验台:uv run 这一下到底做了什么先猜,再运行
先猜:这条命令会怎么走?
1 找 Python.python-version
等待运行
2 对锁pyproject ↔ uv.lock
等待运行
3 同步环境uv.lock → .venv
等待运行
4 起进程.venv/bin/uvicorn
等待运行
⑤跑起来:用 uv 把值班助手从零搭起来
就是全景里那个服务:/gc/summary 同时拉 5 台机器的 GC 日志,order-04 超时。下面每一段输出都是本机原样跑出来的。
# macOS / Linux,官方安装脚本
curl -LsSf https://astral.sh/uv/install.sh | sh
# 或者 brew install uv;Windows 用 winget install astral-sh.uv
uv --version # 本页用的是 uv 0.13.7
$ uv init gc-helper --no-package --python 3.13 Initialized project `gc-helper` at `…/gc-helper` $ cd gc-helper && ls -A .git .gitignore .python-version README.md main.py pyproject.toml
--no-package:值班助手是一个服务,不需要打成包给别人装。本机的 uv 0.13.7 不加这个参数时,会生成带 src/ 目录和 [build-system] 的包结构,见 ⑥ 的第 9 条。
$ uv add fastapi 'uvicorn[standard]' httpx Using CPython 3.13.18 Creating virtual environment at: .venv Resolved 24 packages in 9ms Installed 23 packages in 13ms + fastapi==0.162.2 + httpx==0.28.1 + uvicorn==0.54.0 + uvloop==0.23.0 … $ uv add --dev pytest Resolved 30 packages in 10ms Installed 5 packages in 7ms
这是缓存热的时候。这台机器第一次跑(要下载 Python 和全部包)是 6.6 s。
[project]
name = "gc-helper"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.162.2",
"httpx>=0.28.1",
"uvicorn[standard]>=0.54.0",
]
[dependency-groups]
dev = [
"pytest>=9.2.0",
]
gc-helper v0.1.0
├── fastapi v0.162.2
├── httpx v0.28.1
├── uvicorn[standard] v0.54.0
└── pytest v9.2.0 (group: dev)
version = 1
revision = 5
requires-python = ">=3.13"
[[package]]
name = "anyio"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
wheels = [ { url = "…", hash = "sha256:c66a…" } ]
# 终端 1:假装 5 台机器的日志代理,order-04 故意 5 秒才回 $ uv run uvicorn fakehosts:app --port 9100 # 终端 2:值班助手本身 $ uv run uvicorn main:app --port 8000 INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) # 终端 3 $ curl -s 'localhost:8000/gc/summary?service=order&minutes=10' elapsed_ms: 1151 {'host': 'order-01', 'pauses': 60, 'max_ms': 256.153, 'avg_ms': 33.3, 'error': None} … {'host': 'order-04', 'pauses': 0, 'max_ms': None, 'avg_ms': None, 'error': 'timeout 1s'} {'host': 'order-05', 'pauses': 60, 'max_ms': 253.093, 'avg_ms': 30.7, 'error': None}
1151 ms 是服务刚起来后的第一次请求,多了建连接的开销;全景页里再跑一次是 1009 ms。用 ps 看,uv run 是父进程,真正干活的是它起的子进程 .venv/bin/python .venv/bin/uvicorn main:app。
$ uv run pytest -q 2 passed, 1 warning in 0.31s $ git add pyproject.toml uv.lock .python-version *.py && git commit -m "gc-helper 骨架" # .venv 已经在 uv init 生成的 .gitignore 里,不会被提交
那 1 个 warning 是 Starlette 提示它的 TestClient 以后要换 httpx2,和 uv 无关,pytest 那一页再讲。
git clone 以后只要一句 uv run uvicorn main:app,Python、依赖、环境全都自动备好,版本和你的一模一样。⑥细节·规范·优化
按圈往深处走:先是不知道就会出事的坑,再是团队里该怎么写,最后是为什么快、锁文件怎么覆盖所有平台。
注意事项:九个坑
- 现象
uv pip install six以后,uv run还能用;一跑uv sync,输出- six==1.17.0,被删了。实测- 根源
- sync 默认精确同步,lock 里没有的就删。
uv run不删多余的包,所以问题会藏一阵子。 - 做法
- 一律
uv add six。
- 现象
- 同事 clone 下来,uv 按 pyproject 的范围重新解析,拿到更新的版本,“我这能跑,你那报错”。
- 根源
- pyproject 只写范围,确定版本的是 lock。
- 做法
- 应用项目一定提交 uv.lock。
uv sync← ④ uv run 自带检查
- 现象
- 有人手改了 pyproject 没跑 lock,CI 悄悄重新解析,测的是另一套版本,还照样绿灯。
- 根源
- sync 和 run 发现走散会自动修好 lock,在本地是方便,在 CI 是掩盖。
- 做法
uv sync --locked:走散就报The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.实测
python main.py,用的是系统 Python← ④ venv 是一个目录
- 现象
- 本机
python3是 3.10,报ModuleNotFoundError: No module named 'fastapi'。实测 IDE 没选解释器时也一样。 - 根源
- 包装在 .venv 里,只有 .venv/bin/python 会去那里找。
- 做法
- 用
uv run;IDE 里把解释器指到.venv/bin/python。
- 现象
- Docker 里缓存挂在单独的卷上,或者家目录和项目在不同的盘:uv 打一条警告,退回复制,变慢。
- 根源
- 硬链接只能在同一个文件系统里建。
- 做法
- 这种场合明确设
UV_LINK_MODE=copy,把警告去掉;或者把UV_CACHE_DIR放到同一块盘上。(按文档)
- 现象
- 挪完以后
.venv/bin/uvicorn报找不到解释器。 - 根源
- 脚本第一行写死了绝对路径:
#!/…/gc-helper/.venv/bin/python。实测bin/python也只是指向 uv 装的解释器的链接。 - 做法
- 不拷 .venv,到新位置
uv sync重建,几十毫秒。
- 现象
- pip 的
--extra-index-url会在所有源里挑版本最高的:有人在公网发一个和你内部包同名、版本号更大的包,就被装进来了(依赖混淆攻击)。 - 根源
- “哪个源都行,挑最新”是 pip 的老规则。
- 做法
- uv 默认
first-index:一个包在哪个源先找到,就只用那个源。私服写进[[tool.uv.index]],国内镜像用default = true替换 PyPI。--index-url已改名为--default-index。
uv add 只写下限← ④ 两个文件分工
- 现象
- 写进去的是
fastapi>=0.162.2,没有上限。实测 - 根源
- 应用项目有 lock 兜底,下限够用。要发布给别人用的库不一样:别人装你的库时不读你的 lock,上游出一个不兼容的大版本就会坏。
- 做法
- 应用:保持默认,靠 lock。库:按需加上限,比如
"httpx>=0.28,<1"。
uv init 默认建成了“包”版本行为
- 现象
- 本机 0.13.7 的
uv init gc-helper生成src/gc_helper/和[build-system],每次 sync 都会先构建、安装项目本身。实测 - 根源
- 不是原理推出来的,是 uv 选的默认值,各版本不一样。
- 做法
- 服务和脚本加
--no-package;要发布的库才用默认或--lib。
使用规范:团队里这么写
.venv 不提交,uv init 已经写进 .gitignore。
同事装出来和你不一样。
uv add / uv remove不手改依赖列表,更不手改 uv.lock。
pyproject 和 lock 走散。
uv sync --locked生产镜像再加 --no-dev,pytest 这些不进生产。
CI 测的不是提交的那一套。
uv lock --upgrade-package httpxlock 的 diff 和改 pom 版本号一样走代码评审。
一次升级混进一堆无关的版本变化。
在 pyproject 里写 [tool.uv] required-version = ">=0.13,<0.14"。
uv 还在 0.x,不同版本的默认行为会变。
项目要用的开发工具(ruff、mypy)放 dev 组;偶尔跑一次的用 uvx。
运行依赖越来越胖,生产镜像跟着变大。
深处:为什么快、锁怎么覆盖所有平台、进程长什么样
为什么快
- 缓存加链接:本机实测,同样的包,缓存热时硬链接 0.04 s,强制复制 0.27 s,pip 7.2 s。
- 只读元数据:解析时需要知道每个版本依赖谁。PyPI 能单独提供 wheel 里的元数据文件(PEP 658),uv 不用把整个包下载下来。(按文档)
- Rust、并行:下载和解压并行做;uv 本身是一个原生程序,不用先起 Python。
一份锁怎么覆盖所有平台
- 解析碰到“只在某些平台才要”的依赖(比如带
sys_platform != 'win32'标记的 uvloop),就按标记把求解拆成几支分别解,最后合成一份 lock。(按文档) - 所以 lock 里有不属于本机的 wheel:本机实测有 20 个 Windows、56 个 macOS 的条目。
- sync 时用本机的平台和 Python 版本去套标记,只装套得上的。
uv run 的进程模型
- 实测
ps:uv 留作父进程,子进程是.venv/bin/python .venv/bin/uvicorn …。 - 按文档,uv 会把 Ctrl-C、SIGTERM 等信号转给子进程,所以在终端和容器里都能正常停。
- 生产镜像里常见的写法是先
uv sync --locked --no-dev,启动命令直接写.venv/bin/uvicorn:少一层进程,也省掉每次启动的检查。
字节码要不要预编译
- Python 第一次 import 一个模块时把它编译成
.pyc存起来。uv 安装时默认不做这一步,所以装得快。 - 镜像里每个容器都是新的文件系统,每次启动都要重新编译。设
UV_COMPILE_BYTECODE=1(或--compile-bytecode),构建慢一点,启动快一点。(按文档)
- uv 自己:还在 0.x,一年发很多版。本机 0.13.7 的
uv init默认生成 src 布局的包;网上较早的教程里生成的是扁平的main.py。照着教程敲时,先对一下uv --version。 - 源的参数:
--index-url和--extra-index-url在 0.13.7 的帮助里已标为弃用,换成--default-index和--index。本机核对 - Python:值班助手要 3.11 以上(
asyncio.timeout),本页用 3.13。requires-python写低了,lock 会替所有更老的版本一起求解,可能挑出更旧的包。
下一圈
- workspace:一个仓库多个包,约等于 Maven 多模块
- 单文件脚本带依赖(PEP 723):
uv add --script,值班助手的小工具不用建项目 - 用 uv 写 Dockerfile:多阶段构建、缓存挂载、非 root 运行
- 老项目迁移:从 requirements.txt 或 Poetry 搬到 uv
- 打包和发布:
uv build、uv publish,发到公司私服 - 要 GPU 时:Conda、pixi 和 uv 怎么配合
↺回到⑤:拿⑥改进值班助手
三个练习,每个都在⑤的项目上用一条⑥的内容。
让 CI 守住锁
手改 pyproject 加一行 "orjson",分别跑 uv run --locked python -c 1 和 uv run --frozen python -c 'import orjson',看哪个报错、报什么,再用 uv add orjson 改对。
用到:第 3 条,实验台第 4、5 个场景
给值班助手写 Dockerfile
先复制 pyproject.toml 和 uv.lock,跑 uv sync --locked --no-dev,再复制代码,启动命令写 .venv/bin/uvicorn main:app --host 0.0.0.0,镜像里设 UV_COMPILE_BYTECODE=1。
用到:第 5 条,第三圈的进程模型和字节码
走镜像源
在 pyproject 里加一个 [[tool.uv.index]],写一个国内镜像并设 default = true,删掉缓存再 uv lock,看 uv.lock 里的 source 变成了什么。
用到:第 7 条
自测
1. 用一句话说出 uv 的精华。
pyproject 写意图、uv.lock 钉死整棵依赖树、.venv 按锁算出来;包从全局缓存硬链接,所以环境可以随手删、随时重建,并且永远和锁一致。
2. pip + venv 的老办法,痛在哪?
Python 版本不归它管;pip freeze 只记录这台机器此刻的状态,没有哈希、不分直接和间接依赖、不管别的平台;每个环境都要完整解压复制一遍,慢。
从 ② 和 ④ 约束 1 来。
3. 手动装进 .venv 的包为什么会被删?是哪条原理?
路是单向的:.venv 由 uv.lock 算出来,sync 默认精确同步,lock 里没有的包就删。
从 ④ 核心机制第 3 点来。
4. 预测:rm -rf ~/.cache/uv 以后,已经建好的 .venv 还能用吗?下次重建呢?
能用。硬链接是同一份数据的两个名字,删掉缓存这个名字,.venv 里那个名字还在,数据还在(链接数从 2 变成 1)。下次重建 .venv 时缓存是空的,要重新下载,从几十毫秒变成秒级。
从 ④ 约束 1 的 inode 图推出来,页面上没有原话。
5. 预测:同事在 Windows 上 uv sync,会装 uvloop 吗?uvicorn 还能跑吗?
不装。lock 里 uvloop 带着 sys_platform != 'win32' 的标记,sync 用本机平台去套,套不上就跳过。uvicorn 能跑:它发现没装 uvloop,就用 Python 自带的 asyncio 事件循环,只是慢一点。
从 ④ 核心机制第 2 点和 ① 的图推出来。
6. 放回全景:第 1 站前后各发生了什么?uv 这一步坏了,整条链路会怎样?
前面:你(或 systemd、容器)敲下 uv run uvicorn main:app。后面:第 2 站 uvicorn 在 .venv 里起来,监听 8000 端口,按装没装 uvloop、httptools 选事件循环和 HTTP 解析器,然后等 curl。
第 1 站整个失败(Python 下载不了、私服连不上、--locked 报错),就没有进程在听,curl 直接收到 Connection refused,后面 9 站一站都不会发生。更隐蔽的是装“错”了:比如 uvicorn[standard] 写成了 uvicorn,uvloop 和 httptools 没装,服务照样起来,但第 2 站和第 7 站换成了更慢的实现;或者 lock 没提交,值班机上的 httpx 是另一个版本,第 6 站的超时行为可能就变了。
从 ④ 和 全景第 2 站推出来。