web-dev-qa-db-fra.com

Étendre l'extension setuptools pour utiliser CMake dans setup.py?

J'écris une extension Python qui relie une bibliothèque C++ et j'utilise cmake pour aider avec le processus de construction. Cela signifie qu'en ce moment, la seule façon dont je sais comment la regrouper, Je dois d'abord les compiler avec cmake avant de pouvoir exécuter setup.py bdist_wheel. Il doit y avoir un meilleur moyen.

Je me demandais s'il était possible (ou quelqu'un a essayé) d'invoquer CMake dans le cadre du processus de construction setup.py ext_modules? Je suppose qu'il existe un moyen de créer une sous-classe de quelque chose, mais je ne sais pas où chercher.

J'utilise CMake car cela me donne beaucoup plus de contrôle pour la construction d'extensions de bibliothèques c et c ++ avec des étapes de construction complexes exactement comme je le veux. De plus, je peux facilement créer des extensions Python directement avec cmake avec la commande PYTHON_ADD_MODULE () dans la commande findPythonLibs.cmake. Je souhaite juste que ce ne soit qu'une étape.

20
loneraver

Ce que vous devez essentiellement faire est de remplacer le build_ext classe de commande dans votre setup.py et l'enregistrer dans les classes de commandes. Dans votre implément personnalisé de build_ext, configurez et appelez cmake pour configurer puis construire les modules d'extension. Malheureusement, les documents officiels sont plutôt laconiques sur la façon d'implémenter des commandes distutils personnalisées (voir Extending Distutils ); Je trouve qu'il est beaucoup plus utile d'étudier directement le code des commandes. Par exemple, voici le code source de build_ext commande .

Exemple de projet

J'ai préparé un projet simple composé d'une seule extension C foo et d'un module python module spam.eggs:

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

Fichiers pour tester la configuration

Ce ne sont que quelques bouts simples que j'ai écrits pour tester le script de configuration.

spam/eggs.py (uniquement pour tester les appels de bibliothèque):

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

Script d'installation

C'est là que la magie opère. Bien sûr, il y a beaucoup de place pour des améliorations - vous pouvez passer des options supplémentaires à la classe CMakeExtension si vous en avez besoin (pour plus d'informations sur les extensions, voir Construction d'extensions C et C++ ), rendez les options CMake configurables via setup.cfg en remplaçant les méthodes initialize_options et finalize_options etc.

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

Essai

Construisez la roue du projet, installez-la. Testez la bibliothèque est installée:

$ 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

Exécutez la fonction wrapper à partir de spam.eggs module:

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

Je voudrais ajouter ma propre réponse à cela, comme une sorte d'addendum à ce que décrit le hoefling.

Merci, hoefling, car votre réponse m'a aidé à me mettre sur la bonne voie pour écrire un script d'installation de la même manière pour mon propre référentiel.

Préambule

La principale motivation pour écrire cette réponse est d'essayer de "coller ensemble" les pièces manquantes. L'OP ne précise pas la nature du C/C++ Python en cours de développement; je tiens à préciser dès le départ que les étapes ci-dessous concernent une chaîne de construction cmake C/C++ crée plusieurs fichiers .dll/.so ainsi qu'un fichier *.pyd/so précompilé en plus de certains fichiers génériques .py qui doivent être placé dans le répertoire des scripts.

Tous ces fichiers se concrétisent directement après l'exécution de la commande cmake build ... fun. Il n'y a aucune recommandation pour construire un setup.py de cette façon.

Parce que setup.py implique que vos scripts feront partie de votre package/bibliothèque et que les fichiers .dll Qui doivent être construits doivent être déclarés via la partie bibliothèques, avec les sources et les répertoires inclus, il y a aucun moyen intuitif de dire à setuptools que les bibliothèques, les scripts et les fichiers de données qui résultent d'un appel à cmake -b survenu dans build_ext devraient tous aller à leur place respective. Pire encore si vous souhaitez que ce module soit suivi par setuptools et entièrement désinstallable, ce qui signifie que les utilisateurs peuvent le désinstaller et que chaque trace soit effacée de leur système, si vous le souhaitez.

Le module pour lequel j'écrivais un fichier setup.py est bpy, l'équivalent .pyd/.so De la construction de blender en tant que module python comme décrit ici:

https://wiki.blender.org/wiki//User:Ideasman42/BlenderAsPyModule (meilleures instructions mais lien maintenant mort) http://www.gizmoplex.com/wordpress/compile -blender-as-python-module / (peut-être des instructions pires mais semble toujours en ligne)

Vous pouvez consulter mon référentiel sur github ici:

https://github.com/TylerGubala/blenderpy

C'est ma motivation derrière la rédaction de cette réponse, et j'espère que cela aidera toute autre personne essayant d'accomplir quelque chose de similaire plutôt que de jeter sa chaîne de construction cmake ou, pire encore, d'avoir à maintenir deux environnements de construction distincts. Je m'excuse si c'est hors sujet.

Alors, que dois-je faire pour y parvenir?

  1. Étendez la classe setuptools.Extension Avec une classe à moi, qui ne contient pas d'entrées pour les propriétés sources ou libs

  2. Étendez la classe setuptools.commands.build_ext.build_ext Avec une classe à moi, qui a une méthode personnalisée qui exécute mes étapes de construction nécessaires (git, svn, cmake, cmake --build)

  3. Étendez la classe distutils.command.install_data.install_data (Beurk, distutils... mais il ne semble pas y avoir d'équivalent setuputils) avec une classe à moi, pour marquer les bibliothèques binaires construites pendant l'enregistrement de setuptools création (installed-files.txt) telle que

    • Les bibliothèques seront enregistrées et seront désinstallées avec pip uninstall package_name

    • La commande py setup.py bdist_wheel Fonctionnera également en mode natif et peut être utilisée pour fournir des versions précompilées de votre code source

  4. Étendez la classe setuptools.command.install_lib.install_lib Avec une classe à moi, ce qui garantira que les bibliothèques construites seront déplacées de leur dossier de construction résultant vers le dossier dans lequel setuptools les attend (sous Windows, il mettra le .dll fichiers dans un dossier bin/Release et pas là où setuptools l'attend)

  5. Étendez la classe setuptools.command.install_scripts.install_scripts Avec une classe à moi de sorte que les fichiers de scripts soient copiés dans le bon répertoire (Blender s'attend à ce que le 2.79 ou tout autre répertoire soit à l'emplacement des scripts)

  6. Une fois les étapes de génération effectuées, copiez ces fichiers dans un répertoire connu que setuptools copiera dans le répertoire site-packages de mon environnement. À ce stade, les classes setuptools et distutils restantes peuvent reprendre l'écriture de l'enregistrement installed-files.txt et seront entièrement amovibles!

Échantillon

Voici un échantillon, plus ou moins de mon référentiel, mais taillé pour plus de clarté sur les choses plus spécifiques (vous pouvez toujours vous diriger vers le repo et le regarder par vous-même)

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

Une fois le setup.py Créé de cette façon, la construction du module python est aussi simple que d'exécuter py setup.py, Qui exécutera la construction et produira les fichiers de sortie.

Il est recommandé de produire une roue pour les utilisateurs sur Internet lent ou qui ne veulent pas construire à partir de sources. Pour ce faire, vous souhaiterez installer le package wheel (py -m pip install wheel) Et produire une distribution de roue en effectuant py setup.py bdist_wheel, Puis le télécharger à l'aide de twine comme tout autre package.

8
Tyler Gubala