多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

用Python实现轻量级构建系统:统一管理依赖、测试与发布

用Python实现轻量级构建系统:统一管理依赖、测试与发布 做Python项目做到一定规模你就会发现最花时间的往往不是写业务代码而是把代码“变成”一个能交付、能复现、能自动跑测试和打包的产物。我最近把原来手搓的一堆shell脚本、Makefile和README里的操作步骤重新用Python实现了一套工程构建系统。说白了就是用一套统一命令管理Python项目的依赖安装、环境清理、测试执行、产物打包和发布动作避免每次换台机器、拉个分支都要靠人肉敲一连串命令。这套系统不是什么高大上的CI平台也不是编译工具链它就是一个围绕Python项目生命周期做自动化的轻量框架。如果你的项目已经开始出现“手动执行步骤十来个”“同事跑不起来”“测试环境和生产环境行为不一致”这些问题那这篇内容值得你花十分钟看完里面包含我的设计思路、完整代码和踩坑记录。1. 先把构建系统要解决的真实问题讲透1.1 构建不是“跑一遍打包”而是整个交付链路很多人听到“构建系统”第一反应是编译、链接但Python项目的构建其实要宽得多。它至少包含环境准备、依赖锁定、静态检查、单元测试、资源收集、打包、版本标记、发布上传这些环节。每个环节单独拿出来都不难难的是让它们按固定顺序、在任意一台机器上、不依赖开发者个人操作习惯地跑完。我见过太多的Python项目测试怎么跑、包怎么打、发布前要执行什么全写在一份可能已经过期的README里。新同事接手后先装一堆包再手动跑几个pytest命令最后用twine上传中间只要一步不对结果就完全不可复现。这就是构建系统要解决的核心问题把“会做的人的操作经验”变成“项目自身可执行的行为规范”。一个真实的案例我之前维护过一个内部数据分析工具本机跑测试全绿但同事在Windows上拉下来后因为路径分隔符和venv路径不同直接跑不起来。排查半天发现是我在Makefile里写死了./venv/bin/pythonWindows下根本没有这个目录。如果早一点把构建动作收拢到Python脚本里用pathlib和跨平台判断这个问题根本不会出现。构建系统看着是“工程化”的事实际是帮团队节省“环境扯皮”的时间。1.2 为什么我坚持用Python而不是Makefile在决定用什么技术写构建系统时我反复对比过Makefile、Shell脚本和现成的Python工具最后的结论是构建逻辑也是业务逻辑应该用项目同语言维护而不是用一套“看起来简单但跨平台全是坑”的东西。方案优势实际痛点Makefile内置依赖关系写法简短缩进敏感Windows兼容性差调试不方便Shell脚本系统自带随处可跑参数处理弱路径空格易炸维护成本高Python脚本跨平台生态丰富调试直观首次运行有解释器开销但构建阶段不明显tox/poetry等专业工具功能全社区方案成熟定制复杂流程时反而受约束学习曲线陡我选择“Python 少量标准库”还有一个理由Python自带pathlib、subprocess、venv、tomllib从Python 3.11开始还能直接读pyproject.toml完全不需要第三方依赖就能把构建调度层写出来。相比MakefilePython脚本在出错时能给出真正的Traceback可以设断点甚至在CI日志里一眼看到是哪一行崩了。这个优势在实际维护时非常明显。当然这不是说要自研一套完整的包管理器。依赖解析和打包还是交给pip、build、twine这些专业工具我的构建系统只做“编排”和“统一入口”。简单说它像一个中央厨房的排单系统告诉每个工位什么时间做什么事但具体炒菜还是专业灶台来干。2. 环境和依赖管理构建系统的地基2.1 Python构建系统的目录结构怎么定所有工程化的第一步都是先定目录结构。目录结构一旦乱后面的所有任务都会在路径上踩坑。我推荐的结构是标准的src布局尤其是要打包成wheel或sdist的项目myproj/ ├── pyproject.toml ├── build.py ├── README.md ├── LICENSE ├── src/ │ └── myproj/ │ ├── __init__.py │ ├── core.py │ └── utils.py ├── tests/ │ ├── conftest.py │ └── test_core.py ├── scripts/ │ └── some_tool.py ├── dist/ ├── build/ └── .venv/src布局的好处是当你执行pytest或build时Python不会把项目根目录意外当成包导入避免出现“因为当前目录刚好有同名文件所以能跑换到别处就ImportError”的诡异问题。dist/和build/是构建产物目录应该被先生成出来再写入而不是提前提交到Git仓库。.venv/是虚拟环境目录同样要放进.gitignore。一个小经验scripts/目录放的是项目内的辅助工具比如数据初始化脚本、定时任务入口。这个目录不要和src/混在一起因为它不是要发布给用户的部分只是开发期或运维期的工具。构建系统里的“资源收集”任务会明确区分这两个目录避免把scripts/下的依赖打进正式产物。2.2 依赖锁定requirements.txt只该是“草稿”依赖管理是构建系统最容易翻车的一环。只写requirements.txt而且不锁版本等于告诉项目“每次安装我都给你一个惊喜”。早期我吃过这个亏两个同事先后装依赖装出来的版本不同一个能跑一个不能跑最后发现是requests的小版本不一致。后来我彻底切到pyproject.toml 锁定文件的方式。[project] name demo-build version 0.1.0 requires-python 3.10 dependencies [ requests2.31, click8.1, ] [project.optional-dependencies] dev [ pytest8.0, build1.0, ruff0.4, ] [tool.build-system] requires [setuptools68] build-backend setuptools.build_metadependencies里只放运行期必需的库dev里放构建和测试工具。这样生产环境安装时不会把pytest、ruff这些没必要出现的东西带进去。锁定文件我推荐用pip-compile生成pip-compile pyproject.toml --extra dev -o requirements.lock这条命令会把所有间接依赖的精确版本也解析出来生成一个完整的锁文件。如果你用的是较新的uv一条uv lock也能达到类似效果。锁文件一定要提交到Git仓库这是“可复现构建”的底线。构建系统里的deps任务只认锁文件不认手写的宽松requirements。2.3 虚拟环境构建系统要自己“收好”环境用虚拟环境这件事已经算常识了但我见过很多项目只是在README里写一句“请先创建venv”然后就没有然后了。构建系统应该自己管理虚拟环境的生命周期至少要做到“检查venv是否存在不存在就创建”。import os import subprocess import sys from pathlib import Path ROOT Path(__file__).resolve().parent VENV_DIR ROOT / .venv def venv_python(): 返回当前项目虚拟环境中的Python解释器路径。 bin_dir VENV_DIR / (Scripts if os.name nt else bin) python_path bin_dir / (python.exe if os.name nt else python) if not python_path.exists(): subprocess.run([sys.executable, -m, venv, str(VENV_DIR)], checkTrue) print(f[build] 已创建虚拟环境: {VENV_DIR}) return str(python_path)这段代码的关键在os.name nt判断Windows下venv解释器在Scripts/python.exeLinux和macOS在bin/python。很多人写的构建脚本只考虑Linux换到Windows就炸这个判断是最便宜的解决办法。创建虚拟环境时使用当前sys.executable作为基础解释器好处是用户用什么Python版本启动build.pyvenv就用什么版本不会出现“系统有多个Pythonvenv创建错了版本”的困惑。3. 任务编排把构建动作变成一条统一命令3.1 设计一个build.py调度器统一入口是我搭这套系统时最先确认的原则。不管你在本地还是CI不管你要跑测试还是打包都只执行同一个文件python build.py 任务名。这样做的最大好处是你不再需要同时维护shell脚本、Makefile和CI配置文件三套逻辑构建方式只有一份代码。核心调度器可以写得很朴素不用引入庞大的框架。我用一个装饰器收集任务再用一个函数解析依赖顺序import argparse import shutil import subprocess import sys import time from pathlib import Path ROOT Path(__file__).resolve().parent TASKS {} def task(name, deps()): def wrapper(fn): TASKS[name] {fn: fn, deps: deps} return fn return wrapper def run_command(cmd, **kwargs): print(f[build] { .join(cmd)}) return subprocess.run(cmd, cwdROOT, checkTrue, **kwargs)task装饰器把函数名和它的依赖关系注册到一个全局字典。run_command统一包了一层subprocess.run设置checkTrue后任何子命令失败都会立刻抛异常构建系统随即停止。这种“失败即退出”的设定很重要它保证不会在测试已经挂掉的情况下继续打包避免发布一个坏产物。3.2 常用任务逐个拆解clean、deps、test、buildclean是最不起眼但最实用的任务。Python构建经常出现“旧文件残留导致新产物有问题”的情况比如之前生成的.pyc、dist/下的旧wheel。我习惯把build/、dist/、.pytest_cache/和所有__pycache__都清掉task(clean) def clean(): for folder in [build, dist, .pytest_cache, .ruff_cache]: shutil.rmtree(ROOT / folder, ignore_errorsTrue) for pycache in ROOT.rglob(__pycache__): shutil.rmtree(pycache, ignore_errorsTrue)deps任务负责安装依赖。它必须依赖虚拟环境先存在所以我通常会让deps内部调用venv_python()再通过锁文件安装task(deps) def deps(): py venv_python() run_command([py, -m, pip, install, --upgrade, pip]) run_command([py, -m, pip, install, -r, str(ROOT / requirements.lock)])test任务跑pytest并把结果写到dist/下方便CI收集测试报告task(test, deps(deps,)) def test(): py venv_python() dist_dir ROOT / dist dist_dir.mkdir(exist_okTrue) run_command([ py, -m, pytest, tests/, -q, --junitxmldist/test-report.xml, ])build任务调用python -m build生成符合PyPA规范的wheel和sdist产物。这个任务同样依赖deps因为build这个包是开发依赖之一task(build, deps(test,)) def build(): py venv_python() run_command([py, -m, build, --sdist, --wheel, --outdir, str(ROOT / dist)])3.3 任务依赖解析别在任务里手动调用其他任务一开始我贪省事直接在test函数内部调用deps()结果统计执行时间时非常混乱而且容易造成重复执行。后来改成在装饰器里声明依赖并且写了一个拓扑排序式的解析函数def resolve(name, stackNone): stack stack or [] if name in stack: raise RuntimeError(f[build] 循环依赖: { - .join(stack [name])}) result [] for dep in TASKS[name][deps]: result.extend(resolve(dep, stack [name])) result.append(name) return resultresolve会把任务的依赖扁平化成一个有序列表。比如执行build它会先解析test而test又依赖deps所以最终顺序是deps - test - build。对于重复依赖我会在最终执行前用一个小循环去重exec_list [] for item in resolve(args.task): if item not in exec_list: exec_list.append(item)这样不管用户输入的是build还是deps build执行顺序都是确定且不重复的。构建系统一旦有了这种“确定性顺序”就不会再出现“我先跑了test再跑build就报错”的类问题。4. 多环境构建与发布流水线本地、CI、服务器用同一套脚本4.1 Python构建环境配置不要硬编码目录和参数构建系统要能在开发机、CI和服务器上跑就必须避免硬编码本机路径和敏感信息。我习惯在build.py顶部读环境变量BUILD_ENV用它加载不同配置import os BUILD_ENV os.getenv(BUILD_ENV, dev) CONFIG { dev: { skip_lint: True, publish: False, index_url: https://pypi.tuna.tsinghua.edu.cn/simple, }, ci: { skip_lint: False, publish: False, index_url: https://pypi.tuna.tsinghua.edu.cn/simple, }, prod: { skip_lint: False, publish: True, index_url: https://pypi.org/simple, }, } ACTIVE CONFIG[BUILD_ENV]这里的index_url可以直接传给pip命令用来解决依赖下载慢的问题。比如deps任务里如果配置了index_url就拼到pip install后面pip_cmd [py, -m, pip, install, -r, requirements.lock] if ACTIVE[index_url]: pip_cmd [--index-url, ACTIVE[index_url]]把配置放到环境变量和字典里而不是分散在各处脚本中主要目的是让不同环境的行为差异一眼可见。比如开发环境可以跳过lint但CI和发布环境必须跑完所有检查。回头看这比我在早期项目里用十几个if判断散落在各处要清晰得多。4.2 Python工程打包发布从wheel到一键上传打包和发布是构建系统里离“生产者”最近的一环。使用python -m build生成产物后我还会额外做一个check步骤先校验wheel长描述能否正常渲染再决定是否上传。task(publish, deps(build,)) def publish(): if not ACTIVE[publish]: print(f[build] BUILD_ENV{BUILD_ENV} 不允许发布跳过。) return run_command([twine, check, dist/*]) run_command([twine, upload, dist/*])发布前还有一个容易忽略的点版本号。我习惯把版本号放在src/myproj/__init__.py里构建系统在打包前自动读取它并检查Git工作区是否干净def read_version(): init_file ROOT / src / myproj / __init__.py text init_file.read_text(encodingutf-8) return text.split(__version__ )[1].split()[0] def check_git_clean(): result subprocess.run([git, status, --porcelain], capture_outputTrue, textTrue) if result.stdout.strip(): raise RuntimeError(工作区有未提交文件请先提交再发布。)这个清单不需要太多但“未提交文件禁止发布”这条规则救过我一次。有一次改了代码但没有提交直接在CI上发布了一个与新tag不一致的包后来排查时浪费了很多时间。现在发布前强制检查Git状态从源头上杜绝了这种低级错误。4.3 接入CI本地脚本直接用于远程构建我始终强调“本地和CI共用同一个构建入口”而不是在CI里重新写一遍安装、测试、打包步骤。以GitHub Actions为例我的CI文件可以精简到几乎没有业务逻辑name: build on: [push, pull_request] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.11 - name: Full build env: BUILD_ENV: ci run: | python build.py clean python build.py all这里的all任务是调度器里的一个聚合任务task(all, deps(clean, lint, test, build)) def all_tasks(): pass因为build本身依赖test所以执行all时不会重复跑测试。CI里只需要设置环境变量BUILD_ENVci构建系统就会自动使用ci配置。这样做最大的好处是本地跑不过的构建CI一定也跑不过CI能通过的构建本地大概率也能复现。两边看到的是同一个世界。5. 实际运行中的常见问题与排查技巧5.1 环境不一致导致“我这边明明可以”这可能是Python项目里最经典的幽灵问题。明明测试全绿换个环境就报ImportError或版本冲突。我总结的经验是先检查三件事是否用了venv、是否安装了锁文件里的精确版本、Python版本是否与requires-python一致。排查时可以加一条--verbose参数让deps任务打印实际安装的包版本task(deps) def deps(verboseFalse): ... if verbose: run_command([py, -m, pip, freeze])看到实际安装的版本后再和锁文件对比问题就清楚了。大多数“环境不一致”最终都会指向“某个依赖没有锁版本”所以我在项目里严格要求锁文件必须提交进仓库并且deps任务只认锁文件。5.2 Python脚本跨平台执行的路径坑Windows和Linux在路径上有两处本质差异一是分隔符二是venv目录名。我在build.py里只用pathlib.Path来拼接路径从不手写/或\。另一个坑是subprocess.run的参数传递永远不要传shellTrue也不要拼字符串命令直接用列表。这样既避免路径包含空格时被拆成多个参数也避免shell注入风险。# 正确写法 subprocess.run([str(python_path), -m, pytest, tests/], checkTrue) # 错误写法 subprocess.run(f{python_path} -m pytest tests/, shellTrue) # 路径有空格就炸其实在Windows上我踩过最疼的坑是venv_python()没有判断Scripts目录。第一次写的时候只检查了bin/python结果在Windows上反复创建虚拟环境每次build都重新装一遍依赖。后来加上os.name nt判断这个问题彻底消失。5.3 依赖安装慢、超时怎么办依赖安装慢不完全是网络问题也可能是pip在反复解析版本。解决办法有两个维度一是使用锁文件减少解析时间二是配置镜像源。在国内环境我通常在构建配置里把pip的index-url指向清华大学开源软件镜像站python build.py deps --index-url https://pypi.tuna.tsinghua.edu.cn/simple如果你不想每次手动传参也可以通过环境变量PIP_INDEX_URL设置。要注意的是这个配置属于环境相关不应该写死在项目仓库里而是放到构建配置字典或CI的环境变量中。我一般只在开发环境默认使用镜像源发布环境显式切换到官方源避免上传到公共仓库时出现奇怪的依赖替换。5.4 任务失败时如何保留现场和关键信息构建系统在CI里运行时最怕的是失败后看不到任何有用的日志。我习惯把每次构建写入一个dist/build.log同时输出到终端。实现很轻量不需要第三方库import logging logging.basicConfig( levellogging.INFO, format[build] %(message)s, handlers[ logging.StreamHandler(), logging.FileHandler(ROOT / dist / build.log, encodingutf-8), ], )每次子命令执行前我都用logging.info记录命令内容出异常时捕获CalledProcessError把它的stdout和stderr都记录到日志文件。这样即使CI只保留了最后几百行日志我依然能从build.log里看到完整的失败现场。5.5 构建状态摘要让脚本输出更易读一个成熟的构建系统在任务结束后应该给出一段清晰的状态摘要而不是一堆杂乱输出。我实现了一个简单的执行循环def run_tasks(task_names): for name in task_names: start time.time() TASKS[name][fn]() duration time.time() - start print(f[build] ✅ {name} 完成 ({duration:.2f}s))虽然我平时不提倡在技术文章里用符号表达情绪但在终端输出里加个状态标记非常实用能让人一眼看出哪些任务通过了。如果后续接入CI这段摘要也会作为构建日志的最后几行方便快速定位。下面是我整理的一份常见问题速查表供参考现象可能原因排查方式pytest找不到自定义模块没有使用src布局或缺少__init__.py检查项目结构与python -m pytest用法venv反复重建没有适配Windows的Scripts路径检查build.py中的venv_python判断pip安装版本不一致未使用锁文件执行pip-compile并提交requirements.lock构建报“Permission denied”权限或文件占用检查产物目录是否被其他进程占用CI通过但本机失败环境变量或Python版本不同对比BUILD_ENV和requires-python上传时twine检查失败README长描述格式问题执行twine check dist/*并修复文档格式6. 什么时候不必自己写现成工具与自研的取舍6.1 先用现成工具再决定要不要定制我虽然一直在讲自研构建系统但也不建议所有人上来就重复造轮子。如果你的项目就是一个标准的Python库没有复杂的发布流程和自定义检查那么直接用tox poetry或nox反而更省事。它们已经把多版本测试矩阵、虚拟环境管理、依赖切换这些事情做好了。这里我给一个简单的选型建议场景推荐方案标准库项目只需要跑测试和打wheelpyproject.toml build pytest甚至不用自研需要多Python版本测试矩阵tox 或 nox需要复杂任务编排且团队习惯维护Python脚本自研轻量build.py配合现有工具项目有大量内部流程要和公司系统交互自研构建系统暴露统一命令自研构建系统的优势在于“可编程”。当构建流程中出现“判断某个分支是否发布”“读取远端配置”“生成多个平台差异文件”这类逻辑时Makefile和YAML都会变得力不从心但Python脚本可以优雅处理。只要控制住复杂度不陷入“为了造工具而造工具”自研是值得的。6.2 从build.py演进出更通用的构建命令如果你和我一样维护多个Python项目可以把build.py的核心调度器抽成一个通用库再让每个项目只维护自己的任务文件。比如把task装饰器、resolve、venv_python放到一个包里项目里只写任务函数。这样既保留了每个项目的灵活性又复用了公共逻辑。我自己已经在内部做过这个演进一个叫pybuild的公共包负责任务注册、依赖解析、日志和虚拟环境各个项目只需要写类似task(deploy)的具体行为。这套方式比一开始就上生产级构建框架更贴合中小团队的节奏。如果你还没有构建系统可以先从复制本文的build.py开始先跑通clean、deps、test、build四个任务再按自己的流程扩展发布、检查和多环境配置。实话讲这套构建系统我维护了一年多最大的收益不是省了多少秒而是“构建方式”终于成了团队里可以讨论的代码而不是某个人的脑子。踩过最大的坑是早期把所有任务写在Makefile里换到Windows上就各种不对后来统一用Python实现后几乎没人再问“怎么构建”。如果你也想搞一套我的建议很简单从clean、deps、test、build这四件事开始哪怕只有几百行先让所有操作有一个唯一入口再慢慢加发布、矩阵和插件。这样一路扩展下来你会发现工程构建系统的价值远不只是“自动化”三个字这么简单。
返回列表