如何使用同一份setup.py实现Python包的cythonized与非cythonized开发安装
单份setup.py兼容Python源码部署与Cython编译部署实现方案
现有一套纯Python3编写的包,需要在不同场景下分别以Cython编译形式或原始脚本形式部署,所有源码均为纯Python3代码,要求尽可能使用同一份setup.py实现所有场景的需求。
| 用例类型 | 命令 | 就地安装 | 包含.py模块 | 包含Cython编译.so模块 |
|---|---|---|---|---|
| 1. 开发场景 | pip3 install -e . | 是 | 是 | 无 |
| 2. 未优化安装 | pip3 install . | 否 | 是 | 无 |
| 3. Cython编译安装 | pip3 install . --install-option="--cythonize" | 否 | 否(仅保留__init__.py) | 是 |
| 4. 构建二进制wheel | python3 setup.py bdist_wheel | 否 | 否(仅保留__init__.py) | 是 |
已实现仅包含Cython编译.so共享库、不包含原始.py模块的二进制wheel包构建逻辑(覆盖用例4),通过自定义build_py类实现。现在需要补充逻辑覆盖剩余3个用例。
遇到的核心问题
- 用例1、2不需要执行Cython编译,不应指定
ext_modules参数:- 调用
setup()前很难直接检测用户请求的操作类型,不确定是否通过重写install和develop类实现更合理 - 若采用重写命令类的方案,是否可以合法清空
ext_modules,如何避免cythonize()被提前求值
- 调用
- 用例2场景下执行
pip3 install .会构建egg包,且egg包中意外包含.so文件,不确定是否是cythonize()全局执行导致,如何避免该问题 - 当前方案会同时打包不需要的源码和Cython编译模块,如何实现Cython编译场景下仅安装
__init__.py、不安装其余源码模块
解决方案
核心思路
将Cython编译逻辑改为条件触发,仅当用户执行bdist_wheel命令,或install命令携带--cythonize参数时,才执行Cython编译逻辑,其余场景均走纯Python源码安装逻辑:
- 提前解析命令行参数判断当前执行的命令类型,避免
cythonize()被不必要的场景提前执行 - 动态决定
ext_modules参数和cmdclass的注册逻辑,不需要编译的场景直接使用默认的setuptools逻辑 - Cython编译场景下保留原有的
build_py过滤逻辑,自动过滤掉非__init__.py的源码文件
完整实现代码
import fnmatch import sys from setuptools import setup, find_packages, Extension from setuptools.command.build_py import build_py as build_py_orig try: from Cython.Build import cythonize except ImportError: cythonize = None # 基础配置项 PACKAGE_NAME = "spam" extensions = [ Extension(f'{PACKAGE_NAME}.*', [f'{PACKAGE_NAME}/**/*.py'], extra_compile_args=["-O3", "-Wall"]), ] cython_excludes = [f'{PACKAGE_NAME}/**/__init__.py'] # 检测是否需要执行Cython编译 need_cythonize = False if "bdist_wheel" in sys.argv: need_cythonize = True if "install" in sys.argv and "--cythonize" in sys.argv: need_cythonize = True # 移除自定义参数,避免setuptools识别为无效参数 sys.argv.remove("--cythonize") # Cython编译场景下的源码过滤逻辑 def not_cythonized(tup): (package, module, filepath) = tup return any( fnmatch.fnmatchcase(filepath, pat=pattern) for pattern in cython_excludes ) or not any( fnmatch.fnmatchcase(filepath, pat=pattern) for ext in extensions for pattern in ext.sources ) class build_py_cythonized(build_py_orig): def find_modules(self): modules = super().find_modules() return list(filter(not_cythonized, modules)) def find_package_modules(self, package, package_dir): modules = super().find_package_modules(package, package_dir) return list(filter(not_cythonized, modules)) # 动态配置setup参数 ext_modules = [] cmdclass = {} if need_cythonize and cythonize is not None: ext_modules = cythonize( extensions, exclude=cython_excludes, compiler_directives={ "language_level": 3, "always_allow_keywords": True, }, build_dir="build", ) cmdclass["build_py"] = build_py_cythonized setup( name=PACKAGE_NAME, packages=find_packages(), ext_modules=ext_modules, cmdclass=cmdclass, include_package_data=True, install_requires=[ # 填写你的依赖项 ] )
问题对应说明
ext_modules处理逻辑:- 无需重写
install和develop类,通过提前解析sys.argv即可准确判断用户执行的命令类型 - 不需要Cython编译的场景
ext_modules直接设为空列表,是setuptools支持的合法配置;cythonize()仅在需要编译的场景才会被执行,不会提前求值
- 无需重写
- 普通安装场景egg包包含.so的问题:
- 普通安装场景不会触发
cythonize()执行,不会生成任何.so文件,自然不会被打包到egg包中 - 若需要完全避免egg构建,可在pip安装时添加参数
pip install . --wheel,强制走wheel构建流程
- 普通安装场景不会触发
- 源码过滤问题:
- Cython编译场景下会自动注册自定义的
build_py_cythonized类,自动过滤掉所有非__init__.py的源码模块,仅保留必要的入口文件
- Cython编译场景下会自动注册自定义的
内容的提问来源于stack exchange,提问作者TheDiveO
相关产品推荐
相关产品推荐

