写过几个 Python 项目的人大概都遇到过这种尴尬——代码在自己机器上跑得好好的,换台电脑就报错,同事拉下代码装不上依赖,或者想把工具分享出去却不知道从哪下手。这些问题说到底都指向同一件事,打包发布这套体系没吃透。
这篇文章想把 Python 打包发布这条链路捋清楚,从最基础的概念讲起,一路讲到 setuptools、虚拟环境、依赖锁定,再到不同操作系统下的实操流程。目标是让刚接触工程化的同学也能看懂,同时给有经验的开发者一份可以直接抄的实践清单。
一、先搞懂"打包"到底在打什么
很多人一上来就被 setup.py、pyproject.toml、wheel、sdist 这堆名词绕晕了。其实拆开看没那么复杂,核心就三层东西。
第一层是代码本身的组织形式。一个 .py 文件叫模块(module),一堆模块放进一个带 __init__.py 的文件夹就成了包(package)。这一层跟打包发布关系不大,纯粹是代码组织习惯。
第二层是分发包(distribution package) ,也就是别人 pip install 时真正下载的那个东西。它有两种形态。sdist(source distribution)是源码压缩包,安装时需要在本地编译;wheel 则是预编译好的二进制格式,装的时候直接解压放到位置就行,速度快很多,也是目前官方推荐的标准格式。
第三层是构建系统本身,这是最容易让人犯迷糊的地方。早年 Python 打包全靠 setup.py,你运行 python setup.py sdist bdist_wheel 才能出包,但这个脚本本质上是任意代码执行,安全性和一致性都堪忧。社区后来通过 PEP 517 和 PEP 518 定义了一套标准化流程,核心思路是把"构建工具的选择"和"实际构建逻辑"解耦成 build frontend 和 build backend 两个角色。
打个比方,build frontend(比如 pip、build)就像餐厅的服务员,负责接单——它读取 pyproject.toml 里 [build-system] 这一节,知道该用哪个后端来干活;build backend(比如 setuptools、hatchling、flit-core、poetry-core)才是真正下厨的人,负责把源码变成 wheel 或 sdist。这套解耦设计的好处在于,你可以自由选择后端而不用改变前端的使用方式,pip install 依然是那句熟悉的命令。
有意思的是,尽管新工具层出不穷,setuptools 依然是目前使用最广泛的后端,很大程度是历史存量项目的惯性所致。
下面这张图能帮你建立整体的心智模型:
二、setuptools 实战:从代码到 wheel
理论讲完了,来看点真东西。假设你要发布一个叫 mytool 的小工具,目录结构大致是这样:
mytool/
├── pyproject.toml
├── README.md
├── LICENSE
└── src/
└── mytool/
├── __init__.py
└── cli.py
注意这里用了 src/ 布局而不是把包直接放在根目录,这是当前社区比较推崇的做法,能避免测试时误用本地未安装的代码路径。
pyproject.toml 是整个项目的门面,一个典型的 setuptools 配置长这样:
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "mytool"
version = "0.1.0"
description = "一个演示打包流程的小工具"
readme = "README.md"
requires-python = ">=3.9"
license = { text = "MIT" }
dependencies = [
"requests>=2.31,<3.0",
"click>=8.1",
]
[project.optional-dependencies]
dev = ["pytest>=7.0", "black", "ruff"]
[project.scripts]
mytool = "mytool.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
这份配置文件已经彻底取代了老旧的 setup.py,[project] 这一节按照 PEP 621 标准描述元数据,包括包名、版本、依赖列表,可读性比 Python 脚本高得多,也不存在任意代码执行的风险。[project.scripts] 里那句声明特别值得一提——它告诉安装工具,装完这个包之后要在系统 PATH 里生成一个叫 mytool 的命令行入口,指向 cli.py 里的 main 函数。这就是为什么很多 Python 工具装完就能直接在终端敲命令用,背后靠的就是这个机制。
配置写好之后,构建过程其实就两步:
# 安装构建工具
pip install build twine
# 构建 wheel 和 sdist
python -m build
# 上传到 PyPI(需要提前注册账号和 API token)
twine upload dist/*
python -m build 跑完之后,dist/ 目录下会出现类似 mytool-0.1.0-py3-none-any.whl 和 mytool-0.1.0.tar.gz 两个文件,前者是二进制轮子,后者是源码包。twine 负责把这两样东西安全地传到 PyPI 上,用它而不是直接 python setup.py upload 的原因是 twine 会用 HTTPS 加密传输,避免中间人攻击。
如果只是想在本地开发调试,不想每改一行代码就重新打包安装,可以用可编辑安装:
pip install -e .
这条命令会在 site-packages 里放一个指向你源码目录的软链接,改代码立即生效,调试效率高很多。
三、虚拟环境隔离依赖是门手艺
如果说打包解决的是"怎么把代码交给别人",虚拟环境解决的就是"怎么让代码在自己机器上不打架"。
Python 的一个老毛病是全局安装依赖,装了 A 项目需要 Django 3.2,又装了 B 项目需要 Django 5.0,两者互相覆盖,最后谁都跑不起来。虚拟环境的思路很直接——给每个项目单独搭一个"沙盒" ,里面装的包互不干扰。
目前主流方案大致分三派,各有各的适用场景:
| 工具 | 定位 | 优势 | 局限 |
|---|---|---|---|
venv | Python 内置 | 零依赖,标准库自带 | 功能朴素,不管理依赖版本 |
virtualenv | 第三方增强版 venv | 速度更快,兼容旧版 Python | 需要单独安装 |
conda | 跨语言环境管理 | 能管理非 Python 依赖(如 CUDA) | 体积大,速度慢 |
poetry | 集成式环境+依赖管理 | 环境和锁定一体化 | 学习曲线稍陡 |
对纯 Python 项目而言,venv 往往就够用了,它是 Python 3.3 之后内置的标准库模块,不需要额外安装任何东西。创建和激活的命令是这样的:
# 创建虚拟环境
python -m venv .venv
# 激活(Linux/macOS)
source .venv/bin/activate
# 激活(Windows PowerShell)
.venv\Scripts\Activate.ps1
# 激活(Windows CMD)
.venv\Scripts\activate.bat
激活之后终端提示符前面会多出 (.venv) 这样的标记,此时装的所有包都只会落在这个隔离环境里,不会污染系统 Python。用完退出用 deactivate 命令即可。
conda 则更适合科研或者需要非 Python 依赖(比如某些数值计算库要链接系统级 C++ 库)的场景,它管理的不只是 Python 包,也能装 R、Node.js 甚至底层的编译工具链,代价是环境体积普遍偏大、初始化速度慢一些。
四、依赖锁定:poetry 和 pip-tools 怎么选
虚拟环境解决了隔离问题,但没解决版本一致性问题。你在 dependencies 里写 requests>=2.31,理论上安装的时候可能装到 2.31.0,也可能装到半年后发布的 2.35.2,两次安装拿到的实际版本可能完全不同,这在生产环境里是灾难性的隐患。
依赖锁定(dependency locking) 就是为了解决这个问题——把"我允许的版本范围"和"我实际用的精确版本"分离开,用一份锁文件把安装结果钉死。
pip-tools 的思路
pip-tools 走的是最小干预路线,它不替代 pip,只是给 pip 加了个编译层。工作流程分两步:
先写一份宽松的需求文件 requirements.in:
requests>=2.31
click>=8.1
然后编译成精确锁定的 requirements.txt:
pip install pip-tools
pip-compile requirements.in
生成的 requirements.txt 里每一行都会带上精确版本号和依赖来源注释,类似这样:
requests==2.32.3
# via -r requirements.in
click==8.1.7
# via -r requirements.in
urllib3==2.2.2
# via requests
之后无论谁在哪台机器上执行 pip-sync requirements.txt,装到的版本永远一致。这套方案的好处是心智负担极低,几乎不改变原有的 pip 使用习惯,非常适合已经有大量存量项目、不想大改工作流的团队。
poetry 的思路
poetry 走的是"全家桶"路线,环境管理、依赖声明、锁定、打包发布全部一体化。项目配置直接写在 pyproject.toml 里:
[tool.poetry]
name = "mytool"
version = "0.1.0"
description = "一个演示打包流程的小工具"
[tool.poetry.dependencies]
python = "^3.9"
requests = "^2.31"
click = "^8.1"
[tool.poetry.group.dev.dependencies]
pytest = "^7.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
日常操作只需要几条命令:
# 安装依赖并自动生成 poetry.lock
poetry install
# 新增一个依赖
poetry add requests
# 进入虚拟环境
poetry shell
# 打包发布
poetry build
poetry publish
poetry install 执行完会生成一份 poetry.lock 文件,里面记录了每个包的精确版本以及哈希值,团队里其他人执行 poetry install 时会严格按这份锁文件还原环境,跟 Node.js 的 package-lock.json 是同一个思路。
两者该怎么选,其实没有绝对标准答案,看团队习惯:
| 维度 | pip-tools | poetry |
|---|---|---|
| 学习成本 | 低,贴近原生 pip | 中等,有自己一套命令体系 |
| 环境管理 | 需要配合 venv 单独使用 | 内置环境管理 |
| 打包发布 | 不管这事,需另配 build/twine | 内置 poetry build/publish |
| 适用场景 | 已有项目渐进式改造 | 新项目一站式管理 |
值得一提的是,社区目前也在推动标准化的锁文件格式(比如 PEP 751 相关讨论),因为 poetry.lock 和 pip-tools 生成的锁文件互不兼容,各家工具自成一派,长期来看这块还有整合空间。
五、不同操作系统下的完整打包发布流程
原理讲完,落到实操上,不同操作系统之间的差异主要体现在虚拟环境激活命令和路径分隔符上,核心的打包命令其实是一致的。下面按平台各给一份完整清单。
macOS / Linux
# 1. 创建并激活虚拟环境
python3 -m venv .venv
source .venv/bin/activate
# 2. 安装构建与开发依赖
pip install --upgrade pip build twine
# 3. 本地安装用于测试
pip install -e .
# 4. 运行测试
pytest
# 5. 正式构建
python -m build
# 6. 先上传到 TestPyPI 验证
twine upload --repository testpypi dist/*
# 7. 确认无误后上传正式 PyPI
twine upload dist/*
Linux 下如果涉及带 C 扩展的包(比如用了 Cython 或调用了系统级库),还需要考虑 manylinux 兼容性问题,通常借助 cibuildwheel 这类工具在 Docker 容器里跨发行版编译,确保生成的 wheel 能在不同 glibc 版本的 Linux 上通用。
Windows
# 1. 创建并激活虚拟环境(PowerShell)
python -m venv .venv
.venv\Scripts\Activate.ps1
# 如果遇到执行策略限制,先运行
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
# 2. 后续步骤与 macOS/Linux 完全一致
pip install --upgrade pip build twine
pip install -e .
pytest
python -m build
twine upload dist/*
Windows 上最容易踩坑的地方就是 PowerShell 的脚本执行策略默认是禁止运行未签名脚本的,第一次激活虚拟环境时经常会报权限错误,加一句 Set-ExecutionPolicy 就能绕过去。另外如果用的是 CMD 而不是 PowerShell,激活命令要换成 .venv\Scripts\activate.bat。
三大平台流程对比一览
顺带一提,可执行程序打包
如果目标不是发布 Python 库而是给不装 Python 的用户一个可以直接双击运行的程序,那就是另一套体系了——PyInstaller、cx_Freeze、Nuitka 这类工具会把 Python 解释器和依赖一起打进一个独立的可执行文件(Windows 下是 .exe,macOS 下是 .app)。命令大致是:
pip install pyinstaller
pyinstaller --onefile src/mytool/cli.py
这条路径跟前面讲的 wheel/PyPI 发布是两个完全不同的目标场景,一个面向开发者(通过 pip install 复用你的代码),一个面向终端用户(直接运行,不关心底层是不是 Python 写的),实际工程里需要看分发对象来选。
六、几点实践上的取舍建议
看完整套流程,可能你会问,这么多工具到底该怎么组合才靠谱。这里给几条落地建议。
对开源库项目,setuptools + pyproject.toml 依然是最保险的组合,生态最成熟,出问题时能查到的资料也最多。对内部业务应用,特别是团队规模不大、想要一站式管理的场景,poetry 的整合度会让协作更顺畅,新人加入项目只需要一句 poetry install 就能拿到跟别人完全一致的环境。对已经有大量存量代码、不想大改工作流的团队,pip-tools 是渐进式改造的最佳切入点,改动成本最低。
虚拟环境这件事上,别偷懒——哪怕是最简单的脚本项目,也养成先 python -m venv 再干活的习惯,能省掉后面无数排查"为什么在我机器上是好的"这类玄学问题的时间。
依赖锁定同理,锁文件不是可选项而是必选项,尤其是涉及生产部署的项目,没有锁定版本的依赖清单等于埋了一颗定时炸弹,谁也不知道下一次 pip install 会不会因为上游某个包发布了不兼容的新版本而炸掉整条流水线。
参考资料
Python Packaging User Guide, "Tool recommendations", packaging.python.org/guides/tool…
wbarillon, "The proper use of pyproject.toml for Python applications", Medium, wbarillon.medium.com/why-i-start…
Jay Qi, "The Basics of Python Packaging in Early 2023", DrivenData Blog, drivendata.co/blog/python…
Xebia, "An Updated Guide To Setuptools And Pyproject.toml", xebia.com/blog/an-upd…
"Lock Your Packages with Ease Using pip-tools", Medium (Towards Dev), medium.com/towardsdev/…
Lincoln Loop, "Python Dependency Locking with pip-tools", lincolnloop.com/blog/python…
Brett Cannon, "State of standardized lock files for Python: August 2023", snarky.ca/state-of-st…
Stack Overflow, "Equivalent of 'package.json' and 'package-lock.json' for pip", stackoverflow.com/questions/5…
PEP 517 – A build-system independent format for source trees, peps.python.org/pep-0517/
Quansight Labs, "PEP 517 build system popularity", labs.quansight.org/blog/pep-51…
Python Discourse, "PEP517's definition of frontend and backend is unclear to me", discuss.python.org/t/pep517s-d…