为什么我们还在讨论打包配置?
很多Python开发者都有过类似的经历:本地开发时一切正常,用 pip install . 安装到测试环境后,却报 ModuleNotFoundError。问题往往出在打包配置上——模块没被打包进去,或者依赖关系没声明清楚。打包配置看似是项目发布的最后一步,实则是决定代码能否被正确分发和使用的基石。从传统的 setup.py 到如今官方推荐的 pyproject.toml,这场演进不仅仅是换了个文件格式,更是Python社区对构建系统标准化、安全性和可维护性的一次深刻反思。
setup.py:灵活但脆弱的“脚本时代”
setup.py 本质上是一个可执行的Python脚本。它的强大之处在于其灵活性:你可以写条件判断、读取外部文件、甚至执行命令来动态生成版本号或依赖列表。
from setuptools import setup, find_packages
with open("README.md", "r", encoding="utf-8") as fh:
long_description = fh.read()
setup(
name="my_awesome_pkg",
version="0.1.0",
author="Dev Team",
description="A cool package",
long_description=long_description,
long_description_content_type="text/markdown",
packages=find_packages(where="src"),
package_dir={"": "src"},
install_requires=[
"requests>=2.25.0",
"click==8.0.0"
],
entry_points={
"console_scripts": [
"mycli=my_pkg.cli:main",
],
},
)
这种“代码即配置”的方式在早期非常流行,但也埋下了不少隐患。首先,它引入了执行风险。安装一个包时,setup.py 会被执行,这在供应链安全视角下是个潜在威胁。其次,配置逻辑分散在代码中,缺乏统一标准,不同项目的写法千差万别,工具难以进行静态分析和提供一致支持。最后,也是最常见的问题:开发者很容易遗漏关键配置,比如忘记使用 find_packages() 或者 package_dir 设置错误,导致源码目录(尤其是 src/ 布局)下的模块根本没有被打包。
pyproject.toml:声明式与标准化的新范式
为了解决上述问题,Python社区通过一系列PEP提案(主要是PEP 517、518和621)引入了 pyproject.toml。它的核心思想是将配置从“可执行脚本”转变为“声明式数据”。
一个最基础的、支持setuptools后端的 pyproject.toml 文件包含两个核心部分:
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "my_awesome_pkg"
version = "0.1.0"
authors = [
{name = "Dev Team", email = "dev@example.com"}
]
description = "A cool package"
readme = "README.md"
requires-python = ">=3.8"
dependencies = [
"requests>=2.25.0",
"click==8.0.0"
]
[project.entry-points.console_scripts]
mycli = "my_pkg.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
这种声明式写法的优势非常明显:
- 安全性提升:安装时无需执行任意代码,配置是静态数据。
- 工具友好:统一的TOML格式便于编辑器和工具(如IDE、linter、包管理器)解析和提供支持。
- 配置清晰:元数据(
[project])和工具特定配置([tool.*])分离,结构一目了然。 - 构建系统中立:通过
[build-system]声明构建后端,项目可以自由选择 setuptools、poetry、hatch 等,而不锁定于某个工具。
迁移不是复制粘贴:关键陷阱与解决方案
很多团队在迁移时,简单地把 setup.py 里的字典内容“翻译”成TOML键值对,结果在运行 pip install -e . 或 python -m build 时遭遇失败。以下是几个最常见的坑:
1. 忘记或错误声明 [build-system]
这是迁移失败的头号原因。如果没有正确声明 build-backend,pip等工具会回退到寻找 setup.py 的旧逻辑。你必须明确指定构建后端和其依赖。
2. 将元数据错放在 [tool.setuptools] 下
根据PEP 621,项目名称、版本、描述、依赖等核心元数据必须放在 [project] 段落中。[tool.setuptools] 仅用于配置setuptools特有的行为,比如包发现规则或数据文件包含。
3. 动态版本号读取的转变
在 setup.py 中,你可以用 attr: my_pkg.__version__ 动态读取版本。在 pyproject.toml 中,这需要通过 dynamic 字段声明,并在 [tool.setuptools.dynamic] 中配置来源。
[project]
name = "my_pkg"
dynamic = ["version"]
[tool.setuptools.dynamic]
version = {attr = "my_pkg.__version__"}
4. 包目录布局的显式声明
如果你使用 src/ 目录布局(源码放在 src/my_pkg/ 而非 my_pkg/),在 setup.py 中需要组合使用 package_dir 和 find_packages。在 pyproject.toml 中,这需要显式配置包查找规则。
新旧方案对比与选型建议
那么,在具体项目中该如何选择?下面的表格从几个关键维度进行了对比:
| 对比项 | setup.py (传统方式) | pyproject.toml (现代方式) |
|---|---|---|
| 配置本质 | 可执行Python脚本 | 声明式TOML配置文件 |
| 安全性 | 较低(安装时执行代码) | 高(静态配置) |
| 标准化程度 | 低,写法自由 | 高,遵循PEP 621 |
| 动态配置支持 | 支持良好(可编写任意逻辑) | 有限(通过dynamic字段声明) |
| 工具链兼容性 | 所有旧工具均支持 | 需要pip 21.3+, setuptools 61.0+ 等现代工具 |
| 推荐使用场景 | 维护遗留项目;需要复杂动态逻辑(如根据平台选依赖) | 所有新项目;计划现代化改造的旧项目 |
一个实用的判断标准是:如果你的项目启动于2023年之后,或者你希望采用最受官方和社区推荐、工具支持最好的方式,应毫不犹豫地选择 pyproject.toml。对于历史悠久的遗留项目,如果其 setup.py 包含大量复杂的动态生成逻辑,迁移成本可能较高,可以评估后再决定。但在大多数情况下,即使是旧项目,迁移到 pyproject.toml 带来的长期维护收益也远高于初期投入。
完整的现代打包发布流程
假设你已经写好了一个名为 cool_tool 的项目,并完成了 pyproject.toml 配置。从代码到PyPI的完整流程如下:
- 验证配置:在项目根目录运行
pip install -e .进行可编辑安装测试,确保所有模块和入口点都能正常工作。 - 安装构建工具:
pip install build twine - 构建分发包:运行
python -m build。这个命令会根据pyproject.toml生成源码包(sdist)和 wheel 包到dist/目录。它替代了旧的python setup.py sdist bdist_wheel。 - 本地测试:可以使用
twine check dist/*检查包格式,并用pip install dist/cool_tool-*.whl在虚拟环境中安装测试。 - 上传至PyPI:
twine upload dist/*命令会提示你输入PyPI账号密码或令牌,完成上传。
这个过程清晰、标准化,且与具体的构建后端(setuptools/poetry等)解耦。
写在最后:拥抱演进,但理解本质
从 setup.py 到 pyproject.toml 的转变,是Python生态系统走向更成熟、更健壮的一个缩影。它要求开发者从“写一段配置代码”的思维,转变为“声明项目元数据与构建需求”。
迁移过程中最大的挑战往往不是语法,而是对两种模式底层逻辑差异的理解。一旦你跨越了这个认知门槛,就会发现 pyproject.toml 带来的不仅仅是更简洁的配置文件,更是一种更可预测、更易于协作的项目管理方式。对于团队而言,这意味着更少的“打包玄学”问题和更顺畅的CI/CD流水线。现在,或许是时候检查一下你的旧项目,并开始规划它们的现代化打包之旅了。
原创文章,作者:,如若转载,请注明出处:https://fudengji.cn/article/154/