包含和分发具有 Python C 扩展的第三方库

Including and distributing third party libraries with a Python C extension

我正在构建一个使用“第三方”库的 C Python 扩展——在本例中,我使用单独的构建过程和工具链构建了一个库。调用这个库 libplumbus.dylib.

目录结构为:

grumbo/
  include/
    plumbus.h
  lib/
    libplumbus.so
  grumbo.c
  setup.py

我的 setup.py 看起来大致像:

from setuptools import Extension, setup

native_module = Extension(
    'grumbo',
    define_macros = [('MAJOR_VERSION', '1'),
                     ('MINOR_VERSION', '0')],
    sources       = ['grumbo.c'],
    include_dirs  = ['include'],
    libraries     = ['plumbus'],
    library_dirs  = ['lib'])


setup(
    name = 'grumbo',
    version = '1.0',
    ext_modules = [native_module] )

由于 libplumbus 是一个外部库,当我 运行 import grumbo 我得到:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ImportError: dlopen(/path/to/grumbo/grumbo.cpython-37m-darwin.so, 2): Library not loaded: lib/libplumbus.dylib
  Referenced from: /path/to/grumbo/grumbo.cpython-37m-darwin.so
  Reason: image not found

最简单的设置方法是什么,以便 libplumbus 包含在分发 中并在导入 grumbo 时正确加载? (请注意,这应该适用于 virtualenv)。

我尝试将 lib/libplumbus.dylib 添加到 package_data,但这不起作用,即使我将 -Wl,-rpath,@loader_path/grumbo/lib 添加到扩展的 extra_link_args

此 post 的目标是创建一个 setup.py 来创建源分发。也就是说在运行ning

之后
python setup.py sdist

生成的 dist/grumbo-1.0.tar.gz 可用于通过

安装
pip install grumbo-1.0.tar.gz

我们将从 Linux/MacOS 的 setup.py 开始,然后进行调整以使其也适用于 Windows。


第一步是将附加数据 (includes/library) 放入分布中。我不确定是否真的不可能为模块添加数据,但是 setuptools 提供了为包添加数据的功能,所以让我们从您的模块创建一个包(无论如何这可能是个好主意)。

grumbo的新结构如下所示:

src/
  grumbo/
     __init__.py  # empty
     grumbo.c
     include/
       plumbus.h
     lib/
       libplumbus.so
setup.py

并更改了 setup.py:

from setuptools import setup, Extension, find_packages

native_module = Extension(
                name='grumbo.grumbo',
                sources = ["src/grumbo/grumbo.c"],
              )
kwargs = {
      'name' : 'grumbo',
      'version' : '1.0',
      'ext_modules' :  [native_module],
      'packages':find_packages(where='src'),
      'package_dir':{"": "src"},
}

setup(**kwargs)

它还没有做太多,但至少我们的包裹可以被 setuptools 找到。构建失败,因为缺少包含。

现在让我们通过 package-data:

include 文件夹中的所需包含添加到分发中
...
kwargs = {
      ...,
      'package_data' : { 'grumbo': ['include/*.h']},
}
...

这样我们的 include-files 就被复制到了源码分发中。然而,因为它将构建在我们还不知道的“某处”,所以将 include_dirs = ['include'] 添加到 Extension 定义中并没有削减它。

一定有更好的方法(而且不那么脆弱)来找到正确的包含路径,但这就是我想出的方法:

...
import os
import sys
import sysconfig
def path_to_build_folder():
    """Returns the name of a distutils build directory"""
    f = "{dirname}.{platform}-{version[0]}.{version[1]}"
    dir_name = f.format(dirname='lib',
                    platform=sysconfig.get_platform(),
                    version=sys.version_info)
    return os.path.join('build', dir_name, 'grumbo')

native_module = Extension(
                ...,
                include_dirs  = [os.path.join(path_to_build_folder(),'include')],
)
...

现在,扩展已构建,但尚未加载,因为它未链接到 shared-object libplumbus.so,因此一些符号未解析。

类似于头文件,我们可以将我们的库添加到发行版中:

kwargs = {
          ...,
          'package_data' : { 'grumbo': ['include/*.h', 'lib/*.so']},
}
...

并为链接器添加正确的lib-path:

...
native_module = Extension(
                ...
                libraries     = ['plumbus'],
                library_dirs  = [os.path.join(path_to_build_folder(), 'lib')],
              )
...

现在,我们快完成了:

  • 扩展内置并放入 site-packages/grumbo/
  • 扩展取决于 libplumbus.so,在 ldd
  • 的帮助下可以看出
  • libplumbus.so 被放入 site-packages/grumbo/lib

但是,我们仍然无法导入扩展,因为 import grumbo.grumbo 导致

ImportError: libplumbus.so: cannot open shared object file: No such file or directory

因为加载程序找不到所需的共享对象,它位于与我们的扩展相关的文件夹 .\lib 中。我们可以使用 rpath 来“帮助”加载程序:

...
native_module = Extension(
                ...
                extra_link_args = ["-Wl,-rpath=$ORIGIN/lib/."],
              )
...

现在我们完成了:

>>> import grumbo.grumbo
# works!

构建和安装轮子也应该可行:

python setup.py bdist_wheel

然后:

pip install grumbo-1.0-xxxx.whl

第一个里程碑已经实现。现在我们扩展它,所以它也适用于其他平台。


Linux 和 Macos 的相同源分发:

为了能够在 Linux 和 MacOS 上安装相同的源分发,共享库的两个版本(对于 Linux 和 MacOS)必须存在。一种选择是为共享对象的名称添加后缀:例如有 libplumbus.linux.solibplumbis.macos.so。可以在 setup.py 中选择正确的共享对象,具体取决于平台:

...
import platform
def pick_library():
    my_system = platform.system()
    if my_system == 'Linux':
        return "plumbus.linux"
    if my_system == 'Darwin':
        return "plumbus.macos"
    if my_system == 'Windows':
        return "plumbus"
    raise ValueError("Unknown platform: " + my_system)

native_module = Extension(
                ...
                libraries     = [pick_library()],
                ...
              )

调整 Windows:

在 Windows 上,动态库是 dll 而不是共享对象,因此需要考虑一些差异:

  • 构建C-extension时需要plumbus.lib-文件,我们需要将其放入lib-子文件夹。
  • 在运行时间加载C-extension时,需要plumbus.dll-file.
  • Windows 没有 rpath 的概念,因此我们需要将 dll 放在扩展名旁边,以便可以找到它(另请参阅此 了解更多详细信息).

也就是说文件夹结构应该如下:

src/
  grumbo/
     __init__.py
     grumbo.c
     plumbus.dll           # needed for Windows
     include/
       plumbus.h
     lib/
       libplumbus.linux.so # needed on Linux
       libplumbus.macos.so # needed on Macos
       plumbus.lib         # needed on Windows
setup.py

setup.py也有一些变化。首先,扩展 package_data 所以 dlllib 被拾取:

...
kwargs = {
      ...
      'package_data' : { 'grumbo': ['include/*.h', 'lib/*.so',
                                    'lib/*.lib', '*.dll',      # for windows
                                   ]},
}
...

其次,rpath只能用在Linux/MacOS上,因此:

def get_extra_link_args():
    if platform.system() == 'Windows':
        return []
    else:
        return ["-Wl,-rpath=$ORIGIN/lib/."]
    

native_module = Extension(
                ...
                extra_link_args = get_extra_link_args(),
              )

那个啦!


完整的安装文件(你可能想添加 macro-definition 或类似的,我已经跳过了):

from setuptools import setup, Extension, find_packages

import os
import sys
import sysconfig
def path_to_build_folder():
    """Returns the name of a distutils build directory"""
    f = "{dirname}.{platform}-{version[0]}.{version[1]}"
    dir_name = f.format(dirname='lib',
                    platform=sysconfig.get_platform(),
                    version=sys.version_info)
    return os.path.join('build', dir_name, 'grumbo')


import platform
def pick_library():
    my_system = platform.system()
    if my_system == 'Linux':
        return "plumbus.linux"
    if my_system == 'Darwin':
        return "plumbus.macos"
    if my_system == 'Windows':
        return "plumbus"
    raise ValueError("Unknown platform: " + my_system)


def get_extra_link_args():
    if platform.system() == 'Windows':
        return []
    else:
        return ["-Wl,-rpath=$ORIGIN/lib/."]
    

native_module = Extension(
                name='grumbo.grumbo',
                sources = ["src/grumbo/grumbo.c"],
                include_dirs  = [os.path.join(path_to_build_folder(),'include')],
                libraries     = [pick_library()],
                library_dirs  = [os.path.join(path_to_build_folder(), 'lib')],
                extra_link_args = get_extra_link_args(),
              )
kwargs = {
      'name' : 'grumbo',
      'version' : '1.0',
      'ext_modules' :  [native_module],
      'packages':find_packages(where='src'),
      'package_dir':{"": "src"},
      'package_data' : { 'grumbo': ['include/*.h', 'lib/*.so',
                                    'lib/*.lib', '*.dll',      # for windows
                                   ]},
}

setup(**kwargs)