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

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

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

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

11

Решение

Что вам в основном нужно сделать, это переопределить 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 pathlibdef 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 класс, если вам нужно (для получения дополнительной информации о расширениях см. Построение расширений C и C ++), настройте параметры 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_origclass 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
8

Другие решения

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

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

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

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

Потому что setup.py подразумевает, что ваши скрипты будут частью вашего пакета / библиотеки и что .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-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… однако не существует эквивалента setuputils) с моим собственным классом для отметки встроенных двоичных библиотек во время создания записи в setuptools (instal-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's 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's 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 как и любой другой пакет.

4

По вопросам рекламы [email protected]