PYTHON 项目管理 · 基于 uv 0.12.3 · 写于 2026 年 9 月

从零读懂 uv

写给完全没接触过 uv、也不熟悉 Python 项目规范的人。全程用一个真实的小项目 jev-cli 做示例:每个文件为什么存在,每条命令改动了什么,底层到底发生了什么,出了问题怎么排查。

贯穿全文的三样东西(后文用这三种颜色区分): pyproject.toml · 清单→ uv.lock · 小票→ .venv · 冰箱
示例项目 jev-cli uv 0.12.3 Python 3.12.13 macOS · Apple 芯片 快照时间 2026 年 9 月 示例项目介绍 ↓
完整目录

示例项目 jev-cli

  1. 基础概念
  2. 三层心智模型
  3. uv 是什么
  4. 项目结构
  5. pyproject.toml
  6. uv.lock 与依赖解析
  7. .venv 虚拟环境
  8. uv run 做了什么
  9. 易混淆的命令
  10. 命令手册
  11. 常见场景
  12. 单文件脚本
  13. 应用与包
  14. 读懂报错
  15. 自测与速查
第一部分基础先把词汇和最重要的心智模型建立起来。后面所有内容都建立在这里。
第 1 课

先认识几个基础概念

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 需要 pydantic 1.x,项目 B 需要 2.x。如果都装到系统里,二者必然冲突。给每个项目一个自己的虚拟环境,就互不干扰了。

锁文件lockfile

一份记录"每个包(包括间接依赖)的精确版本号"的清单,uv 项目里的 uv.lock 就是。它保证今天、明天、在你或别人的电脑上装出来的东西完全一样。

解释器版本位置谁装的
cpython-3.133.13.15~/.local/share/uv/python/…uv 自动下载
cpython-3.123.12.13~/.local/share/uv/python/…uv 自动下载 · 示例项目在用
cpython-3.93.9.6/usr/bin/python3macOS 自带

上表整理自写作时 uv python list --only-installed 的输出。在你的电脑上运行它,就能看到你自己有哪些版本。

为什么示例项目不用系统自带的 3.9?因为 typesafe-sdk 要求 Python ≥ 3.10。uv 发现系统版本不够,就用了自己管理的 3.12。你不需要手动安装任何 Python,uv 会处理。另外,macOS 自带的 Python 是给系统用的,最好别往里面装东西。
本课要点
  • 解释器运行代码,包提供可复用的代码,PyPI 是包的仓库。
  • 依赖分直接和间接两种;你只声明直接依赖,间接依赖由工具自动计算。
  • 虚拟环境让每个项目拥有独立的包集合,锁文件让安装结果可以复现。
第 2 课

最重要的心智模型:三层

理解 uv 最关键的一点:你的依赖信息存在三个地方,各自扮演不同的角色。用"买菜"来类比:

几乎所有 uv 命令都可以归结为"让这三层保持一致":

  • uv add 包名:写进清单 → 更新小票 → 放进冰箱,三层一起更新。
  • uv lock:只根据清单重新生成小票。
  • uv sync:按小票把冰箱整理成完全一致的状态,缺的补上,多的扔掉。
  • uv run:运行前先更新小票、补上冰箱里缺的东西,然后运行。注意,它默认只补不扔。

动手看一看:三层状态模拟器

下面是在一个全新项目里实际执行的 9 个操作,终端输出原样保留。一步步点下去,观察每一层怎么变化:绿色是新增,红色划线是删除。

pyproject.toml
uv.lock
.venv
记住这一句清单(pyproject.toml)由你来改;小票(uv.lock)和冰箱(.venv)交给 uv 维护,永远不要手改,也不要用 pip 往冰箱里塞东西。
本课要点
  • pyproject.toml 写意图(范围),uv.lock 写结果(精确版本),.venv 是实际安装。
  • uv add/uv remove 三层一起改;uv sync 严格同步;uv run 只补不删。
  • 绕过 uv 的改动(手改 pyproject、pip 安装)会让三层不一致,uv 会在下一次 sync 或 run 时纠正。
第 3 课

uv 是什么,替代了什么

uv 是一个用 Rust 编写的 Python 项目管理工具。过去做同样的事要组合好几个工具,uv 把它们合成了一个,而且快很多。

要做的事以前常用的工具在 uv 里
安装、切换 Python 版本pyenv、官网安装包uv python install / uv python pin
创建虚拟环境python -m venv、virtualenv自动完成(或 uv venv)
安装包pipuv add
锁定精确版本pip-tools、Poetryuv lock(自动)
运行项目先 source activate 再 pythonuv run
安装命令行工具pipxuv tool install / uvx

网上的旧教程,在 uv 项目里怎么写

很多教程还是 pip 时代写的。遇到时照这张表翻译即可:

旧教程里写的在 uv 项目里写成说明
python -m venv venv
source venv/bin/activate
不需要uv run 会自动创建和使用 .venv
pip install requestsuv add requests同时记录到 pyproject.toml 和 uv.lock
pip uninstall requestsuv remove requests
pip install -r requirements.txtuv add -r requirements.txt把旧依赖一次性迁入 pyproject.toml
pip freeze > requirements.txtuv export --format requirements-txt一般不需要,uv.lock 已经起到这个作用
pip install --upgrade requestsuv lock --upgrade-package requests然后 uv sync 或直接 uv run
python main.pyuv run main.py这是最容易忘的一条
pipx install ruffuv 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。
第二部分项目里的文件逐个认识项目里的文件:它们长什么样、为什么这样设计、背后的机制是什么。
第 4 课

一个 uv 项目的结构,逐个文件看

点击文件列表里的文件名,看每个文件是谁创建的、做什么用、能不能手动修改、要不要提交到 git。以示例项目 jev-cli 为例,内容是写作时的真实文件。

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)。
第 5 课

pyproject.toml:项目的身份证

这是现代 Python 项目的标准配置文件。它不是 uv 独有的,而是由 PEP 621 规范定义的。任何工具看到它,就知道"这是一个 Python 项目,叫什么,需要什么"。

先看懂 TOML 语法

TOML 是一种配置文件格式,只需要知道三条规则:

  • 用方括号括起来的 [project] 是一个段落(TOML 里叫"表"),下面的内容都属于它。[tool.uv] 这种带点的写法表示嵌套:tool 表里的 uv 表。
  • name = "jev-cli" 是键 = 值,字符串要加双引号。
  • [ "a", "b" ] 是列表,可以跨行写,最后一项后面的逗号可有可无。

逐行解读你的文件

[project]
项目基本信息段。PEP 621 规定的标准字段都写在这里。
name = "jev-cli"
项目名。由 uv init 根据文件夹名或 --name 参数生成。只有打包发布时这个名字才重要。
version = "0.1.0"
项目自己的版本号。自己用的小工具可以不管它。
description = "一个用来体验…"
一句话描述,只是说明用途,不影响运行。
requires-python = ">=3.12"
重要:声明项目支持哪些 Python 版本。uv 选择解释器、解析依赖时都会遵守它。第 6 课有一个因为它而解析失败的真实例子。
dependencies = [ "python-dotenv>=1.2.3", "typesafe-sdk>=0.7.1", ]
最重要的部分:直接依赖列表。uv add 会自动往这里加一行,并写上"不低于当前最新版"的约束;uv remove 会删掉对应的行。间接依赖不会出现在这里,它们只记录在 uv.lock 中。

版本号的含义:语义化版本

大多数 Python 包的版本号遵循"语义化版本"(SemVer)惯例,三个数字各有含义:

2.13.5
主版本有不兼容的改动。从 1 升到 2,你的代码可能需要修改。
次版本新增了功能,保持向后兼容。
修订号只修 bug,保持向后兼容。

注意两点:一是这只是惯例,不是强制规定,有的包并不严格遵守;二是主版本为 0 时(比如 typesafe-sdk 0.7.1)表示还在早期开发阶段,次版本升级也可能带来不兼容的改动。

版本约束符号怎么读

写法意思什么时候用
>=0.7.10.7.1 或更新的任何版本uv add 的默认写法,最常见
==0.7.1必须正好是 0.7.1明确需要固定某个版本时。一般交给 uv.lock 就够了
~=0.7.1≥0.7.1 且 <0.8(只接受修订号更新)想要 bug 修复,但不想要新功能带来的变化
>=2,<32.x 系列里的任意版本明确避开下一个主版本
!=2.1.0除 2.1.0 以外都行某个版本有已知 bug 时排除它

指定版本的写法:uv add "typesafe-sdk==0.7.1"。命令里的约束要加引号,否则 shell 会把 > 当成重定向符号,结果生成一个名叫 =0.7.1 的文件。

为什么默认只写下限(>=),不写上限?上限写得太紧,会让你的项目和别的包"凑不到一起",导致解析失败。精确版本已经由 uv.lock 固定住了,所以 pyproject.toml 里保持宽松反而更好。

你将来可能看到的其他段落

[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 版本的包更要留心。
第 6 课

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 课。

它长什么样

uv.lock(节选)
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.lock 是给机器读的。它由 uv 自动维护,而且要提交到 git。

用 uv tree 看依赖关系

想知道谁依赖谁,运行 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 里实际装的多,这很正常。
第 7 课

.venv:项目专属的"冰箱"

.venv 就是这个项目的虚拟环境。它只是一个普通文件夹,没什么神秘的。这一课讲清楚它里面有什么、为什么必须用 uv run,以及它会不会占用很多空间。

.venv 的主要结构
.venv/
├── pyvenv.cfg              # 说明这个环境用的是哪个解释器
├── bin/
│   ├── python → …python3.12 # 指向 uv 管理的 Python 3.12(符号链接)
│   ├── activate            # "激活"脚本(用 uv run 就不需要)
│   └── dotenv              # 某些包自带的命令行工具也装在这里
└── lib/python3.12/
    └── site-packages/      # ← 所有装好的包都在这里
        ├── typesafe_sdk/
        ├── dotenv/
        └── …
.venv/pyvenv.cfg(示例项目的实际内容,路径有缩写)
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__.py
一个常见的坑当前目录也在查找范围里,而且排在前面。如果你把自己的文件命名成 json.py、random.py、dotenv.py 这类和标准库或第三方包同名的名字,import 会先找到你的文件,导致奇怪的报错。给文件起名时避开这些名字。

包名和 import 名为什么不一样

uv add 时用的名字,和代码里 import 的名字,经常对不上:

uv add 用的名字(发行包名)代码里 import 的名字
python-dotenvimport dotenv
typesafe-sdkimport typesafe_sdk
typing-extensionsimport 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 显示 .venv 有 12 MB?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 几乎不额外占空间;需要清理的是全局缓存。
第三部分日常使用每天都会用到的命令:它们各自做了什么、改动了什么、在什么场景下用。
第 8 课

uv run main.py 背后发生了什么

这是你用得最多的命令。它看起来只是"运行一下",其实每次都会先检查一遍环境,把缺的补上,然后才执行你的代码。

  1. 找到项目根目录从当前目录开始往上找 pyproject.toml。找到了,就知道"这是哪个项目"。所以在项目的子目录里运行也没问题。
  2. 确定 Python 版本读取 .python-version(示例项目里是 3.12),并确认它满足 requires-python。电脑上没有这个版本就自动下载。
  3. 确保 .venv 存在没有就用选定的解释器新建一个;版本不对就重建。
  4. 检查 uv.lock 是否过期如果你手改过 pyproject.toml,uv 会先重新锁定,更新 uv.lock。
  5. 补齐 .venv把 uv.lock 里有、.venv 里没有的包装上。注意:默认只补不删,.venv 里多出来的包会保留。想要严格一致,用 uv sync 或 uv run --exact。
  6. 用 .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。
第 9 课

容易混淆的命令:到底有什么区别

很多命令看起来差不多,执行后的结果却不一样。这一课把最容易搞混的几组放在一起对比。每一组的输出都是写作时实际运行得到的。

① 装包:uv add、uv pip install、手改 pyproject.toml

结论永远用 uv add。另外两种都会让三层暂时或永久地不一致。

操作pyproject.tomluv.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 lockuv syncuv 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
--frozen 的坑正因为它不看 pyproject.toml,你手动加的依赖会被静默忽略,就像上面的 six。日常开发不要用它;它适合"确定 uv.lock 是对的、只想尽快装好"的场景,比如部署时。

③ 升级:哪些操作真的会升级,哪些不会

结论已锁定的版本只要仍满足约束,就不会自动升级。想升级,要么用 --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全部升级所有包(包括间接依赖)都升到约束允许的最新版
真实输出:分两步升级 vs 一步升级
# 分两步:先改小票,再让冰箱跟上
$ 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.tomluv.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 richuv add --dev pytest
写在 pyproject.toml 的哪里[project] 的 dependencies[dependency-groups] 的 dev
uv sync / uv run 时装不装装默认也装
uv sync --no-dev 时装不装(已装的会被卸载)
项目发布成包后,别人安装时一起安装不会安装
删除用uv remove richuv remove --dev pytest
真实输出:--no-dev 会卸载开发依赖
$ 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 必须落在新范围内
真实输出:pin 之后,uv run 自动重建 .venv
$ 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-packagepyproject.toml + main.pyuv run main.py小工具、学习项目(示例项目 jev-cli 就是这种)
uv init 名字(即 --package)pyproject.toml + src/名字/__init__.pyuv 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 或提高下限。
第 10 课

命令手册:每条命令改动了什么

每条命令都写明了"执行后依次发生什么"。右侧标签表示这条命令会写入哪些文件或目录。想看命令之间的区别,回到 第 9 课。

标签说明:pyproject uv.lock .venv .python-version 表示会被修改(颜色与三层模型一致);只读 表示只查看,不改动任何东西。

uv init 项目名 --no-package新建多个文件新建一个项目。只生成描述文件,不安装任何东西。
执行后依次发生:
  1. 给了项目名就新建同名文件夹;没给就在当前目录里初始化。
  2. 生成 pyproject.toml:name 取文件夹名,requires-python 取当前选用的 Python 版本(用 -p 可以指定),dependencies 为空。
  3. 生成 .python-version,写入这个版本号。
  4. 生成 main.py(打印一句 Hello)和 README.md。目录里已有的同名文件不会被覆盖。
  5. 默认初始化 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。
执行后依次发生:
  1. 把约束写进 pyproject.toml 的 dependencies。没写版本时,写成 >=当前最新版。
  2. 重新解析所有依赖,更新 uv.lock。新包的间接依赖也一并确定下来。
  3. .venv 不存在就创建。
  4. 下载缺少的包(缓存里已有的直接用),装进 .venv。
  5. 用 + / - 打印 .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移除一个直接依赖,连同只被它用到的间接依赖一起清理。
执行后依次发生:
  1. 从 pyproject.toml 里删掉这一行。
  2. 重新解析,更新 uv.lock。
  3. 从 .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在项目环境里运行命令。先更新锁、补装缺少的包,再运行;不删除多余的包。
执行后依次发生:
  1. 找到项目,确定 Python 版本,.venv 不存在或版本不对就(重新)创建。
  2. pyproject.toml 改过的话,重新解析并更新 uv.lock。
  3. 把 uv.lock 里有、.venv 里缺的包装上。多余的包保留。
  4. 用 .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 完全一致:缺的装上,多的删掉。不运行代码。
执行后依次发生:
  1. pyproject.toml 改过的话,先更新 uv.lock。
  2. .venv 不存在或 Python 版本不对就(重新)创建。
  3. 安装缺少的包;版本不对的换成 uv.lock 里的版本。
  4. 卸载 uv.lock 里没有的包:用 pip 私自装的、从 pyproject.toml 删掉后残留的,全部清掉。
  5. 默认包括 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。
执行后依次发生:
  1. 读取 pyproject.toml。
  2. 重新解析依赖。已经锁定、而且仍满足约束的版本保持不变。
  3. 写回 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 课的真实输出)。
执行后:按 uv.lock 画出依赖树。注意它并非完全只读:uv.lock 过期时,它会先更新 uv.lock(但不安装任何东西)。不想让它动 uv.lock,加 --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.txt
uv 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 版本。
执行后依次发生:
  1. 立即:把 .python-version 改成 3.13。只做这一件事。
  2. 下一次 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 项目里基本用不到。
执行后:创建 .venv,但不看 pyproject.toml,也不装任何包。在 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
第 11 课

常见场景:照着做就行

从零开始一个新项目

新建项目
$ 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 负责固定版本。

升级某个库到新版本

升级 typesafe-sdk
$ 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 做过几个项目后再回来看,会更有收获。
第 12 课 进阶 · 可跳过

单文件脚本与临时工具

不是每段代码都值得建一个项目。写个一次性的小脚本,或者试一下某个库,uv 有更轻量的办法。

自带依赖声明的脚本(PEP 723)

Python 有一个标准(PEP 723),允许把依赖直接写在脚本文件的开头,用一段特殊注释表示。uv 可以读懂它:

创建并添加依赖(真实输出)
$ uv init --script hello.py
$ uv add --script hello.py rich
hello.py 现在的内容
# /// 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

想在不改动项目的情况下试一个库:

--with
$ uv run --with rich python -c "import rich; print('ok')"
Installed 4 packages in 4ms
ok

rich 只存在于这一次运行里,pyproject.toml、uv.lock、.venv 都不会被改动。

命令行工具

像 ruff、pytest 这类"装了之后在终端里用"的工具,有 uvx、uv tool install、uv add --dev 三种装法,区别见 第 9 课。

本课要点
  • 单文件脚本用 PEP 723 头部声明依赖,uv run 脚本.py 自动准备临时环境。
  • --with 临时加包,不留痕迹。
第 13 课 进阶 · 可跳过

应用与包:src 结构是怎么回事

uv 0.12 的 uv init 默认生成"包"结构,示例项目 jev-cli 用的是更简单的"应用"结构。这一课讲清楚两者的区别。

应用(--no-package)包(--package,默认)
代码在哪项目根目录的 main.pysrc/项目名/__init__.py
怎么运行uv run main.pyuv run 项目名(一个命令)
pyproject.toml只有 [project]多了 [project.scripts] 和 [build-system]
项目自身装进 .venv 吗不装装(可编辑模式)
适合脚本、小工具、学习要发布到 PyPI、要被别的项目 import、要做成命令

包结构实际长什么样

用 uv init pkgdemo --package 新建一个示例,下面是它的真实内容:

[project.scripts] pkgdemo = "pkgdemo:main"
定义一个命令。格式是 命令名 = "模块:函数":安装后,终端里会出现一个叫 pkgdemo 的命令,执行时调用 pkgdemo 模块里的 main() 函数。
[build-system] requires = ["uv_build>=0.12.3,<0.13.0"] build-backend = "uv_build"
告诉工具用什么把项目"打包"。这里用的是 uv 自带的打包后端 uv_build。有了它,项目才能被打包并安装。
# src/pkgdemo/__init__.py def main() -> None: print("Hello from pkgdemo!")
代码放在 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] 定义打包方式。
  • 项目自身以可编辑方式安装,改代码不用重装。
第五部分排错与复习遇到报错不要慌。uv 的报错信息其实写得很清楚,关键是知道怎么读。
第 14 课

读懂报错

下面每个报错都是写作时实际触发的。每个都按"现象 → 原因 → 怎么办"来拆解。

① 找不到模块 最常见
$ 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 不会被修改,可以放心重试。

③ Python 版本范围不匹配 requires-python 冲突
$ 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。

⑤ 锁文件过期 使用了 --locked
$ 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 重新生成。

第 15 课

自测与速查

先自己想一想,再点开看答案。能答对大部分,说明你已经掌握了 uv 的核心。

用 uv add 装一个包,会改动哪三样东西?
pyproject.toml、uv.lock、.venv。写进清单,更新小票,装进冰箱。见第 2 课。
同事克隆了你的项目,他需要做什么才能跑起来?
uv sync 然后 uv run main.py,或者直接 uv run main.py。前提是你提交了 uv.lock;如果需要密钥,还要照 .env.example 建一个 .env。见第 10 课。
用 uv pip install rich 装了 rich,然后运行 uv sync,rich 还在吗?
不在了。rich 没有记录在 uv.lock 里,uv sync 会严格同步,把它删掉。应该用 uv add rich。见模拟器第 7、8 步。
手动从 pyproject.toml 删掉一个依赖,然后 uv run,这个包还在 .venv 里吗?
还在。uv run 会更新 uv.lock,但默认只补不删。运行 uv sync(或 uv run --exact)才会移除。见第 8 课。
为什么 uv add python-dotenv 之后,代码里要写 import dotenv?
发行包名和 import 名是两回事。发行包名是 PyPI 上注册的名字,import 名由包里的代码目录决定。见第 7 课。
删掉 .venv 会丢失什么?
什么都不会丢。.venv 完全由 pyproject.toml + uv.lock 决定,uv sync 就能原样重建;得益于缓存,只需一两秒。见第 7 课。
电脑上用的是 Python 3.13,为什么 requires-python = ">=3.9" 会导致装不上一个要求 3.10 的包?
uv.lock 是通用锁文件,要为 requires-python 允许的所有 Python 版本求解,3.9 上无解就整体失败。把 requires-python 改成 ">=3.10"。见第 14 课报错 ③。
uv add typesafe-sdk "pydantic<2" 成功了,但装的是 0.6.0 而不是最新版,为什么?
解析器回退了。最新版要求 pydantic ≥2.12,与你的约束冲突,于是 uv 找到了仍兼容 pydantic 1 的旧版本。见第 6 课。
把约束从 "idna==3.6" 改成 "idna>=3.6",然后运行 uv lock,会升级到最新版吗?
不会。3.6 仍然满足 >=3.6,已锁定的版本保持不变。要升级得用 uv lock --upgrade-package idna 或 uv sync --upgrade-package idna。见第 9 课 ③。
uv.lock 已经过期时,uv sync --locked 和 uv sync --frozen 分别会怎样?
--locked 报错退出;--frozen 不检查,照旧的 uv.lock 安装,你在 pyproject.toml 里新加的依赖会被忽略。见第 9 课 ②。

速查表

uv init 名字 --no-package新建项目
uv add 包名添加依赖(三层一起更新)
uv remove 包名移除依赖
uv run main.py运行(自动锁定并补齐环境)
uv sync严格按 uv.lock 同步 .venv
uv 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。