写 Python 最舒服的阶段是两三个脚本的时候。所有东西摊在一个目录里,python xxx.py 就能跑,改起来随心所欲。

麻烦出现在第十个脚本。这时候你会遇到:A 脚本 import B 脚本,B 又 import 回来;sys.path.append 写了七八处;配置文件路径是硬编码的相对路径,换个目录运行就崩;requirements.txt 里躺着二十个包,没人知道哪个还需要。

这篇记录一下把它收拾干净的过程。不是标准答案,是我自己踩过一遍的顺序。

第一步:定目录结构

最省事的做法是 src 布局:

project/
├─ pyproject.toml
├─ src/
│ └─ mytool/
│ ├─ __init__.py
│ ├─ cli.py
│ └─ core.py
└─ tests/
└─ test_core.py

把代码放进 src/ 里,多一层目录,换来的是”必须安装才能 import”——这逼着你把包写对,而不是靠当前目录能不能碰巧 import 成功。这个约束很值。

第二步:pyproject.toml 替掉 setup.py

setup.py 那套写法已经过时了。现在的做法是把元数据写进 pyproject.toml,用 PEP 621 定义的字段:

[project]
name = "mytool"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["requests"]

[project.optional-dependencies]
dev = ["pytest", "ruff", "mypy"]

构建后端可以选 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 的字段上,真正写业务代码的时间反而不多。但改完之后,我改动的胆子大了很多——这是看不见的收益。