只打包用 Cython 编译的 python 库的二进制编译 .so 文件

Package only binary compiled .so files of a python library compiled with Cython

我有一个名为 mypack 的包,里面有一个模块 mymod.py,并且 __init__.py。 出于某种不在争论中的原因,我需要打包这个模块编译 (也不允许 .py 或 .pyc 文件)。也就是说,__init__.py 是唯一的 分发压缩文件中允许的源文件。

文件夹结构为:

. 
│  
├── mypack
│   ├── __init__.py
│   └── mymod.py
├── setup.py

我发现 Cython 可以通过转换 .so 库中的每个 .py 文件来做到这一点 可以直接用 python 导入。

问题是:setup.py 文件必须如何才能轻松打包和安装?

目标系统有一个 virtualenv,必须在其中安装软件包 任何允许轻松安装和卸载的方法(easy_install、pip 等都是 欢迎)。

我尝试了所有我力所能及的。我阅读了 setuptoolsdistutils 文档, 所有与计算器相关的问题, 并尝试了各种命令(sdist、bdist、bdist_egg 等),其中有很多 setup.cfg 和 MANIFEST.in 文件条目的组合。

我得到的最接近的是下面的安装文件,它将 class bdist_egg 命令也删除 .pyc 文件,但这会破坏安装。

在 venv 中安装 "manually" 文件的解决方案是 也很好,前提是包含在适当文件中的所有辅助文件 安装都涵盖了(我需要在 venv 中 运行 pip freeze 看看 mymod==0.0.1).

运行 它与:

python setup.py bdist_egg --exclude-source-files

并(尝试)使用

安装它
easy_install mymod-0.0.1-py2.7-linux-x86_64.egg

您可能会注意到,目标是 linux 64 位 python 2.7.

from Cython.Distutils import build_ext
from setuptools import setup, find_packages
from setuptools.extension import Extension
from setuptools.command import bdist_egg
from setuptools.command.bdist_egg import  walk_egg, log 
import os

class my_bdist_egg(bdist_egg.bdist_egg):

    def zap_pyfiles(self):
        log.info("Removing .py files from temporary directory")
        for base, dirs, files in walk_egg(self.bdist_dir):
            for name in files:
                if not name.endswith('__init__.py'):
                    if name.endswith('.py') or name.endswith('.pyc'):
                        # original 'if' only has name.endswith('.py')
                        path = os.path.join(base, name)
                        log.info("Deleting %s",path)
                        os.unlink(path)

ext_modules=[
    Extension("mypack.mymod", ["mypack/mymod.py"]),
]

setup(
  name = 'mypack',
  cmdclass = {'build_ext': build_ext, 
              'bdist_egg': my_bdist_egg },
  ext_modules = ext_modules,
  version='0.0.1',
  description='This is mypack compiled lib',
  author='Myself',
  packages=['mypack'],
)

更新。 按照@Teyras 的回答,可以按照答案中的要求构建一个轮子。 setup.py 文件内容为:

import os
import shutil
from setuptools.extension import Extension
from setuptools import setup
from Cython.Build import cythonize
from Cython.Distutils import build_ext

class MyBuildExt(build_ext):
    def run(self):
        build_ext.run(self)
        build_dir = os.path.realpath(self.build_lib)
        root_dir = os.path.dirname(os.path.realpath(__file__))
        target_dir = build_dir if not self.inplace else root_dir
        self.copy_file('mypack/__init__.py', root_dir, target_dir)

    def copy_file(self, path, source_dir, destination_dir):
        if os.path.exists(os.path.join(source_dir, path)):
            shutil.copyfile(os.path.join(source_dir, path), 
                            os.path.join(destination_dir, path))


setup(
  name = 'mypack',
  cmdclass = {'build_ext': MyBuildExt},
  ext_modules = cythonize([Extension("mypack.*", ["mypack/*.py"])]),
  version='0.0.1',
  description='This is mypack compiled lib',
  author='Myself',
  packages=[],
  include_package_data=True )

重点是设置packages=[],。需要覆盖 build_ext class run 方法才能在 wheel 中获取 __init__.py 文件。

这正是这类问题 the Python wheels formatdescribed in PEP 427 – 旨在解决。

轮子是 Python 鸡蛋的替代品(were/are 有很多问题)– they are supported by pip, can contain architecture-specific private binaries (here is one example of such an arrangement)并且被 Python 社区普遍接受在这些事情上有利害关系。

这是 aforelinked Python on Wheels 文章中的一个 setup.py 片段,展示了如何设置二进制分布:

import os
from setuptools import setup
from setuptools.dist import Distribution

class BinaryDistribution(Distribution):
    def is_pure(self):
        return False

setup(
    ...,
    include_package_data=True,
    distclass=BinaryDistribution,
)

… 在您正在使用的较旧的 setuptools 类 的 leu 中(但可能仍以某种方式仍然受到规范支持)。正如概述的那样,为您的分发目的制作 Wheels 非常简单 - 正如我从经验中回忆的那样,wheel 模块的构建过程有点认识到 virtualenv,或者在其他.

无论如何,我认为,将 setuptools 基于 egg 的 API 换成基于轮子的工具应该可以为您省去一些严重的痛苦。

我建议你使用wheel 格式(fish2000 建议)。然后,在您的 setup.py 中,将 packages 参数设置为 []。您的 Cython 扩展仍将构建,并且生成的 .so 文件将包含在生成的 wheel 包中。

如果你的 __init__.py 没有包含在 wheel 中,你可以覆盖 Cython 提供的 build_ext class 的 run 方法并从你的源中复制文件tree 到构建文件夹(路径可以在 self.build_lib 中找到)。

虽然打包成轮子绝对是您想要的,但最初的问题是关于从包中排除 .py 源文件。 @Teyras 在 Using Cython to protect a Python codebase 中解决了这个问题,但他的解决方案使用了 hack:它从对 setup() 的调用中删除了 packages 参数。这可以防止 build_py 从 运行 步骤确实排除了 .py 文件,但它也排除了任何您想要包含在包中的数据文件。 (例如,我的包有一个名为 VERSION 的数据文件,其中包含包版本号。)更好的解决方案是将 build_py setup 命令替换为仅复制的自定义命令数据文件。

您还需要如上所述的 __init__.py 文件。所以自定义 build_py 命令应该创建 __init_.py 文件。我发现编译的 __init__.so 在导入包时运行,所以只需要一个空的 __init__.py 文件来告诉 Python 该目录是一个可以导入的模块。

您的自定义 build_py class 看起来像:

import os
from setuptools.command.build_py import build_py

class CustomBuildPyCommand(build_py):
    def run(self):
        # package data files but not .py files
        build_py.build_package_data(self)
        # create empty __init__.py in target dirs
        for pdir in self.packages:
            open(os.path.join(self.build_lib, pdir, '__init__.py'), 'a').close()

并配置setup覆盖原来的build_py命令:

setup(
   ...
   cmdclass={'build_py': CustomBuildPyCommand},
)

不幸的是, is wrong and may break a lot of stuff, as can e.g. be seen in 。不要使用它。您不应从 dist 中排除所有包,而应仅排除将被 cython 化并编译为共享对象的 python 文件。

下面是一个工作示例;它使用 from the question 。示例项目包含包含两个模块 spam.eggsspam.bacon 的包 spam,以及包含一个模块 spam.fizz.buzz:

的子包 spam.fizz
root
├── setup.py
└── spam
    ├── __init__.py
    ├── bacon.py
    ├── eggs.py
    └── fizz
        ├── __init__.py
        └── buzz.py

模块查找在 build_py 命令中完成,因此您需要使用自定义行为对其进行子类化。

简单情况:编译所有源代码,不做任何例外

如果您要编译每个 .py 文件(包括 __init__.pys),覆盖 build_py.build_packages 方法就足够了,使它成为一个 noop。因为 build_packages 什么都不做,所以根本不会收集 .py 文件,并且 dist 将只包含 cythonized 扩展:

import fnmatch
from setuptools import find_packages, setup, Extension
from setuptools.command.build_py import build_py as build_py_orig
from Cython.Build import cythonize


extensions = [
    # example of extensions with regex
    Extension('spam.*', ['spam/*.py']),
    # example of extension with single source file
    Extension('spam.fizz.buzz', ['spam/fizz/buzz.py']),
]


class build_py(build_py_orig):
    def build_packages(self):
        pass


setup(
    name='...',
    version='...',
    packages=find_packages(),
    ext_modules=cythonize(extensions),
    cmdclass={'build_py': build_py},
)

复杂情况:将 cythonized 扩展与源模块混合

如果你想只编译选定的模块而其余的保持不变,你将需要更复杂的逻辑;在这种情况下,您需要覆盖模块查找。在下面的示例中,我仍然将 spam.baconspam.eggsspam.fizz.buzz 编译为共享对象,但保留 __init__.py 文件不变,因此它们将作为源模块包含在内:

import fnmatch
from setuptools import find_packages, setup, Extension
from setuptools.command.build_py import build_py as build_py_orig
from Cython.Build import cythonize


extensions = [
    Extension('spam.*', ['spam/*.py']),
    Extension('spam.fizz.buzz', ['spam/fizz/buzz.py']),
]
cython_excludes = ['**/__init__.py']


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(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(
    name='...',
    version='...',
    packages=find_packages(),
    ext_modules=cythonize(extensions, exclude=cython_excludes),
    cmdclass={'build_py': build_py},
)