别再手动写packages了!用setuptools的find_packages()自动打包你的Python多模块项目
别再手动维护Python包列表了!用find_packages()实现智能模块发现
当你第一次尝试打包一个包含多个子模块的Python项目时,setup.py文件中那个看似简单的packages参数可能会让你抓狂。想象一下这样的场景:你的项目结构随着开发不断变化,每次新增一个子包,你都得记得回来更新这个列表。更糟的是,如果你不小心漏掉了一个包,用户在安装时就会遇到"ModuleNotFoundError"——而你可能要到用户反馈时才会发现这个问题。
1. 为什么手动维护packages列表是个糟糕的主意
在典型的Python项目中,随着功能模块的增加,目录结构往往会变得越来越复杂。比如一个机器学习项目可能包含以下子包:
project/ ├── data/ │ ├── __init__.py │ ├── loaders.py │ └── preprocessors.py ├── models/ │ ├── __init__.py │ ├── neural_net.py │ └── traditional.py ├── utils/ │ ├── __init__.py │ ├── logger.py │ └── metrics.py └── tests/ ├── __init__.py ├── test_data.py └── test_models.py手动维护packages列表时,你的setup.py可能是这样的:
from setuptools import setup setup( name="my_project", version="0.1", packages=[ "project", "project.data", "project.models", "project.utils", # 很容易忘记添加新创建的包 ], # 其他参数... )这种方式的三大致命缺陷:
- 维护成本高:每次新增子包都需要手动更新列表
- 容易出错:遗漏包会导致安装后无法导入
- 包含不需要的包:可能不小心包含测试包或示例代码
提示:根据PyPA的打包指南,手动指定packages已被列为不推荐做法,特别是在项目结构复杂的情况下。
2. find_packages()的工作原理与基本用法
setuptools提供的find_packages()函数通过扫描项目目录自动发现Python包,解决了上述所有问题。它的工作逻辑非常直观:
- 递归扫描指定目录(默认为setup.py所在目录)
- 识别所有包含
__init__.py的目录(即Python包) - 返回这些包的导入路径列表
最基本的用法简单到令人发指:
from setuptools import setup, find_packages setup( name="my_project", version="0.1", packages=find_packages(), # 自动包含所有子包! )这个简单的改变带来的好处立竿见影:
- 自动包含新包:新建的子包会被自动发现
- 减少错误:不再有遗漏包的问题
- 一致性保证:打包行为与项目结构始终保持同步
2.1 理解find_packages的扫描规则
为了更好地控制打包过程,我们需要了解find_packages()的扫描行为:
包含规则:
- 目录必须包含
__init__.py文件 - 包名必须是有效的Python标识符(不能以数字开头等)
- 目录必须包含
排除规则:
- 默认排除名称中包含"test"或"tests"的目录
- 不扫描以点(.)开头的隐藏目录
下表对比了手动指定与自动发现的差异:
| 特性 | 手动指定packages | find_packages() |
|---|---|---|
| 维护成本 | 高(需手动更新) | 零维护 |
| 准确性 | 依赖开发者记忆 | 自动保证 |
| 灵活性 | 静态列表 | 动态适应项目变化 |
| 测试包处理 | 可能意外包含 | 默认排除 |
| 新开发者友好度 | 低(需了解全部结构) | 高(自动适应) |
3. 高级过滤:精确控制包含哪些包
虽然find_packages()的默认行为已经能解决大部分问题,但有时我们需要更精细的控制。比如:
- 排除特定的子包(如示例代码、实验性功能)
- 只包含特定模式的包
- 处理特殊的目录结构
3.1 使用exclude参数过滤不需要的包
最常见的需求是排除测试代码和示例:
packages=find_packages(exclude=["tests", "tests.*", "examples", "examples.*"])这个配置会:
- 排除顶级tests目录
- 排除所有tests的子包(如tests.unit)
- 排除examples目录及其子包
注意:exclude模式使用Unix shell风格的通配符,所以
tests.*可以匹配所有tests的子包。
3.2 使用include参数指定包含模式
当项目结构特别复杂时,你可能想明确指定包含哪些包:
packages=find_packages(include=["project", "project.*"])这会:
- 包含顶级project包
- 包含所有project的子包
- 排除其他所有包
3.3 处理特殊目录结构
有时项目可能采用非标准布局,比如:
src/ └── my_pkg/ ├── __init__.py └── submodule.py这时需要指定查找的根目录:
packages=find_packages(where="src")并在setup.py中配置package_dir告诉setuptools如何映射:
setup( # ... packages=find_packages(where="src"), package_dir={"": "src"}, )4. 实战:从手动到自动的完整迁移案例
让我们通过一个真实场景演示如何将现有项目从手动packages迁移到find_packages()。
4.1 初始项目结构
假设我们有一个Flask web应用项目:
flask_app/ ├── app/ │ ├── __init__.py │ ├── models/ │ │ ├── __init__.py │ │ ├── user.py │ │ └── product.py │ ├── routes/ │ │ ├── __init__.py │ │ ├── api.py │ │ └── web.py │ └── utils/ │ ├── __init__.py │ ├── auth.py │ └── decorators.py ├── tests/ │ ├── __init__.py │ ├── test_models.py │ └── test_routes.py ├── scripts/ │ ├── __init__.py │ └── deploy.py └── setup.py4.2 原始setup.py(手动维护)
from setuptools import setup setup( name="flask_app", version="1.0", packages=[ "app", "app.models", "app.routes", "app.utils", # 忘记添加新创建的包怎么办? # scripts目录应该包含吗? ], )4.3 改进后的setup.py(自动发现)
from setuptools import setup, find_packages setup( name="flask_app", version="1.0", packages=find_packages(exclude=["tests*", "scripts*"]), # 明确排除测试和脚本目录 install_requires=[ "flask>=2.0", "sqlalchemy>=1.4", ], )4.4 验证打包结果
安装前检查哪些包会被包含:
python setup.py --verbose check构建源码发布包并查看内容:
python setup.py sdist tar -tzvf dist/flask_app-1.0.tar.gz你应该看到类似这样的输出,确认只包含了app及其子包:
drwxr-xr-x 0 user group 0 Jan 1 12:00 flask_app-1.0/ drwxr-xr-x 0 user group 0 Jan 1 12:00 flask_app-1.0/app/ drwxr-xr-x 0 user group 0 Jan 1 12:00 flask_app-1.0/app/models/ drwxr-xr-x 0 user group 0 Jan 1 12:00 flask_app-1.0/app/routes/ drwxr-xr-x 0 user group 0 Jan 1 12:00 flask_app-1.0/app/utils/5. 常见问题与最佳实践
5.1 为什么我的包没有被正确包含?
如果发现某些包没有被find_packages()包含,检查以下几点:
确认存在__init__.py:
- 没有
__init__.py的目录不会被识别为Python包 - 即使是空文件也必须存在
- 没有
检查exclude模式:
- 确认没有意外排除了需要的包
- 模式匹配是大小写敏感的
验证目录结构:
- 确保包位于setup.py所在的目录或子目录
- 对于非标准布局,记得设置
where参数
5.2 性能考虑:大型项目中的优化
对于包含数百个子包的超大型项目,find_packages()的递归扫描可能会带来明显的性能开销。这时可以考虑:
使用include限制范围:
find_packages(include=["core*", "utils*"])拆分项目:
- 考虑将大型项目拆分为多个较小的包
- 使用namespace packages组织代码
5.3 与构建工具链的集成
现代Python项目通常使用更高级的构建工具,这些工具也与find_packages()良好集成:
Poetry配置示例:
[tool.poetry] packages = [ { include = "app", from = "src" }, { include = "app.*", from = "src" } ]Flit配置示例:
[build-system] requires = ["flit_core>=3.2"] build-backend = "flit_core.buildapi" [tool.flit.module] name = "app"5.4 测试你的打包配置
建立自动化测试验证打包行为是个好习惯:
- 创建一个简单的测试脚本:
import sys import pkgutil def test_imports(): for _, modname, _ in pkgutil.iter_modules(["app"]): __import__(f"app.{modname}")- 在CI/CD流水线中添加打包验证步骤:
# .github/workflows/test.yml jobs: test-packaging: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 - run: pip install . - run: python -m pytest tests/test_packaging.py6. 超越find_packages:现代Python打包实践
虽然find_packages()解决了包发现的问题,但现代Python打包还有更多最佳实践值得关注:
6.1 使用pyproject.toml替代setup.py
PEP 517和PEP 518引入了pyproject.toml作为新的标准配置文件:
[build-system] requires = ["setuptools>=42"] build-backend = "setuptools.build_meta" [project] name = "my_project" version = "0.1.0" dependencies = [ "requests>=2.25", "numpy>=1.20", ] [tool.setuptools.packages] find = {} # 使用默认的find_packages()6.2 打包非Python文件
如果你的包需要包含数据文件(如模板、静态资源等),需要使用package_data或MANIFEST.in:
setup( # ... package_data={ "app": ["templates/*.html", "static/*.css"], }, include_package_data=True, )6.3 命名空间包的处理
对于跨多个分发版的共享命名空间,使用PEP 420风格的命名空间包:
project/ ├── src/ │ ├── company/ │ │ └── product/ │ │ ├── __init__.py │ │ └── module.pysetup.py配置:
setup( packages=find_packages(where="src"), package_dir={"": "src"}, namespace_packages=["company"], )7. 从理论到实践:真实项目中的决策点
在实际项目中采用find_packages()时,有几个关键决策需要考虑:
测试代码的处理方式:
- 完全排除(推荐用于生产部署)
- 作为额外依赖包含(
tests_require参数) - 打包为独立的分发包
示例代码的打包策略:
- 与主包一起分发
- 作为独立示例项目
- 完全排除,仅保留在文档中
开发工具的选择:
- 经典setuptools(兼容性好)
- Poetry(更适合应用开发)
- Flit(适合纯Python小包)
在最近的一个Web服务项目中,我们采用了这样的配置:
setup( name="web_service", version="0.1", packages=find_packages(exclude=["tests*", "examples*"]), extras_require={ "dev": [ "pytest>=6.0", "black>=21.0", ], "docs": [ "sphinx>=4.0", "furo>=2021.0", ], }, python_requires=">=3.8", )这种配置实现了:
- 生产安装只包含运行时必需的包
- 开发者可以通过
pip install -e .[dev]获取测试工具 - 明确指定了Python版本要求
