前言
setup.py曾经是 Python 项目打包的唯一入口。你在项目根目录放一个setup.py,调用里面的setup()函数声明项目信息,然后跑python setup.py install就能装到环境里。这套流程统治了十几年,网上大量教程至今仍在教它。
但它的时代基本结束了,而且结束得很硬:Python 3.12 起,标准库里的distutils包已被移除(PEP 632)。老代码里那句from distutils.core import setup在 3.12 上会直接抛ModuleNotFoundError: No module named 'distutils'。与此同时,python setup.py install这类直接调用的方式早已被官方标记为不推荐,取而代之的是基于 PEP 517 / 518 / 621 的「声明式配置 + 构建前端」模式。
本文按「入门 — 进阶 — 精通」三段走:先讲清setup.py是什么、为什么还能看到它;再讲如何把它迁移到声明式配置;最后讲现代的构建流程与产物。本文给的是迁移路径,不是让你继续用老命令。
一、入门:setup.py 是什么
setup.py是一个 Python 脚本,它调用打包工具的setup()函数来声明项目信息。最老一版的写法从distutils导入:
# 适用于 Python 3.11 及更早版本;Python 3.12 起 distutils 已被移除,此写法会报错
from distutils.core import setup
setup(
name="demo-lib",
version="0.1.0",
py_modules=["demo_lib"],
)后来人们普遍改用setuptools(它功能更强,支持依赖声明、入口点等):
# 适用于 Python 3.8+(需已安装 setuptools),但已不是推荐写法
from setuptools import setup, find_packages
setup(
name="demo-lib",
version="0.1.0",
packages=find_packages(),
python_requires=">=3.9",
install_requires=["requests>=2.31"],
entry_points={
"console_scripts": [
"demo-cli=demo_lib.cli:main",
],
},
)find_packages()会自动找出当前目录下的包(含__init__.py的目录)。install_requires声明运行依赖,python_requires声明支持的 Python 版本,entry_points里的console_scripts用来生成命令行入口。
这套写法今天仍然能跑,前提是环境里装了setuptools。它的问题是:把「项目元数据」和「构建逻辑」混在一个可执行的 Python 脚本里,构建过程因此变得不可控——脚本能做的事太多了。
二、为什么 distutils 没了
distutils是早期随 Python 一起分发的打包库,setuptools最初就是在它之上扩展出来的。两条线长期并存,职责重叠,维护负担却分散。PEP 632 决定把它从标准库移除:
- Python 3.10 起标记为废弃;
- Python 3.12 起正式移除,标准库里不再有
distutils; - 官方文档明确说明:「从 Python 3.12 起,导入
distutils的代码将不再工作」; - 对实在无法迁移的项目,官方给出的过渡办法是安装
setuptools——它仍然提供一个distutils供旧代码使用。
# 在 Python 3.12+ 上运行旧的 distutils 写法时会看到类似报错
ModuleNotFoundError: No module named 'distutils'看到这个报错,正确的反应不是去找一个distutils的第三方复刻包,而是把项目迁移到pyproject.toml。setuptools提供的distutils兼容层只是给迁移拖延时间的,不该当作长期方案。
三、进阶:从脚本到声明式配置
第一步迁移是「声明与逻辑分离」:把setup()的关键字参数搬进setup.cfg,让setup.py退化成一行调用。这是一种过渡形态。
# setup.cfg:声明式配置
[metadata]
name = demo-lib
version = 0.1.0
description = 一个演示用的小库
[options]
packages = find:
python_requires = >=3.9
install_requires =
requests>=2.31
[options.entry_points]
console_scripts =
demo-cli = demo_lib.cli:main# 适用于 Python 3.8+(需已安装 setuptools),仅用于兼容老工具链
from setuptools import setup
setup()这样做的好处是配置变成纯数据,不再有可执行逻辑;但setup.cfg是setuptools自己的格式,不是所有构建后端都认。再往前一步,就该用跨后端的pyproject.toml了。
四、精通:pyproject.toml 与 PEP 621
pyproject.toml是现在的标准配置文件。它由几个 PEP 共同定义:
| PEP | 引入的内容 |
|---|
| PEP 518 | [build-system]表:声明构建依赖与构建后端 |
| PEP 517 | 定义构建后端的标准接口,前端不再需要知道后端细节 |
| PEP 621 | 规范[project]表:项目元数据的标准字段 |
# pyproject.toml:现代写法
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "demo-lib"
version = "0.1.0"
description = "一个演示用的小库"
readme = "README.md"
requires-python = ">=3.9"
dependencies = [
"requests>=2.31",
]
[project.optional-dependencies]
dev = ["pytest>=7"]
[project.scripts]
demo-cli = "demo_lib.cli:main"
[tool.setuptools.packages.find]
where = ["src"]对比一下迁移前后的对应关系:
setup.py里的写法 | pyproject.toml里的写法 |
|---|
name="demo-lib" | [project]的name |
version="0.1.0" | [project]的version |
install_requires=[...] | [project]的dependencies |
python_requires=">=3.9" | [project]的requires-python |
entry_points={"console_scripts": [...]} | [project.scripts] |
packages=find_packages() | [tool.setuptools.packages.find] |
extras_require={"dev": [...]} | [project.optional-dependencies] |
迁移之后,setup.py可以整个删掉。构建命令也随之变化——不再调用setup.py,而是用构建前端。
五、构建与验证
现代流程用 PyPA 的build工具(第三方工具,不是标准库)在隔离环境里调用后端构建:
# 安装构建工具
python -m pip install build
# 构建,产物在 dist/
python -m builddist/下会得到 wheel(.whl)和源码包(.tar.gz)。随后在干净环境里装 wheel 验证:
python -m pip install dist/demo_lib-0.1.0-py3-none-any.whl
# 验证包能导入、命令行入口能用
python -c "import demo_lib; print(demo_lib.__name__)"
demo-cli 3 4不要再执行python setup.py install。这个命令绕过了构建前端和隔离机制,会把中间文件留在源码目录,也得不到可复现的产物。同理,python setup.py sdist、python setup.py bdist_wheel这类直接调用也应替换为python -m build。
推荐配套的 src 布局:
demo-lib/
├── pyproject.toml
├── README.md
└── src/
└── demo_lib/
├── __init__.py
├── core.py
└── cli.py代码放在src/demo_lib/,配置里通过[tool.setuptools.packages.find]的where = ["src"]告诉工具去哪里找包。这种布局强制「必须先安装才能导入」,能提前暴露打包遗漏。
六、迁移实战:一个完整的小项目
# 适用于 Python 3.9+,文件 src/demo_lib/core.py
def add(a, b):
"""返回两数之和。"""
return a + b# 适用于 Python 3.9+,文件 src/demo_lib/__init__.py
"""demo_lib:演示从 setup.py 迁移到 pyproject.toml。"""
__version__ = "0.1.0"
from .core import add
__all__ = ["add", "__version__"]# 适用于 Python 3.9+,文件 src/demo_lib/cli.py
import sys
from .core import add
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
if len(argv) != 2:
print("用法:demo-cli 数字1 数字2")
return 2
try:
left, right = (int(x) for x in argv)
except ValueError:
print("两个参数都必须是整数")
return 2
print(add(left, right))
return 0
if __name__ == "__main__":
raise SystemExit(main())逐行推演:__init__.py用相对导入from .core import add把函数提到包的名字空间,外部from demo_lib import add即可使用。cli.py的main默认读命令行参数,允许调用方传入列表;参数个数不对或无法转成整数时打印提示并返回退出码 2;成功时打印结果并返回 0。raise SystemExit(main())把返回码交给操作系统。
迁移步骤按顺序是:
# 1. 新建 pyproject.toml,把 setup.py 的字段翻译过去
# 2. 代码挪进 src/ 布局(可选但推荐)
# 3. 删掉 setup.py(确认配置完整的前提下)
# 4. 构建并验证
python -m pip install build
python -m build
python -m pip install dist/demo_lib-0.1.0-py3-none-any.whl
demo-cli 3 4第 4 步打印 7,说明[project.scripts]的入口配置生效。至此这个项目已经不再依赖setup.py。
常见坑点
坑点 1:在 3.12 上照抄from distutils.core import setup。❌ 老教程里的这行在Python 3.12 及以上直接抛ModuleNotFoundError: No module named 'distutils'(PEP 632 已把 distutils 移出标准库)。 ✅ 迁移到pyproject.toml加setuptools后端;只有临时救急时才依赖setuptools提供的distutils兼容层。
坑点 2:继续用python setup.py install。❌ 这条命令绕过构建前端与隔离,产物不可复现,中间文件堆在源码目录。 ✅ 用python -m build构建,再python -m pip install dist/xxx.whl安装。
坑点 3:以为setup.cfg是通用的跨后端标准。❌ 把全部配置写成setup.cfg,换构建后端后配置读不出来——它是setuptools的格式,不是跨后端的。 ✅ 元数据放pyproject.toml的[project](PEP 621);只有 setuptools 特有的细节才放进[tool.setuptools]。
坑点 4:[build-system]的requires与build-backend对不上。❌requires里没写对应后端包,或后端名拼错,构建时报找不到后端。 ✅ 二者保持一致:用 setuptools 时requires = ["setuptools>=61"]、build-backend = "setuptools.build_meta"。
坑点 5:删了 setup.py 却没把字段搬全。❌ 删掉setup.py前忘了迁python_requires或entry_points,装完发现版本不受限、命令行入口不存在。 ✅ 按前文的对应关系表逐条核对,尤其是requires-python与[project.scripts]。
坑点 6:src 布局下忘了配置查找路径。❌ 代码放src/demo_lib/,但配置里没有[tool.setuptools.packages.find]的where = ["src"],构建出来的 wheel 里没有包。 ✅ 补上where = ["src"],构建后解包检查 wheel 内容确认包在内。
坑点 7:以为build和twine随 Python 附带。❌ 没安装就python -m build——抛No module named build。 ✅ 先python -m pip install build;要上传再python -m pip install twine。两者都是 PyPA 的第三方工具。
坑点 8:只在源码目录里测,从没真正装过。❌ 一直靠python -c "import demo_lib"(因为恰好在项目根目录)验证,发布后用户安装却导入失败。 ✅ 构建 wheel、在干净环境里安装后再验证导入与入口,这才是打包的最终验收。
总结
| 阶段 | 配置载体 | 构建方式 | 状态 |
|---|
| 最老 | setup.py(distutils) | python setup.py install | 3.12 起 distutils 已移除 |
| 过渡 | setup.py(setuptools) | python setup.py ... | 仍可运行,但不推荐 |
| 半声明式 | setup.cfg+ 空setup.py | 同上 | setuptools 专有格式 |
| 现代 | pyproject.toml(PEP 621) | python -m build | 推荐 |
从setup.py到pyproject.toml,本质上是从「可执行脚本」走向「声明式数据 + 标准构建接口」。理解了这个转向,就明白了为什么老教程里的命令今天会报错,也知道了迁移该往哪个方向走:把元数据写进[project],把构建后端写进[build-system],用python -m build出产物,用干净环境安装验证。这四步走完,项目就站到了现代打包的地基上。