marimo 仓库的 pytest-changed 插件:基于 Ruff 依赖图精准运行受影响测试
【免费下载链接】marimoA reactive notebook for Python — run reproducible experiments, query with SQL, execute as a script, deploy as an app, and version with git. Stored as pure Python. All in a modern, AI-native editor.项目地址: https://gitcode.com/GitHub_Trending/ma/marimo
本文介绍 marimo 开源仓库中内置的 pytest 插件packages/pytest_changed:它通过git diff找出你改动过的 Python 文件,再利用 Ruff 的analyze graph能力构建项目依赖图,经 BFS 图遍历定位所有受影响文件,最终只运行其中真正可能受影响的测试。读完本文,你将掌握该插件的完整用法(对比分支、预览、包含未改动测试、限定目录)、全部命令行参数,以及其底层五个阶段的源码级实现原理,可直接在 marimo 或任何基于 git + pytest 的 Python 项目中落地使用。
插件解决的问题:全量测试的浪费
在一个大型 Python 仓库中,每次提交前跑完整测试套件非常耗时。以 marimo 仓库为例,其 pyproject.toml 定义了test与test-optional两组依赖,测试覆盖了运行时(tests/_runtime/)、AST 编译(tests/_ast/)、服务端(tests/_server/)、插件(tests/_plugins/)等几十个目录,全量回归的成本很高。
packages/pytest_changed的定位是按变更智能缩小测试范围:它分析你的 git 变更,通过依赖图搜索找出所有受变更影响的文件,然后只运行可能被影响的测试。它不依赖任何启发式规则(如"改了哪个模块就测哪个模块"),而是基于 Ruff 对 Python 导入关系(包括字符串形式的导入)的静态分析,做到既有精确度又避免遗漏下游依赖方。
快速上手:激活与基本用法
该插件以 pytest 内置插件(-p)方式激活,无需安装额外包。在 marimo 仓库根目录执行:
pytest -p packages.pytest_changed --changed-from=main--changed-from=main指定与main分支对比,找出自main以来发生变更的文件,并只运行受影响的测试。
使用 uv 运行(推荐,可同时锁定 Python 版本与依赖组):
uv run --python 3.12 --group test pytest -p packages.pytest_changed --changed-from=main tests/这里--python 3.12指定解释器版本,--group test使用 pyproject.toml 中[dependency-groups]定义的test依赖组(包含pytest~=9.0.2、pytest-picked>=0.5.1等),tests/作为测试根目录参数。
对比不同的 git 引用
插件默认支持任意 git ref,常见场景:
# 对比 HEAD:检测暂存区(staged)以来的改动 pytest -p packages.pytest_changed --changed-from=HEAD # 对比远程分支 pytest -p packages.pytest_changed --changed-from=origin/main从源码看,--changed-from的值会被原样传给git diff --name-only <ref>(见 packages/pytest_changed/init.py),因此只要是 git 能解析的引用(分支名、tag、HEAD~3等)都可以使用。
预览将要运行的测试
不想真正执行测试时,用 pytest 自带的--collect-only仅收集并打印被选中的用例:
pytest -p packages.pytest_changed --changed-from=main --collect-only tests/这在确认插件选集是否符合预期、或在 CI 调试时非常有用。
包含未改动的测试
--include-unchanged会在受影响的测试之外,额外运行所有其他测试,相当于"受影响测试优先、全量兜底":
pytest -p packages.pytest_changed --changed-from=main --include-unchanged tests/值得注意的实现细节是:一旦设置了该选项,插件会直接跳过变更检测与依赖图构建(这两步是整个流程中最昂贵的部分),因为最终反正要跑全部测试。对应源码在pytest_configure中:
if config.getoption("include_unchanged"): print_("--include-unchanged set - running all tests") config.option.changed_test_files = None return见 packages/pytest_changed/init.py。
限定到指定测试目录
插件尊重 pytest 常规的路径过滤语义,你可以在命令尾部传入目录,将选集进一步约束到该目录内(该目录会同时成为测试文件过滤的基准):
pytest -p packages.pytest_changed --changed-from=main tests/_runtime/底层通过get_test_base从 pytest 调用参数中解析第一个存在的目录作为test_base,只有位于该目录下的受影响文件才会被视为测试文件(见 packages/pytest_changed/init.py)。
配置选项一览
插件在pytest_addoption中注册了两个选项,归属ruff-graph参数组(见 packages/pytest_changed/init.py):
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
--changed-from=<ref> | store | None | 要对比的 git 引用(如HEAD、main、origin/main)。该选项是激活插件的必要条件,未提供时插件不执行任何分析,pytest 按常规模式运行 |
--include-unchanged | store | False | 布尔开关,值为true/1/yes(不区分大小写)时开启。开启后除受影响测试外还运行全部未受影响的测试 |
两个选项的解析行为也可以从源码确认:--changed-from未设置时pytest_configure直接return(L238-L241);--include-unchanged使用type=lambda x: x.lower() in ("true", "1", "yes")解析字符串布尔值(L44)。
工作原理:五个阶段的源码级剖析
插件的执行逻辑完整实现在 packages/pytest_changed/init.py(约 376 行,无第三方依赖,仅用标准库subprocess/json/collections.deque与 pytest API)。其流程分为五步:
阶段 1:用 git diff 找出变更文件
get_changed_files执行git diff --name-only <ref>,只保留存在且后缀为.py的文件:
result = subprocess.run( ["git", "diff", "--name-only", ref], cwd=repo_root, capture_output=True, text=True, check=True, )见 packages/pytest_changed/init.py。该函数同时返回一个critical_changed标志——如果变更文件中包含pyproject.toml这类影响所有测试的配置文件,插件会放弃缩小范围,直接运行全部测试(L272-L279)。当前仓库中critical_files只包含pyproject.toml(L62),因为它集中定义了依赖、pytest 配置与打包元数据,改动它意味着任何测试的运行时环境都可能变化。
阶段 2:用 ruff analyze graph 构建依赖图
get_dependency_graph调用uvx运行固定版本的 Ruff:
[ "uvx", "ruff@0.15.18", "analyze", "graph", "--detect-string-imports", "--direction", direction, ".", ]见 packages/pytest_changed/init.py。几个关键决策:
- 固定
ruff@0.15.18:源码注释明确说明,因为 Ruff 的analyze graph尚属实验性功能,固定版本可避免输出格式或行为变化带来的解析失败(L118-L120); --detect-string-imports:让 Ruff 额外识别字符串形式(动态)导入,避免遗漏这类隐式依赖;--direction dependents:图的方向是"谁依赖了我",这正是寻找下游受影响文件的正确方向;- 输出为 JSON,解析后统一转换为绝对路径再参与后续遍历(L135-L145)。
阶段 3:BFS 图遍历找出所有受影响文件
find_affected_files以变更文件为起点做广度优先搜索(BFS),沿"dependents"边扩散,用visited集合处理环与去重:
affected: set[Path] = set(changed_files) visited: set[str] = set() queue: deque[Path] = deque(changed_files) while queue: current = queue.popleft() ... dependents = dependency_graph.get(current_str, []) for dependent in dependents: ...见 packages/pytest_changed/init.py。由于 Python 模块间依赖可能形成环(A 导入 B、B 也导入 A),visited集合保证了遍历必然终止;结果集合包含变更文件本身及其全部间接下游,例如改动marimo/_runtime/cell.py后,直接导入它的模块、再导入这些模块的模块都会被逐层波及。
阶段 4:过滤出测试文件
find_test_files按三条规则过滤受影响文件:文件存在、文件名以test_开头(marimo 的测试命名约定)、且位于test_base目录之下(packages/pytest_changed/init.py)。test_base默认取 pytest 调用参数中第一个存在的目录,否则回退到仓库根目录(L192-L207),这也是上文中"限定目录"用法能生效的原因。
阶段 5:运行选中的测试
插件通过两个 pytest hook 完成最后的选集注入:
pytest_configure:执行上述全部分析。找到受影响测试后,将文件列表写入config.option.changed_test_files,并把config.args直接替换为受影响测试文件路径(L316-L322)。分析过程中的关键决策都会打印到终端(变更文件清单、受影响文件数、选中的测试文件),方便人工核验;pytest_collection_modifyitems:收集完成后按changed_test_files对用例做二次筛选。三种分支语义清晰(L338-L376):changed_test_files is None:全量运行(对应--include-unchanged或配置文件变更的场景);- 空集合:所有用例被标记为
skip,跳过理由为Not affected by changes from <ref>; - 非空集合:只保留属于受影响文件的用例,其余通过
pytest_deselected钩子排除,并打印"运行 N 个用例、跳过 M 个用例"的摘要。
此外,仓库根目录通过git rev-parse --show-toplevel获取(L246-L254),保证在子目录中执行命令也能正确定位仓库。
仓库内的工程化实践:跨版本矩阵脚本
marimo 没有止步于插件的裸调用,而是将其封装进了 scripts/pytest-changed.sh:该脚本在Python 3.10 / 3.11 / 3.12 / 3.13四个版本、test-optional依赖组上循环执行插件测试,并对每个版本汇总通过/失败结果。其核心调用为:
uv run --python "$PY_VERSION" --group "$GROUP" pytest tests/ \ -v \ -k "not test_cli" \ --durations=10 \ -p packages.pytest_changed \ --changed-from="$CHANGED_FROM" \ --include-unchanged=false \ --picked=first \ "$@"见 scripts/pytest-changed.sh。其中:
--python/--group/--from分别控制版本、依赖组与对比引用(默认main);--picked=first来自test依赖组中的pytest-picked插件,与 pytest-changed 配合使用;--include-unchanged=false显式关闭兜底全量,确保矩阵测试只跑受影响用例;- 结尾的
"$@"允许透传额外 pytest 参数(如-k 'test_foo' -x)。
在 CI 场景下,插件源码还对"对比引用不存在"给出了明确提示:如果 checkout 时未拉取完整历史(例如 GitHub Actions 的actions/checkout默认浅克隆),git diff会失败,此时需要在检出步骤配置fetch-depth: 0(见 packages/pytest_changed/init.py 中的错误信息)。
使用边界与注意事项
结合源码可以总结出以下适用前提与限制,帮助你在自己的项目中正确使用:
- 必须位于 git 仓库内:插件依赖
git rev-parse --show-toplevel,非 git 目录会直接以错误退出(L253-L254); - 只追踪
.py文件:git diff结果中非 Python 文件(如前端.ts、配置.json)不会触发测试选择,除非命中pyproject.toml这类关键文件(L85); - 测试文件按
test_*.py命名约定识别:如果你的测试命名不符合该约定,需要自行调整find_test_files中的过滤逻辑(L226-L230); - 依赖图依赖 Ruff 的静态分析:
ruff analyze graph目前仍标记为实验性,插件通过固定ruff@0.15.18版本规避不稳定性;导入关系极为动态(如运行时动态拼接模块名)的场景可能无法被完整捕捉; --include-unchanged会短路整个分析:它不是"受影响测试优先执行",而是"全部执行",只是用选项语义表达了"不遗漏任何测试"的兜底诉求(L259-L262)。
总结
packages/pytest_changed是 marimo 仓库中一个"小而完整"的工程工具:核心逻辑全部集中在 packages/pytest_changed/init.py 单文件内,通过git diff+ruff analyze graph+ BFS 遍历 + pytest hook 的组合,实现了依赖感知的精准测试选集,并配合 scripts/pytest-changed.sh 在多 Python 版本矩阵中落地。无论你是 marimo 的贡献者,还是想在自有 Python 项目中复刻"改多少、测多少"的开发体验,都可以直接以该插件为模板——其无第三方运行时依赖、仅依赖 git 与 uvx 的设计,让移植成本几乎为零。
【免费下载链接】marimoA reactive notebook for Python — run reproducible experiments, query with SQL, execute as a script, deploy as an app, and version with git. Stored as pure Python. All in a modern, AI-native editor.项目地址: https://gitcode.com/GitHub_Trending/ma/marimo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考