背景

在 Python 项目开发中,随着代码包数量和复杂度的增加,为了更好地管理多个代码包的命名空间及其依赖,推荐使用 PEP 420 提供的命名空间包功能。通过这种方式,可以构建属于同一发行商(vendor)下的多个独立代码包,且这些包可以分别位于不同的代码仓库中。

在此基础上,某些代码包可能需要进一步支持可选功能模块(例如 optional1optional2),用户可以根据需要选择安装这些功能模块。本文将分阶段说明如何实现:

  1. 按 PEP 420 构建统一的目录风格以管理同一 vendor 下的多个代码包。
  2. 为某个代码包添加支持可选功能的扩展设计及测试策略。

项目目录结构

按 PEP 420 构建命名空间包

以下是适用于多个代码包的推荐目录结构,每个代码包独立版本管理,且可以分别推送到 PyPI:

vendor-package1-repo/
├── src/
│   └── vendor/
│       └── package1/
│           ├── __init__.py  # 定义主包的接口
│           ├── core.py      # 主包核心功能
├── tests/
│   └── test_core.py         # 测试主包功能
├── setup.py                  # 配置分发信息
├── README.md                 # 项目说明文档
└── pyproject.toml            # 构建工具配置

vendor-package2-repo/
├── src/
│   └── vendor/
│       └── package2/
│           ├── __init__.py  # 定义 package2 的接口
│           ├── feature.py   # package2 核心功能
├── tests/
│   └── test_feature.py      # 测试 package2 功能
├── setup.py                  # 配置分发信息
├── README.md                 # 项目说明文档
└── pyproject.toml            # 构建工具配置

增加支持可选功能的目录结构

对于某些代码包(例如 vendor.package1),可能需要进一步支持可选功能模块。此时可在原目录结构中增加子模块:

vendor-package1-repo/
├── src/
│   └── vendor/
│       └── package1/
│           ├── __init__.py  # 定义主包的接口
│           ├── core.py      # 主包核心功能
│           ├── optional1/
│           │   ├── __init__.py  # optional1 的入口
│           │   └── feature1.py
│           └── optional2/
│               ├── __init__.py  # optional2 的入口
│               └── feature2.py
├── tests/
│   ├── test_core.py          # 测试主包功能
│   ├── optional1/
│   │   └── test_feature1.py  # 测试 optional1
│   └── optional2/
│       └── test_feature2.py  # 测试 optional2
├── setup.py                  # 配置分发信息
├── README.md                 # 项目说明文档
└── pyproject.toml            # 构建工具配置

setup.pypyproject.toml 配置

Python 官方(PyPA)推荐使用 pyproject.toml 进行打包,而不再依赖 setup.py,原因如下:

  • setup.py 直接执行代码,可能导致不安全的行为(如 setup.py install 可能会执行恶意代码)。
  • setup.py 方式已被 PEP 517PEP 518 取代,现代 pip 版本不会直接调用 setup.py
  • pyproject.toml 提供标准化的构建系统,使得不同构建工具(如 setuptoolspoetryhatch)可以互换。

setup.py 方式(旧方法,兼容性考虑)

from setuptools import setup, find_namespace_packages

setup(
    name="vendor-package1",
    version="0.1.0",
    packages=find_namespace_packages(where="src"),
    package_dir={"": "src"},
    install_requires=["requests", "numpy"],
    extras_require={
        "optional1": ["matplotlib"],
        "optional2": ["pandas"]
    },
)

配置说明

  • extras_require 定义了可选功能及其依赖:

    • optional1optional2 分别对应各功能模块的依赖。
    • all 用于安装所有可选功能。
  • find_namespace_packages 自动发现命名空间包。
  • package_dir 指定源码根目录为 src,避免包根目录污染。

pyproject.toml 方式(推荐方法)

vendor-package1-repo/pyproject.toml 中:

[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "vendor-package1"
version = "0.1.0"
dependencies = [
    "requests",
    "numpy"
]

[project.optional-dependencies]
optional1 = ["matplotlib"]
optional2 = ["pandas"]

[tool.setuptools]
packages = ["vendor.package1"]

主包入口设计

src/vendor/package1/__init__.py 中,通过 Facade 模式提供统一的入口,同时支持按需导入可选模块。

示例代码

# src/vendor/package1/__init__.py

# 导入主包核心功能
from .core import CoreClass

__all__ = ["CoreClass"]

# 尝试导入 optional1 和 optional2,如果未安装,则忽略
try:
    from .optional1 import OptionalFeature1
    __all__.append("OptionalFeature1")
except ImportError:
    pass

try:
    from .optional2 import OptionalFeature2
    __all__.append("OptionalFeature2")
except ImportError:
    pass

使用示例

用户安装主包后,可以直接使用核心功能:

from vendor.package1 import CoreClass

obj = CoreClass()
obj.some_method()

如果安装了 optional1optional2,可以按需导入:

from vendor.package1.optional1 import OptionalFeature1
from vendor.package1.optional2 import OptionalFeature2

feature1 = OptionalFeature1()
feature2 = OptionalFeature2()

测试策略

由于每个代码包及其可选功能均位于独立代码仓库中,并独立版本管理,因此测试需要考虑以下需求:

  1. 统一全量测试:测试主包功能以及所有可选功能。
  2. 部分功能测试:测试主包功能以及某个具体的可选功能。
  3. 版本兼容性测试:验证主包与不同版本的依赖包是否兼容。

示例测试代码

全量测试

在全量测试中,需要确保安装了所有可选功能的依赖:

pip install vendor-package1[all]

示例代码:

# tests/test_all.py
import unittest
from vendor.package1 import CoreClass
from vendor.package1.optional1 import OptionalFeature1
from vendor.package1.optional2 import OptionalFeature2

class TestAllFeatures(unittest.TestCase):
    def test_core(self):
        obj = CoreClass()
        self.assertEqual(obj.some_method(), "expected result")

    def test_optional1(self):
        feature1 = OptionalFeature1()
        self.assertTrue(feature1.some_method())

    def test_optional2(self):
        feature2 = OptionalFeature2()
        self.assertTrue(feature2.some_method())

部分功能测试

只安装需要测试的功能依赖,例如:

pip install vendor-package1[optional1]

示例代码:

# tests/optional1/test_feature1.py
import unittest
from vendor.package1.optional1 import OptionalFeature1

class TestOptionalFeature1(unittest.TestCase):
    def test_feature1(self):
        feature1 = OptionalFeature1()
        self.assertTrue(feature1.some_method())

版本兼容性测试

在测试环境中,明确安装需要测试的依赖版本,例如:

pip install vendor-package2==1.0.0

测试代码应验证功能是否正常运行:

# tests/test_version_compatibility.py
import unittest
from vendor.package1 import CoreClass

class TestVersionCompatibility(unittest.TestCase):
    def test_with_specific_version(self):
        obj = CoreClass()
        self.assertTrue(obj.compatible_with_package2())

构建与分发

添加 MANIFEST.in

MANIFEST.in 文件中指定要包含的文件,确保 src/ 目录正确被打包,同时排除无关的缓存文件:

global-exclude *.pyc
global-exclude __pycache__
recursive-exclude * *.pyc
recursive-include src *

此外,为了避免非必要的文件影响最终的包体积,我们不包含 docs/tests/ 目录,因为:

  • docs/ 目录通常包含文档和示例,适用于开发阶段,而不是最终的分发包。
  • tests/ 目录仅用于测试,不影响最终用户的使用,因此无需包含。

使用 setup.py 进行打包(旧方式,兼容性考虑)

python setup.py sdist bdist_wheel

其中:

  • sdist(Source Distribution):生成 .tar.gz 源码包,适用于需要构建的环境。
  • bdist_wheel(Binary Distribution):生成 .whl 轮子包,适用于直接安装的环境,提高安装速度。

但官方已不推荐使用此方式,建议改为 pyproject.toml 方式。

使用 pyproject.tomlpython -m build 进行打包(推荐)

安装 build 工具(如果尚未安装):

pip install build

然后使用以下命令进行打包:

python -m build

这将生成 dist/ 目录,包含 .tar.gz.whl 供发布。

dist 目录中生成的分发包

生成的包会出现在 dist/ 目录中,例如:

dist/
  yourpackage-0.1.0.tar.gz
  yourpackage-0.1.0-py3-none-any.whl

.tar.gz.whl 的内容和作用

  • .tar.gz(源代码包)

    • 包含所有源代码,适用于需要从源码构建的情况。
    • 安装时,pip 会自动运行 setup.py install 进行编译和安装。
  • .whl(轮子包)

    • 预编译的二进制格式,可以直接安装。
    • 安装速度快,适用于发布到 PyPI 供其他用户下载。

egg-infodist-info 目录的作用

  • egg-info/ 目录

    • 在打包过程中,setuptools 可能会在源代码目录下生成 yourpackage.egg-info/ 目录。
    • 该目录包含包的元数据,如 PKG-INFOSOURCES.txtdependency_links.txt,用于 pipsetuptools 解析包的信息。
    • 这个目录通常不会包含在最终的 .whl.tar.gz 里,但可能会残留在源代码目录下。
  • dist-info/ 目录

    • .whl 包通常包含 yourpackage-X.X.X.dist-info/ 目录。
    • 该目录包含 .whl 的元数据,如 METADATAWHEELRECORD,用于存储包的详细信息、依赖关系和安装记录。
    • 这是 wheel 格式包特有的,确保安装时 pip 能正确解析包的信息。

上传到 PyPI

  1. 注册 PyPI 账户
    如果尚未注册 PyPI 账户,请访问 https://pypi.org/ 进行注册。
  2. 创建 .pypirc(可选)
    方便上传时自动填写用户名和密码:

    [pypi]
    username = your-username
    password = your-password
  3. 上传包到 PyPI

    twine upload dist/*

    成功上传后,用户可以通过 pip install yourpackage 直接安装你的包。

本地测试安装

在上传前,你可以本地测试安装你的包:

pip install dist/yourpackage-0.1.0-py3-none-any.whl

这样可以验证 .whl 是否正确可用。

如果有任何问题,可以检查 twine check dist/* 确保包的格式正确。


用户安装与使用

安装主包

用户只需安装主包时:

pip install vendor-package1

安装特定可选功能

用户需要安装 optional1optional2 时:

pip install vendor-package1[optional1]
pip install vendor-package1[optional2]

安装所有可选功能

一次性安装所有可选功能:

pip install vendor-package1[all]

注意事项

  1. 清晰的文档说明:在 README.md 中说明可选功能的用途及安装方法,帮助用户快速上手。
  2. 依赖管理:确保 extras_require 中的依赖版本与功能需求匹配。
  3. 版本兼容性:明确标注主包和可选模块之间的兼容版本关系,避免因版本不匹配导致问题。
  4. 向后兼容性:在 __init__.py 中使用 try-except 捕获未安装的可选模块,避免因缺少依赖导致错误。

通过上述设计,vendor.package1 及其可选功能模块可以实现灵活的安装与使用,同时保证多个代码包间的独立性和兼容性。


vistart
0 声望0 粉丝

未破壳的雏。