首页/Python 与数据实践/monorepo 与调试
Python 与数据实践 · 第 24 章

monorepo 与调试

工程化的最后一课,收两个尾。其一,项目做大了会「裂变」——对外一个应用,底下还养着工具库、算法库、若干脚本,仓库该怎么组织才不乱?其二,代码一多难免出岔子,全靠 print 排查既慢又伤士气,得学会让程序停在出问题的那一…

约 21 分钟8,318 字26 个代码示例

工程化的最后一课,收两个尾。其一,项目做大了会「裂变」——对外一个应用,底下还养着工具库、算法库、若干脚本,仓库该怎么组织才不乱?其二,代码一多难免出岔子,全靠 print 排查既慢又伤士气,得学会让程序停在出问题的那一行,当场审问。前半章用 uv 的 workspace 把一群项目装进一个仓库统一供养;后半章拆开断点调试的原理,再把它接进 monorepo 工程。本章配了两个可以直接动手的示例:insight-lab/(monorepo 工程)与 debug-demo/(调试原理演示)。


1. 一个仓库装下一窝项目

1.1 monorepo 长什么样

先看现象:一个数据项目做着做着,往往会长出好几样东西——一个对外的应用、一个画图的库、一个算指标的库。默认做法是每样开一个仓库,这叫 multirepo(多仓库);monorepo(单仓库)反其道而行:只要这些项目由同一拨人维护、彼此紧密配合,就全部放进同一个代码仓库。

本章示例工程 insight-lab 的布局:

insight-lab/
├── apps/
│   └── report-cli/        # 应用:终端周报
├── packages/
│   ├── statskit/          # 库:统计计算
│   └── vizkit/            # 库:终端图表
├── pyproject.toml         # workspace 根配置
└── README.md

1.2 和多仓库比一比

对比维度 多仓库(multirepo) 单仓库(monorepo)
代码复用 先发成 pip 包,别人才用得上 直接引源码,改完立刻生效
跨项目改动 多仓分别提 PR,版本号对齐全靠人肉 一次提交,所有关联改动齐步走
工具与规范 每仓自己配一套 全仓统一,一处配置全局受益
大范围重构 牵扯多仓,风险高回报低 整仓都在工具链眼皮底下,放心动手
持续集成 每仓一条流水线 一条流水线,还能只测受影响的成员
适合谁 团队独立、发版节奏互不相同 紧密协作的应用群、SDK 家族、工具集

经验之谈:项目之间「血缘」越近,monorepo 越划算;真到了各养各的团队、各排各的发版,再拆仓不迟。

2. 用 uv workspace 搭骨架

uv workspace 官方文档:https://docs.astral.sh/uv/concepts/projects/workspaces/#using-workspaces 以下操作都假定 uv 已装好(第 23 章的内容)。

2.1 根项目先立起来

mkdir insight-lab && cd insight-lab
uv init

uv init 生成的是一个普通单项目骨架,接下来在根目录 pyproject.toml 里补一段声明,把它升级成工作区:

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

members 就是「户口本」:列在上面的子项目,才算这个工作区的成员。

2.2 members 的通配写法

members 支持写 glob 模式,以后新增子目录不必每次回来登记:

模式 圈定范围
packages/* packages/ 下的直接子目录
packages/** packages/ 下所有层级子目录(含嵌套)
libs/* libs/ 下的直接子目录
apps/* apps/ 下的直接子目录

目录也可以并列多个,各管一摊:

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

2.3 创建成员包

uv init --lib packages/statskit   # 库:按 src 布局生成,自带构建配置
uv init --lib packages/vizkit
uv init apps/report-cli           # 应用:只管自己跑,普通初始化即可

--lib 与不加的区别在第 22 章讲过:库要被别人 import、要能构建发布,得有 src 布局和构建后端这些行头;应用不对外发布,就不需要。

2.4 sources:把成员连起来

工作区里的成员也要互相依赖。本工程里,vizkit 要用 statskit 的归一化函数,report-cli 则两个都直接用:

# packages/vizkit/pyproject.toml
dependencies = [
    "rich>=13.7.0",
    "statskit",
]

[tool.uv.sources]
statskit = { workspace = true }
# apps/report-cli/pyproject.toml
dependencies = [
    "statskit",
    "vizkit",
]

[tool.uv.sources]
statskit = { workspace = true }
vizkit = { workspace = true }

[tool.uv.sources] 是 workspace 机制的心脏。光在 dependencies 里写名字,uv 默认去 PyPI 找包;而 sources 里那行 workspace = true 等于告诉 uv:这个名字别去下载,去工作区里找同名成员,直接用它的源码。于是你改 statskit 的代码,vizkit 立刻用上新版,中间不存在「重新安装」这个动作。

两个细节:

  1. 某个包名在 PyPI 和工作区里同时存在时,工作区优先;
  2. 确实想用 PyPI 上的同名包,显式写 statskit = { workspace = false } 把链接关掉。

2.5 一条命令装齐

uv sync --all-packages

这条命令替你干了四件事:

  1. 把所有成员的 pyproject.toml 读齐;
  2. 解析整棵依赖树——成员之间的依赖也算在内;
  3. 在根目录生成唯一的 uv.lock;
  4. 在根目录创建唯一的 .venv,所有依赖装进这一个环境。

和第 17 章 venv 的「一个项目一个环境」对照:workspace 是整仓一个环境,成员包以可编辑方式装进去,import 兄弟包像 import 第三方库一样自然(第 15 章讲的 import 搜索路径在背后生效)。顺带的规矩:uv.lock 要提交进 git,.venv 不要。

2.6 跑起来

uv run --package report-cli python apps/report-cli/main.py

--package 指定以哪个成员的身份运行。跑通后会打印统计摘要和两张终端柱状图:

本周 7 天,日均 158.1 元
最高 245.5,最低 96.5

                每日销售额
┏━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ 条目 ┃  数值 ┃ 分布                     ┃
┡━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 周一 │ 128.0 │ █████████████            │
│ 周二 │  96.5 │ █████████                │
│ ……   │    …… │ ……                       │
└──────┴───────┴──────────────────────────┘

3. workspace 日常速查

3.1 Makefile 收纳长命令

uv run --package report-cli python apps/report-cli/main.py 这种命令敲两遍就腻了,交给 Makefile:

.PHONY: report build

report:
    uv run --package report-cli python apps/report-cli/main.py

build:
    uv build --package statskit
    uv build --package vizkit

以后 make report 一句话的事。

3.2 加第三方依赖

# 给某个成员包加(写进它自己的 pyproject.toml)
uv add --package vizkit <包名>

# 给根项目加开发依赖
uv add --dev debugpy

3.3 构建

uv build                     # 全部成员一起构建
uv build --package statskit  # 只构建某一个

产物落在各成员的 dist/ 目录,拿去发布走的就是第 22 章的流程。

3.4 看依赖网:uv tree

uv tree --package report-cli   # 看某个成员的依赖树
uv tree                        # 看全部成员的

输出示例:

report-cli v0.1.0
├── statskit v0.1.0 (workspace)
└── vizkit v0.1.0 (workspace)
    ├── rich v13.9.4
    └── statskit v0.1.0 (workspace)

自家成员带 (workspace) 标记,内部依赖与外部依赖一眼分明。

4. 引用成员包的三种姿势

[tool.uv.sources] 里除了 workspace,还支持另外两种来源。

4.1 源码链接(workspace 模式)

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

日常开发的首选:本地源码直连,改完即生效,不需要 pip install -e .,也不需要重新 add。

4.2 路径引用

[tool.uv.sources]
statskit = { path = "../statskit" }

引用工作区之外的本地包——比如某个库还没搬进这个仓库,先临时挂上用用。相对路径、绝对路径都可以。

4.3 Git 引用

[tool.uv.sources]
statskit = { git = "https://github.com/your-org/statskit.git", rev = "v0.1.0" }

引用某个 Git 仓库的指定版本(rev 可以是 tag、分支或 commit),适合依赖「还没发到 PyPI、但远端仓库已经有了」的包。


到这里,「组织」的问题就绪:仓库有了、环境统一了、成员互相认识了。可工程一跑起来难免出岔子——数据不对、图表怪异、某行代码的结果离谱。print 调试法在小脚本里凑合,在多包工程里会把人逼疯。下半场换主题:断点调试。

5. 断点凭什么能让程序说停就停

5.1 解释器留的后门:sys.settrace

先认识被调试的目标,debug-demo/price.py,一个折后价计算:

# price.py:一笔订单的折后价计算

def final_price(original: float, discount: int) -> float:
    """discount 为折扣百分比,例如 75 表示七五折。"""
    rate = discount / 100
    saved = original * rate
    return original - saved


order = final_price(280.0, 75)
print(f"应付金额:{order:.2f}")

再看 debug-demo/mini_tracer.py——一个二十来行的「最小调试器」:

# mini_tracer.py:一个手写的「最小调试器」

import os
import sys

# 定位同目录下的目标脚本并读入源码
here = os.path.dirname(os.path.abspath(__file__))
target = os.path.join(here, "price.py")
source = open(target, encoding="utf-8").read()


def tracer(frame, event, arg):
    """每发生一次调试事件就触发:event 说什么事发生了,frame 是当时的栈帧。"""
    print(f"事件 {event}|{frame.f_code.co_name}() 第 {frame.f_lineno} 行")
    return tracer


sys.settrace(tracer)
exec(source, {"__name__": "__main__"})

运行 python mini_tracer.py,解释器的执行轨迹被一行行「直播」出来:

事件 call|<module>() 第 1 行
事件 line|<module>() 第 1 行
事件 line|<module>() 第 4 行
事件 line|<module>() 第 11 行
事件 call|final_price() 第 4 行
事件 line|final_price() 第 6 行
事件 line|final_price() 第 7 行
事件 line|final_price() 第 8 行
事件 return|final_price() 第 8 行
事件 line|<module>() 第 12 行
应付金额:70.00
事件 return|<module>() 第 12 行

解读一下:

  • sys.settrace(tracer) 注册追踪函数后,解释器每往前执行一步都会回调它,把「正在发生什么」实时递出来;
  • event 是事件类型:call(进入某个函数)、line(即将执行某行)、return(函数返回)……
  • frame 是当时的栈帧:第几行(f_lineno)、在哪个函数(f_code.co_name)、局部变量有哪些,全在里面;
  • tracer 把自己 return 回去,意思是「这个帧后续的行事件也继续通知我」。

调试器的全部秘密就这层窗户纸:断点,无非是「事件到了指定文件的指定行,就暂停」;单步,无非是「每走一行都停,等你发令」;变量面板,无非是暂停那一刻去 frame 里把局部变量读出来。debugpy 这类工业级工具,是在同一套机制之上把体验和工程化做到了极致。

5.2 一场调试里的四个角色

原理清楚后,再看你按下 F5 那一刻,台上一共有谁:

┌──────────────┐    DAP(JSON 消息)   ┌──────────────┐   sys.settrace   ┌──────────────┐
│  编辑器调试界面 │ ◄──────────────────► │  debugpy 适配器 │ ◄──────────────► │    目标程序    │
│ 断点·单步·变量  │                      │   (翻译官)    │    每步执行回调    │  (Python 进程)│
└──────────────┘                      └──────────────┘                  └──────────────┘
  • 编辑器调试界面:红点断点、变量面板、单步按钮——你直接面对的那一层;
  • debugpy(调试适配器):两头都懂的角色,对编辑器说 DAP,对 Python 用 settrace;
  • DAP(Debug Adapter Protocol):编辑器与适配器之间的 JSON 消息协议。有了这层通用语言,VS Code 不必懂 Python,debugpy 也不必懂 VS Code——换一个编辑器,适配器照样能对接;
  • 目标程序:真正执行代码的 Python 进程。

6. 单文件调试:launch 模式

调试单个文件最省事的方式是 launch(发射)模式:拉起程序、装上调试器、建立连接,全由编辑器一口气完成。以 debug-demo 为例,在 VS Code 里建 .vscode/launch.json:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "调试当前打开的文件",       // 配置名,调试面板下拉框里显示
      "type": "debugpy",                // 用哪个调试适配器:Python 自然是 debugpy
      "request": "launch",              // 启动模式:由编辑器负责拉起程序
      "program": "${file}",             // 调试谁:当前编辑器里打开的文件
      "console": "integratedTerminal"   // 程序输出走 VS Code 内置终端
    }
  ]
}

用法三步:在行号左边点一下,打下红色断点;按 F5 选中这个配置;程序跑到断点行自动停住。停住之后可以单步(F10 逐行、F11 进入函数)、看变量面板、看调用栈。debug-demo/ 里已经放好这份配置,可以直接体验。

7. 工程调试:attach 模式

launch 模式有个软肋:程序得由编辑器拉起。而 monorepo 里的应用必须靠 uv run 启动——依赖装在 workspace 的共享环境里,编辑器直接 launch 找不到那套环境。解法换个思路:程序照旧由 uv 启动,只是让它「挂起等连接」,编辑器再主动贴上去。这就是 attach(附加)模式。

7.1 第一步:装上调试器

uv add --dev debugpy

装进根项目的开发依赖组([dependency-groups])——整个工作区共享一个 .venv,装一次处处可用。

7.2 第二步:让应用「挂起待命」

在 insight-lab/Makefile 里加一个 debug 目标(记得把它补进 .PHONY):

debug:
    uv run --package report-cli python -m debugpy --listen 5678 --wait-for-client apps/report-cli/main.py

两个参数是关键:

  • --listen 5678:在本机 5678 端口开一个调试服务,等人来连;
  • --wait-for-client:客户端没连上之前,一行业务代码都不执行——保证断点从第一行就生效。

7.3 第三步:编辑器侧的附加配置

insight-lab/.vscode/launch.json:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "附加到 make debug",
      "type": "debugpy",
      "request": "attach",              // 附加模式:连到一个已经在跑的进程
      "connect": {
        "host": "localhost",
        "port": 5678                    // 与 --listen 的端口保持一致
      }
    }
  ]
}

7.4 两步启动

  1. 终端里 make debug:应用启动但原地挂起,等待连接;
  2. VS Code 里按 F5 选「附加到 make debug」:连上之后程序开跑,命中断点即停。

连不上先查两处:launch.json 里的端口和 --listen 是否一致;终端里程序是否真的停在 debugpy 的等待提示上。

attach 模式的妙处在于:不管程序怎么起的——uv、容器、还是远程服务器——只要它能开调试端口,编辑器就能贴上去。工程越大,这招越值钱。

8. 作业

习题一:复刻一个自己的 monorepo

照本章流程从零搭一遍(别直接抄 insight-lab,换个业务场景,比如「待办应用 + 日程库 + 提醒库」),要求:

  1. 根配置的 members 用上 glob 通配;
  2. 至少两个库包 + 一个应用,且库之间存在依赖(A 依赖 B);
  3. uv sync --all-packages 一键装齐,uv run --package 跑通,uv tree 能看到 (workspace) 标记;
  4. 把两个库包 uv build 构建出来,发布到阿里云制品仓库(第 22 章私有发布的做法)。

习题二:把断点调试接进 monorepo

在习题一的工程(或本章 insight-lab)上完成:

  1. 先跑一遍 debug-demo/mini_tracer.py,把 call / line / return 的事件流亲眼看过;
  2. 给应用加上 debugpy 监听目标,用 Makefile 收纳;
  3. 编辑器 attach 上去,在库包内部的函数里打断点,验证两件事:跨包的断点一样能停;暂停时变量面板能看到库包里的局部变量。

下一章:25 · Jupyter 交互环境——至此,从写代码到建工程、到打包发布、再到排障调试,「工程交付」这条线完整收官。接下来换挡进入数据实践:从交互式的 Jupyter 笔记本开始,看看数据探索为什么离不开它。