我把十个 Python 脚本收拾成了一个包
写 Python 最舒服的阶段是两三个脚本的时候。所有东西摊在一个目录里,python xxx.py 就能跑,改起来随心所欲。
麻烦出现在第十个脚本。这时候你会遇到:A 脚本 import B 脚本,B 又 import 回来;sys.path.append 写了七八处;配置文件路径是硬编码的相对路径,换个目录运行就崩;requirements.txt 里躺着二十个包,没人知道哪个还需要。
这篇记录一下把它收拾干净的过程。不是标准答案,是我自己踩过一遍的顺序。
第一步:定目录结构
最省事的做法是 src 布局:
project/ |
把代码放进 src/ 里,多一层目录,换来的是”必须安装才能 import”——这逼着你把包写对,而不是靠当前目录能不能碰巧 import 成功。这个约束很值。
第二步:pyproject.toml 替掉 setup.py
setup.py 那套写法已经过时了。现在的做法是把元数据写进 pyproject.toml,用 PEP 621 定义的字段:
[project] |
构建后端可以选 setuptools、hatchling、poetry、pdm,对一个小项目来说选哪个都能干活。我的建议是别纠结——挑一个用起来最不别扭的,先用着。今年年初冒出来的 uv 速度很夸张,但那时候它还只是个装包工具,构建这块我仍然交给 setuptools。
第三步:依赖分层
这一步的收益最直接。把依赖分成两类:
- 运行时依赖:不装就跑不起来的
- 开发依赖:测试、格式化、类型检查用的
以前这些东西混在一个 requirements.txt 里,结果是生产环境装了一堆用不上的工具,还多了几个攻击面。分层之后,pip install mytool 只装它真正需要的。
第四步:装工具链
这是我的组合,仅供参考:
- ruff:一个工具干掉了 flake8、isort,还有一部分 black 的活。快,配置少,这是我换掉旧组合的主要原因。
- mypy:类型检查。第一遍跑出来几百个错是正常的,别慌,逐模块开。
- pytest:测试。先给核心逻辑写几个,不用追求覆盖率。
- pre-commit:把上面这些挂在提交动作上,否则一定会忘。
第五步:类型注解
加类型注解一开始是负担,写着写着就变了——编辑器能给你补全、能提前报错,重构的时候敢动代码了。
这件事我拖了很久,理由是”小项目没必要”。后来发现真正的收益不在别人看不看得懂,而在我自己三个月后还改不改得动。没有类型的代码,改一个函数签名得先搜一遍调用点,再猜一遍每个参数到底是什么;有类型的话,编辑器直接告诉你哪里不匹配。
对我来说还有一个意外收获:加了类型之后,让 AI 编程工具改代码的成功率明显提高。它不需要满仓库猜这个变量到底是字符串还是列表。
第六步:CI
GitHub Actions 上一个最小配置就够:装依赖、跑 ruff、跑 mypy、跑 pytest。二三十行 YAML,换来的是”提交前不用自己想有没有忘事”。
我的建议是 lint 和 test 都设成必过,但 mypy 可以先设成不阻塞——类型改造是长期活,一上来就卡住提交,人会产生放弃的念头。
第七步:让别人也能装上
自己用的时候 pip install -e . 就够了。要让别人用上——或者让另一台机器上的自己用上——得先把包构建出来。
标准做法是构建 wheel:一条命令生成 .whl 文件,然后把文件或者索引地址发出去。小团队搞个内网简单索引就够(把 whl 丢在一个 HTTP 目录下),正规一点上私有 PyPI。
这一步会暴露前面所有没做好的地方:版本号没更新、依赖没写全、包里的数据文件没打进去、__init__.py 里的导入在安装后失效。我第一次构建就踩了两个——忘了把模板文件算进包数据,版本号还停在初始值。
另一个容易忽略的是”怎么发布”。手动构建上传,做两次就烦,而且一定会忘。把发布写成一个脚本或者一个 CI 任务固定下来,比记在心里可靠。
还有个细节:内部包最好把可追溯信息写进版本字符串,比如带上提交号。出问题的时候能一眼看到现场装的是哪个版本,省掉大量扯皮。
踩过的坑
- editable install 的坑:
pip install -e .之后改了pyproject.toml里的入口点,得重装才生效 - 相对 import 混乱:脚本直接跑和作为模块跑,import 行为不一样。放进 src 布局统一按模块跑就没这问题
- Windows 路径:拼路径一律用
pathlib,别自己拼字符串 __init__.py别堆东西:导出要克制,堆多了会导致循环 import- 密钥别进仓库:用
.env加.gitignore,或者交给 CI 的 secrets。我第一次差点把一个测试用的 token 提上去,文件是删了,但真提上去就得轮换一遍 - 库的依赖要写上下界:内部工具只写下界无所谓,库不设上界,用户迟早会因为上游大版本更新挂掉
- 换行符统一交给
.gitattributes:Windows 和 Linux 来回切的时候,别指望自己记得住
回报在三个月后
这套收拾下来,代码本身的功能一行没改,但可维护性完全是两个项目。写包结构这件事的回报不在今天,在三个月后你想改一个函数的时候——那次改动是从一个文件传到另一个文件,还是从三个地方各改一遍,差别很大。
工具的选择其实没那么重要。ruff 换 black、hatchling 换 poetry,这些决定的影响远小于”有没有测试”和”依赖有没有分层”。
补一句实际的感受:这套改造前前后后花了我两个周末,其中一多半时间花在补测试和填 pyproject.toml 的字段上,真正写业务代码的时间反而不多。但改完之后,我改动的胆子大了很多——这是看不见的收益。