0

Расширение setuptools для использования CMake в setup.py?

53

Я разрабатываю расширение для Python, которое связывает библиотеку на C++. Для этого я использую CMake, чтобы упростить процесс сборки. В текущей реализации, чтобы упаковать расширение, мне нужно сначала скомпилировать его с помощью CMake, а затем запускать setup.py bdist_wheel. Я уверен, что существует более эффективный способ.

Интересно, возможно ли (или пробовал ли кто-то) вызвать CMake в процессе сборки ext_modules внутри setup.py? Полагаю, есть способ создать подкласс чего-то, но я не уверен, где искать информацию по этому поводу.

Я выбираю CMake, так как он предоставляет мне больше контроля при сборке расширений для библиотек на C и C++ с комплексными этапами сборки, как мне нужно. Кроме того, я могу легко собирать расширения для Python напрямую с помощью команды PYTHON_ADD_MODULE() в findPythonLibs.cmake. Мне просто хотелось бы, чтобы это был один шаг.

1 ответ(ов)

0

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

Спасибо, hoefling, ваш ответ помог мне на верном пути к написанию скрипта настройки для моего репозитория.

Предисловие

Основная мотивация для написания этого ответа - попытаться "склеить" недостающие кусочки. Автор вопроса не уточняет природу разрабатываемого модуля Python на C/C++; я хотел бы сразу уточнить, что приведенные ниже шаги предназначены для цепочки сборки C/C++ с использованием CMake, которая создает несколько файлов .dll/.so, а также предварительно собранный файл *.pyd/.so, в дополнение к некоторым общим файлам .py, которые необходимо разместить в каталоге скриптов.

Все эти файлы появляются непосредственно после выполнения команды сборки CMake... весело. Нет рекомендаций по сборке setup.py таким образом.

Поскольку setup.py подразумевает, что ваши скрипты будут частью вашего пакета/библиотеки, а .dll фалы, которые должны быть собраны, должны быть объявлены через раздел "библиотеки" с указанными источниками и директориями заголовков, нет интуитивно понятного способа сказать setuptools, что библиотеки, скрипты и файлы данных, полученные в результате одного вызова cmake -b, который произошел в build_ext, должны быть размещены в своих соответствующих местах. Хуже всего, если вы хотите, чтобы этот модуль отслеживался setuptools и был полностью удаляемым, что означает, что пользователи смогут удалить его и стереть все следы с их системы, если они этого пожелают.

Модуль, для которого я писал setup.py, называется bpy, это .pyd/.so эквивалент модуля Python для сборки Blender, как описано здесь:

https://wiki.blender.org/wiki//User:Ideasman42/BlenderAsPyModule (лучшие инструкции, но сейчас ссылка не работает)
http://www.gizmoplex.com/wordpress/compile-blender-as-python-module/ (возможно, худшие инструкции, но, похоже, они все еще онлайн)

Вы можете ознакомиться с моим репозиторием на GitHub здесь:

https://github.com/TylerGubala/blenderpy

Эта мотивация стоит за написанием этого ответа, и я надеюсь, что он поможет кому-то, кто пытается достичь чего-то подобного, не отказываясь от своей цепочки сборки CMake или, что еще хуже, не создавая два отдельных окружения для сборки. Прошу прощения, если это не по теме.

Что мне делать, чтобы это осуществить?

  1. Расширить класс setuptools.Extension своим собственным классом, который не содержит записей для свойств sources или libs.

  2. Расширить класс setuptools.commands.build_ext.build_ext своим собственным классом, который имеет метод, выполняющий необходимые мне шаги сборки (git, svn, cmake, cmake --build).

  3. Расширить класс distutils.command.install_data.install_data (фу, distutils... однако, похоже, что эквивалента в setuptools нет) своим собственным классом, чтобы пометить собранные бинарные библиотеки во время создания записи setuptools (installed-files.txt) так, чтобы:

    • Библиотеки были зарегистрированы и будут удалены с помощью pip uninstall package_name.
    • Команда py setup.py bdist_wheel тоже будет работать нативно и может использоваться для предоставления предварительно собранных версий вашего исходного кода.
  4. Расширить класс setuptools.command.install_lib.install_lib своим собственным классом, который будет гарантировать, что собранные библиотеки перемещаются из их результирующей директории сборки в ту директорию, в которой setuptools ожидает их увидеть (в Windows .dll файлы будут размещены в каталоге bin/Release, а не там, где это ожидает setuptools).

  5. Расширить класс setuptools.command.install_scripts.install_scripts своим собственным классом, чтобы файлы скриптов копировались в правильную директорию (Blender ожидает, что директория 2.79 или какая бы то ни была, будет находиться в расположении скриптов).

  6. После выполнения шагов сборки скопировать эти файлы в известную директорию, которую setuptools скопирует в директорию site-packages вашего окружения. На этом этапе остальные классы setuptools и distutils могут продолжить запись записи installed-files.txt и будут полностью удаляемыми!

Пример

Вот пример, более или менее из моего репозитория, но обрезанный для ясности более специфических моментов (вы всегда можете перейти в репозиторий и посмотреть его сами).

from distutils.command.install_data import install_data
from setuptools import find_packages, setup, Extension
from setuptools.command.build_ext import build_ext
from setuptools.command.install_lib import install_lib
from setuptools.command.install_scripts import install_scripts
import struct
import os
import shutil
import pathlib

BITS = struct.calcsize("P") * 8
PACKAGE_NAME = "example"

class CMakeExtension(Extension):
    """Расширение для выполнения сборки cmake"""

    def __init__(self, name, sources=[]):
        super().__init__(name=name, sources=sources)

class InstallCMakeLibsData(install_data):
    """Обертка для добавления данных установки в egg-info"""

    def run(self):
        self.outfiles = self.distribution.data_files

class InstallCMakeLibs(install_lib):
    """Получаем библиотеки из родительского дистрибутива и используем их как выходные файлы"""

    def run(self):
        self.announce("Перемещение файлов библиотек", level=3)
        self.skip_build = True

        bin_dir = self.distribution.bin_dir

        libs = [os.path.join(bin_dir, _lib) for _lib in 
                os.listdir(bin_dir) if 
                os.path.isfile(os.path.join(bin_dir, _lib)) and 
                os.path.splitext(_lib)[1] in [".dll", ".so"]
                and not (_lib.startswith("python") or _lib.startswith(PACKAGE_NAME))]

        for lib in libs:
            shutil.move(lib, os.path.join(self.build_dir, os.path.basename(lib)))

        self.distribution.data_files = [os.path.join(self.install_dir, 
                                                     os.path.basename(lib))
                                        for lib in libs]

        self.distribution.run_command("install_data")
        super().run()

class InstallCMakeScripts(install_scripts):
    """Установка скриптов в каталоге сборки"""

    def run(self):
        self.announce("Перемещение файлов скриптов", level=3)
        self.skip_build = True

        bin_dir = self.distribution.bin_dir
        scripts_dirs = [os.path.join(bin_dir, _dir) for _dir in
                        os.listdir(bin_dir) if
                        os.path.isdir(os.path.join(bin_dir, _dir))]

        for scripts_dir in scripts_dirs:
            shutil.move(scripts_dir,
                        os.path.join(self.build_dir,
                                     os.path.basename(scripts_dir)))
        self.distribution.scripts = scripts_dirs
        super().run()

class BuildCMakeExt(build_ext):
    """Сборка с использованием cmake вместо неявной сборки python setuptools"""

    def run(self):
        for extension in self.extensions:
            if extension.name == 'example_extension':
                self.build_cmake(extension)
        super().run()

    def build_cmake(self, extension: Extension):
        self.announce("Подготовка среды сборки", level=3)
        build_dir = pathlib.Path(self.build_temp)
        extension_path = pathlib.Path(self.get_ext_fullpath(extension.name))
        os.makedirs(build_dir, exist_ok=True)
        os.makedirs(extension_path.parent.absolute(), exist_ok=True)

        self.announce("Конфигурирование проекта cmake", level=3)
        # Здесь укажите необходимые аргументы cmake

        self.spawn(['cmake', '-H' + SOURCE_DIR, '-B' + self.build_temp, '-DWITH_PLAYER=OFF', '-DWITH_PYTHON_INSTALL=OFF',
                    '-DWITH_PYTHON_MODULE=ON', f"-DCMAKE_GENERATOR_PLATFORM=x{'86' if BITS == 32 else '64'}"])

        self.announce("Сборка бинарников", level=3)
        self.spawn(["cmake", "--build", self.build_temp, "--target", "INSTALL", "--config", "Release"])

        self.announce("Перемещение собранного модуля python", level=3)
        bin_dir = os.path.join(build_dir, 'bin', 'Release')
        self.distribution.bin_dir = bin_dir

        pyd_path = [os.path.join(bin_dir, _pyd) for _pyd in
                    os.listdir(bin_dir) if
                    os.path.isfile(os.path.join(bin_dir, _pyd)) and
                    os.path.splitext(_pyd)[0].startswith(PACKAGE_NAME) and
                    os.path.splitext(_pyd)[1] in [".pyd", ".so"]][0]

        shutil.move(pyd_path, extension_path)

setup(name='my_package',
      version='1.0.0a0',
      packages=find_packages(),
      ext_modules=[CMakeExtension(name="example_extension")],
      description='Пример расширяемого модуля cmake',
      long_description=open("./README.md", 'r').read(),
      long_description_content_type="text/markdown",
      keywords="test, cmake, extension",
      classifiers=["Intended Audience :: Developers",
                   "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
                   "Natural Language :: English",
                   "Programming Language :: C",
                   "Programming Language :: C++",
                   "Programming Language :: Python",
                   "Programming Language :: Python :: 3.6",
                   "Programming Language :: Python :: Implementation :: CPython"],
      license='GPL-3.0',
      cmdclass={
          'build_ext': BuildCMakeExt,
          'install_data': InstallCMakeLibsData,
          'install_lib': InstallCMakeLibs,
          'install_scripts': InstallCMakeScripts
      })

После того как setup.py был написан таким образом, сборка модуля Python становится такой же простой, как выполнение команды py setup.py, что приведет к выполнению сборки и производству выходных файлов.

Рекомендуется производить wheel для пользователей с медленным интернетом или теми, кто не хочет собирать из источников. Для этого вам необходимо установить пакет wheel (py -m pip install wheel) и произвести дистрибутив wheel, выполнив команду py setup.py bdist_wheel, а затем загрузить его с помощью twine, как любой другой пакет.

Чтобы ответить на вопрос, пожалуйста, войдите или зарегистрируйтесь