UV Workspace:一条命令管好所有 Python 子包
Python 项目一旦拆成多个子包,麻烦就开始了。
common 改了一行,api 没生效。job 本地能跑,CI 里找不到包。 有人偷偷 pip install -e ../common,过两周换台机器,现场又还原不出来。
这种问题我第一眼不会怀疑业务代码。先看项目结构。十次里面有七八次,是依赖管理从一开始就没收口。
以前我们常这么放:
pay-platform/
├── api/
├── jobs/
├── common/
└── requirements.txt
看着清爽,跑起来全靠人品。
api 引 common,有人加 PYTHONPATH:
export PYTHONPATH=$PWD/common:$PYTHONPATH
有人在虚拟环境里装可编辑包:
pip install -e ../common
这两种我都不太喜欢。不是不能用,是太容易变成“我电脑上可以”。尤其是新人拉代码,第一天就在环境上耗半天,业务代码一行没看。
UV Workspace 适合处理这种 Python 单仓多包的场景。官方文档里 workspace 就是多个 package 放在一个仓库里统一管理,每个包有自己的 pyproject.toml,但共享一个 uv.lock,uv lock 会作用在整个 workspace,uv run 和 uv sync 也可以通过 --package 指定某个子包执行。
我一般会这样拆:
pay-platform/
├── pyproject.toml
├── uv.lock
├── packages/
│ ├── pay_common/
│ │ ├── pyproject.toml
│ │ └── src/pay_common/money.py
│ ├── pay_api/
│ │ ├── pyproject.toml
│ │ └── src/pay_api/check_order.py
│ └── pay_jobs/
│ ├── pyproject.toml
│ └── src/pay_jobs/retry_bill.py
根目录 pyproject.toml 不要写得花里胡哨,先把 workspace 收住:
[project]
name = "pay-platform"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [][tool.uv.workspace]
members = ["packages/*"]
真正的业务依赖,放到各自子包里。
比如公共包 pay_common:
[project]
name = "pay-common"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
里面写点真实业务味的代码:
# packages/pay_common/src/pay_common/money.py
from decimal import Decimal, ROUND_HALF_UPdeffen_to_yuan_text(fen: int) -> str:
if fen < 0:
raise ValueError(f"amount fen must be positive, got {fen}")
yuan = Decimal(fen) / Decimal(100)
return str(yuan.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
pay_api 要用它,不要写相对路径,不要改 sys.path,直接声明它来自 workspace:
[project]
name = "pay-api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pay-common",
"fastapi>=0.115",
][tool.uv.sources]
pay-common = { workspace = true }
这个 { workspace = true } 很关键。它告诉 uv:这个依赖别去 PyPI 找,就在当前 workspace 里找。官方文档也明确说,workspace member 之间的依赖要显式声明,并且 workspace member 是 editable 的。
业务代码里就正常 import:
# packages/pay_api/src/pay_api/check_order.py
from pay_common.money import fen_to_yuan_textdefbuild_order_line(order_id: str, amount_fen: int) -> dict:
return {
"order_id": order_id,
"amount": fen_to_yuan_text(amount_fen),
"source": "api",
}
if __name__ == "__main__":
print(build_order_line("P20260626001", 1299))
这时候根目录敲一条命令:
uv sync
环境就该是什么样就是什么样。该装的第三方包装上,该链接的本地子包也链接上。uv sync 会按 lockfile 同步环境,workspace 里的项目也会以 editable 方式安装,所以你改 pay_common 的代码,不需要反复重装。
跑指定子包也别进目录切来切去:
uv run --package pay-api python -m pay_api.check_order
输出大概这样:
{'order_id': 'P20260626001', 'amount': '12.99', 'source': 'api'}
这里有个坑我得单独拎出来。
不要以为放进 packages/* 就自动能互相 import。能不能引用,还是看子包自己的 dependencies 有没有声明。比如 pay_jobs 要用 pay_common,也得写:
[project]
name = "pay-jobs"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pay-common",
"httpx>=0.28",
][tool.uv.sources]
pay-common = { workspace = true }
然后任务代码就干净了:
# packages/pay_jobs/src/pay_jobs/retry_bill.py
from pay_common.money import fen_to_yuan_textdefprint_retry_bill(row: dict) -> None:
bill_no = row["bill_no"]
amount = fen_to_yuan_text(row["amount_fen"])
print(f"[retry-bill] bill_no={bill_no}, amount={amount}")
if __name__ == "__main__":
print_retry_bill({"bill_no": "BILL-7788", "amount_fen": 3050})
执行:
uv run --package pay-jobs python -m pay_jobs.retry_bill
这套东西爽的地方不在“命令少”,而在边界清楚。
pay_api 依赖什么,写在 pay_api/pyproject.toml。pay_jobs 依赖什么,写在 pay_jobs/pyproject.toml。 公共包怎么被引用,写在 [tool.uv.sources]。 版本最终锁到根目录 uv.lock。
CI 里也不用猜:
uv sync --locked
uv run --package pay-api python -m pay_api.check_order
uv run --package pay-jobs python -m pay_jobs.retry_bill
--locked 这个我建议 CI 里加上。有人改了依赖但没提交 lockfile,CI 就该直接炸。别等上线时才发现两个人装出来的依赖树不一样。
UV Workspace 不是非得所有项目都用。单个脚本、小工具、一次性爬数据,没必要搞这么重。
但只要你已经出现了三个信号,就该上了:一个仓库里有多个 Python 包;子包之间互相引用;团队里开始有人用 PYTHONPATH 和 pip install -e 续命。
这时候别再补文档了。
把 workspace 配起来,根目录一条 uv sync,先把环境这件事从“靠人记住”改成“工具保证”。这一步改完,后面排查问题会少很多脏噪音。