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

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

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

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

Ответ 1

В основном вам нужно переопределить build_ext команд build_ext в вашем build_ext setup.py и зарегистрировать его в классах команд. В своем пользовательском значении build_ext настройте и вызовите cmake для настройки, а затем сборки модулей расширения. К сожалению, официальные документы довольно лаконичны относительно того, как реализовать пользовательские команды distutils (см. Расширение Distutils); Я считаю гораздо более полезным изучать код команд напрямую. Например, вот исходный код команды build_ext.

Пример проекта

Я подготовил простой проект, состоящий из одного расширения C foo и модуля python spam.eggs:

so-42585210/
├── spam
│   ├── __init__.py  # empty
│   ├── eggs.py
│   ├── foo.c
│   └── foo.h
├── CMakeLists.txt
└── setup.py

Файлы для тестирования установки

Это всего лишь несколько простых заглушек, которые я написал для проверки сценария установки.

spam/eggs.py (только для проверки вызовов библиотеки):

from ctypes import cdll
import pathlib


def wrap_bar():
    foo = cdll.LoadLibrary(str(pathlib.Path(__file__).with_name('libfoo.dylib')))
    return foo.bar()

spam/foo.c:

#include "foo.h"

int bar() {
    return 42;
}

spam/foo.h:

#ifndef __FOO_H__
#define __FOO_H__

int bar();

#endif

CMakeLists.txt:

cmake_minimum_required(VERSION 3.10.1)
project(spam)
set(src "spam")
set(foo_src "spam/foo.c")
add_library(foo SHARED ${foo_src})

Сценарий установки

Здесь происходит волшебство. Конечно, есть много возможностей для улучшений - вы можете передать дополнительные опции классу CMakeExtension если вам нужно (для получения дополнительной информации о расширениях см. Building C и C++ Extensions), сделайте параметры CMake настраиваемыми с помощью setup.cfg путем переопределения методов initialize_options finalize_options и т.д.

import os
import pathlib

from setuptools import setup, Extension
from setuptools.command.build_ext import build_ext as build_ext_orig


class CMakeExtension(Extension):

    def __init__(self, name):
        # don't invoke the original build_ext for this special extension
        super().__init__(name, sources=[])


class build_ext(build_ext_orig):

    def run(self):
        for ext in self.extensions:
            self.build_cmake(ext)
        super().run()

    def build_cmake(self, ext):
        cwd = pathlib.Path().absolute()

        # these dirs will be created in build_py, so if you don't have
        # any python sources to bundle, the dirs will be missing
        build_temp = pathlib.Path(self.build_temp)
        build_temp.mkdir(parents=True, exist_ok=True)
        extdir = pathlib.Path(self.get_ext_fullpath(ext.name))
        extdir.mkdir(parents=True, exist_ok=True)

        # example of cmake args
        config = 'Debug' if self.debug else 'Release'
        cmake_args = [
            '-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=' + str(extdir.parent.absolute()),
            '-DCMAKE_BUILD_TYPE=' + config
        ]

        # example of build args
        build_args = [
            '--config', config,
            '--', '-j4'
        ]

        os.chdir(str(build_temp))
        self.spawn(['cmake', str(cwd)] + cmake_args)
        if not self.dry_run:
            self.spawn(['cmake', '--build', '.'] + build_args)
        os.chdir(str(cwd))


setup(
    name='spam',
    version='0.1',
    packages=['spam'],
    ext_modules=[CMakeExtension('spam/foo')],
    cmdclass={
        'build_ext': build_ext,
    }
)

тестирование

Постройте колесо проекта, установите его. Тестовая библиотека установлена:

$ pip show -f spam
Name: spam
Version: 0.1
Summary: UNKNOWN
Home-page: UNKNOWN
Author: UNKNOWN
Author-email: UNKNOWN
License: UNKNOWN
Location: /Users/hoefling/.virtualenvs/stackoverflow/lib/python3.6/site-packages
Requires: 
Files:
  spam-0.1.dist-info/DESCRIPTION.rst
  spam-0.1.dist-info/INSTALLER
  spam-0.1.dist-info/METADATA
  spam-0.1.dist-info/RECORD
  spam-0.1.dist-info/WHEEL
  spam-0.1.dist-info/metadata.json
  spam-0.1.dist-info/top_level.txt
  spam/__init__.py
  spam/__pycache__/__init__.cpython-36.pyc
  spam/__pycache__/eggs.cpython-36.pyc
  spam/eggs.py
  spam/libfoo.dylib

Запустите функцию оболочки из модуля spam.eggs:

$ python -c "from spam import eggs; print(eggs.wrap_bar())"
42

Ответ 2

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

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

преамбула

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

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

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

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

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

Вы можете проверить мой репозиторий на github здесь:

https://github.com/TylerGubala/blenderpy

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

Итак, что мне сделать, чтобы достичь этого?

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

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

  3. Расширьте класс distutils.command.install_data.install_data (yuck, distutils... однако, похоже, что нет эквивалента setuputils) с моим собственным классом, чтобы пометить встроенные двоичные библиотеки во время создания записи в setuptools (установленный 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 могут записать запись instal-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

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

class CMakeExtension(Extension):
    """
    An extension to run the cmake build

    This simply overrides the base extension class so that setuptools
    doesn't try to build your sources for you
    """

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

        super().__init__(name = name, sources = sources)

class InstallCMakeLibsData(install_data):
    """
    Just a wrapper to get the install data into the egg-info

    Listing the installed files in the egg-info guarantees that
    all of the package files will be uninstalled when the user
    uninstalls your package through pip
    """

    def run(self):
        """
        Outfiles are the libraries that were built using cmake
        """

        # There seems to be no other way to do this; I tried listing the
        # libraries during the execution of the InstallCMakeLibs.run() but
        # setuptools never tracked them, seems like setuptools wants to
        # track the libraries through package data more than anything...
        # help would be appriciated

        self.outfiles = self.distribution.data_files

class InstallCMakeLibs(install_lib):
    """
    Get the libraries from the parent distribution, use those as the outfiles

    Skip building anything; everything is already built, forward libraries to
    the installation step
    """

    def run(self):
        """
        Copy libraries from the bin directory and place them as appropriate
        """

        self.announce("Moving library files", level=3)

        # We have already built the libraries in the previous build_ext step

        self.skip_build = True

        bin_dir = self.distribution.bin_dir

        # Depending on the files that are generated from your cmake
        # build chain, you may need to change the below code, such that
        # your files are moved to the appropriate location when the installation
        # is run

        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)))

        # Mark the libs for installation, adding them to 
        # distribution.data_files seems to ensure that setuptools' record 
        # writer appends them to installed-files.txt in the package egg-info
        #
        # Also tried adding the libraries to the distribution.libraries list, 
        # but that never seemed to add them to the installed-files.txt in the 
        # egg-info, and the online recommendation seems to be adding libraries 
        # into eager_resources in the call to setup(), which I think puts them 
        # in data_files anyways. 
        # 
        # What is the best way?

        # These are the additional installation files that should be
        # included in the package, but are resultant of the cmake build
        # step; depending on the files that are generated from your cmake
        # build chain, you may need to modify the below code

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

        # Must be forced to run after adding the libs to data_files

        self.distribution.run_command("install_data")

        super().run()

class InstallCMakeScripts(install_scripts):
    """
    Install the scripts in the build dir
    """

    def run(self):
        """
        Copy the required directory to the build directory and super().run()
        """

        self.announce("Moving scripts files", level=3)

        # Scripts were already built in a previous step

        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)))

        # Mark the scripts for installation, adding them to 
        # distribution.scripts seems to ensure that the setuptools' record 
        # writer appends them to installed-files.txt in the package egg-info

        self.distribution.scripts = scripts_dirs

        super().run()

class BuildCMakeExt(build_ext):
    """
    Builds using cmake instead of the python setuptools implicit build
    """

    def run(self):
        """
        Perform build_cmake before doing the 'normal' stuff
        """

        for extension in self.extensions:

            if extension.name == 'example_extension':

                self.build_cmake(extension)

        super().run()

    def build_cmake(self, extension: Extension):
        """
        The steps required to build the extension
        """

        self.announce("Preparing the build environment", 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)

        # Now that the necessary directories are created, build

        self.announce("Configuring cmake project", level=3)

        # Change your cmake arguments below as necessary
        # Below is just an example set of arguments for building Blender as a Python module

        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"
                    f"{'86' if BITS == 32 else '64'}"])

        self.announce("Building binaries", level=3)

        self.spawn(["cmake", "--build", self.build_temp, "--target", "INSTALL",
                    "--config", "Release"])

        # Build finished, now copy the files into the copy directory
        # The copy directory is the parent directory of the extension (.pyd)

        self.announce("Moving built python module", 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)

        # After build_ext is run, the following commands will run:
        # 
        # install_lib
        # install_scripts
        # 
        # These commands are subclassed above to avoid pitfalls that
        # setuptools tries to impose when installing these, as it usually
        # wants to build those libs and scripts as well or move them to a
        # different place. See comments above for additional information

setup(name='my_package',
      version='1.0.0a0',
      packages=find_packages(),
      ext_modules=[CMakeExtension(name="example_extension")],
      description='An example cmake extension module',
      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 (py -m pip install wheel) и произвести распределение колеса, выполнив py setup.py bdist_wheel, а затем загрузить его, используя twine как и любой другой пакет.