从零读懂 uv
写给完全没接触过 uv、也不熟悉 Python 项目规范的人。全程用一个真实的小项目 jev-cli 做示例:每个文件为什么存在,每条命令改动了什么,底层到底发生了什么,出了问题怎么排查。
先认识几个基础概念
uv 本身不难,难的是它背后的几个 Python 概念。先把这些词弄懂,后面的内容就会顺很多。
- Python 解释器interpreter
真正"运行"
.py文件的那个程序。你输入python3 main.py时,python3就是解释器。一台电脑上常常同时装着好几个版本的解释器。写作时用的这台 Mac 上就有三个,见下表。
- 模块与包module / package
模块就是一个
.py文件;包是一个装着模块的文件夹。import dotenv就是在找一个叫dotenv的模块或包。日常说"装一个包",指的是发行包(distribution):别人打包好、发布出来的代码,比如
python-dotenv。注意:发行包的名字和 import 时用的名字不一定相同,第 7 课会讲原因。- PyPI
Python 官方的公共包仓库,网址是 pypi.org。
uv add默认就是从这里下载包。- 依赖dependency
一个项目需要用到的包。分两种:
- 直接依赖:你亲自要求安装的,比如
typesafe-sdk。 - 间接依赖(也叫传递依赖):依赖的依赖。
typesafe-sdk自己又需要pydantic、httpx2等,它们会被自动装上。示例项目只加了 2 个包,实际却装了 14 个,原因就在这里。
- 直接依赖:你亲自要求安装的,比如
- 虚拟环境virtual environment
项目专属的一个独立文件夹(就是项目里的
.venv),里面装着这个项目需要的所有包,并指向一个特定版本的解释器。为什么需要它?假设项目 A 需要
pydantic1.x,项目 B 需要 2.x。如果都装到系统里,二者必然冲突。给每个项目一个自己的虚拟环境,就互不干扰了。- 锁文件lockfile
一份记录"每个包(包括间接依赖)的精确版本号"的清单,uv 项目里的
uv.lock就是。它保证今天、明天、在你或别人的电脑上装出来的东西完全一样。
| 解释器 | 版本 | 位置 | 谁装的 |
|---|---|---|---|
| cpython-3.13 | 3.13.15 | ~/.local/share/uv/python/… | uv 自动下载 |
| cpython-3.12 | 3.12.13 | ~/.local/share/uv/python/… | uv 自动下载 · 示例项目在用 |
| cpython-3.9 | 3.9.6 | /usr/bin/python3 | macOS 自带 |
上表整理自写作时 uv python list --only-installed 的输出。在你的电脑上运行它,就能看到你自己有哪些版本。
typesafe-sdk 要求 Python ≥ 3.10。uv 发现系统版本不够,就用了自己管理的 3.12。你不需要手动安装任何 Python,uv 会处理。另外,macOS 自带的 Python 是给系统用的,最好别往里面装东西。- 解释器运行代码,包提供可复用的代码,PyPI 是包的仓库。
- 依赖分直接和间接两种;你只声明直接依赖,间接依赖由工具自动计算。
- 虚拟环境让每个项目拥有独立的包集合,锁文件让安装结果可以复现。
最重要的心智模型:三层
理解 uv 最关键的一点:你的依赖信息存在三个地方,各自扮演不同的角色。用"买菜"来类比:
写你想要什么,允许一个范围。由你(或 uv add)来写。
记录具体买到了什么:每个包的精确版本,包括间接依赖。由 uv 自动生成。
pydantic 2.13.5 …
真正装好、代码可以 import 的包。由 uv 按小票安装。
site-packages/…
几乎所有 uv 命令都可以归结为"让这三层保持一致":
uv add 包名:写进清单 → 更新小票 → 放进冰箱,三层一起更新。uv lock:只根据清单重新生成小票。uv sync:按小票把冰箱整理成完全一致的状态,缺的补上,多的扔掉。uv run:运行前先更新小票、补上冰箱里缺的东西,然后运行。注意,它默认只补不扔。
动手看一看:三层状态模拟器
下面是在一个全新项目里实际执行的 9 个操作,终端输出原样保留。一步步点下去,观察每一层怎么变化:绿色是新增,红色划线是删除。
- pyproject.toml 写意图(范围),uv.lock 写结果(精确版本),.venv 是实际安装。
uv add/uv remove三层一起改;uv sync严格同步;uv run只补不删。- 绕过 uv 的改动(手改 pyproject、pip 安装)会让三层不一致,uv 会在下一次 sync 或 run 时纠正。
uv 是什么,替代了什么
uv 是一个用 Rust 编写的 Python 项目管理工具。过去做同样的事要组合好几个工具,uv 把它们合成了一个,而且快很多。
| 要做的事 | 以前常用的工具 | 在 uv 里 |
|---|---|---|
| 安装、切换 Python 版本 | pyenv、官网安装包 | uv python install / uv python pin |
| 创建虚拟环境 | python -m venv、virtualenv | 自动完成(或 uv venv) |
| 安装包 | pip | uv add |
| 锁定精确版本 | pip-tools、Poetry | uv lock(自动) |
| 运行项目 | 先 source activate 再 python | uv run |
| 安装命令行工具 | pipx | uv tool install / uvx |
网上的旧教程,在 uv 项目里怎么写
很多教程还是 pip 时代写的。遇到时照这张表翻译即可:
| 旧教程里写的 | 在 uv 项目里写成 | 说明 |
|---|---|---|
python -m venv venvsource venv/bin/activate | 不需要 | uv run 会自动创建和使用 .venv |
pip install requests | uv add requests | 同时记录到 pyproject.toml 和 uv.lock |
pip uninstall requests | uv remove requests | |
pip install -r requirements.txt | uv add -r requirements.txt | 把旧依赖一次性迁入 pyproject.toml |
pip freeze > requirements.txt | uv export --format requirements-txt | 一般不需要,uv.lock 已经起到这个作用 |
pip install --upgrade requests | uv lock --upgrade-package requests | 然后 uv sync 或直接 uv run |
python main.py | uv run main.py | 这是最容易忘的一条 |
pipx install ruff | uv tool install ruff | 见第 9 课 |
它为什么快
除了 Rust 本身快,uv 还有一个全局缓存:下载过的包都存在 ~/.cache/uv(可用 uv cache dir 查看)。第二个项目再用同一个包时,直接从缓存里克隆过去,几乎不用等,也不会多占磁盘空间。原理见 第 7 课。
- uv 一个工具覆盖了 Python 版本、虚拟环境、装包、锁定、运行、命令行工具。
- 看到
pip install就换成uv add,看到python xxx.py就换成uv run xxx.py。
一个 uv 项目的结构,逐个文件看
点击文件列表里的文件名,看每个文件是谁创建的、做什么用、能不能手动修改、要不要提交到 git。以示例项目 jev-cli 为例,内容是写作时的真实文件。
对照一下:刚执行完 uv init 时,只有 pyproject.toml、.python-version、.gitignore、README.md 和 .git/。uv.lock 和 .venv/ 要等你第一次执行 uv add、uv sync 或 uv run 时才会出现。.env、.env.example 和 main.py 是项目自己加的,与 uv 无关。
关于 git:提交什么,不提交什么
规则很简单:可以重新生成的,或者含有秘密的,不提交;描述项目本身的,提交。
| 提交到 git | 不提交(已写进 .gitignore) |
|---|---|
pyproject.toml、uv.lock、.python-version、main.py、.gitignore、.env.example |
.venv/(可以用 uv sync 重建)、.env(含 API Key)、__pycache__/(Python 自动生成的缓存) |
- uv 自己的文件只有四个:
pyproject.toml、uv.lock、.python-version、.venv/。 - 提交"描述"(清单和小票),不提交"产物"(.venv)和"秘密"(.env)。
pyproject.toml:项目的身份证
这是现代 Python 项目的标准配置文件。它不是 uv 独有的,而是由 PEP 621 规范定义的。任何工具看到它,就知道"这是一个 Python 项目,叫什么,需要什么"。
先看懂 TOML 语法
TOML 是一种配置文件格式,只需要知道三条规则:
- 用方括号括起来的
[project]是一个段落(TOML 里叫"表"),下面的内容都属于它。[tool.uv]这种带点的写法表示嵌套:tool表里的uv表。 name = "jev-cli"是键 = 值,字符串要加双引号。[ "a", "b" ]是列表,可以跨行写,最后一项后面的逗号可有可无。
逐行解读你的文件
uv init 根据文件夹名或 --name 参数生成。只有打包发布时这个名字才重要。uv add 会自动往这里加一行,并写上"不低于当前最新版"的约束;uv remove 会删掉对应的行。间接依赖不会出现在这里,它们只记录在 uv.lock 中。版本号的含义:语义化版本
大多数 Python 包的版本号遵循"语义化版本"(SemVer)惯例,三个数字各有含义:
注意两点:一是这只是惯例,不是强制规定,有的包并不严格遵守;二是主版本为 0 时(比如 typesafe-sdk 0.7.1)表示还在早期开发阶段,次版本升级也可能带来不兼容的改动。
版本约束符号怎么读
| 写法 | 意思 | 什么时候用 |
|---|---|---|
>=0.7.1 | 0.7.1 或更新的任何版本 | uv add 的默认写法,最常见 |
==0.7.1 | 必须正好是 0.7.1 | 明确需要固定某个版本时。一般交给 uv.lock 就够了 |
~=0.7.1 | ≥0.7.1 且 <0.8(只接受修订号更新) | 想要 bug 修复,但不想要新功能带来的变化 |
>=2,<3 | 2.x 系列里的任意版本 | 明确避开下一个主版本 |
!=2.1.0 | 除 2.1.0 以外都行 | 某个版本有已知 bug 时排除它 |
指定版本的写法:uv add "typesafe-sdk==0.7.1"。命令里的约束要加引号,否则 shell 会把 > 当成重定向符号,结果生成一个名叫 =0.7.1 的文件。
你将来可能看到的其他段落
[dependency-groups]开发时才用的依赖,比如测试框架 pytest。用
uv add --dev pytest添加后会出现:[dependency-groups] dev = [ "pytest>=9.1.1", ]
[project.scripts][build-system]把项目做成"可安装的包"时才需要。在 uv 0.12 中,
uv init默认会生成它们。示例项目只是一个脚本,用不到,所以创建后删掉了它们。第 13 课会详细讲它们的作用。[tool.uv]uv 自己的设置。
[tool.xxx]是约定俗成的写法:每个工具都可以在 pyproject.toml 里占一个自己的tool段,互不干扰,比如[tool.ruff]、[tool.pytest.ini_options]。
[project]里最重要的两项:requires-python和dependencies。- pyproject.toml 里写宽松的范围(通常只有下限),精确版本交给 uv.lock。
- 主版本号变化意味着可能不兼容;0.x 版本的包更要留心。
uv.lock 与依赖解析
pyproject.toml 里写的是 typesafe-sdk>=0.7.1,这是一个范围。从范围得到精确版本的过程叫"依赖解析",uv.lock 就是解析的结果。
为什么需要锁文件
今天满足 >=0.7.1 的最新版是 0.7.1,半年后可能就是 0.9.0。如果只有 pyproject.toml,你今天装的和半年后同事装的就不是同一个版本,间接依赖更是可能全都变了。"在我电脑上能跑"的问题就是这样来的。
uv.lock 把某一时刻解析出来的每一个包的精确版本、下载地址和文件哈希值都记录下来,包括间接依赖。只要有它,任何人在任何时间装出来的环境都一模一样。
uv 怎么从"范围"得到"精确版本"
每个包发布时都会声明自己需要什么。比如 typesafe-sdk 0.7.1 声明了"需要 pydantic ≥ 2.12、需要 Python ≥ 3.10"等。uv 把你的要求和所有包的要求放在一起,找出一组能同时满足的版本,一般尽量选最新的。这个过程叫依赖解析。
什么时候会发生解析?uv add、uv remove、uv lock 一定会;uv sync 和 uv run 在发现 pyproject.toml 改过时也会。解析的结果写进 uv.lock。
还有一条很重要的规则:已经锁定的版本,uv 会尽量保持不变。即使 PyPI 上出了新版本,只要旧版本仍满足约束,再次解析时也不会自动升级。想升级必须明确说出来(第 9 课详细对比)。这正是锁文件的意义:版本只在你要求时才变。
一个真实的"回退"例子
在一个新项目里同时要求 typesafe-sdk 和 pydantic<2。typesafe-sdk 最新版要求 pydantic ≥ 2.12,二者矛盾。uv 没有报错,而是做了这件事:
$ uv add typesafe-sdk "pydantic<2" Resolved 13 packages in 1.45s + pydantic==1.10.26 + typesafe-sdk==0.6.0 # ← 不是最新的 0.7.1! + msgspec==0.21.1 # ← 0.6.0 版才有的依赖 …
uv 往回找,发现 typesafe-sdk 0.6.0 还支持 pydantic 1,就选了它,并在 pyproject.toml 里写下了 "typesafe-sdk>=0.6.0"。这是正确的解,但很可能不是你想要的。所以养成习惯:uv add 之后扫一眼输出,看版本号是不是你预期的。
如果明确要求 0.7 以上(uv add "typesafe-sdk>=0.7" "pydantic<2"),就无路可退了,uv 会报错。怎么读这类报错,见 第 14 课。
它长什么样
version = 1 revision = 3 requires-python = ">=3.12" [[package]] # 双方括号 = 列表里的一项,每个包一项 name = "annotated-types" version = "0.8.0" # 精确版本 source = { registry = "https://pypi.org/simple" } # 从哪里下载 wheels = [ { url = "https://files.pythonhosted.org/…whl", hash = "sha256:f072f4d8…" }, # 防篡改校验 ]
为什么 uv.lock 里的包比 .venv 里多?
示例项目的 .venv 装了 14 个包,uv.lock 里却有 16 个条目。多出来的两个:一个是项目自己(jev-cli);另一个是 httpx2-jsfetch,示例项目所在的 Mac 上根本没装它。看看 uv.lock 里的这几行:
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
{ name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },marker 是条件:httpx2-jsfetch 只在 emscripten 平台(运行在浏览器里的 Python)上才需要。uv.lock 是通用锁文件,它为所有可能的平台和 Python 版本一次性解好,所以会把这些条件依赖也记下来;实际安装时,uv 只装当前平台需要的那部分。
这也是 uv.lock 那么长的原因:比如 pydantic-core 含有编译过的代码,需要为不同系统、不同 Python 版本各准备一个安装包,光它一个就在 uv.lock 里列了六十多个下载地址。
用 uv tree 看依赖关系
想知道谁依赖谁,运行 uv tree。这是示例项目在写作时的输出:
jev-cli v0.1.0 ├── python-dotenv v1.2.3 # 直接依赖 └── typesafe-sdk v0.7.1 # 直接依赖 ├── httpx2 v2.13.1 # ↓ 以下都是间接依赖 │ ├── anyio v4.15.1 │ │ ├── idna v3.20 │ │ └── typing-extensions v4.16.0 │ ├── httpcore2 v2.13.1 │ │ ├── h11 v0.16.0 │ │ └── truststore v0.10.4 │ └── … ├── pydantic v2.13.5 │ ├── annotated-types v0.8.0 │ ├── pydantic-core v2.46.5 │ └── … ├── tenacity v9.1.4 └── typing-extensions v4.16.0
常用变体:uv tree --depth 1 只看直接依赖;uv tree --outdated 标出有新版本的包;uv tree --invert --package pydantic 反过来查"谁依赖了 pydantic"。
- 解析 = 找到一组同时满足所有约束的版本;已锁定的版本不会自动升级。
- 解析器会自动回退到旧版本来满足约束,所以
uv add后要看一眼实际选中的版本。 - uv.lock 覆盖所有平台,比 .venv 里实际装的多,这很正常。
.venv:项目专属的"冰箱"
.venv 就是这个项目的虚拟环境。它只是一个普通文件夹,没什么神秘的。这一课讲清楚它里面有什么、为什么必须用 uv run,以及它会不会占用很多空间。
.venv/ ├── pyvenv.cfg # 说明这个环境用的是哪个解释器 ├── bin/ │ ├── python → …python3.12 # 指向 uv 管理的 Python 3.12(符号链接) │ ├── activate # "激活"脚本(用 uv run 就不需要) │ └── dotenv # 某些包自带的命令行工具也装在这里 └── lib/python3.12/ └── site-packages/ # ← 所有装好的包都在这里 ├── typesafe_sdk/ ├── dotenv/ └── …
home = ~/.local/share/uv/python/cpython-3.12-macos-aarch64-none/bin
implementation = CPython
uv = 0.12.3
version_info = 3.12
include-system-site-packages = false # 与系统里装的包完全隔离
prompt = jev-cli为什么必须用 uv run,import 才找得到包
Python 执行 import 时,只会在几个固定位置里找:标准库、当前目录,以及正在运行的那个解释器对应的包目录(site-packages)。
- 用
uv run,运行的是.venv/bin/python,找的就是.venv/lib/python3.12/site-packages,你用 uv 装的包都在这里。 - 用系统的
python3,找的是系统自己的目录,那里没有这些包,于是报ModuleNotFoundError。
想确认某个包到底是从哪里导入的,可以直接问它:
$ uv run python -c "import typesafe_sdk; print(typesafe_sdk.__file__)"
项目目录/.venv/lib/python3.12/site-packages/typesafe_sdk/__init__.pyjson.py、random.py、dotenv.py 这类和标准库或第三方包同名的名字,import 会先找到你的文件,导致奇怪的报错。给文件起名时避开这些名字。包名和 import 名为什么不一样
uv add 时用的名字,和代码里 import 的名字,经常对不上:
| uv add 用的名字(发行包名) | 代码里 import 的名字 |
|---|---|
python-dotenv | import dotenv |
typesafe-sdk | import typesafe_sdk |
typing-extensions | import typing_extensions |
发行包名是作者在 PyPI 上注册的名字,import 名由包里的代码决定,两者没有强制关系。另外,Python 的名字里不能有 -,所以 typesafe-sdk 在代码里就成了 typesafe_sdk。想知道 import 名,最可靠的办法是看包的文档。
每个项目一个 .venv,会不会很占空间?
不会。听起来像是"每个项目都下载一份解释器和所有依赖",但实际上 uv 让它们共享同一份数据:
- 解释器符号链接
.venv/bin/python不是一份真正的 Python,只是一个"快捷方式":$ ls -l .venv/bin/python .venv/bin/python -> ~/.local/share/uv/python/cpython-3.12-macos-aarch64-none/bin/python3.12不管有多少个项目用 3.12,磁盘上都只有那一份约 72 MB 的解释器。
- 依赖包写时复制 clone
包第一次被需要时下载进全局缓存
~/.cache/uv,之后装到任何项目的 .venv,都是从缓存里克隆过去。这是 uv 在 macOS 和 Linux 上的默认方式(Windows 上用硬链接)。macOS 默认使用 APFS 文件系统。克隆出来的文件和缓存里的原文件共用同一块磁盘空间,只有某个文件被修改时才会真正复制一份。装好的包基本不会被修改,所以十个项目都用
typesafe-sdk,磁盘上实际也只存了一份。
du -sh .venv 统计的是文件的名义大小,它分辨不出克隆出来的文件和原文件共用了空间。这 12 MB 并不会在缓存之外再占一次磁盘。这也是删掉 .venv 再重建只要一两秒的原因:不用重新下载,只是从缓存克隆一遍。真正会越来越大的是缓存本身:旧版本的包、已删除项目用过的包都还留在里面。清理方法:
$ uv cache dir # 缓存在哪里(macOS 上默认是 ~/.cache/uv) $ du -sh ~/.cache/uv # 占了多少空间 $ uv cache prune # 只删不再需要的条目,比较安全,推荐 $ uv cache clean # 全部清空:项目不会坏,只是下次安装要重新下载 $ uv python uninstall 3.13 # 删掉不用的 Python 版本
为什么清空缓存不会弄坏项目?因为克隆出来的文件是独立的,即使原件被删除,.venv 里那一份依然完好。只有在手动把链接模式设成 symlink 时,清空缓存才会弄坏环境,所以不建议改这个设置。
关于 .venv 的三条规则
- 可以随时删除。运行
uv sync就会按 uv.lock 原样重建。环境出现奇怪问题时,这是最有效的"重启大法"。 - 不提交到 git。它和你的电脑、系统相关,别人拿去也用不了。别人只需要 pyproject.toml 和 uv.lock,就能在自己电脑上重建。
- 不要往里手动装东西。用 pip 装进去的包 uv.lock 里没有记录,下次
uv sync时会被清掉(模拟器第 7、8 步演示过)。
"激活"虚拟环境是什么?
传统做法是先运行 source .venv/bin/activate。它做的事情很简单:把 .venv/bin 放到 PATH 环境变量的最前面,于是你之后输入的 python 就是 .venv 里那个,终端提示符也会变成 (jev-cli) $。输入 deactivate 恢复原状。
用 uv 的话基本不需要激活,uv run 每次都会自动使用 .venv。下面两种方式效果相同:
# 方式一(推荐):一步到位 $ uv run main.py # 方式二:传统方式,先激活再运行 $ source .venv/bin/activate $ python main.py $ deactivate
- 只有用 .venv 里的 Python(也就是
uv run)运行,才能 import 到项目的包。 - uv add 用的包名和 import 名不一定相同,以包的文档为准。
- 解释器和包在所有项目间共享,.venv 几乎不额外占空间;需要清理的是全局缓存。
uv run main.py 背后发生了什么
这是你用得最多的命令。它看起来只是"运行一下",其实每次都会先检查一遍环境,把缺的补上,然后才执行你的代码。
- 找到项目根目录从当前目录开始往上找
pyproject.toml。找到了,就知道"这是哪个项目"。所以在项目的子目录里运行也没问题。 - 确定 Python 版本读取
.python-version(示例项目里是3.12),并确认它满足requires-python。电脑上没有这个版本就自动下载。 - 确保 .venv 存在没有就用选定的解释器新建一个;版本不对就重建。
- 检查 uv.lock 是否过期如果你手改过 pyproject.toml,uv 会先重新锁定,更新 uv.lock。
- 补齐 .venv把 uv.lock 里有、.venv 里没有的包装上。注意:默认只补不删,.venv 里多出来的包会保留。想要严格一致,用
uv sync或uv run --exact。 - 用 .venv 里的 Python 执行命令相当于运行
.venv/bin/python main.py,所以import typesafe_sdk能找到包。
第一次在新项目里运行时,能看到这些步骤的痕迹(新建示例项目的真实输出):
$ uv run main.py Using CPython 3.12.13 # 第 2 步:选定解释器 Creating virtual environment at: .venv # 第 3 步:新建 .venv Hello from demo! # 第 6 步:你的程序输出
控制它"修不修":--locked、--frozen、--exact
默认情况下 uv run 会自动修好环境,这在自己电脑上很方便。但有时你希望它别自作主张,比如在自动化部署里,你希望"uv.lock 过期了就报错",而不是悄悄更新它。
| 写法 | uv.lock 过期时 | .venv 里多余的包 | 适合 |
|---|---|---|---|
uv run | 自动重新锁定 | 保留 | 日常开发 |
uv run --locked | 报错退出 | 保留 | CI、部署:确保提交的 uv.lock 是最新的 |
uv run --frozen | 不检查,直接按现有 uv.lock | 保留 | 明确不想动 uv.lock 时 |
uv run --exact | 自动重新锁定 | 删除 | 想要和 uv sync 一样严格 |
--locked 的真实报错(手改了 pyproject.toml 之后运行,模拟器第 5 步):
$ uv run --locked main.py error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. hint: To update the lockfile, run `uv lock`.
uv sync 也支持 --locked 和 --frozen,含义相同。
对比:绕过 uv,直接用系统的 python3
如果在示例项目里直接运行 python3 main.py,用的是 macOS 自带的 3.9.6,它根本不会去 .venv 里找包:
$ python3 main.py Traceback (most recent call last): … from dotenv import load_dotenv ModuleNotFoundError: No module named 'dotenv'
uv run 不只能运行 .py 文件
$ uv run python # 打开交互式 Python,可以直接 import 项目依赖来试验 $ uv run pytest # 运行 .venv 里装的命令行工具 $ uv run --with rich python # 临时加上 rich 试一试,不写进项目(见第 9 课) $ uv run --env-file .env main.py # 让 uv 替你加载 .env(不需要 python-dotenv)
- uv run = 找项目 → 选 Python → 建 .venv → 更新锁 → 补包 → 运行。
- 默认只补不删;
--locked用于"过期就报错",--frozen用于"完全不动 uv.lock"。 - 看到
ModuleNotFoundError,先检查是不是忘了用uv run。
容易混淆的命令:到底有什么区别
很多命令看起来差不多,执行后的结果却不一样。这一课把最容易搞混的几组放在一起对比。每一组的输出都是写作时实际运行得到的。
① 装包:uv add、uv pip install、手改 pyproject.toml
结论永远用 uv add。另外两种都会让三层暂时或永久地不一致。
| 操作 | pyproject.toml | uv.lock | .venv | 之后运行 uv sync | 别人 clone 后 |
|---|---|---|---|---|---|
uv add rich | 写入 | 立即更新 | 立即安装 | 保留 | 也有 rich |
uv pip install rich | 不变 | 不变 | 立即安装 | 被删除 | 没有 rich |
| 手动在 dependencies 里加一行 | 写入 | 过期,下次 lock/sync/run 时才更新 | 下次 sync/run 时才安装 | 装上 | 取决于你有没有更新并提交 uv.lock |
手改 pyproject.toml 并不算错,只是改完要记得运行一次 uv sync(或 uv lock),再把 uv.lock 一起提交。第 2 课模拟器的第 4~8 步完整演示了后两种情况。
② 让改动生效:uv lock、uv sync、uv run
结论uv lock 只动小票;uv sync 把冰箱整理得和小票一模一样;uv run 只补不扔,然后运行。
| 它会…… | uv lock | uv sync | uv run |
|---|---|---|---|
| pyproject.toml 改过时,更新 uv.lock | 会 | 会 | 会 |
| 安装 .venv 里缺少的包 | 不会 | 会 | 会 |
| 删除 .venv 里多余的包 | 不会 | 会 | 不会(加 --exact 才会) |
| .venv 不存在时创建它 | 不会 | 会 | 会 |
| 运行你的代码 | 不会 | 不会 | 会 |
| 典型场景 | 升级版本、只想更新锁文件 | 刚 clone 项目、重建环境、确保环境干净 | 日常运行代码 |
uv sync 和 uv run 都可以加 --locked 或 --frozen,二者经常被搞混。测试方法:在 pyproject.toml 里手动加一行 "six"(不更新 uv.lock),然后分别运行:
$ uv sync --locked error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. # → 发现 uv.lock 过期,报错退出,什么都没改 $ uv sync --frozen Checked 6 packages in 0.81ms # → 完全不看 pyproject.toml,照旧的 uv.lock 检查了一遍。six 根本没装! $ uv sync Resolved 9 packages in 2ms Installed 1 package in 1ms + six==1.17.0 # → 先更新 uv.lock,再装上 six
| 参数 | uv.lock 过期时 | 一句话记忆 |
|---|---|---|
| (不加) | 自动更新 uv.lock | 帮我修好 |
--locked | 报错退出 | uv.lock 必须是最新的,否则别动 |
--frozen | 不检查,照旧的 uv.lock 执行 | 就用现在这份 uv.lock,别管 pyproject.toml |
③ 升级:哪些操作真的会升级,哪些不会
结论已锁定的版本只要仍满足约束,就不会自动升级。想升级,要么用 --upgrade-package,要么提高约束的下限。
测试准备:项目依赖 idna,约束是 >=3.6,uv.lock 里锁定的是 3.6,PyPI 上最新版是 3.20。然后逐个尝试:
| 操作 | 升级了吗 | 实际发生了什么 |
|---|---|---|
把约束从 ==3.6 手改成 >=3.6,再 uv lock | 没有 | 3.6 仍然满足 >=3.6,uv 保持原样 |
uv add idna(它已经是依赖) | 没有 | 输出 Checked 1 package,pyproject.toml 也没变 |
uv lock --upgrade-package idna | 升了(只改 uv.lock) | 输出 Updated idna v3.6 -> v3.20;.venv 里还是 3.6,要等下次 sync/run |
uv sync --upgrade-package idna | 升了(一步到位) | 更新 uv.lock,同时把 .venv 里的 3.6 换成 3.20 |
uv add "idna>=3.10" | 升了 | pyproject.toml 的下限变成 3.10;3.6 不再满足,uv 为它重新选了最新的 3.20 |
uv lock --upgrade | 全部升级 | 所有包(包括间接依赖)都升到约束允许的最新版 |
# 分两步:先改小票,再让冰箱跟上 $ uv lock --upgrade-package idna Resolved 2 packages in 422ms Updated idna v3.6 -> v3.20 $ uv run python -c "import idna; print(idna.__version__)" Uninstalled 1 package in 1ms # ← uv run 发现 .venv 与 uv.lock 不一致,先换版本 Installed 1 package in 3ms 3.20 # 一步到位 $ uv sync --upgrade-package idna Resolved 2 packages in 433ms - idna==3.6 + idna==3.20
注意 --upgrade-package 只会在 pyproject.toml 允许的范围内升级,不会修改 pyproject.toml。如果你写的是 <4,它最多升到 3.x;想跨过这个上限,得先改约束。另外,升级后记得运行一遍程序,确认新版本没有带来问题。
④ 删除依赖:uv remove 与手动删除
结论uv remove 一步清理干净;手动删除后,只有 uv sync 才会把包从 .venv 里移除。
| 操作 | pyproject.toml | uv.lock | .venv 里的包 |
|---|---|---|---|
uv remove requests | 删除这一行 | 更新 | 卸载 requests,以及只被它用到的间接依赖 |
手动删掉这一行,然后 uv run | 已删除 | 更新 | 仍然保留(只补不删) |
手动删掉这一行,然后 uv sync | 已删除 | 更新 | 卸载 |
uv remove 只能删除直接依赖。想删一个间接依赖(比如 requests 带进来的 urllib3)会报错:
$ uv remove urllib3 error: The dependency `urllib3` could not be found in `project.dependencies`
这很合理:只要 requests 还在,它就需要 urllib3。想去掉 urllib3,只能去掉需要它的那个包。拼错名字,或者要删的包其实在 dev 组里(应该用 uv remove --dev),也会看到同样的报错。
⑤ 普通依赖与开发依赖:uv add 与 uv add --dev
结论程序运行时需要的,用 uv add;只在开发时用的(测试、代码检查),用 uv add --dev。
uv add rich | uv add --dev pytest | |
|---|---|---|
| 写在 pyproject.toml 的哪里 | [project] 的 dependencies | [dependency-groups] 的 dev |
uv sync / uv run 时装不装 | 装 | 默认也装 |
uv sync --no-dev 时 | 装 | 不装(已装的会被卸载) |
| 项目发布成包后,别人安装时 | 一起安装 | 不会安装 |
| 删除用 | uv remove rich | uv remove --dev pytest |
$ uv sync --no-dev Resolved 8 packages in 2ms Uninstalled 5 packages in 22ms - iniconfig==2.3.0 - packaging==26.3 - pluggy==1.6.0 - pygments==2.21.0 - pytest==9.1.1 # pytest 和它的间接依赖都被移除 $ uv sync # 不加 --no-dev,又全部装回来 Installed 5 packages in 4ms
⑥ 切换 Python 版本:install、pin 与 requires-python
结论想让项目换一个 Python 版本,用 uv python pin;uv python install 只负责下载,不改变项目。
| 操作 | 改动了什么 | 对 .venv 的影响 |
|---|---|---|
uv python install 3.13 | 把 3.13 下载到 uv 的全局目录,不改项目里任何文件 | 无 |
uv python pin 3.13 | 把 .python-version 改成 3.13 | 下次 sync/run 时删掉旧 .venv,用 3.13 重建 |
修改 requires-python | 改变项目"声明支持"的版本范围,uv.lock 要按新范围重新解析 | 不直接影响,但 .python-version 必须落在新范围内 |
$ uv python pin 3.13 Updated `.python-version` from `3.12` -> `3.13` $ uv run python --version Using CPython 3.13.15 Removed virtual environment at: .venv # ← 旧的 3.12 环境被删除 Creating virtual environment at: .venv # ← 用 3.13 重建 Installed 6 packages in 4ms # ← 按 uv.lock 重新装包 Python 3.13.15
切回去也是同样的过程。由于包都在缓存里,来回切换只需几毫秒。
⑦ 创建环境:uv venv 与 uv sync
结论在 uv 项目里用 uv sync(或者直接 uv run),基本用不到 uv venv。
uv venv 只创建一个空的虚拟环境,不看 pyproject.toml,也不装任何包:
$ uv venv Using CPython 3.13.15 Creating virtual environment at: .venv Activate with: source .venv/bin/activate # site-packages 里除了两个内部文件,什么包都没有
uv sync 则会创建环境,并按 uv.lock 装好所有包。uv venv 是给不用 pyproject.toml 的老式工作流准备的(配合 uv pip install)。
⑧ 使用命令行工具:四种方式
结论看"谁需要它、要用多久":偶尔用一次,用 uvx;自己常用,用 uv tool install;团队都得用,用 uv add --dev;临时试个库,用 uv run --with。
| 方式 | 装在哪里 | 记录进项目吗 | 用完还在吗 | 适合 |
|---|---|---|---|---|
uvx ruff check . | 缓存里的临时环境 | 否 | 不在 PATH 里,下次运行直接复用缓存 | 偶尔用一次的工具 |
uv tool install ruff | 全局的独立环境,命令放进 PATH | 否 | 一直在,终端里直接输入 ruff | 你在各个项目里都常用的工具 |
uv add --dev ruff | 项目的 .venv | 是,写进 dev 组 | 在,用 uv run ruff | 团队约定必须用、版本要统一的工具 |
uv run --with rich … | 缓存里的临时环境,叠加在项目环境上 | 否 | 只在这一次运行里有效 | 临时试一个库,不想弄脏项目 |
⑨ 新建:三种 uv init
| 命令 | 生成什么 | 怎么运行 | 适合 |
|---|---|---|---|
uv init 名字 --no-package | pyproject.toml + main.py | uv run main.py | 小工具、学习项目(示例项目 jev-cli 就是这种) |
uv init 名字(即 --package) | pyproject.toml + src/名字/__init__.py | uv run 名字 | 要发布、要做成命令、要被别的项目 import(第 13 课) |
uv init --script 文件.py | 只有一个 .py 文件,依赖写在文件开头 | uv run 文件.py | 一次性的小脚本(第 12 课) |
- 装包只用
uv add;删包只用uv remove,它只能删直接依赖。 uv lock只改锁文件,uv sync严格同步,uv run只补不删。--locked:过期就报错;--frozen:忽略 pyproject.toml 的改动,小心使用。- 版本不会自动升级,要用
--upgrade-package或提高下限。
命令手册:每条命令改动了什么
每条命令都写明了"执行后依次发生什么"。右侧标签表示这条命令会写入哪些文件或目录。想看命令之间的区别,回到 第 9 课。
标签说明:pyproject uv.lock .venv .python-version 表示会被修改(颜色与三层模型一致);只读 表示只查看,不改动任何东西。
uv init 项目名 --no-package新建多个文件新建一个项目。只生成描述文件,不安装任何东西。
- 给了项目名就新建同名文件夹;没给就在当前目录里初始化。
- 生成
pyproject.toml:name取文件夹名,requires-python取当前选用的 Python 版本(用-p可以指定),dependencies为空。 - 生成
.python-version,写入这个版本号。 - 生成
main.py(打印一句 Hello)和README.md。目录里已有的同名文件不会被覆盖。 - 默认初始化 git 仓库并生成
.gitignore。
不会:创建 .venv、生成 uv.lock、下载任何包。它们要等第一次 add/sync/run 时才出现。
常用参数:--no-package 简单结构(初学者推荐);--package 包结构(uv 0.12 的默认,见第 13 课);--script 文件.py 单文件脚本(第 12 课);-p 3.12 指定 Python;--no-readme、--vcs none 不生成 README、不初始化 git。
$ uv init my-tool --no-package -p 3.12
Initialized project `my-tool` at `…/my-tool`三种 init 的对比:第 9 课 ⑨
uv add 包名pyprojectuv.lock.venv添加一个依赖。三层一起更新:写进清单 → 重新锁定 → 安装进 .venv。
- 把约束写进 pyproject.toml 的
dependencies。没写版本时,写成>=当前最新版。 - 重新解析所有依赖,更新 uv.lock。新包的间接依赖也一并确定下来。
- .venv 不存在就创建。
- 下载缺少的包(缓存里已有的直接用),装进 .venv。
- 用
+/-打印 .venv 里实际发生的变化。
特殊情况:解析失败时 pyproject.toml 保持原样,可以放心重试;包已经是依赖时,不会升级它,只输出 Checked(见 第 9 课 ③)。
$ uv add requests + certifi==2026.7.22 + charset-normalizer==3.5.1 + idna==3.20 + requests==2.34.2 # 你要的包 + urllib3==2.8.0 # 其余都是它的间接依赖 $ uv add "requests==2.31.0" # 指定版本(记得加引号) $ uv add rich httpx # 一次添加多个 $ uv add -r requirements.txt # 从旧的 requirements.txt 批量导入
装完扫一眼输出里的版本号:解析器可能为了满足约束选了旧版本(第 6 课"回退"的例子)。
和 uv pip install、手改 pyproject.toml 的区别:第 9 课 ①
uv add --dev 包名pyprojectuv.lock.venv添加只在开发时使用的依赖(测试、代码检查工具等)。
uv add 的流程完全一样,唯一的区别是约束写进 [dependency-groups] 的 dev 组,而不是 dependencies。$ uv add --dev pytest $ uv remove --dev pytest # 删除时也要加 --dev
开发依赖什么时候装、什么时候不装:第 9 课 ⑤
uv remove 包名pyprojectuv.lock.venv移除一个直接依赖,连同只被它用到的间接依赖一起清理。
- 从 pyproject.toml 里删掉这一行。
- 重新解析,更新 uv.lock。
- 从 .venv 卸载这个包,以及不再被任何包需要的间接依赖;还被别的包用着的会保留。
$ uv remove requests
- idna==3.20
- requests==2.34.2
- urllib3==2.8.0只能删除直接依赖;对间接依赖或拼错的名字会报错 could not be found in `project.dependencies`。
和手动删除的区别:第 9 课 ④
uv run 命令uv.lock.venv在项目环境里运行命令。先更新锁、补装缺少的包,再运行;不删除多余的包。
- 找到项目,确定 Python 版本,.venv 不存在或版本不对就(重新)创建。
- pyproject.toml 改过的话,重新解析并更新 uv.lock。
- 把 uv.lock 里有、.venv 里缺的包装上。多余的包保留。
- 用 .venv 里的 Python 执行你给的命令。
环境已经一致时:第 1~3 步什么都不改,几乎不花时间。详见第 8 课。
$ uv run main.py $ uv run --locked main.py # uv.lock 过期就报错 $ uv run --exact main.py # 同时删除 .venv 里多余的包 $ uv run --with rich main.py # 临时多加一个包,不写进项目
和 uv sync、uv lock 的区别:第 9 课 ②
uv syncuv.lock.venv让 .venv 与 uv.lock 完全一致:缺的装上,多的删掉。不运行代码。
- pyproject.toml 改过的话,先更新 uv.lock。
- .venv 不存在或 Python 版本不对就(重新)创建。
- 安装缺少的包;版本不对的换成 uv.lock 里的版本。
- 卸载 uv.lock 里没有的包:用 pip 私自装的、从 pyproject.toml 删掉后残留的,全部清掉。
- 默认包括 dev 组的开发依赖。
$ uv sync $ uv sync --no-dev # 不装(并卸载)开发依赖 $ uv sync --locked # uv.lock 过期时报错,不自动更新 $ uv sync --frozen # 忽略 pyproject.toml 的改动,只按现有 uv.lock $ uv sync --inexact # 不删除多余的包 $ uv sync --upgrade-package 包名 # 升级一个包并立即装上
最常用的场景:刚 clone 一个项目、删了 .venv 想重建、手改 pyproject.toml 之后。
--locked 和 --frozen 的真实对比:第 9 课 ②
uv lockuv.lock只根据 pyproject.toml 重新生成 uv.lock,不碰 .venv。
- 读取 pyproject.toml。
- 重新解析依赖。已经锁定、而且仍满足约束的版本保持不变。
- 写回 uv.lock。版本有变化时,会打印
Updated 包 v旧 -> v新。
不会:安装或卸载任何包。.venv 要等下一次 sync/run 才会跟上。
平时 add、remove、sync、run 都会自动锁定,很少需要手动运行它。它真正有用的地方是升级:
$ uv lock --upgrade-package typesafe-sdk # 只升级这一个包(在约束范围内取最新版) $ uv lock --upgrade # 所有包都升到允许的最新版 $ uv lock --check # 只检查 uv.lock 是否过期,不修改
哪些操作会升级、哪些不会:第 9 课 ③
uv treeuv.lock(过期时)以树状图显示依赖关系(见第 6 课的真实输出)。
--frozen。$ uv tree --depth 1 # 只看直接依赖 $ uv tree --outdated # 标出有新版本的包 $ uv tree --invert --package pydantic # 谁依赖了 pydantic
uv export只输出把 uv.lock 的内容转换成其他格式输出,比如 requirements.txt。
> 重定向。只有某个平台或工具只认 requirements.txt 时才需要。$ uv export --format requirements-txt > requirements.txtuv python list只读列出可用的 Python 版本,包括系统自带的和 uv 下载的。
$ uv python list --only-installed # 只看已经装好的(第 1 课的表格就来自它)
uv python install / uninstall仅全局目录下载或删除 uv 管理的 Python 版本,不改动任何项目。
~/.local/share/uv/python/(或从那里删除)。不改项目文件,也不影响系统自带的 Python。通常不用手动安装:uv run、uv sync 发现缺少需要的版本时会自动下载。每个版本只装一份,所有项目共用。
$ uv python install 3.13 $ uv python uninstall 3.13
uv python pin 3.13.python-version让项目换用另一个 Python 版本。
- 立即:把
.python-version改成 3.13。只做这一件事。 - 下一次
uv run/uv sync时:发现 .venv 的版本不对,删掉旧 .venv,用 3.13 重建,再按 uv.lock 装好所有包(缺 3.13 的话会先下载)。
$ uv python pin 3.13
Updated `.python-version` from `3.12` -> `3.13`前提是 3.13 满足 pyproject.toml 里的 requires-python。
完整过程的真实输出:第 9 课 ⑥
uvx 工具名不影响项目临时下载并运行一个 Python 命令行工具,等同于 uv tool run。
$ uvx ruff check . # 检查代码问题 $ uv tool install ruff # 想长期使用就全局安装,之后直接输入 ruff
和 uv tool install、uv add --dev、uv run --with 的区别:第 9 课 ⑧
uv pip ….venv兼容 pip 的接口。在 uv 项目里请不要用它安装包。
uv pip install xxx 只把包装进 .venv,不会写进 pyproject.toml 和 uv.lock。下次 uv sync 时,这些包就会被删掉(模拟器第 7、8 步)。它存在是为了兼容老项目的工作流。在 uv 项目里,只推荐用它来查看:uv pip list 列出 .venv 里装了什么。
uv venv.venv创建一个空的虚拟环境。uv 项目里基本用不到。
uv sync。对比:第 9 课 ⑦
uv cache dir / prune / clean仅全局缓存查看或清理 uv 的全局包缓存(macOS 上默认在 ~/.cache/uv)。
dir 只打印位置;prune 删除不再需要的条目;clean 清空全部缓存。都不会弄坏已有项目,只是之后可能要重新下载。$ uv cache dir $ uv cache prune $ uv cache clean
uv 命令 --help只读查看任意命令的全部参数。遇到不懂的参数,先试试它。
--help 看简要说明;uv help 命令 看每个参数的详细解释(本教程里很多细节就是从这里核实的)。
$ uv add --help $ uv help sync $ uv --version
常见场景:照着做就行
从零开始一个新项目
$ uv init my-tool --no-package # 1. 创建项目文件夹和基础文件 $ cd my-tool # 2. 进入项目 $ uv add requests # 3. 添加需要的库 $ uv run main.py # 4. 编辑 main.py 后运行 $ git add . && git commit -m "init" # 5. 提交(.venv 会被 .gitignore 自动排除)
拿到别人的 uv 项目(或者换了一台电脑)
$ git clone <仓库地址> && cd <项目> $ cp .env.example .env # 如果项目需要密钥,照模板填上自己的 $ uv sync # 按 uv.lock 装出一模一样的环境(也可以跳过,直接 uv run) $ uv run main.py
这就是提交 uv.lock 的意义:对方不需要问你"装的是哪个版本"。
把旧的 pip 项目迁移到 uv
假设旧项目里有 main.py 和 requirements.txt(这个流程实测可用):
$ cd 旧项目 $ uv init --no-package # 已有的 main.py 不会被覆盖 $ uv add -r requirements.txt # 依赖原样写进 pyproject.toml,并生成 uv.lock $ uv run main.py # 确认没问题后,可以删掉旧的 venv/ 文件夹和 requirements.txt
requirements.txt 里如果写的是 == 固定版本,迁入后 pyproject.toml 里也是 ==。可以考虑改成 >=,让 uv.lock 负责固定版本。
升级某个库到新版本
$ uv tree --outdated --depth 1 # 1. 看看哪些直接依赖有新版本 $ uv sync --upgrade-package typesafe-sdk # 2. 在允许的范围内升到最新,并立即装上 $ uv run main.py # 3. 跑一遍,确认新版本没带来问题 $ git add uv.lock && git commit -m "upgrade typesafe-sdk" # 4. 提交新的 uv.lock
第 2 步只改 uv.lock 和 .venv,pyproject.toml 保持不变。如果新版本超出了 pyproject.toml 里的范围(比如你写了 <1,而新版是 1.0),就要改用 uv add "typesafe-sdk>=1.0",它会同时提高下限。所有升级方式的对比见 第 9 课 ③。
同事加了新依赖,你 git pull 之后
$ git pull # 拿到了新的 pyproject.toml 和 uv.lock $ uv sync # 按新的 uv.lock 装上新包、删掉被移除的包
直接 uv run 也能把新包装上,但同事删掉的包会残留在你的 .venv 里(只补不删)。拉取后运行一次 uv sync 最稳妥。
不小心用 pip 装了包
如果这个包确实需要,用 uv add 包名 把它正式记录下来(已经装好的不会重复下载);如果不需要,运行 uv sync,它会被自动清理掉。
环境出现奇怪的问题
$ rm -rf .venv # 删掉"冰箱"。放心,清单和小票都还在 $ uv sync # 按小票重新装满
在 VS Code 里使用
VS Code 需要知道用哪个解释器,否则会在 import 下画红色波浪线。按 ⌘ ⇧ P → 输入 "Python: Select Interpreter" → 选择 ./.venv/bin/python。通常 VS Code 也会自动检测到 .venv 并提示你选择它。
单文件脚本与临时工具
不是每段代码都值得建一个项目。写个一次性的小脚本,或者试一下某个库,uv 有更轻量的办法。
自带依赖声明的脚本(PEP 723)
Python 有一个标准(PEP 723),允许把依赖直接写在脚本文件的开头,用一段特殊注释表示。uv 可以读懂它:
$ uv init --script hello.py $ uv add --script hello.py rich
# /// script # requires-python = ">=3.12" # dependencies = [ # "rich>=15.0.0", # ] # /// def main() -> None: print("Hello from hello.py!") if __name__ == "__main__": main()
运行时 uv 读取这段注释,自动为这个脚本准备一个临时环境(放在缓存里,不会生成 .venv),装好 rich,然后运行:
$ uv run hello.py
Installed 4 packages in 4ms
Hello from hello.py!这个文件可以直接发给别人。只要对方装了 uv,uv run hello.py 就能跑起来,不需要任何其他文件。它相当于把一个迷你版的 pyproject.toml 塞进了脚本里。
临时多加一个包:--with
想在不改动项目的情况下试一个库:
$ uv run --with rich python -c "import rich; print('ok')"
Installed 4 packages in 4ms
okrich 只存在于这一次运行里,pyproject.toml、uv.lock、.venv 都不会被改动。
命令行工具
像 ruff、pytest 这类"装了之后在终端里用"的工具,有 uvx、uv tool install、uv add --dev 三种装法,区别见 第 9 课。
- 单文件脚本用 PEP 723 头部声明依赖,
uv run 脚本.py自动准备临时环境。 --with临时加包,不留痕迹。
应用与包:src 结构是怎么回事
uv 0.12 的 uv init 默认生成"包"结构,示例项目 jev-cli 用的是更简单的"应用"结构。这一课讲清楚两者的区别。
| 应用(--no-package) | 包(--package,默认) | |
|---|---|---|
| 代码在哪 | 项目根目录的 main.py | src/项目名/__init__.py |
| 怎么运行 | uv run main.py | uv run 项目名(一个命令) |
| pyproject.toml | 只有 [project] | 多了 [project.scripts] 和 [build-system] |
| 项目自身装进 .venv 吗 | 不装 | 装(可编辑模式) |
| 适合 | 脚本、小工具、学习 | 要发布到 PyPI、要被别的项目 import、要做成命令 |
包结构实际长什么样
用 uv init pkgdemo --package 新建一个示例,下面是它的真实内容:
命令名 = "模块:函数":安装后,终端里会出现一个叫 pkgdemo 的命令,执行时调用 pkgdemo 模块里的 main() 函数。uv_build。有了它,项目才能被打包并安装。src/ 下的同名文件夹里。__init__.py 让这个文件夹成为一个包,import pkgdemo 时执行的就是它。$ uv run pkgdemo Using CPython 3.13.15 Creating virtual environment at: .venv Building pkgdemo @ file:///…/pkgdemo # ← 先把项目自己打包 Built pkgdemo @ file:///…/pkgdemo Installed 1 package in 1ms # ← 装进自己的 .venv Hello from pkgdemo!
安装后,.venv/bin/ 里多了一个 pkgdemo 命令文件,这就是 [project.scripts] 的效果。
改了代码要重新安装吗?
不用。uv 以"可编辑"方式安装项目自身,它指向的就是你 src/ 里的源代码,改完下次 uv run 直接生效。只有改了 pyproject.toml 里的 [project.scripts](比如新增一个命令)时,才需要 uv sync 一下,让新命令出现在 .venv/bin/ 里。
- 只是写个程序自己跑,用
--no-package;要发布、要被 import、要做成命令,用包结构。 [project.scripts]定义命令,[build-system]定义打包方式。- 项目自身以可编辑方式安装,改代码不用重装。
读懂报错
下面每个报错都是写作时实际触发的。每个都按"现象 → 原因 → 怎么办"来拆解。
$ python3 main.py ModuleNotFoundError: No module named 'dotenv'
- 原因
运行代码的不是项目 .venv 里的 Python,或者这个包根本没有装(第 7 课)。
- 排查
1. 是用
uv run运行的吗? 2. pyproject.toml 的 dependencies 里有这个包吗?没有就uv add。 3. 包名和 import 名是否搞混了?比如装的是python-dotenv,import 的是dotenv。
$ uv add "typesafe-sdk>=0.7" "pydantic<2" × No solution found when resolving dependencies: ╰─▶ Because typesafe-sdk>=0.7.0 depends on pydantic>=2.12.0 and your project depends on pydantic<2, we can conclude that your project and typesafe-sdk>=0.7.0 are incompatible. And because your project depends on typesafe-sdk>=0.7, we can conclude that your project's requirements are unsatisfiable.
- 怎么读
这是一段推理,按 "Because A and B, we can conclude C" 的结构读:因为 typesafe-sdk ≥0.7 需要 pydantic ≥2.12,而你要求 pydantic <2,所以二者不兼容;又因为你要求 typesafe-sdk ≥0.7,所以无解。高亮的两句就是冲突的双方。
- 怎么办
放宽其中一方:去掉
pydantic<2,或者接受旧版 typesafe-sdk。失败时 pyproject.toml 不会被修改,可以放心重试。
$ uv add typesafe-sdk # 在一个 requires-python = ">=3.9" 的项目里 × No solution found when resolving dependencies for split (markers: │ python_full_version == '3.9.*'): ╰─▶ Because the requested Python version (>=3.9) does not satisfy Python>=3.10 and all versions of typesafe-sdk depend on Python>=3.10, we can conclude that all versions of typesafe-sdk cannot be used. … hint: The `requires-python` value (>=3.9) includes Python versions that are not supported by your dependencies (e.g., all versions of typesafe-sdk only supports >=3.10). Consider using a more restrictive `requires-python` value (like >=3.10).
- 怎么读
容易让人困惑的地方:当时用的明明是 3.13,为什么还会失败?因为 uv.lock 是通用的(第 6 课),它要为
requires-python允许的所有版本求解,而 3.9 上装不了 typesafe-sdk。第一行的python_full_version == '3.9.*'就是在说"卡在 3.9 这个分支上"。- 怎么办
hint已经给出答案:把 pyproject.toml 的requires-python改成">=3.10"。遇到报错时,一定要读到最后的 hint。
$ uv add typesafe-sdkk × No solution found when resolving dependencies: ╰─▶ Because typesafe-sdkk was not found in the package registry and your project depends on typesafe-sdkk, we can conclude that your project's requirements are unsatisfiable.
- 怎么办
检查拼写,去 pypi.org 搜一下正确的发行包名。常见错误是把 import 名当成了包名,比如
uv add dotenv装到的并不是 python-dotenv。
$ uv run --locked main.py error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. hint: To update the lockfile, run `uv lock`.
- 怎么办
有人改了 pyproject.toml 却没有更新 uv.lock。本地运行
uv lock,把新的 uv.lock 一起提交。
通用排错技巧
- 读到最后。uv 的
hint:行往往直接给出解决办法。 - 加
-v看详细过程,比如uv sync -v,能看到 uv 在哪一步、用了哪个解释器、从哪里下载。 - 重建环境。
rm -rf .venv && uv sync能解决大多数"莫名其妙"的问题。 - 确认版本。
uv --version、uv run python --version。
其他常见问题
能用 pip install 装包吗?
在 uv 项目里不要这样做。pip(包括 uv pip install)只把包装进 .venv,不会记录到 pyproject.toml 和 uv.lock。后果是:别人的环境里没有这个包,你下次 uv sync 时它也会被删掉。一律用 uv add。
我手动改了 pyproject.toml,接下来要做什么?
运行 uv sync,三层就一致了。如果直接 uv run,它会更新 uv.lock、补装新加的包;但如果你删掉了某个依赖,uv run 不会把它从 .venv 里移除(模拟器第 6 步),需要 uv sync 才会移除。
.python-version 和 requires-python 有什么区别?
requires-python = ">=3.12" 是兼容范围:项目能在哪些版本上运行,uv.lock 会为这个范围内的所有版本求解。
.python-version 里的 3.12 是这台电脑上实际使用的版本。前者是范围,后者是范围里的一个具体取值。
main.py 里的 if __name__ == "__main__": 是什么意思?
直接运行某个文件时,Python 会把这个文件的 __name__ 设为 "__main__";如果它是被 import 进来的,__name__ 就是模块名。
所以这一行的意思是:只有直接运行这个文件时才执行 main(),被 import 时不自动执行。比如写测试时,可以用 from main import 某个函数 只引入需要的函数,而不会把整个程序跑起来。
__pycache__ 文件夹是什么?可以删吗?
Python 把 .py 文件编译成字节码后缓存在这里,下次启动会快一点。可以随便删,会自动重新生成。已经写进 .gitignore 了。
git 合并时 uv.lock 冲突了怎么办?
不要手动合并。先解决 pyproject.toml 的冲突,然后接受任意一方的 uv.lock,再运行 uv lock 重新生成。
自测与速查
先自己想一想,再点开看答案。能答对大部分,说明你已经掌握了 uv 的核心。
用 uv add 装一个包,会改动哪三样东西?
同事克隆了你的项目,他需要做什么才能跑起来?
uv sync 然后 uv run main.py,或者直接 uv run main.py。前提是你提交了 uv.lock;如果需要密钥,还要照 .env.example 建一个 .env。见第 10 课。用 uv pip install rich 装了 rich,然后运行 uv sync,rich 还在吗?
uv add rich。见模拟器第 7、8 步。手动从 pyproject.toml 删掉一个依赖,然后 uv run,这个包还在 .venv 里吗?
uv sync(或 uv run --exact)才会移除。见第 8 课。为什么 uv add python-dotenv 之后,代码里要写 import dotenv?
删掉 .venv 会丢失什么?
uv sync 就能原样重建;得益于缓存,只需一两秒。见第 7 课。电脑上用的是 Python 3.13,为什么 requires-python = ">=3.9" 会导致装不上一个要求 3.10 的包?
">=3.10"。见第 14 课报错 ③。uv add typesafe-sdk "pydantic<2" 成功了,但装的是 0.6.0 而不是最新版,为什么?
把约束从 "idna==3.6" 改成 "idna>=3.6",然后运行 uv lock,会升级到最新版吗?
uv lock --upgrade-package idna 或 uv sync --upgrade-package idna。见第 9 课 ③。uv.lock 已经过期时,uv sync --locked 和 uv sync --frozen 分别会怎样?
速查表
uv init 名字 --no-package新建项目uv add 包名添加依赖(三层一起更新)uv remove 包名移除依赖uv run main.py运行(自动锁定并补齐环境)uv sync严格按 uv.lock 同步 .venvuv sync --upgrade-package 包名升级某个包并立即装上uv tree查看依赖树uv python pin 3.13切换项目的 Python 版本uv run --with 包名 …临时加一个包试试uvx 工具名临时运行一个命令行工具rm -rf .venv && uv sync环境坏了就重建uv cache prune清理缓存里不再需要的包1. 加库用 uv add,运行用 uv run,不要用 pip 和系统的 python3。
2. pyproject.toml 归你改;uv.lock 和 .venv 归 uv 管。
3. 提交 pyproject.toml 和 uv.lock;不提交 .venv 和 .env。