Python技术迷

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_UP

deffen_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_text

defbuild_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_text

defprint_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,先把环境这件事从“靠人记住”改成“工具保证”。这一步改完,后面排查问题会少很多脏噪音。