把 Python 代码变成能被pip install直接装上的包这事听着简单真操作起来却处处有讲究。我早先在多个项目之间来回复制工具函数改一处要全工程同步直到认真走了一遍 python 包发布流程才体会到“先打包、再发布、后安装”这条链路的含金量。所谓 python 包发布流程简单说就是把项目补充标准打包配置构建出 wheel 和源码包上传到 PyPI 或私有仓库再让使用方用一条pip install xxx完成部署。它真正解决的问题是把散落各处的.py文件收敛成一套有版本号、有依赖声明、可验证安装的正式交付物。这篇文章既适合第一次发布包的新手也适合想在团队内部落地私有包机制的开发者。1. 整体设计与思路拆解动手之前先想明白1.1 为什么值得走发布流程如果你只是在一个项目里写点脚本直接放一个模块就够了一旦这个代码要在第二个项目里用马上就会碰壁。复制粘贴一段还好复制整个工具包会产生分支改 bug 时你根本不知道哪些副本还没同步。发布流程把所有功能统一到标准安装机制下版本、依赖、入口命令都由元数据接管代码在任意环境里都能以一致的方式装进去。很多开发者不做发布是因为第一次时总觉得配置复杂但其实一次配好后面每个项目都是同一套模板。内部项目和公开项目在这件事上没有本质区别公开项目用 PyPI内部项目用私有索引或直接推 wheels 到 Nexus/Artifactory。工具都一样差别只在上传目标和认证方式。早点养成发布习惯对个人成长和团队协作都有好处。尤其是当你发现自己开始频繁“我项目里也有这个函数我直接复制过来”时就是时候认真搭一次发布流程了。1.2 构建工具选型setuptools、poetry、hatch 怎么选现在 Python 的打包体系已经全面转向 PEP 517/518也就是用pyproject.toml声明构建后端。你不再需要维护setup.py有些后端保留它是为了兼容但它已经不再是必需项。常见构建后端有以下这些工具构建后端配置风格适合场景setuptoolssetuptools.build_metapyproject.toml 可选 setup.py老项目、生态兼容性最好、处处能跑poetrypoetry-corepyproject.toml专有字段依赖管理、发布一体化的个人/小团队项目flitflit_core.buildapi精简 pyproject.toml标准库外小包、追求极简hatchhatchlingpyproject.toml新一代功能全版本管理方便我自己的原则是包体很小、不想引入额外概念就用 setuptools项目里依赖本身已经很复杂还希望用一个工具管理虚拟环境和发布就上 poetry只是给内部团队发一个小工具flit 或 hatch 也很顺手。不要听别人说谁最好就盲目迁移发布流程稳定比新潮更重要。后端一旦选好最好整个项目周期不换因为不同后端生成的元数据结构会有细微差异切换容易引入低级错误。1.3 src 布局还是平面布局这个选择很关键目录结构上我强烈推荐 src 布局也就是把真正的包代码放在src/目录下src/ demo_utils/ __init__.py而不是把demo_utils直接放在项目根目录。原因很现实平面布局下在项目根目录执行python -c import demo_utils时解释器会优先从当前目录导入你导入的是源码目录而不是构建产物如果打包配置漏掉了某个文件本地测试时根本发现不了等用户报错才追悔莫及。src 布局强制你在测试时只能从已安装的包导入构建错漏会当场暴露。这不是矫情我见过很多线上包“本地能装装完 import 失败”的案例根因就是平面布局导致测试环境与发布产物不一致。新建包时请默认选择 src 布局。2. 核心细节解析与实操要点把 pyproject.toml 吃透2.1 pyproject.toml 里必须有的两个区块pyproject.toml是今天 Python 打包的事实标准文件最少要包含两个部分[build-system]和[project]。[build-system]告诉构建工具“谁来构建、构建时需要什么依赖”。常见写法[build-system] requires [setuptools68, wheel] build-backend setuptools.build_metabuild-backend是构建入口wheel通常需要显式放进去否则某些老版本 setuptools 可能不会自动生成 wheel。不用 setup.py 的时候这段配置就够用了。[project]里的核心字段包括name包在仓库里的唯一名字PyPI 上不能有大写字母和空格一般用短横线连接例如demo-utils。versionPEP 440 版本号见下文。description一句话说明会显示在 PyPI 项目首页。readme通常指向 README.md会渲染在 PyPI 页面。requires-python声明支持的 Python 最低版本pip 会据此筛选。dependencies运行时依赖列表。license许可证信息。老写法是license {text MIT}较新的 PEP 639 支持 SPDX 表达式按 setuptools 版本选择。optional-dependencies按场景拆分的扩展依赖比如dev里放测试工具。还有一个容易忽略的[project.urls]区块可以把 Homepage、Source、Documentation 链接填进去让 PyPI 页面更完整。别小看这些元数据它直接影响别人对你项目的信任度。2.2 版本号和依赖声明最容易埋雷的位置版本号必须符合 PEP 440。常见错误是把1.0.0-beta这种写法直接填进去正确做法是1.0.0b1。预发布版本有固定规则a/b/rc加数字以及本地版本标识。乱写版本号不会立刻报错但会导致比较大小出现诡异问题比如1.0.0beta和1.0.0的关系不稳定pip 做版本解析时可能会出现不符合直觉的结果。版本号维护有两种思路手动维护version 0.1.0简单直接或者用setuptools-scm从 git tag 自动生成版本省去手动同步。我推荐有 git 习惯的项目直接用setuptools-scm只要 commit 打了v0.1.0标签构建出的 version 就自动是0.1.0不会出现“代码改了 version 忘了改”的尴尬。依赖声明要写在dependencies不要只写一个requirements.txt。requirements.txt是给应用环境锁依赖用的包本身必须以元数据声明依赖否则 pip 安装你的包时根本不会拉取依赖库。条件依赖还可以写环境标记比如dependencies [ importlib-metadata4.0; python_version 3.10, ]这行表示只在 Python 3.10 以下版本才安装 importlib-metadata。有了这些声明pip 在解析依赖时才能正确处理。2.3 数据文件、入口点和许可证很多包不只是 .py 文件模板、默认配置文件、测试数据这些需要分别处理。默认情况下wheel 只包含 Python 模块和被 setuptools 识别到的包数据普通非 py 文件常常会丢。处理方式有三种package-data声明包内的数据文件[tool.setuptools.package-data]按包名列出需要包含的文件MANIFEST.in声明 sdist 里需要包含的非代码文件比如 LICENSE、docs、测试数据。如果 README 和 LICENSE 在项目根目录setuptools 通常会自动把 README 纳入但 LICENSE 不一定。我见过不少包的 sdist 解压出来没有 LICENSE原因就是没在 MANIFEST.in 里显式包含。许可证缺失会导致别人无法安全引用你的代码这不是小问题。入口点[project.scripts]用来创建命令行命令比如[project.scripts] demo-utils demo_utils.cli:main用户安装包后环境里会多一个demo-utils命令等价于执行demo_utils/cli.py里的main()。这样就能把“安装一个包”和“安装一个工具”绑定起来不再需要用户去记python -m xxx。3. 实操过程和发布实现把第一个包送到 PyPI3.1 准备目录骨架与最小配置假设你有一个模块src/demo_utils项目结构如下demo_utils_project/ ├── pyproject.toml ├── README.md ├── LICENSE ├── src/ │ └── demo_utils/ │ ├── __init__.py │ ├── cli.py │ └── data/ │ └── default.yaml └── tests/ └── test_basic.py一个能用的最小 pyproject.toml 是这样[build-system] requires [setuptools68, wheel] build-backend setuptools.build_meta [project] name demo-utils version 0.1.0 description 一个演示用 Python 工具包 readme README.md requires-python 3.8 license {text MIT} dependencies [click8.0] [project.optional-dependencies] dev [pytest, twine, build] [project.urls] Homepage https://github.com/yourname/demo-utils [project.scripts] demo-utils demo_utils.cli:main动手前先确认本机环境已配好基础工具把 pip 升级到最新然后安装构建和上传工具python -m pip install --upgrade pip python -m pip install build twine3.2 本地可编辑安装并自测在项目根目录执行可编辑安装python -m pip install -e .-e会把当前源码目录以可编辑方式挂进环境改代码不用重装适合开发期验证。安装完成后做三个检查导入模块、运行命令、确认入口。python -c import demo_utils; print(demo_utils.__version__) demo-utils --help这一步能过滤掉绝大多数低级错误。如果这时候报“找不到模块”多半是打包配置或目录结构出了问题别急着进下一步。环境配置到这里如果出现问题优先看是不是当前虚拟环境没激活或者pip指向了错误解释器。3.3 构建 wheel 和 sdist在项目根目录执行python -m build完成后dist/下会出现两个文件.whl和.tar.gz。前者是预构建安装包pip 安装时优先使用后者是源码包供没有匹配 wheel 的平台安装。两者都应该上传不要只传 wheel。先用 twine 检查元数据是否规范python -m twine check dist/*再用系统工具看一眼包内容unzip -l dist/*.whl # Linux/macOS tar tzf dist/*.tar.gz重点确认模块文件在不在数据文件在不在有没有混入__pycache__或.env。这一步发现的意外比上传后才发现问题要好处理得多。3.4 先上传到 Test PyPI验证无误再传正式版我建议所有新包第一次发布都先用 Test PyPI 演练。Test PyPI 是一个独立站点接口规则和正式 PyPI 基本一致但上传失败或包内容有问题不会影响真实用户。先注册 test.pypi.org 账号在账户设置里创建一个 API tokentoken 只显示一次务必保存。创建~/.pypirc文件[distutils] index-servers pypi testpypi [testpypi] repository https://test.pypi.org/legacy/ username __token__ password pypi-Ag...你的token出于安全考虑token 别写死在仓库内本地开发时放~/.pypircCI 里用环境变量传入。上传到测试环境python -m twine upload --repository testpypi dist/*然后在一个全新虚拟环境里安装验证python -m venv /tmp/venv-test source /tmp/venv-test/bin/activate python -m pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ demo-utils为什么加--extra-index-url指向正式 PyPI因为 test.pypi.org 上的包不全你的依赖很可能只有正式 PyPI 才有只用一个 indexpip 找不到依赖会直接失败。装完再执行demo-utils --help验证。测试通过后上传正式 PyPIpython -m twine upload dist/*如果不指定--repository默认就是pypi。正式上传后同样用干净环境安装验证一次确认线上安装结果和本地一致。提示dist/目录里如果混着旧版本构建产物twine upload dist/*会把它们一起上传同一个版本号第二次上传会直接报错。发布前先ls -la dist/确认没有遗留文件。3.5 把发布流程固化到 CI手动发布适合早期验证但版本一多容易忘步骤。我个人习惯把构建和发布放进 CI每次在 git 上打v*标签CI 自动构建、跑测试、推送 PyPI。一个简化版 GitHub Actions 流程文件大致是这样name: release on: push: tags: [v*] jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.12 - run: python -m pip install build - run: python -m build - run: python -m pip install twine - run: python -m twine upload dist/* env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}CI 里不要用密码登录用 scope 限定在单个项目的 API token权限足够且可撤销。发布行为一旦变成流水线动作想误操作都难。4. 常见问题与排查技巧实录那些发完包才知道的坑4.1 上传报错速查表现象原因处理办法HTTP 401 Unauthorized用户名/令牌不对确认 username 是__token__password 是完整 API tokenHTTP 403 Forbiddentoken 权限不足或包名已被占用检查 token 作用域改名项目或申请对应用户名File already exists同名同版本包已存在升级version一个 patch重新构建后再上传400/422 Invalid元数据不合法比如 license 字段格式、readme 路径错误先本地twine check并修复后重新 buildpip 安装时找不到依赖只存在于 testpypi 上安装时显式追加--extra-index-url https://pypi.org/simple/遇到上传失败时先从报错类型判断400 系列几乎都是元数据或认证问题sdist 格式问题多数是版本号或字段拼写错误。上传前先跑twine check是性价比最高的一步。4.2 包内容不全装上了却缺文件说一下我自己的真实经历。早期我发布过一个配置模板类工具pyproject.toml 里的package-data没写对本地测试时因为用到的是源码目录数据文件明明存在功能一切正常上传后用户一装运行时直接报“找不到模板文件”。后来我养成了几个习惯每次发布前解压查看 wheel 内容确认数据文件真的进了压缩包。尽量使用 src 布局迫使自己和用户都用“已安装的包”做测试。将数据文件放在模块子目录里再通过importlib.resources读取比拼接绝对路径更可靠。还有一个容易漏的是 MANIFEST.in。如果你需要在 sdist 里带测试用例、文档图标、LICENSE一定要在MANIFEST.in里列全include LICENSE include README.md recursive-include tests *.py很多人的仓库结构没问题但 sdist 内容不全就是因为没有这个文件。4.3 发布前的安全与隐私自查发布到公共仓库的最大风险不是代码 bug而是把不该公布的敏感信息打进了包里。构建过程默认不会排除.env、config/secret.yaml这类文件如果你的目录里恰好有这些文件且被当作包数据包含一次上传就足以造成信息泄露。我的固定自查流程是构建前先看git status把未跟踪的敏感文件清除。构建后扫一遍 wheel 和 sdist 的文件列表重点找.env、*.pem、*.key、__pycache__。.gitignore只管 git拦截不了打包工具别依赖它兜底。如果之前发过包含敏感信息的包立刻在 PyPI 删除对应版本并去轮换所有可能被泄露的密钥。注意API token 一定不要写进公开仓库或被 Git 跟踪泄露后要立刻在站点后台撤销并重新生成。4.4 多平台兼容与编译扩展的坑纯 Python 包构建出的 wheel 一般带py3-none-any标签任何 Python 3 环境都能安装不需要为每个平台单独构建。但如果你有 C 扩展、编译型依赖或平台特定二进制情况就完全不同一个在 Linux 上编译的 wheel 不能装到 Windows 或 macOS 上。此时你有两条路sdist 发布后让用户自行编译但体验差还要求用户本地有编译器环境。用 cibuildwheel 在 CI 里为各平台各 Python 版本构建 wheel然后一起上传。cibuildwheel 是社区标准做法能生成cp39-cp39-manylinux_x86_64.whl这类带平台标签的文件。文件名里的平台标签是构建工具自动生成的千万不要手工改改坏了 pip 会直接拒绝安装。发布这类包之前建议用 tox 或 nox 在多个 Python 版本上跑一遍测试避免“只在自己电脑上正常”的经典问题。5. 发布流程定形后的最后几条经验5.1 我常走一遍的发布前自检清单发布前我不会直接上传而是按顺序过一遍代码层面测试全部通过入口命令在本机可执行包能在干净环境装上。元数据层面twine check无告警README 和 LICENSE 确实包含在内版本号没有重复。内容层面解压 wheel 和 sdist核对文件列表确认没有敏感文件和意外残留。流程层面先上 Test PyPI 安装验证再正式上传最后再用干净环境复验一次。这套自检大概多花十分钟却能省去用户侧的投诉和反复发版的尴尬。我见过太多人把流程跳到最后一步结果要么缺文件要么版本冲突最后反而更慢。5.2 三个让我长记性的小习惯第一所有发布动作尽量可重复。手动执行命令一时爽下次不一定记得住当时的选择所以我把标准过程写进 README 或者 CI。第二每次迭代都更新 CHANGELOG哪怕只是“修复了一个小 bug”。版本号本身不表达内容CHANGELOG 才表达用户会因此更信任你的项目。第三坚持先 Test PyPI 再正式 PyPI即使发过几十个包我也没跳过。第二遍重复带来的挫败感比多花三分钟演练强烈得多。我个人在多次发布后的体会是这个流程最值钱的部分不是“能传上去”而是“可验证、可回滚、可复用”。一个包从源码到 PyPI 的过程一旦固定成习惯后续每个项目都只是换个name和入口点的小事。最后再分享一个小技巧无论你手动发布还是用 CI发布后立刻用一个全新的虚拟环境安装并执行一次入口命令这短短半分钟的验证能挡掉几乎所有“打包时看着没问题、实际用起来报错”的意外。