聊到Python项目,很多人写着写着就会发现一个尴尬的问题:项目里的工具函数越来越多,每次新开一个项目都要复制粘贴一遍,改个bug还得同步到所有副本里。这时候最优雅的解法就是把那部分通用代码封装成一个标准Python库,然后用pip install直接安装使用。今天这篇就完整记录一下我多次实践后的封装流程、踩坑经验以及整个设计思路,从目录结构到发布PyPI一次讲透。
这篇文章适合谁?如果你们团队里有几个项目经常共用同一套工具模块,或者你想把自己的通用代码沉淀成可复用组件,又或者你刚接触Python打包,想搞明白setup.py和pyproject.toml到底怎么配的,这篇都适合你。我会尽量把每一步的“为什么”也说清楚,而不只是给一份能抄的配置。
1. 为什么要把代码封装成标准Python库
先聊一个基本问题:我写了一个模块,直接import不行吗?为什么非要折腾打包这一套?我个人的答案是:单个脚本复制粘贴当然能跑,但一旦代码量到几百行以上、被多个项目引用,复制粘贴就会变成灾难。
1.1 直接复制模块文件的问题
很多初学者(包括早期的我)的做法是,把utils.py直接拷贝到新项目里,然后from utils import xx。这个方案有三个典型痛点:
第一,版本失控。你在A项目里改了utils.py的某个函数,B项目里还是旧版。过两个月A项目跑得好好的,B项目突然报一个诡异异常,查半天发现是同一个工具函数两个版本行为不一致。
第二,依赖不清。如果这个工具模块依赖第三方库,比如requests、numpy,拷贝文件时没人会记得记录下来。换一台机器部署,跑起来才发现缺依赖,然后被“No module named xxx”反复折磨。
第三,代码结构松散。复制粘贴意味着模块散落在各个项目的不同目录,没有一个统一入口管理和测试。你很难对散落的同一份代码写单元测试、做文档、定版本号。
1.2 封装成标准库带来的收益
封装成可通过pip install安装的标准库后,上面这些问题会被系统性解决:
- 统一版本管理:每次发布都有
__version__和tag标记,哪个项目装在哪个版本一目了然。 - 依赖自动解析:在打包配置里声明
install_requires,用户pip install时自动拉取依赖库,不再手忙脚乱地逐个补装。 - 集中维护与测试:库的源码集中在一个仓库里,可以单独写测试、跑CI,代码质量有保障。
- 多人协作更顺畅:同事直接
pip install你的库,不用问他“你这个工具的源码放在哪”。
另外还有一个隐性收益:一旦代码被打包并发布(哪怕只是发布到公司内网源),它就从“私人脚本”升级为“团队基础设施”。你会有动力写文档、写类型标注、补测试,整个代码的成熟度会明显上一个台阶。
2. 打包前的核心准备工作
很多人一上来就写setup.py,写到一半发现要么缺MANIFEST.in,要么包名冲突,又或者pip install后import还是失败。这些问题的根源,多半是项目目录结构就没摆对。
2.1 标准目录结构长什么样
我推荐的最小可打包结构是这样:
myproject/ ├── pyproject.toml ├── setup.py ├── README.md ├── LICENSE ├── src/ │ └── mylib/ │ ├── __init__.py │ ├── core.py │ └── utils.py └── tests/ └── test_core.py注意几个容易被忽略的点:
第一,源码放在src/下而不是项目根目录下。这是主流Python打包实践推荐的“src布局”。它能强制你在开发时也通过安装后的路径导入包,避免出现“项目根目录能跑、装到别处就import失败”的假象。很多老项目习惯直接在根目录放包文件夹,打包时也能用,但容易踩“本地运行正常、安装后找不到包”的坑,我不建议新手这么干。
第二,__init__.py不能空着。至少写一个版本号声明:
__version__ = "0.1.0"这个文件决定了import mylib后你能拿到哪些顶层属性。很多人忽略它,结果装上后import mylib.core能用,但import mylib后啥也没有,用起来就很不顺手。
第三,tests/目录建议从一开始就建。打包过程不会自动验证代码功能,只有测试能帮你兜底。哪怕只写一个冒烟测试,也比完全没有强。
2.2 setup.py、pyproject.toml 和 setup.cfg 怎么分工
这是很多新手最迷糊的地方。三个文件看着像都在干同一件事,实际各有侧重。
setup.py是传统的打包入口脚本,负责描述元数据和执行自定义命令。它的存在几乎是历史惯性,但至今仍被大量项目沿用。
setup.cfg是setuptools的配置文件,把元数据、选项从setup.py里剥离出来,让代码更简洁,也更便于静态解析。
pyproject.toml是PEP 517/518提出的新标准,它声明了“这个项目用什么后端工具来构建”,比如setuptools.build_meta。它正在成为Python打包的事实标准,新项目推荐优先写它。
我的建议是:新手从pyproject.toml+setup.py(一个空壳或兼容层)开始。为什么还要保留setup.py?因为老工具、某些CI流水线、以及部分pip版本依然会尝试从setup.py读取信息。保留一个极简的兼容壳可以省掉很多环境差异问题:
# setup.py from setuptools import setup setup()这样pyproject.toml里的配置就是真身,setup.py只是给老路径用的入口。
2.3 包名与项目名的区别
这是一个非常容易绊倒新手的细节。你在pyproject.toml里写的name是发行包名字(distribution name),比如requests;而你import时用的是导入包名(import package name),也就是目录名,比如requests对应import requests。
这两个名字可以不同。发到PyPI的包叫my-project,但代码里import myproject完全合法。我见过有人为了让包名好看,硬把目录名改成与PyPI名完全一致,结果目录里全是连字符,根本没法import。正确的做法是:发行包名用人类可读的名字(可用连字符、下划线、点),导入包名严格遵循Python标识符规则。
3. 核心文件配置详解与实操
结构定好了,接下来具体配置每个文件。我会直接给出一份能用的配置,然后逐项拆解含义。
3.1 pyproject.toml 关键字段逐条拆解
下面这份配置是我实测过可以直接用的模板:
[build-system] requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [project] name = "mylib" version = "0.1.0" description = "一个演示用的Python库" readme = "README.md" requires-python = ">=3.8" license = { text = "MIT" } authors = [ { name = "Your Name", email = "you@example.com" }, ] keywords = ["python", "pip", "utility"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Topic :: Software Development :: Libraries", ] dependencies = [ "requests>=2.25.0", ] [project.optional-dependencies] dev = [ "pytest>=7.0.0", "build>=0.10.0", ] [project.urls] Homepage = "https://example.com" Repository = "https://github.com/yourname/mylib" [tool.setuptools.packages.find] where = ["src"]重点说几个字段:
name:发行包名,用于pip install mylib。注意PyPI强制要求包名唯一,发布前在PyPI搜一下有没有重名。version:版本号,每次迭代要手动更新。进阶做法是用dynamic = ["version"]从包内__version__读取,但新手先手动维护最不容易出错。readme:指向README.md,PyPI页面会渲染它。很多人的README用Markdown写的,但忘了指定readme,导致PyPI页面上显示的是原始文档字符串,观感很差。dependencies:运行时依赖列表。pip安装时会自动解析并安装这些依赖。注意requests那个>=2.25.0的写法是“最低版本下限”,实际安装时会装当前环境里满足条件的最新版。optional-dependencies:可选依赖。比如开发测试要用的pytest装到dev分组下,用户pip install mylib[dev]才额外安装,平时不增加负担。[tool.setuptools.packages.find] where = ["src"]:告诉setuptools去src目录递归找包。这是src布局的关键配置,漏掉它会导致构建出来的包是空的。
3.2 README 与 LICENSE 别凑合
PyPI页面和文档质量直接影响别人愿不愿意用你的库。README至少应该包含:一句话简介、安装方式、最小示例代码、许可证说明。我的经验是,README里放一个能直接复制运行的示例,比写十段API文档都管用。
LICENSE建议选MIT或Apache-2.0。不写LICENSE的话,原则上别人是不能合法使用你的代码的,这在公司内部还好,公开项目会劝退很多潜在用户。LICENSE文件直接在源码里放一份,打包时setuptools会把根目录的LICENSE文件自动带上。
3.3 包内代码编写规范
包内代码和普通脚本有个重要区别:不要假设运行目录。你不能写open("data.json")这种依赖当前工作目录的代码,而要相对包内部路径去定位资源文件。如果需要读取包内自带的资源文件,用importlib.resources或pkgutil.get_data:
import pkgutil data = pkgutil.get_data("mylib", "data/config.json")另外,__init__.py里建议做一层对外API的收敛。比如只让你觉得稳定的函数暴露给用户,内部实现细节放到子模块里。这样后续重构内部实现时,只要对外接口不变,用户侧完全无感。
4. 完整实操:从零构建并本地安装验证
配置写完了,现在走一遍完整的构建和安装流程。我假设你已经有基础代码,我们直接开始验证。
4.1 安装构建工具链
先确保环境中有一份相对较新的pip和构建工具。老生常谈,但我还是要说:在虚拟环境里操作,尽量不要用系统Python直接装。用venv隔离,避免污染全局环境:
python -m venv .venv source .venv/bin/activate # Windows下是 .venv\Scripts\activate pip install --upgrade pip pip install buildbuild是一个标准化的构建前端工具,它会读取pyproject.toml的build-system配置,在隔离环境里完成构建。比直接用python setup.py sdist bdist_wheel更符合新规范,也不容易受本机已安装包的干扰。
4.2 执行构建命令
在项目根目录运行:
python -m build正常的话会在项目下生成dist/目录,包含两个产物:
mylib-0.1.0.tar.gz:源代码分发包(sdist)mylib-0.1.0-py3-none-any.whl:二进制轮子包(wheel)
这两个文件的用途不一样。sdist是给没有网络或需要自己构建的环境用的,它包含完整源码和构建配置;wheel是预构建好的安装包,pip安装时直接解压落地,速度更快。发布时两个都传PyPI,PyPI会自动区分平台和Python版本给用户选择。
4.3 本地安装与冒烟测试
构建成功不代表包能用,先本地安装验证:
pip install dist/mylib-0.1.0-py3-none-any.whl然后进入一个干净的Python交互环境,注意不要在你项目根目录下测试(否则Python可能会误用当前目录的源码而掩盖安装问题):
cd /tmp python -c "import mylib; print(mylib.__version__); print(mylib.Core().hello())"正常输出版本号和功能结果,说明包结构和导入逻辑没问题。我最常碰到的情况是这里import mylib直接ModuleNotFoundError,原因十有八九是[tool.setuptools.packages.find] where = ["src"]配错了,或者src/mylib/__init__.py不存在导致setuptools没把它识别为包。
4.4 可编辑安装模式注意事项
开发过程中每次改动都要重新构建安装一遍,很烦人。正确姿势是用可编辑安装:
pip install -e .这样安装后,Python导入解析直接指向你的源码目录,改了代码立即生效。但注意:-e安装需要项目根目录有可用的构建配置,并且基于PEP 660的可编辑安装要求setuptools>=64。如果报了奇怪错误,先升级setuptools再试。
我个人习惯是:开发阶段用-e,验证打包和发布时一定用非编辑模式完整装一遍。因为可编辑模式可能会“掩盖”一些文件缺失问题——源码在你电脑上肯定在,但真到了别人电脑上未必能跑。
5. 发布到PyPI并管理版本迭代
本地好用只是第一步,要让别人(或别的机器)能直接pip install你发布的正式版,需要把它推到PyPI。
5.1 注册PyPI账号并生成Token
去PyPI官网注册账号,然后在“Account settings”里创建一个API token。注意token的权限建议只勾选对应项目,不要用全账号权限的token,降低泄露风险。我们不建议在命令行直接明文写密码,现在的twine和pip都支持输入token作为密码,操作起来也安全很多。
5.2 用twine上传
上传工具我用得最多的是twine,它比python setup.py upload(已淘汰)更安全也更稳定:
pip install twine twine upload dist/*按提示输入用户名(用__token__)和token密码。上传成功后,你的库会出现在PyPI项目页上,所有人都可以执行:
pip install mylib这里有个常见翻车点:如果再执行一次构建和上传,PyPI会拒绝相同版本号。每次发版前必须先更新pyproject.toml里的version字段,并且建议遵循语义化版本规范:主版本号在API不兼容时递增,次版本号在向后兼容的功能新增时递增,修订号在bugfix时递增。
5.3 测试发布环境
很多人不知道PyPI有一个独立的测试环境test.pypi.org,专门用来演练发布流程。我强烈建议第一次发布时先去测试环境跑一遍,配置方法是在根目录创建.pypirc文件:
[distutils] index-servers = pypi testpypi [pypi] username = __token__ password = <你的正式token> [testpypi] repository = https://test.pypi.org/legacy/ username = __token__ password = <你的测试token>然后执行:
twine upload -r testpypi dist/*装回来验证:
pip install --index-url https://test.pypi.org/simple/ mylib这是一个非常值得养成的习惯。正式PyPI上传后发现问题虽然也能删版本,但已经装了你包的人会拉到有问题的版本,影响面不可控。测试环境随便折腾,发现错了删掉重传就行。
5.4 版本迭代与changelog管理
随着版本增多,我建议在项目里维护一份CHANGELOG.md,用 Keep a Changelog 的风格记录每次变更。别小看这个东西,三个月后你自己都会忘记某个行为是哪一版改的。配合Git tag,比如v0.1.0,可以做到代码、版本、文档三者一一对应。我实操中经常用到的发布命令序列是:
python -m build twine check dist/* twine upload dist/* git tag v0.1.0 git push origin v0.1.0twine check这一步很多人会跳过,但它能提前检查README格式、元数据完整性等问题,几秒钟的事,值得养成习惯。
6. 常见问题与排查技巧实录
这一节我把自己和其他同事在封装打包路上真正踩过的坑按症状整理出来,方便你对号入座排查。
6.1 ModuleNotFoundError:装了但找不到包
症状是pip install mylib成功,但import mylib直接报错。排查优先级:
- 确认安装的包名是否正确:
pip list | grep mylib。 - 确认是否src布局但没配
packages.find。 - 确认包目录里是否有
__init__.py,没有的话Python不认为它是包。 - 确认当前环境是不是你安装时的那个环境,虚拟环境装错是高频问题。
6.2 依赖装不上或版本冲突
当dependencies里声明了requests>=2.25.0,但用户环境里已装了最新requests 2.31.0,pip通常直接满足条件。真正让人头疼的是两个包对同一个第三方库的上限要求冲突,比如你的库要求numpy<2.0,另一个包要求numpy>=2.0,会导致pip报“ResolutionImpossible”。
应对策略是:依赖声明尽量放宽下界、收敛上界。只有确实已知某个版本及以上会破坏你的功能时,才收紧上限。尽量避免在dependencies里写一堆虽然你代码里引用了但只是某个函数才会用到的包。能用可选依赖分组的,尽量分组。
6.3 README在PyPI上不渲染
最常见原因是pyproject.toml里漏了readme字段,或README用了本地图片相对路径,PyPI的markdown渲染器解析不到。另一个问题是粗心把readme = "README.txt"写成了不存在文件名,构建直接报错。解决方法是构建后用twine check检查,它会明确提示README问题。
6.4 wheel文件名带奇怪的平台标签
理论上纯Python项目生成的wheel是py3-none-any,即跨平台跨Python版本。如果生成的是py3-none-linux_x86_64之类的带平台标记的文件,说明打包时setuptools误判了你的包含有二进制扩展,或者你用了某些C扩展库。纯Python项目的处理办法是检查代码里有没有意外包含.so或.dll文件、是否误用了ext_modules配置。我的一个经验是:先把项目彻底clean掉旧构建产物再重新python -m build,很多时候是缓存惹的祸。
6.5 pip安装卡在“Building wheel”
如果你是给用户发布的预构建wheel,用户不该遇到这一步。但如果用户用pip install git+https://...直接安装源码库,就会触发本地构建。此时需要用户本地有编译环境。规避方法是尽量发布wheel包,同时不要依赖需要编译的原生库,除非你有意做多平台分发。
6.6 版本号重复上传失败
报错文本通常是“File already exists”或“400 Client Error”。解决办法是更新版本号重新构建。如果只是某个文件传重了,PyPI不允许覆盖,必须提升版本号。这套规则虽然繁琐,但恰恰是保障供应链安全的关键机制,没必要嫌它麻烦。
说实话,把代码封装成标准库这件事,技术难度并不高,真正拉开差距的是工程习惯。我个人在实际操作中的体会是:目录结构先摆对、pyproject.toml配置逐项写清楚、构建上传前务必走一遍测试环境,这三步做扎实了,后面基本不会出大问题。还有一个最后想分享的小技巧是,无论多小的工具库,都建议在包里写一行__version__,并在代码里通过from mylib import __version__读取。这个小小的约定能让你在几十个项目里快速定位每个环境装的是哪个版本,排查兼容问题时节省大量时间。