数据STUDIO

UV Workspace:一条命令管好所有 Python 子包

Image
  • UV Workspace 不是"更快的 pip"——是把 Cargo workspace 模式搬到了 Python,你声明"有哪些包、它们要什么",uv 保证所有包在同一套依赖版本上运转。
  • 核心三层:根配置声明成员边界 → PubGrub SAT 一次性求解全局依赖图 → --package 按需调度,只跑你改的包。
  • 关键数字:针对性 PR 测试时间砍 90%(Doppel 企业数据),全局构建从 10 分钟降到 4 分钟,Airflow 120+ 包跑在同一个 lockfile 上。
  • 从 init --workspace开始,文末有完整可运行的 3 包 workspace 代码,复制粘贴就能跑。
Image

01你的项目长了三颗头

上个月我在改一个 side project 的公共库——common-lib/ 里一个日期格式函数,就一行代码。

改完。跑测试。api/ 通过了。切到 worker/,报错——它还在用旧版本的 common-lib,因为我忘了在 worker 目录再跑一次 pip install -e .。

这不是 bug,这是 Python 多项目管理的基础设施塌方。

你的仓库大概也这样:api/、worker/、common-lib/ 三个目录,共享一批基础代码。每次 common-lib 动一行,你要手动跑三次 pip install -e .。CI 里 pip install -r requirements.txt 跑了 5 分钟——而且 api/ 和 worker/ 锁的 requests 版本经常不一样。三个月后,没人知道哪个版本是"正确的"。

Rust 程序员听到这里会困惑。Cargo workspace 从 2018 年就是标配——根 Cargo.toml 里写 [workspace],members = ["crate-a", "crate-b"],一个 cargo build 编译所有,一个 Cargo.lock 锁死所有版本。十年了,Python 这边一直在用胶水和祈祷。

现在不用了。Astral 把 Cargo workspace 的设计骨架搬到了 uv 里——而且比原版更快。

最蠢的一次,我写了个 Makefile,里面是 cd common-lib && pip install -e . && cd ../api && pip install -e . && cd ../worker && pip install -e .。新人入职跑了 10 分钟装依赖,然后问我为什么 worker 的 requests 版本和 api 不一样。

02你以为自己在管依赖,其实只是按下葫芦浮起瓢

多包项目的痛苦表面上看是"操作太多了"——pip install -e ./common-lib && pip install -e ./api && pip install -e ./worker。

但真正的根因更底层:Python 的包管理工具,从来没有把"一个仓库里的多个包"当作一等公民。

pip 的模型是"一个环境 + 从 PyPI 拉包"。你的 common-lib 不在 PyPI 上——它在隔壁目录。你只能靠 -e . 这种开发模式的 hack,模拟出"本地包也是依赖"的假象。但这个假象有三道裂缝:

  1. 版本漂移:pip 不保证 api/ 和 worker/ 用同一版本的传递依赖。今天 api/ 的 CI 装到 requests==2.31.0,明天 worker/ 的 CI 装了 requests==2.32.1——因为解析是各自为政的。
  2. 环境膨胀:三个子包在同一个 .venv 里,api/ 能 import 到 worker/ 的依赖——即使 api/ 的 pyproject.toml 根本没声明。本地跑一切正常,部署到生产时 ModuleNotFoundError。
  3. CI 十分钟等待:每次 PR 都要完整重建环境——不管你只改了 api/ 的一行路由,还是只动了 common-lib/ 的一个工具函数。改一行,等 5 分钟。

Apache Airflow 的团队对此最有发言权——他们维护着 GitHub 上最大的 Python monorepo 之一:120 多个分发包,120 万行代码。迁移到 uv workspace 之前,他们的描述是"单一依赖大杂烩"——所有 provider 和 core 共享全局环境,依赖解析经常锁死,多分钟级别的构建是日常。

这不是 pip 的问题。pip 当年就不是为 monorepo 设计的。你需要的不是"更快的 pip",是一个从底层逻辑上理解"一个仓库可以有多个包"的工具。

03一张表,定义"谁属于这个仓库"

uv workspace 的核心想法简单到只有三层——你先声明边界,它负责求解,你按需执行。

第一层:边界声明。在仓库根目录的 pyproject.toml 里加一个 [tool.uv.workspace] 表:

[project]
name = "my-monorepo"
version = "0.1.0"
requires-python = ">=3.12"

[tool.uv.workspace]
members = ["packages/*", "apps/*"]
exclude = ["packages/deprecated"]

members 是 glob 模式——packages/* 表示 packages/ 下的每个子目录都是一个 workspace 成员。exclude 是你不想管的目录,比如废弃代码、vendor 进来的第三方代码。

每个成员目录里要有自己的 pyproject.toml:

# packages/common-lib/pyproject.toml
[project]
name = "common-lib"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["pydantic>=2.0"]

第二层:兄弟包映射。api/ 要依赖 common-lib,但它在隔壁目录,不在 PyPI 上。在根pyproject.toml 的 [tool.uv.sources] 里声明:

[tool.uv.sources]
common-lib = { workspace = true }

这一行告诉 uv:当 package 声明 dependencies = ["common-lib"] 时,不从 PyPI 拉,用本地 workspace 里的那个版本,并且以编辑模式安装——你改 common-lib/ 的任何一行,所有依赖它的成员立刻生效,不需要手动重装。

第三层:区分 workspace 的两种形态。如果你想让根目录本身也是一个可安装的包(比如根目录有个主应用),保留 [project] 表——这叫 rooted workspace。如果根目录只是个配置容器,不想让它被当作包安装,删掉 [project] 表——这叫 virtual workspace。virtual workspace 只做一件事:协调成员 + 管理统一的 .venv + 托管 uv.lock。

三层就这些。没有 Makefile,没有 requirements-dev.txt,没有 tox.ini,没有 shell 脚本里揉在一起的 cd .. && pip install -e 咒语。

uv 怎么发现这是个 workspace?从你当前目录向上遍历找 pyproject.toml。找到一个后继续向上看,看它的父目录有没有含 [tool.uv.workspace] 的 pyproject.toml。如果有,验证当前目录被 members glob 覆盖、不被 exclude 排除。这条发现链保证你在 monorepo 的任何深度跑 uv sync,uv 都能找到根并做正确的解析。

04一次求解,全仓库一致;只跑你改的那个包

声明式配置只是门面。真正干活的地方在依赖解析引擎——这才是 uv workspace 区别于 pip/Poetry/PDM 的根本。

Image

传统的 Python 包管理工具是怎么做 monorepo 的?逐个包解析。api/ 求一次依赖图,worker/ 再求一次,common-lib/ 再求一次。三次独立求解 = 三次机会出现版本分歧。

uv 的做法是把整个 workspace 的依赖当作一道 CSP(约束满足问题),一次性求解。这个引擎叫 PubGrub,源自 Dart 生态,uv 用 Rust 重写并加了几个关键增强。

Image

PubGrub 的工作流:

  1. 从空白开始,只记录一个"虚拟根"——代表所有成员的依赖声明的合集
  2. 从"未决策包"列表中选优先级最高的——优先处理 URL 依赖(Git/本地路径),然后是精确版本约束(==),最后是"高度冲突"的包
  3. 选一个候选版本,优先用 uv.lock 里已有的版本
  4. 把候选版本的依赖加入"未决策"队列,后台预取包元数据加速后续步骤
  5. 发现冲突 → 记录不兼容 → 回溯换版本 → 重新决策
  6. 重复直到全局图满足所有约束,写入 uv.lock

这不是微优化。这是范式转移——所有成员共享一个 uv.lock,没有版本漂移的空间。

但这就带来一个棘手的问题:不同平台需要不同包怎么办?比如 Linux 上用 uvloop 加速 asyncio,Windows 上用 winloop——两个包在同一个依赖名上打架,传统 solver 会直接报冲突。

uv 用一个叫 forking resolver 的技术解决。它检查依赖声明的 PEP 508 环境标记(sys_platform == "linux" / sys_platform == "win32")。当两个分支的标记互斥时,uv fork 出两条独立求解路径,各自算出来的结果共存于同一个 uv.lock。你本地跑 uv sync 时,uv 读你机器的标记,只物化匹配的那条分支。

这意味着一个 uv.lock 管所有平台——Linux 开发机、macOS 笔记本、Windows 桌面、CI 的 Ubuntu runner,从同一个 lockfile 得到各自正确的包版本。


解析完,轮到执行。这里才是日常开发的体验分水岭。

# 全量同步——初始化或大改动后用
uv sync --all-packages --all-groups

# 针对性同步——PR 只改了 api/,只跑它
uv sync --package api

# 在指定包环境里执行命令
uv run --package api uvicorn api.main:app --reload

# 只跑某个包的测试
uv run --package common-lib pytest tests/

uv sync --package api 的意思是:从统一 uv.lock 里提取 api 的传递闭包——只安装 api 自己声明了需要的包,不多装。worker 的依赖不进入这个环境。

这就是 Doppel 这家金融科技公司迁移到 uv workspace 后,针对性 PR 测试时间砍掉 90% 的原因——不需要等整个 monorepo 的依赖装完,只装你改的那个包所需的。

他们迁移前的情况和我们大多数人一样:微服务靠 pip + Poetry 混搭,部署从开发机直推,Docker 镜像里塞了整个 monorepo 的依赖超集。迁移后:

  • 构建部署从 10 分钟+ 降到 4 分钟内
  • 测试管道整体缩短 50%
  • Docker 镜像从"全家桶"缩到"单服务精确依赖"

Apache Airflow 的玩法更激进——用 uv sync --package <provider> 做包级别的环境隔离。开发者进入某个 provider 目录后,只能 import 该 provider 声明的依赖。这直接消灭了他们的"幽灵依赖"问题——以前某个 provider 无意中依赖了 Airflow Core 的内部函数,但没声明,重构时大面积炸开。

我们团队遇到过——api/ 的代码里 import 了 redis,但 pyproject.toml 里没写。本地跑了两个月没问题,部署到 K8s 那天直接 CrashLoopBackOff。排查了两个小时才发现是依赖声明漏了。

05三个你必须知道的坑

nv workspace 不是银弹。有几个硬限制你需要在入坑前就知道——不是"以后可能会修",是"架构层面就长这样"。

坑一:循环依赖直接报错,不给商量的余地。

传统 pip 在安装时对循环依赖比较宽容——A 依赖 B,B 也依赖 A?pip 可能通过安装顺序"蒙混过关",只要最后 import 的时候两个包都在就行。

uv 不惯着。因为 PubGrub 是在"逻辑层面"构建依赖图而非"安装层面",它数学性地要求无环。一旦 {A, B} 形成一个可达闭包含环,PubGrub 立即终止,报出冲突集合。

这不是 bug,是逼迫你做正确的代码分层——出现循环依赖,说明你需要把共享接口抽到第三个包 common-interfaces/ 里。

坑二:共享 .venv 不会帮你防 import 泄漏。

上一节说了 uv sync --package 可以按包粒度同步。但这只影响安装了什么,不影响Python 能 import 什么。

Python 的 import 系统是全局的——只要包被装进了 .venv/lib/python3.x/site-packages/,整个环境里的任何代码都能 import 它。如果 api/ 的 pyproject.toml 没声明 requests,但 worker/ 声明了,你在 api/ 目录下写 import requests——本地跑完全没问题,部署到独立的 Docker 镜像时 ModuleNotFoundError。

唯一的解法是 CI 里加一道检查:跑完 uv sync --package api 后,用 import-linter 或 pip check 验证所有 import 都有对应的声明依赖。自动化,别靠人肉。

坑三:PyCharm 还没完全跟上。(2026 年中)

JetBrains 的 PyCharm 在 workspace 模式支持上还有滞后。Bug 编号 PY-89867:PyCharm 的 uv workspace 集成无法在成员包之间正确解析 import 关系,除非每个成员包的本地 pyproject.toml 里也重复一份 [tool.uv.sources] 声明——而 CLI 本身只需要根配置里声明一次。

这是 IDE 层面的问题,不影响命令行运行。VS Code + Pylance 在这块支持更好。如果你团队用 PyCharm,目前需要在"DRY(根配置集中管理)"和"IDE 自动补全不坏"之间选一个。

06什么时候该用 uv workspace,什么时候不该用

从这三个坑可以反推出一个决策框架:

用 uv workspace,当你的项目满足以下大部分:

  • ≥3 个子包共享同一个或几个公共库
  • 多个子包需要保证依赖版本绝对一致(同一个 requests 版本,同一个 pydantic 版本)
  • CI 里花在依赖安装上的时间超过 2 分钟
  • 团队里有人因为"该在哪个目录装依赖"吵过架

不用 uv workspace,当:

  • 只有一个包——uv init 就够了,workspace 是多余的复杂度
  • 各子包之间没有共享依赖——拆成独立 git repo 更干净
  • 团队用了 Pantsbuild 或 Bazel 并且配置已经稳定——这些工具在 Google/Meta 级别的 monorepo 上更合适,uv workspace 是为 3-50 个包的中型仓库设计的甜区
  • 你的项目有复杂的包间循环依赖,暂时没时间重构——先拆环,再 migrate

本质上 uv workspace 解决的是**"代码在一个仓库,但依赖管理却分裂"**这个具体矛盾。如果你的仓库没这个矛盾,不需要它。如果有,它比手写 Makefile、Poetry path deps、以及"要不拆成三个 git repo 算了"都要好。

07从零搭建一个 workspace(完整代码)

假设你要搭一个最小可用的 monorepo:一个公共库 common-lib,一个 API 服务 api,API 依赖公共库。

第一步:初始化 workspace。

mkdir my-monorepo && cd my-monorepo
uv init --workspace

这会生成一个带 [tool.uv.workspace] 的根 pyproject.toml。编辑它:

# my-monorepo/pyproject.toml
[project]
name = "my-monorepo"
version = "0.1.0"
requires-python = ">=3.12"

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

[tool.uv.sources]
common-lib = { workspace = true }

第二步:创建公共库。

mkdir -p packages/common-lib
cd packages/common-lib
uv init --no-workspace

编辑 packages/common-lib/pyproject.toml:

[project]
name = "common-lib"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["pydantic>=2.0"]

写一点实际代码 packages/common-lib/src/common_lib/__init__.py:

from pydantic import BaseModel

class HealthCheck(BaseModel):
    status: str = "ok"
    version: str = "0.1.0"

def get_version() -> str:
return "0.1.0"

第三步:创建 API 服务,依赖公共库。

cd ../../  # 回到 monorepo 根
mkdir -p packages/api
cd packages/api
uv init --no-workspace

编辑 packages/api/pyproject.toml:

[project]
name = "api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "common-lib",
    "fastapi>=0.115",
    "uvicorn>=0.30",
]

[dependency-groups]
dev = ["pytest>=8.0", "ruff>=0.13"]

注意 dependencies 里写了 "common-lib"——uv 会自动通过根配置的 [tool.uv.sources] 把它解析到本地 workspace 路径,而不是查 PyPI。

写 packages/api/src/api/main.py:

from fastapi import FastAPI
from common_lib import HealthCheck, get_version

app = FastAPI()

@app.get("/health")
def health():
return HealthCheck(version=get_version()).model_dump()

第四步:锁死依赖,同步环境,跑起来。

cd ../../  # 回到 monorepo 根
uv lock               # 全局解析,生成 uv.lock(提交到 git)
uv sync --all-packages --all-groups  # 全量同步

uv run --package api uvicorn api.main:app --reload  # 跑起来

打开 http://127.0.0.1:8000/health,你应该看到 {"status":"ok","version":"0.1.0"}。

第五步:验证兄弟包联动。

修改 packages/common-lib/src/common_lib/__init__.py,把 version 改成 "0.2.0"。刷新浏览器——不需要重装、不需要重启、不需要任何手动操作。因为 common-lib 是以 editable 模式安装的,改代码即时生效到所有依赖它的包。

第六步:CI 配置(GitHub Actions 精简版)。

name: CI
on:
pull_request:
push:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- run: uv sync --package api --locked
- run: uv run --package api pytest
- run: uv run ruff check .

关键在 --locked:告诉 CI"严格按 lockfile,不要擅自改版本"。如果 PR 改了 pyproject.toml 但忘了更新 uv.lock,CI 直接报错——不会悄悄装一套新版本声称通过了。


迁移 checklist(已有 Python 多包项目,想切到 uv workspace):

  1. uv init --workspace 在仓库根创建 workspace 配置
  2. 把每个子包目录做成标准 pyproject.toml 结构(uv init --no-workspace 再编辑)
  3. 在根 [tool.uv.sources] 声明兄弟包映射
  4. uv lock 生成全局锁文件
  5. uv sync --all-packages --all-groups 验证本地环境
  6. CI 改造:setup-uv@v6 + uv sync --package <name> --locked
  7. 针对性 PR:只跑 --package 对应子包的测试
  8. 旧文件清理:requirements.txt、.flake8、tox.ini 逐个删除
  9. 加上 import-linter 检查,防止跨包依赖泄漏

整个迁移,一个 4-5 个包的 Python monorepo 通常一个下午搞定。Doppel 用了 AI coding agent 辅助,一天完成了 10+ 微服务的配置转换。

uv workspace 不是让 Python 变得更像 Rust。它只是把一个已经被 Rust/Cargo 团队验证了十年的正确模式,用 Rust 重写了一份给 Python 用。

你的 common-lib 下次改一行代码时,不需要再跑三次 pip install -e . 了。

Pantsbuild 和 Bazel 不会消失——Google 级别的 monorepo 需要的是构建系统,不是包管理器。uv workspace 抢的是 pip/Poetry/PDM 的地盘,不是 Pants 的地盘。这两个赛道本来就不一样。


标签:#Python #uvworkspace #monorepo #依赖管理 #工程化 #Rust #Cargo

Image