setuptools完整指南:构建、打包与发布Python包

发布时间:2026/10/17 14:00:34
setuptools完整指南:构建、打包与发布Python包
做 Python 开发的人不管你是天天写脚本的还是维护过几个正经开源的早晚会遇到同一个问题代码写好了怎么让别人一条pip install就用上答案基本绕不开setuptools。这个库可以说是整个 Python 打包分发生态里的“老地基”绝大多数第三方包、公司内部工具、甚至你自己随手写的那个小工具最终都是靠它构建成可分发的安装包再送到包索引仓库里供人下载。今天这篇就完整聊聊 setuptools 怎么用、为什么要这么用以及我实际踩过的那些坑。这篇文章适合两类人一类是刚写完自己的第一个“像样”的库想发布出去给朋友或同事用但面对 setup.py、pyproject.toml、wheel 这些名词一头雾水的新手另一类是已经在用 setuptools但每次都是复制粘贴别人的配置出了问题只会pip install -e .碰运气的开发者。我会把从“零配置”到“发布上线”的全流程拆开讲透包括版本号规范、入口点、数据文件打包这些容易被忽略但实际影响巨大细节。1. 先搞清楚setuptools 在整个打包生态里的位置1.1 为什么几乎每个 Python 库都绕不开它先把概念捋清楚。Python 本身只是一个解释器当你说“我写了一个 Python 库”的时候本质上你写的是一堆.py文件。要让别人能方便地使用这堆文件就得把它们按约定组织好、压缩或归档成某种格式这个动作就叫“构建”。而pip install xxx拉下来的那个xxx其实也是一个已经被构建好的包。setuptools 干的事就是“把项目里的代码、资源、元信息汇总成一个符合规范的可分发文件”。它提供了一套描述项目信息的语法像 name、version、dependencies也提供了收集代码、生成发行版的工具函数。这么说可能有点抽象你记住一句话setuptools 就是 Python 项目的“打包员”它决定了你这个项目的名字、版本、依赖、入口命令最终以什么形态出现在别人的机器上。为什么说“绕不开”因为当前 pip 在安装大多数包的时候默认使用的构建后端就是 setuptools。即便某些库换用了更新的构建工具比如 hatchling、flit它们内部也大量借鉴了 setuptools 的约定。更重要的是互联网上大量历史遗留项目的配置都是基于 setuptools 写的你在工作里接手老项目时几乎必然会撞见 setup.py。理解了 setuptools等于拿到了理解所有 Python 打包方案的钥匙。1.2 构建后端选型setuptools 与 hatchling、flit、poetry 的取舍每次讲到 setuptools总有人问那 Poetry、Hatch 不是更好吗我用 Poetry 管理依赖不香吗我的看法是工具没有绝对的好坏只有合不合适。setuptools老牌、稳定、生态最广。几乎所有网上教程、历史项目、企业旧代码都基于它。缺点就是有些配置写法比较绕比如package_data和MANIFEST.in就容易把人搞晕。hatchling新派配置干净对现代项目结构比如 src 布局支持得很好但你在遇到特殊情况时找到的参考方案较少。flit极简适合纯 Python 小项目但它刻意不支持某些复杂包装场景比如 C 扩展。poetry它不只做打包还管依赖解析、虚拟环境是个“全家桶”。用起来爽但如果你想嵌入 CI、发布到私有仓库或者给别人看的示例反而多了一层转换成本。如果你只想把代码安全、通用、容易维护地发出去setuptools 依然是最稳的选择。尤其当你的项目要发布很多年或者需要兼容各种奇怪的老环境时setuptools 的兼容性优势更明显。1.3 理解“构建”到底在构建什么sdist 与 wheel在动手配置之前还得认识两个关键词sdist 和 wheel。它们都是 setuptools 产出的“分发格式”但形态和用途完全不同。sdist源码分发包后缀是.tar.gz里面装的是你的全部源码、配置文件、README、license以及构建所需的信息。别人拿到 sdist 后必须在他的机器上执行构建才能生成可安装的东西。它像“毛坯房交付”或“菜谱”需要对方具备一定的“动手能力”。wheel轮子预构建分发包后缀是.whl本质上是一个 ZIP 压缩包里面的文件结构已经按照 Python 安装规则排好pip 拿到后直接解压、移动文件就能用。它像“精装房”或“半成品菜”不需要对方有构建环境。所以发布的时候标准做法是两种都生成。sdist 方便需要改源码的人二次构建wheel 让绝大多数用户秒装。setuptools 本身不会直接给你生成 wheel需要一个叫wheel的库配合现在通过python -m build命令可以直接一次性产出两种格式后面我会演示。打个比方sdist 像是把自己的菜谱给别人对方得先学会做这道菜wheel 像是做好封装好的料理包对方打开、加热就能吃。大多数用户要的是后者所以现在 wheel 才是主流分发格式。2. 从零开始配好一个包的“身份信息”2.1 pyproject.toml 与 setup.py 怎么选现在你打开一个开源项目可能会看到setup.py也可能会看到pyproject.toml还有的两种都有。这是什么情况简单梳理下历史。早年间Python 打包的“标准动作”就是在项目根目录放一个setup.py里面调用setuptools.setup()函数传一堆参数。这套机制跑了很多年但有个问题如果你想定义“用哪个构建后端来构建这个项目”setup.py 表达不了。后来社区推出了 PEP 517/518提出了统一的项目构建入口声明也就是pyproject.toml。这个文件里可以用[build-system]表声明“我用 setuptools 还是别的工具来做构建”。所以现在推荐的写法是用 pyproject.toml 声明构建系统项目的元信息也尽量写进 pyproject.toml 里的[project]表。setup.py 可以退化成一行兜底兼容代码甚至在纯 pyproject.toml 配置下都不需要存在。我个人的建议新项目直接上 pyproject.toml。除非你的项目还有某些极其怪异的动态逻辑必须依赖执行 Python 代码来生成比如根据环境变量动态改配置否则完全不需要手写 setup.py。这样配置更可读、更容易静态分析也能避免 setup.py 里那些偷偷 print、偷偷读文件的操作后续维护成本低很多。2.2 核心字段逐个拆解不管是 setup.py 还是 pyproject.toml描述一个包无非就是几类信息身份、依赖、内容、入口点。下面按 pyproject.toml 的写法逐个说明。[project] name myutil version 0.1.0 description 一个用于演示的 Python 工具包 readme README.md requires-python 3.8 license {text MIT} authors [ {name 某开发者, email devexample.com}, ] dependencies [ requests2.20, click8.0, ]name包的“身份证号”。它会出现在pip install myutil里命名规则是小写字母、数字、短横线或下划线一旦发布到了公共索引这个名字基本就永久占用了起名务必想清楚。version版本号不是随便填的。必须符合 PEP 440 规范比如1.0.0、2.1.0rc1、1.2.3.post1。要是格式非法构建不一定报错但上传或安装阶段可能遇到各种奇怪问题。readme指向 README 文件包索引仓库的展示页面会自动渲染它这对项目形象很重要。requires-python声明支持的最低 Python 版本。别小看这行它能让 pip 在解析依赖时自动跳过你支持的版本范围避免用户装了以后才发现跑不起来。dependencies运行时依赖列表。注意这里与requirements.txt不同requirements.txt更多是“环境锁定”而dependencies是“声明这个包能跑起来需要哪些库”通常写宽松的版本区间而不是钉死。2.3 包内文件怎么被识别packages 与 package_dir有了身份信息接下来最关键的问题你的.py文件到底怎么被装入包里最容易犯的错是只在配置里写好了名字和版本却忘了告诉 setuptools“你的代码文件在哪个目录”。如果你用的是扁平的目录结构比如项目根目录下直接放一个myutil/文件夹那么配置写[tool.setuptools.packages.find]就可以自动发现。[tool.setuptools.packages.find] include [myutil*]这段配置的意思是扫描当前目录或指定目录下所有符合myutil*模式、含有__init__.py的目录把他们当作 Python 包收录。但如果你采用更规范的 src 布局也就是把代码放在src/myutil/下就需要额外说明包根在哪[tool.setuptools.packages.find] where [src]src 布局的好处是项目根目录被你放多少杂七杂八的测试文件、示例脚本都不会被误收进包里能避免很多“为什么发布了但包不完整”之类的问题。我强烈建议新项目用 src 布局。2.4 数据文件、脚本与入口点配置一个完整的包往往不止有.py文件还可能带配置模板、静态资源、图标甚至需要提供一个“命令行工具”。这些怎么搞先说数据文件。在 wheel 包中包目录内的非.py文件默认不会被自动包含需要显式声明。如果你的数据文件就是放在某个包目录下面可以用[tool.setuptools.package-data] myutil [data/*.json, templates/*.html]如果是项目根目录下的零散文件比如你想让包内某个函数能读取一份外部配置文件就需要配合MANIFEST.in。MANIFEST.in是给 sdist 用的清单文件里面写include README.md、recursive-include myutil/data *之类的规则。注意wheel 里靠 package-datasdist 里靠 MANIFEST.in两者管辖范围不完全重叠容易漏后面章节我会专门讲排查方法。再说命令行入口。如果你希望用户装完包后在终端敲一个myutil就能运行你的程序就要配置 scripts[project.scripts] myutil myutil.cli:main这行配置的意思是生成一个名为myutil的可执行命令安装后实际去调用myutil.cli模块里的main函数。这在 Windows、Linux、macOS 上都会被自动装配成正确的可执行文件用户完全不用关心脚本路径。3. 实操从零开始构建并分发一个 Python 包3.1 项目目录结构与代码准备先说结论一个比较标准的 setuptools 项目目录长这样myutil/ ├── src/ │ └── myutil/ │ ├── __init__.py │ ├── core.py │ └── cli.py ├── tests/ │ └── test_core.py ├── README.md ├── LICENSE ├── MANIFEST.in └── pyproject.toml我来手写一个最小可用的示范包。src/myutil/__init__.py里留个版本号__version__ 0.1.0src/myutil/core.py写一个简单的处理函数def greet(name: str) - str: return fHello, {name}!src/myutil/cli.py写一个命令行入口import sys from myutil import core def main(): name sys.argv[1] if len(sys.argv) 1 else World print(core.greet(name))这段代码本身很简单但注意这里用到了“从包内部导入”的写法它要求myutil确实作为一个包被安装或加入 Python 路径否则python cli.py直接运行是会失败的。3.2 编写构建配置完整代码示例然后写核心文件pyproject.toml。下面这份配置可以直接用于上面这个结构[build-system] requires [setuptools68, wheel] build-backend setuptools.build_meta [project] name myutil version 0.1.0 description 一个演示用的工具包 readme README.md requires-python 3.8 license {text MIT} authors [{name 某开发者, email devexample.com}] dependencies [] [project.scripts] myutil myutil.cli:main [tool.setuptools.packages.find] where [src]逐行解释一下[build-system]声明构建后端是 setuptools要求版本不低于 68。wheel也必须放在这里因为生成 wheel 格式需要它。[project]项目元信息。dependencies这里先留空但格式已经对了后面要加就说一个requests2.20。[project.scripts]把myutil.cli:main暴露成命令行命令。[tool.setuptools.packages.find]告诉 setuptools 去src目录下找包。如果你好奇“为什么不直接写 setup.py”你大可以试试。但你会发现 setup.py 里那么多packages参数、entry_points参数字符串用 tomli 读取和写起来都容易出错尤其当项目变大以后动态代码调试起来非常恶心。这套 pyproject.toml 配置的最大优势是它是静态的、可校验的别人一眼就能看清这个项目打包了哪些内容。3.3 本地构建与安装验证配置写完了先别急着发版。我们在本地完整走一遍“构建→安装→运行”的链路。首先要安装一个统一的构建工具buildpython -m pip install build然后执行python -m build这条命令会依次生成 sdist 和 wheel 文件输出放在dist/目录下。你可以看到类似这样的结果dist/ ├── myutil-0.1.0-py3-none-any.whl └── myutil-0.1.0.tar.gz注意 wheel 文件名里的py3-none-any它表示“这是一个纯 Python、不依赖特定系统架构的包任何 Python 3 环境都能装”。接下来验证安装。强烈建议开一个全新的虚拟环境别直接装到你日常开发环境里python -m venv /tmp/venv_myutil source /tmp/venv_myutil/bin/activate pip install dist/myutil-0.1.0-py3-none-any.whl然后测试myutil # 输出 Hello, World! myutil 张三 # 输出 Hello, 张三!再到 Python 里测一下导入from myutil import core print(core.greet(测试))如果这些都正常说明这个包的基本功能没问题。本地验证永远放在发布之前别直接发个坏包上去再回头改。3.4 发布到包索引仓库本地验证通过以后就可以考虑上传到公共包索引仓库了。要上传得先准备账号和凭证通常需要在包索引网站上注册账号然后在本地配置 API token。上传用twine这个工具它可以对上传的文件做完整性检查并安全传输python -m pip install twine twine check dist/*twine check会检查 README 渲染、文件元信息是否合法。如果输出没有报错就可以上传twine upload dist/*这个命令会提示你输入用户名和密码/API token。上传成功后你会在终端看到这个包版本的下载地址。此时任何一台能访问公共包索引的机器都可以执行pip install myutil这么一条命令就完成安装了。提示如果你只是在公司内网或实验室内部使用也可以在自己搭的私有索引仓库上重复这套流程只需给 twine 指定--repository-url参数。整个逻辑一模一样只是目标地址不同。3.5 更新版本与日常维护流程发版不是一次性的以后你改了代码总要发新版本。版本号怎么管理我习惯用语义化版本也就是主版本号.次版本号.修订号修复 bug、不影响已有接口 → 升修订号例如0.1.0→0.1.1新增功能、保持向后兼容 → 升次版本号例如0.1.0→0.2.0接口不兼容、破坏性变更 → 升主版本号例如0.1.0→1.0.0每次发版的流程大概是修改代码并同步更新__init__.py里的版本号和 pyproject.toml 中的版本号或配置成动态版本。跑一遍测试。python -m build重新生成 dist。清理旧的 dist 文件推荐删除整个 dist 目录后重新构建避免旧文件混淆。twine check dist/*再twine upload dist/*。有个小技巧把dist/、build/、*.egg-info/都写进.gitignore别让构建产物污染你的代码库。每次发版以“干净的 dist 目录”为准而不是在旧产物上叠加。4. 常见问题与排查技巧实录4.1 构建出来的 wheel 里没有代码/数据文件这是我见过最多的问题没有之一。现象是本地跑得好好的构建也成功了但换台机器 pip 安装后ModuleNotFoundError。原因多半是packages没有正确收集到你的包目录。如果你没有写[tool.setuptools.packages.find]或者where写错了路径setuptools 会以为你的项目里根本没有包于是 generates 一个“空包”。排查方法很简单构建完先别急着上传直接查看 wheel 文件内容python -m zipfile -l dist/myutil-0.1.0-py3-none-any.whl如果发现列表里只有 metadata 文件、没有myutil/目录那就说明包收集失败。你要么检查where路径要么确认src目录下存在__init__.py。数据文件丢失的道理也类似。记住一个口诀代码文件靠 packages数据文件靠 package-datasdist 里额外还要 MANIFEST.in。三层都要检查缺一不可。4.2 本地安装时一直用的是旧版本改了几次代码pip install -e .也执行了但怎么测都觉得代码没更新这种情况八成是“装进了错误的解释器环境”。比如你系统里原来有一个 Python 3.8后来又自己编译了个 Python 3.11两个环境各自有 site-packages。如果你没激活对应虚拟环境就执行 pip很容易把包装到一个你根本不看的环境里。另外pip 有时候会从本地缓存里取包。遇到这种奇怪的“更新无效”可以尝试pip install --no-cache-dir -U dist/myutil-0.1.0-py3-none-any.whl还有一点开发阶段不要反复构建 wheel、再安装 wheel应该用可编辑安装pip install -e .-e模式会把项目的源码路径直接注册到环境里你对源码的修改立刻生效省去每次重新构建的功夫。发布前再重新构建完整包做验证。4.3 依赖冲突与版本区间陷阱当你的包声明requests2.20pip 在解析时可能为了满足你的要求升级别人的依赖也可能反过来因为别人对 requests 的版本限制导致你的包无法安装。这就涉及“依赖地狱”。经验是在dependencies里不要过度钉死版本写下限和上限就好。比如requests2.20,3,这样既保证了基本功能又留给依赖解析器足够的空间。如果某些库的 API 频繁变化你才需要设一个更紧的上限。另外注意install_requires里别放只在开发时需要的东西比如 pytest、twine。这些应该放到[project.optional-dependencies]里的 extra 中否则用户安装你的包时会被迫拉一堆不必要的重量级依赖。4.4 入口点命令不生效配置了[project.scripts]构建也成功安装后终端敲命令却提示“command not found”。这种情况先别怀疑配置先检查安装时用的 Python 和当前终端的PATH。如果你用的是虚拟环境安装后必须激活同一个虚拟环境再敲命令source /path/to/venv/bin/activate myutil如果命令能执行但报ModuleNotFoundError那可能你只安装了源码包sdist安装过程没有构建成功导致入口点脚本存在但包模块没被正确装载。彻底一点的做法是卸载后强制安装 wheel 文件pip uninstall myutil -y pip install dist/myutil-0.1.0-py3-none-any.whl再试入口命令。4.5 其他高频坑点速查表我整理一个速查表方便你以后排查问题现象可能原因排查方向版本号上传失败PEP 440 格式不合法核对version比如不能写v1.0、1.0.0-beta1包名已存在公共索引上已被占用换名字或用作用域包名README 显示乱码readme 字段未指定或格式不支持确认readme README.md内容用 markdown 可在twine check中发现安装时报缺少某模块依赖没写全检查 dependencies 和 import 的模块是否齐全sdist 无法构建MANIFEST.in 有问题本地python -m build --sdist查看具体报错wheel 是平台相关意外包含原生扩展确认没有混入.so/.dll等文件或确认构建环境一致5. 进阶玩法与细节优化建议5.1 extras_require为不同使用场景提供可选依赖你的包可能有“基础版”和“完整版”的差别。比如某个工具核心功能不需要第三方库但如果要画图就得装 matplotlib。这时候就可以用 optional dependencies。在 pyproject.toml 里这样写[project.optional-dependencies] plot [matplotlib3.5] test [pytest7.0]用户如果只需要基础版pip install myutil需要画图功能pip install myutil[plot]开发测试pip install myutil[test]这是一个很干净的依赖拆分策略。要注意的是这里定义的 key如plot、test会成为用户安装时的一个“标签”命名要有意义、别冲突。5.2 version 动态化从 git 标签或单一文件取版本手动在多个文件里同步版本号很容易漏改。setuptools 支持动态版本。最简单的一种方式在 pyproject.toml 里不写死版本而是声明从包内的某个文件读取[project] dynamic [version] [tool.setuptools.dynamic] version {attr myutil.__version__}这样版本号只在src/myutil/__init__.py里维护一份其他配置都引用它。还有更高级的 setuptools_scm直接从 git 标签生成版本号。比如打个v0.2.0的 tag构建时版本号自动就是0.2.0。这个方案适合我这种“经常忘记改版本号”的人但需要额外引入依赖python -m pip install setuptools-scm然后 pyproject.toml 里[build-system] requires [setuptools68, setuptools-scm, wheel] build-backend setuptools.build_meta [tool.setuptools_scm]动态版本虽好用但初学者不建议一上来就用因为它会让“构建的可复现性”变差。先把静态版本用熟再考虑自动化。5.3 让包兼容不同 Python 版本写包的时候总会面临“要不要支持旧版本 Python”的选择。这不见得是技术问题更多是维护成本问题。当你声明requires-python 3.8你实际上承诺你的代码在每个 3.8 以上版本都能运行。这就意味着你要注意别在代码里使用老版本解释器不支持的语法。比如 Python 3.8 没有match语句、列表泛型写法list[int]在 3.8 上不支持得用List[int]或from __future__ import annotations。别依赖某个只在最新版本才有的标准库 API。为了保证兼容我经常在代码里这样处理try: from functools import cache except ImportError: from functools import lru_cache cache lru_cache(maxsizeNone)这种“渐进增强”写法在开源包里很常见既能在新版本上享受更好的性能又不会让老版本直接崩掉。但是维护多版本兼容确实会增加测试负担。如果你只有一个人我建议你设定一个“支持区间”比如支持最近两个大版本就足够了别无限往旧了兼容。精力有限发出去的东西质量优先。5.4 打包后自测清单与最佳实践最后总结一下我自己发布包之前必跑的自测清单。每次发版我会在全新虚拟环境里依次执行这些步骤确认无误才上传清理 dist 和 build 目录保证构建纯净。python -m build生成 sdist 和 wheel。twine check dist/*检查元信息。新建虚拟环境安装 wheel验证导入和命令行入口。再新建一个更老的 Python 环境如果可行验证版本兼容声明是否可信。检查 sdist 里是否包含所有必需文件数据、模板、license、readme。最后才twine upload dist/*。这套流程看起来繁琐但真的能拦住绝大多数发布事故。我吃过一次亏有次直接把dist下的旧 sdist 也打包上传了结果别人通过 sdist 安装时因为缺失MANIFEST.in里的数据文件直接报错。从那以后我每次都会手动看一眼 dist 目录里到底有哪些文件确认新旧产物都对应正确版本再上传。另外还有一个建议尽量养成给每次发布写CHANGELOG.md的习惯。版本号不只是给机器看的也是给用户看的。别人要不要升级凭什么升级都靠这个文件说明白。setuptools 解决的是“怎么装”的问题而 CHANGELOG 解决的是“为什么升”的问题两者配套你的项目才会越来越专业。根据我个人经验setuptools 这套体系虽然历史悠久但它能活到今天恰恰是因为经得起真实世界各种复杂场景的折腾。你花一个下午把构建、发布、验证整条链路跑通之后所有 Python 项目都会受益。下次再有人问你“你的库怎么安装”你终于可以理直气壮地说一句pip install myutil。