Свернуть несколько подмодулей на одно расширение Cython

Этот setup.py:

from distutils.core import setup
from distutils.extension import Extension
from Cython.Build import cythonize

extensions = (
    Extension('myext', ['myext/__init__.py',
                        'myext/algorithms/__init__.py',
                        'myext/algorithms/dumb.py',
                        'myext/algorithms/combine.py'])
)
setup(
    name='myext',
    ext_modules=cythonize(extensions)
)

Не имеет предполагаемого эффекта. Я хочу, чтобы он создал один myext.so, который он делает; но когда я вызываю его через

python -m myext.so

Я получаю:

ValueError: Attempted relative import in non-package

из-за того, что myext пытается ссылаться на .algorithms.

Любая идея, как это сделать?

Ответ 1

Прежде всего, я должен отметить, что невозможно скомпилировать один .so файл с подпакетами, используя Cython. Поэтому, если вам нужны подпакеты, вам нужно сгенерировать несколько файлов .so, так как каждый .so может представлять только один модуль.

Во-вторых, не похоже, что вы можете скомпилировать несколько файлов Cython/Python (я специально использую язык Cython) и связать их в один модуль.

Я пытался скомпилировать несколько файлов Cython в один .so любом случае, как с помощью distutils и с помощью ручной компиляции, и он всегда не может быть импортирован во время выполнения.

Кажется, что хорошо связать скомпилированный файл Cython с другими библиотеками или даже с другими файлами C, но что-то идет не так, когда связываются вместе два скомпилированных файла Cython, и в результате получается неправильное расширение Python.

Единственное решение, которое я вижу, - это скомпилировать все как один файл Cython. В моем случае я отредактировал мой setup.py чтобы сгенерировать один файл .pyx который, в свою очередь, include каждый файл .pyx в моем исходном каталоге:

includesContents = ""
for f in os.listdir("src-dir"):
    if f.endswith(".pyx"):
        includesContents += "include \"" + f + "\"\n"

includesFile = open("src/extension-name.pyx", "w")
includesFile.write(includesContents)
includesFile.close()

Затем я просто компилирую extension-name.pyx. Конечно, это нарушает пошаговую и параллельную компиляцию, и вы можете столкнуться с дополнительными конфликтами имен, так как все вставляется в один и тот же файл. С другой стороны, вам не нужно писать никаких файлов .pyd.

Я, конечно, не назвал бы это предпочтительным методом сборки, но если все обязательно должно быть в одном модуле расширения, это единственный способ, которым я могу это сделать.

Ответ 2

Этот ответ предоставляет прототип для Python3 (который может быть легко адаптирован для Python2) и показывает, как несколько Cython -module могут быть объединены в одно расширение /shared-library/pyd-file.

Я храню его по историческим/дидактическим причинам - в этом ответе дается более сжатый рецепт, который представляет собой хорошую альтернативу предложению @Mylin поместить все в один и тот же pyx файл.


Предварительное примечание: Начиная с Cython 0.29, Cython использует многофазную инициализацию для Python> = 3.5. Необходимо отключить многофазную инициализацию (в противном случае PyInit_xxx недостаточно, см. этот SO-post), что можно сделать, передав -DCYTHON_PEP489_MULTI_PHASE_INIT=0 компилятору gcc/other.


При объединении нескольких расширений Cython (пусть они называются bar_a и bar_b) в один общий объект (пусть его называют foo), основной проблемой является операция import bar_a из-за способа загрузки модулей работает на Python (очевидно, упрощенно, эта SO-запись содержит больше информации):

  1. Найдите bar_a.so (или аналогичный), используйте ldopen для загрузки общей библиотеки и вызовите PyInit_bar_a, который инициализирует/зарегистрирует модуль, если не удастся
  2. Ищите bar_a.py и загружайте его, если не удалось...
  3. Найдите bar_a.pyc и загрузите его, если не удалось - ошибка.

Шаги 2. и 3. очевидно потерпят неудачу. Теперь проблема в том, что нет bar_a.so, который можно найти, и хотя функцию инициализации PyInit_bar_a можно найти в foo.so, Python не знает, где искать и бросает поиск.

К счастью, есть доступные хуки, поэтому мы можем научить Python искать нужные места.

При импорте модуля Python использует искатели из sys.meta_path, которые возвращают правильный загрузчик для модуля (для простоты я использую устаревший рабочий процесс с загрузчики, а не module-spec). Средство поиска по умолчанию возвращает None, то есть нет загрузчика, и это приводит к ошибке импорта.

Это означает, что нам нужно добавить пользовательский искатель к sys.meta_path, который распознает наши связанные модули и возвратные загрузчики, которые, в свою очередь, назовут правильный PyInit_xxx -function.

Недостающая часть: Как пользовательский искатель может проникнуть в sys.meta_path? Было бы довольно неудобно, если бы пользователю пришлось делать это вручную.

Когда импортируется подмодуль пакета, сначала загружается пакет __init__.py -module, и это место, где мы можем внедрить наш пользовательский искатель.

После вызова python setup.py build_ext install для настройки, представленной ниже, устанавливается единственная общая библиотека, и субмодули могут быть загружены как обычно:

>>> import foo.bar_a as a
>>> a.print_me()
I'm bar_a
>>> from foo.bar_b import print_me as b_print
>>> b_print()
I'm bar_b

Собираем все вместе:

Структура папок:

../
 |-- setup.py
 |-- foo/
      |-- __init__.py
      |-- bar_a.pyx
      |-- bar_b.pyx
      |-- bootstrap.pyx

__init__.py:

# bootstrap is the only module which 
# can be loaded with default Python-machinery
# because the resulting extension is called 'bootstrap':
from . import bootstrap

# injecting our finders into sys.meta_path
# after that all other submodules can be loaded
bootstrap.bootstrap_cython_submodules()

bootstrap.pyx:

import sys
import importlib

# custom loader is just a wrapper around the right init-function
class CythonPackageLoader(importlib.abc.Loader):
    def __init__(self, init_function):
        super(CythonPackageLoader, self).__init__()
        self.init_module = init_function

    def load_module(self, fullname):
        if fullname not in sys.modules:
            sys.modules[fullname] = self.init_module()
        return sys.modules[fullname]

# custom finder just maps the module name to init-function      
class CythonPackageMetaPathFinder(importlib.abc.MetaPathFinder):
    def __init__(self, init_dict):
        super(CythonPackageMetaPathFinder, self).__init__()
        self.init_dict=init_dict

    def find_module(self, fullname, path):
        try:
            return CythonPackageLoader(self.init_dict[fullname])
        except KeyError:
            return None

# making init-function from other modules accessible:
cdef extern from *:
    """
    PyObject *PyInit_bar_a(void);
    PyObject *PyInit_bar_b(void);
    """
    object PyInit_bar_a()
    object PyInit_bar_b()

# wrapping C-functions as Python-callables:
def init_module_bar_a():
    return PyInit_bar_a()

def init_module_bar_b():
    return PyInit_bar_b()


# injecting custom finder/loaders into sys.meta_path:
def bootstrap_cython_submodules():
    init_dict={"foo.bar_a" : init_module_bar_a,
               "foo.bar_b" : init_module_bar_b}
    sys.meta_path.append(CythonPackageMetaPathFinder(init_dict))  

bar_a.pyx:

def print_me():
    print("I'm bar_a")

bar_b.pyx:

def print_me():
    print("I'm bar_b")

setup.py:

from setuptools import setup, find_packages, Extension
from Cython.Build import cythonize

sourcefiles = ['foo/bootstrap.pyx', 'foo/bar_a.pyx', 'foo/bar_b.pyx']

extensions = cythonize(Extension(
            name="foo.bootstrap",
            sources = sourcefiles,
    ))


kwargs = {
      'name':'foo',
      'packages':find_packages(),
      'ext_modules':  extensions,
}


setup(**kwargs)

NB: Этот ответ послужил отправной точкой для моих экспериментов, однако он использует PyImport_AppendInittab, и я не вижу способа, как это можно подключить к обычному питону.

Ответ 3

Этот ответ следует базовому шаблону ответа @ead, но использует немного более простой подход, который устраняет большую часть стандартного кода.

Единственное отличие - более простая версия bootstrap.pyx:

import sys
import importlib

# Chooses the right init function     
class CythonPackageMetaPathFinder(importlib.abc.MetaPathFinder):
    def __init__(self, name_filter):
        super(CythonPackageMetaPathFinder, self).__init__()
        self.name_filter =  name_filter

    def find_module(self, fullname, path):
        if fullname.startswith(self.name_filter):
            # use this extension-file but PyInit-function of another module:
            return importlib.machinery.ExtensionFileLoader(fullname,__file__)


# injecting custom finder/loaders into sys.meta_path:
def bootstrap_cython_submodules():
    sys.meta_path.append(CythonPackageMetaPathFinder('foo.')) 

По сути, я смотрю, начинается ли имя импортируемого модуля с foo. и, если это произойдет, я повторно использую стандартный подход importlib для загрузки модуля расширения, передавая текущее имя файла .so в качестве пути для поиска - правильное имя функции init (их несколько) будет выведено из имени пакета.

Очевидно, что это всего лишь прототип - можно захотеть сделать некоторые улучшения. Например, сейчас import foo.bar_c может привести к несколько необычному сообщению об ошибке: "ImportError: dynamic module does not define module export function (PyInit_bar_c)", можно вернуть None для всех имен подмодулей, которых нет в белом списке.