K 的一隅

Python Python 语言核心

多包同仓:monorepo 里 Python 包如何相处

monorepo 中多个 Python 包共享仓库;workspace 声明成员包;包间依赖用可编辑路径或内部版本约束。

7 分钟阅读 更新于 2026-07-28
本文目录

一个产品仓库里同时放着 API 服务、公共 SDK 和 CLI 工具——三个独立包,却共享一次 code review 与同一套 CI。monorepo(单仓多包)解决的是「一起改、一起测、版本对齐」;代价是依赖图要在仓内画清楚:谁依赖谁、谁先 build、发布时是打成一个 wheel 还是多个。目录可以按 packages 与 apps 分,但核心仍是每个子目录有自己的 pyproject 与可 import 的包名。

monorepo 解决什么

对比「每个 一个 git 仓库」:

单仓多包多仓库
重构一次 PR要协调多个 PR 与版本号
共享 lint / test 配置每仓复制或抽公共 repo
CI 一次跑全量或受影响子集各仓流水线独立

Python 没有官方 monorepo 标准,但 pyproject.toml + workspace 工具(uv、Poetry workspace、hatch 等)已形成常见做法。选工具时看团队是否已用 uv 锁文件、是否要发多个 wheel 到 PyPI,再定 workspace 语法,比先拆十个空仓库省事。

典型目录:apps 与 packages

text
my-monorepo/
  pyproject.toml          # 根 workspace 配置
  uv.lock
  packages/
    shared/
      pyproject.toml
      src/shared/
        __init__.py
        ids.py
    api/
      pyproject.toml
      src/api/
        __init__.py
        routes.py
  apps/
    cli/
      pyproject.toml
      src/cli/
        main.py

packages/shared 放公共逻辑;packages/apiapps/cli依赖 shared。根目录 pyproject.tomlworkspace 把成员 串起来(uv 示例):

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

pyproject.toml 声明对仓内 依赖

toml
[project]
name = "api"
version = "0.1.0"
dependencies = ["shared"]

[tool.uv.sources]
shared = { workspace = true }

workspace = true 表示安装时链到仓内路径,而不是去 PyPI 找 shared——开发期改 shared 立刻反映到 api,无需先 pip publishpip install 一轮。

包间 import 怎么写

仓内 仍按正常模块名 import,前提是各 pyproject.toml 里声明了 packages / src 布局并已 sync

python
"""packages/api/src/api/routes.py"""

from __future__ import annotations

from shared.ids import new_request_id


def handle_ping() -> dict[str, str]:
    rid = new_request_id()
    return {"status": "ok", "request_id": rid}

shared.ids 来自另一个 workspace 成员,不是相对路径 ../../shared 这种脚本式写法—— 边界靠元数据维护,不靠 sys.path 手搓。手改 PYTHONPATHmonorepo 里是技术债,迟早在某台 CI 机器上爆 ModuleNotFoundError

依赖方向与版本

monorepo 里仍要避免循环 依赖shared 不应 import api。公共层向下无业务 依赖,应用层向上引用,是稳定分层。若发现 shared 需要 api 里的类型,往往说明该类型应下沉到 shared 或抽到第三个

对外 发布 时,每个 通常仍单独打 wheel、单独版本号;仓内 shared bump minor 后,依赖 它的 apipyproject.toml 里收紧版本约束,再一起 tag。有的团队用工具从 git 生成各 版本,有的手动维护——关键是一次 发布 清单里列清哪些 变了,避免 PyPI 上 api 已升级、shared 仍是旧版的不匹配。

根目录执行 uv sync 会解析整个 workspace,装齐所有成员及其 依赖;CI 可 uv sync --package api 只装子图做增量测试,缩短 PR 反馈时间。

与单包项目的差异

单包仓库:一个 pyproject.toml、一个 uv run 入口。monorepo:多个 pyproject.toml,命令常带 名,例如 uv run --package cli python -m cli.main。文档和 makefile 里写清「改 shared 后跑哪些测试」,比默认全量 pytest 省时间。受影响测试(只跑依赖变更 的用例)可以后续再加,先保证命令可复制。

测试与受影响范围

改 shared 包后,至少跑依赖它的 api 与 cli 的测试。全仓 pytest 在包少时可行;包多了可在 makefile 里写 test-shared、test-api 等目标,CI 按路径 filter。monorepo 的价值是「一次 PR 改接口与调用方」;若测试仍分十个仓库手动触发,优势就打了折扣。

发布清单示例

发版日前列清单:shared 是否 bump 版本、api 是否更新对 shared 的版本约束、cli 是否跟随、CHANGELOG 是否合并、哪些 wheel 要 upload。仓内 workspace 不自动等于 PyPI 上版本对齐;对外发布仍要人工或脚本核对每个包的 pyproject version。

与单仓库脚本的边界

apps/cli 若是「只在本仓用的入口」,可以不发布到 PyPI,只在 CI 里打镜像或 artifact。packages/shared 若是对外 SDK,则必须可独立 build wheel。目录名叫 apps 还是 packages 是约定,关键是 pyproject 里 name 与发布策略一致。

根目录 README 应画一张「包依赖图」:shared ← api ← 无;shared ← cli。新人改代码前先看图,避免在 cli 里写 shared 不该知道的业务细节。monorepo 不是取消分层,而是让分层在一个 PR 里可验证。

共享 ruff、pytest 配置可以放在根 pyproject 的 tool 段,子包继承;各包仍可 override testpaths。统一 lint 规则减少「这个包过 ruff、那个包不过」的摩擦。CI 在根目录 uv sync 后 make lint 一次扫全仓,比每个子目录单独配 job 省维护。

若只有一个包会对外发布,其他是内部 apps,文档写清「PyPI 上只有 shared 与 api,cli 仅 Docker 镜像」。避免使用者 pip install 到错误的 name,或误以为 monorepo 根目录本身是一个可 install 的包——根 pyproject 往往只有 workspace 配置,没有 project.name。

跨包重构时,先改 shared 接口与测试,再改 api 与 cli 的 import,最后 bump 各包版本。顺序反了容易留下中间态:api 引了新 shared 但 lock 未 sync,CI 报 ModuleNotFoundError。workspace 工具不会替你保证语义兼容,接口变更仍要测试覆盖。

clone 后黄金路径:根目录 uv sync → make test → 改 packages/shared → make test-shared(或等价命令)→ 提交 pyproject、lock 与子包代码同一 PR。monorepo 的 PR 应让 reviewer 看出「哪些包受影响」,标题或描述里带包名比笼统的 fix bug 更友好。

当包数量继续增长,可以考虑按目录 CODEOWNERS 指定 reviewer,或 CI 里只跑 git diff 影响到的 workspace 成员测试。monorepo 不是银弹:仓过大时 clone 与全量 lint 变慢,需要工具链配合。但在中小规模 Python 产品里,shared 与多个入口同仓仍是最常见的折中——关键是 workspace 元数据清晰、依赖无环、发布清单可重复。

子包版本不必永远 lockstep:shared 发 2.0 而 api 仍兼容 1.x 时,api 的 pyproject 应写 shared>=2,❤️ 而非无约束 shared。消费者 pip install api 时会拉满足约束的 shared;仓内开发则 workspace 链到当前源码。理解「仓内链路与 PyPI 解析」两条线,monorepo 才不容易在发版日踩坑。

根 pyproject 可放 tool.ruff、tool.pytest.ini_options 等全仓共享配置,子包 pyproject 只声明 name、version、dependencies。新加子包时 checklist:补 members glob、写 pyproject、uv sync、补测试与 CODEOWNERS。monorepo 的纪律是元数据与测试跟代码同一 PR,而不是先 merge 代码再补配置。

对外文档写清「从 PyPI 装哪个包、仓内开发 clone 后 sync 哪条命令」,避免用户 clone 整个 monorepo 只为用一个 SDK。若 CLI 仅内部使用,不必追求 PyPI 名短好记,而应追求 Docker 镜像 tag 与 git tag 对齐,运维侧同样受益。workspace 成员改名或移动目录时,同步改 members glob 与各包 pyproject 里的 workspace 引用,否则 sync 会 silently 漏包。

多包同仓的测试目录可放在各包内 tests/,也可在根目录 tests/ 按包分子目录;关键是 pytest 的 pythonpath 或 editable install 能 import 到 workspace 成员。发版脚本可以循环 packages/* 执行 build,产出多个 dist 目录或汇总到根 dist/,团队选一种并写进 makefile,避免手工漏打某个 wheel。

monorepo 根目录不必是可安装的包;若根 pyproject 只有 tool.uv.workspace 而无 project.name,pip install . 在根目录会失败是预期行为。文档明确「请 install 子包或 uv sync 整个 workspace」,比让人猜根目录能不能 import 更友好。

跨团队共享 monorepo 时,约定 packages 放库、apps 放入口,命名与 PyPI 上最终 name 一致,减少「目录叫 foo、包名叫 bar」的映射成本。每次新增 workspace 成员都在 README 依赖图补一笔,比 oral tradition 更不易腐化。

security 与 license 扫描可在根 CI 一次跑全 workspace,子包共享 policy。monorepo 放大了「一次改、全仓测」的收益,也放大了误改 shared 的影响面——接口变更走 review、测试、CHANGELOG 三步,与单包库同样严格。

新人第一天 clone 后,先 uv sync 再打开依赖图 README,改哪个包、跑哪条 make test 就不会迷路;这比记住十个目录名更可持续,也更利于 onboarding。单仓多包适合接口经常一起演进的团队;若各包完全独立、很少联调,多仓库有时更简单——选型看变更是否常常跨包。

收束

monorepo 不是把多个项目硬塞进一个文件夹,而是用 workspace 声明成员包、用元数据表达仓内依赖、用统一 lock 与 CI 保证可复现。Python 包仍是 import 单位;monorepo 只是让多个包在同一仓库里版本共舞,而不互相踩 sys.path。