
聊到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.tomlsetup.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 [setuptools61.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 youexample.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 [ requests2.25.0, ] [project.optional-dependencies] dev [ pytest7.0.0, build0.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.mdPyPI页面会渲染它。很多人的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_dataimport 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源代码分发包sdistmylib-0.1.0-py3-none-any.whl二进制轮子包wheel这两个文件的用途不一样。sdist是给没有网络或需要自己构建的环境用的它包含完整源码和构建配置wheel是预构建好的安装包pip安装时直接解压落地速度更快。发布时两个都传PyPIPyPI会自动区分平台和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的可编辑安装要求setuptools64。如果报了奇怪错误先升级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里声明了requests2.25.0但用户环境里已装了最新requests 2.31.0pip通常直接满足条件。真正让人头疼的是两个包对同一个第三方库的上限要求冲突比如你的库要求numpy2.0另一个包要求numpy2.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 githttps://...直接安装源码库就会触发本地构建。此时需要用户本地有编译环境。规避方法是尽量发布wheel包同时不要依赖需要编译的原生库除非你有意做多平台分发。6.6 版本号重复上传失败报错文本通常是“File already exists”或“400 Client Error”。解决办法是更新版本号重新构建。如果只是某个文件传重了PyPI不允许覆盖必须提升版本号。这套规则虽然繁琐但恰恰是保障供应链安全的关键机制没必要嫌它麻烦。说实话把代码封装成标准库这件事技术难度并不高真正拉开差距的是工程习惯。我个人在实际操作中的体会是目录结构先摆对、pyproject.toml配置逐项写清楚、构建上传前务必走一遍测试环境这三步做扎实了后面基本不会出大问题。还有一个最后想分享的小技巧是无论多小的工具库都建议在包里写一行__version__并在代码里通过from mylib import __version__读取。这个小小的约定能让你在几十个项目里快速定位每个环境装的是哪个版本排查兼容问题时节省大量时间。