Пользовательская сборка Python setuptools/distutils для дополнительного пакета с Makefile

Преамбула. Инструменты установки Python используются для распространения пакета. У меня есть пакет Python (назовем его my_package), в котором есть несколько пакетов extra_require. Все работает, просто найдите (установка и сборка пакета, а также дополнительные функции, если они были запрошены), так как все extra_require были сами пакетами python, и pip все правильно разрешил. Простой pip install my_package работал как шарм.

Настройка: Теперь, для одного из дополнений (назовем его extra1), мне нужно вызвать двоичный файл библиотеки, отличной от Python, X.

Сам модуль X (исходный код) был добавлен в кодовую базу my_package и включен в дистрибутив my_package. К сожалению для меня, для использования X необходимо сначала скомпилировать в двоичный файл на целевой машине (реализация C++; я предполагаю, что такая компиляция должна произойти на этапе сборки установки my_package). В библиотеке X есть библиотека Makefile, оптимизированная для компиляции на разных платформах, поэтому все, что нужно, это запустить make в соответствующем каталоге библиотеки X в my_package во время процесса сборки.

Вопрос №1: как запустить команду терминала (например, make в моем случае) во время процесса сборки пакета с помощью setuptools/distutils?

Вопрос #2: как сделать так, чтобы такая терминальная команда выполнялась только в том случае, если в процессе установки указан соответствующий extra1?

Пример:

  1. Если кто-то запустит pip install my_package, такой дополнительной компиляции библиотеки X не произойдет.
  2. Если кто-то запускает pip install my_package [extra1], необходимо скомпилировать модуль X, чтобы соответствующий двоичный файл был создан и доступен на целевой машине.

person Sergey Aganezov jr    schedule 15.12.2016    source источник
comment
Не совсем. Он а) не имеет ответа на ситуацию, когда такая установка требуется, только когда задействована дополнительная1. б) Это не очень информативно / подробно. Я был бы признателен за более подробный ответ, и я считаю, что это было бы очень информативно для сообщества, если бы был предоставлен довольно подробный ответ.   -  person Sergey Aganezov jr    schedule 21.12.2016
comment
Есть ли у X setup.py и, следовательно, является ли он обычным пакетом Python?   -  person fpbhb    schedule 22.12.2016
comment
Нет, X — это cpp-пакет с Makefile. Makefile сам по себе очень гибкий и поддерживает несколько платформ. Итак, все, что нужно сделать, — это сделать одну команду make в соответствующей подпапке.   -  person Sergey Aganezov jr    schedule 23.12.2016
comment
Это можно сделать, но это сложно. Я бы рекомендовал обращаться с X как с зависимостью, отличной от Python, которую нельзя установить с помощью pip. т.е. вам (и вашим пользователям) придется установить X с помощью диспетчера пакетов ОС или вручную. Обратите внимание, что вы даже не можете надеяться на достойный make на всех платформах.   -  person fpbhb    schedule 23.12.2016
comment
Я запомню это. Теперь моя идея будет заключаться в следующем: попытаться найти автоматическое решение в отношении вопроса, который я разместил, а также иметь запасной план, чтобы я мог обратиться к конечному пользователю, устанавливающему пакет X отдельно.   -  person Sergey Aganezov jr    schedule 24.12.2016
comment
Можете ли вы скомпилировать бинарный файл и распространять свой проект как колесо, а не исходный пакет (или в дополнение к нему)?   -  person Brad Campbell    schedule 09.02.2017


Ответы (2)


Этот вопрос не давал мне покоя еще долго после того, как я прокомментировал его два года назад! Недавно у меня была почти такая же проблема, и я нашел документацию ОЧЕНЬ скудной, как, я думаю, большинство из вас должно было испытать. Поэтому я попытался немного изучить исходный код setuptools и distutils, чтобы посмотреть, смогу ли я найти более или менее стандартный подход к обоим вопросам, которые вы спросил.


Первый вопрос, который вы задали

Вопрос №1: как запустить команду терминала (например, make в моем случае) во время процесса сборки пакета с помощью setuptools/distutils?

имеет много подходов, и все они включают установку cmdclass при вызове setup. Параметр cmdclass из setup должен быть сопоставлением между именами команд, которые будут выполняться в зависимости от потребностей сборки или установки дистрибутива, и классами, которые наследуются от distutils.cmd.Command базового класса (в качестве примечания: класс setuptools.command.Command является производным от класса distutils' Command, поэтому вы можете получить его непосредственно от реализации setuptools).

cmdclass позволяет вам определить любое имя команды, например, что сделал ayoon, а затем выполнить его специально при вызове python setup.py --install-option="customcommand" из командной строки. . Проблема в том, что это не стандартная команда, которая будет выполняться при попытке установить пакет через pip или вызовом python setup.py install. Стандартный подход к этому — проверить, какие команды setup будут пытаться выполняться при обычной установке, а затем перегрузить эту конкретную cmdclass команду.

Изучив setuptools.setup и distutils.setup, setup будет выполнять команды, которые он находится в командной строке, что позволяет предположить, что это просто install. В случае setuptools.setup это вызовет серию тестов, которые увидят, следует ли прибегать к простому вызову класса команд distutils.install, и если этого не произойдет, он попытается запустить bdist_egg. В свою очередь, эта команда делает много вещей, но в решающей степени решает, следует ли вызывать команды build_clib, build_py и/или build_ext. distutils.install при необходимости просто запускает build, который также запускает build_clib. , build_py и/или build_ext. Это означает, что независимо от того, используете ли вы setuptools или distutils, при необходимости сборки из исходного кода команды build_clib, build_py и/или build_ext будут запущены, так что это те, которые мы хотим перегрузить с помощью cmdclass из setup, вопрос становится тем, какой из трех.

  • build_py используется для «сборки» чистых пакетов Python, поэтому мы можем спокойно его игнорировать.
  • build_ext используется для построения объявленных модулей расширения, которые передаются через параметр ext_modules вызова функции setup. Если мы хотим перегрузить этот класс, основным методом, который создает каждое расширение, является build_extension (или здесь для distutils)
  • build_clib используется для сборки объявленных библиотек, которые передаются через параметр libraries вызова функции setup. В этом случае основным методом, который мы должны перегрузить с нашим производным классом, является build_libraries (здесь для distutils).

Я поделюсь примером пакета, который создает статическую библиотеку toy c через Makefile с помощью команды setuptools build_ext. Этот подход можно адаптировать для использования команды build_clib, но вам придется проверить исходный код build_clib.build_libraries.

setup.py

import os, subprocess
import setuptools
from setuptools.command.build_ext import build_ext
from distutils.errors import DistutilsSetupError
from distutils import log as distutils_logger


extension1 = setuptools.extension.Extension('test_pack_opt.test_ext',
                    sources = ['test_pack_opt/src/test.c'],
                    libraries = [':libtestlib.a'],
                    library_dirs = ['test_pack_opt/lib/'],
                    )

class specialized_build_ext(build_ext, object):
    """
    Specialized builder for testlib library

    """
    special_extension = extension1.name

    def build_extension(self, ext):

        if ext.name!=self.special_extension:
            # Handle unspecial extensions with the parent class' method
            super(specialized_build_ext, self).build_extension(ext)
        else:
            # Handle special extension
            sources = ext.sources
            if sources is None or not isinstance(sources, (list, tuple)):
                raise DistutilsSetupError(
                       "in 'ext_modules' option (extension '%s'), "
                       "'sources' must be present and must be "
                       "a list of source filenames" % ext.name)
            sources = list(sources)

            if len(sources)>1:
                sources_path = os.path.commonpath(sources)
            else:
                sources_path = os.path.dirname(sources[0])
            sources_path = os.path.realpath(sources_path)
            if not sources_path.endswith(os.path.sep):
                sources_path+= os.path.sep

            if not os.path.exists(sources_path) or not os.path.isdir(sources_path):
                raise DistutilsSetupError(
                       "in 'extensions' option (extension '%s'), "
                       "the supplied 'sources' base dir "
                       "must exist" % ext.name)

            output_dir = os.path.realpath(os.path.join(sources_path,'..','lib'))
            if not os.path.exists(output_dir):
                os.makedirs(output_dir)

            output_lib = 'libtestlib.a'

            distutils_logger.info('Will execute the following command in with subprocess.Popen: \n{0}'.format(
                  'make static && mv {0} {1}'.format(output_lib, os.path.join(output_dir, output_lib))))


            make_process = subprocess.Popen('make static && mv {0} {1}'.format(output_lib, os.path.join(output_dir, output_lib)),
                                            cwd=sources_path,
                                            stdout=subprocess.PIPE,
                                            stderr=subprocess.PIPE,
                                            shell=True)
            stdout, stderr = make_process.communicate()
            distutils_logger.debug(stdout)
            if stderr:
                raise DistutilsSetupError('An ERROR occured while running the '
                                          'Makefile for the {0} library. '
                                          'Error status: {1}'.format(output_lib, stderr))
            # After making the library build the c library's python interface with the parent build_extension method
            super(specialized_build_ext, self).build_extension(ext)


setuptools.setup(name = 'tester',
       version = '1.0',
       ext_modules = [extension1],
       packages = ['test_pack', 'test_pack_opt'],
       cmdclass = {'build_ext': specialized_build_ext},
       )

test_pack/__init__.py

from __future__ import absolute_import, print_function

def py_test_fun():
    print('Hello from python test_fun')

try:
    from test_pack_opt.test_ext import test_fun as c_test_fun
    test_fun = c_test_fun
except ImportError:
    test_fun = py_test_fun

test_pack_opt/__init__.py

from __future__ import absolute_import, print_function
import test_pack_opt.test_ext

test_pack_opt/src/Makefile

LIBS =  testlib.so testlib.a
SRCS =  testlib.c
OBJS =  testlib.o
CFLAGS = -O3 -fPIC
CC = gcc
LD = gcc
LDFLAGS =

all: shared static

shared: libtestlib.so

static: libtestlib.a

libtestlib.so: $(OBJS)
    $(LD) -pthread -shared $(OBJS) $(LDFLAGS) -o $@

libtestlib.a: $(OBJS)
    ar crs $@ $(OBJS) $(LDFLAGS)

clean: cleantemp
    rm -f $(LIBS)

cleantemp:
    rm -f $(OBJS)  *.mod

.SUFFIXES: $(SUFFIXES) .c

%.o:%.c
    $(CC) $(CFLAGS) -c $<

test_pack_opt/src/test.c

#include <Python.h>
#include "testlib.h"

static PyObject*
test_ext_mod_test_fun(PyObject* self, PyObject* args, PyObject* keywds){
    testlib_fun();
    return Py_None;
}

static PyMethodDef TestExtMethods[] = {
    {"test_fun", (PyCFunction) test_ext_mod_test_fun, METH_VARARGS | METH_KEYWORDS, "Calls function in shared library"},
    {NULL, NULL, 0, NULL}
};

#if PY_VERSION_HEX >= 0x03000000
    static struct PyModuleDef moduledef = {
        PyModuleDef_HEAD_INIT,
        "test_ext",
        NULL,
        -1,
        TestExtMethods,
        NULL,
        NULL,
        NULL,
        NULL
    };

    PyMODINIT_FUNC
    PyInit_test_ext(void)
    {
        PyObject *m = PyModule_Create(&moduledef);
        if (!m) {
            return NULL;
        }
        return m;
    }
#else
    PyMODINIT_FUNC
    inittest_ext(void)
    {
        PyObject *m = Py_InitModule("test_ext", TestExtMethods);
        if (m == NULL)
        {
            return;
        }
    }
#endif

test_pack_opt/src/testlib.c

#include "testlib.h"

void testlib_fun(void){
    printf("Hello from testlib_fun!\n");
}

test_pack_opt/src/testlib.h

#ifndef TESTLIB_H
#define TESTLIB_H

#include <stdio.h>

void testlib_fun(void);

#endif

В этом примере библиотека c, которую я хочу создать с помощью пользовательского Makefile, имеет только одну функцию, которая выводит "Hello from testlib_fun!\n" на стандартный вывод. Скрипт test.c представляет собой простой интерфейс между python и единственной функцией этой библиотеки. Идея состоит в том, что я сообщаю setup, что хочу создать расширение c с именем test_pack_opt.test_ext, которое имеет только один исходный файл: сценарий интерфейса test.c, и я также сообщаю расширению, что оно должно быть связано со статической библиотекой libtestlib.a. Главное, что я перегружаю cmdclass build_ext с помощью specialized_build_ext(build_ext, object). Наследование от object необходимо только в том случае, если вы хотите иметь возможность вызывать super для отправки в методы родительского класса. Метод build_extension принимает экземпляр Extension в качестве второго аргумента, чтобы хорошо работать с другими экземплярами Extension, которые требуют поведения по умолчанию build_extension, я проверяю, имеет ли это расширение имя специального, и если это не так, я вызываю Метод build_extension super.

Для специальной библиотеки я вызываю Makefile просто с помощью subprocess.Popen('make static ...'). Остальная часть команды, переданной в оболочку, предназначена только для перемещения статической библиотеки в определенное место по умолчанию, в котором должна быть найдена библиотека, чтобы иметь возможность связать ее с остальной частью скомпилированного расширения (которое также только что скомпилировано с использованием super). build_extension способ).

Как вы понимаете, существует ооочень много способов организовать этот код по-разному, нет смысла перечислять их все. Я надеюсь, что этот пример служит иллюстрацией того, как вызывать Makefile и какие производные классы cmdclass и Command следует перегрузить для вызова make в стандартной установке.


Теперь по вопросу 2.

Вопрос #2: как сделать так, чтобы такая терминальная команда выполнялась только в том случае, если в процессе установки указана соответствующая дополнительная1?

Это было возможно с устаревшим параметром features для setuptools.setup. Стандартный способ — попытаться установить пакет в зависимости от требований, которые выполнены. install_requires перечисляет обязательные требования, extras_requires перечисляет необязательные требования. Например, из setuptools документация

setup(
    name="Project-A",
    ...
    extras_require={
        'PDF':  ["ReportLab>=1.2", "RXP"],
        'reST': ["docutils>=0.3"],
    }
)

вы можете принудительно установить необязательные пакеты, вызвав pip install Project-A[PDF], но если по какой-то причине требования для названного дополнительного 'PDF' будут выполнены заранее, pip install Project-A в конечном итоге будет иметь ту же функциональность "Project-A". Это означает, что способ установки «Проект-А» не настраивается для каждого дополнительного параметра, указанного в командной строке, «Проект-А» всегда будет пытаться установить одним и тем же способом и может иметь ограниченную функциональность из-за недоступности. необязательные требования.

Насколько я понял, это означает, что для того, чтобы ваш модуль X был скомпилирован и установлен только в том случае, если указан [extra1], вы должны поставлять модуль X как отдельный пакет и зависеть от него через файл extras_require. Давайте представим, что модуль X будет отправлен в my_package_opt, ваша установка для my_package должна выглядеть так:

setup(
    name="my_package",
    ...
    extras_require={
        'extra1':  ["my_package_opt"],
    }
)

Что ж, извините, что мой ответ получился таким длинным, но я надеюсь, что это поможет. Не стесняйтесь указывать на любую концептуальную ошибку или ошибку в именовании, поскольку я в основном пытался вывести ее из исходного кода setuptools.

person lucianopaz    schedule 06.02.2018
comment
sources_path = os.path.commonprefix(sources) Должна ли эта линия вместо этого вызывать os.path.commonpath? commonprefix не обязательно возвращает правильный путь, а просто префикс строки. Например, os.path.commonprefix(["/existing1", "/existing2"]) == "/existing". - person dkasak; 16.02.2019

К сожалению, документации по взаимодействию между setup.py и pip крайне мало, но вы сможете сделать что-то вроде этого:

import subprocess

from setuptools import Command
from setuptools import setup


class CustomInstall(Command):

    user_options = []

    def initialize_options(self):
        pass

    def finalize_options(self):
        pass

    def run(self):
        subprocess.call(
            ['touch',
             '/home/{{YOUR_USERNAME}}/'
             'and_thats_why_you_should_never_run_pip_as_sudo']
        )

setup(
    name='hack',
    version='0.1',
    cmdclass={'customcommand': CustomInstall}
)

Это дает вам возможность запускать произвольный код с помощью команд, а также поддерживает синтаксический анализ различных пользовательских параметров (здесь не показано).

Поместите это в файл setup.py и попробуйте следующее:

pip install --install-option="customcommand" .

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

(.venv) ayoon:tmp$ pip install -vvv --install-option="customcommand" .
/home/ayoon/tmp/.venv/lib/python3.6/site-packages/pip/commands/install.py:194: UserWarning: Disabling all use of wheels due to the use of --build-options / -
-global-options / --install-options.                                                                                                                        
  cmdoptions.check_install_build_global(options)
Processing /home/ayoon/tmp
  Running setup.py (path:/tmp/pip-j57ovc7i-build/setup.py) egg_info for package from file:///home/ayoon/tmp
    Running command python setup.py egg_info
    running egg_info
    creating pip-egg-info/hack.egg-info
    writing pip-egg-info/hack.egg-info/PKG-INFO
    writing dependency_links to pip-egg-info/hack.egg-info/dependency_links.txt
    writing top-level names to pip-egg-info/hack.egg-info/top_level.txt
    writing manifest file 'pip-egg-info/hack.egg-info/SOURCES.txt'
    reading manifest file 'pip-egg-info/hack.egg-info/SOURCES.txt'
    writing manifest file 'pip-egg-info/hack.egg-info/SOURCES.txt'
  Source in /tmp/pip-j57ovc7i-build has version 0.1, which satisfies requirement hack==0.1 from file:///home/ayoon/tmp
Could not parse version from link: file:///home/ayoon/tmp
Installing collected packages: hack
  Running setup.py install for hack ...     Running command /home/ayoon/tmp/.venv/bin/python3.6 -u -c "import setuptools, tokenize;__file__='/tmp/pip-j57ovc7
i-build/setup.py';f=getattr(tokenize, 'open', open)(__file__);code=f.read().replace('\r\n', '\n');f.close();exec(compile(code, __file__, 'exec'))" install --
record /tmp/pip-_8hbltc6-record/install-record.txt --single-version-externally-managed --compile --install-headers /home/ayoon/tmp/.venv/include/site/python3
.6/hack customcommand                                                                                                                                       
    running install
    running build
    running install_egg_info
    running egg_info
    writing hack.egg-info/PKG-INFO
    writing dependency_links to hack.egg-info/dependency_links.txt
    writing top-level names to hack.egg-info/top_level.txt
    reading manifest file 'hack.egg-info/SOURCES.txt'
    writing manifest file 'hack.egg-info/SOURCES.txt'
    Copying hack.egg-info to /home/ayoon/tmp/.venv/lib/python3.6/site-packages/hack-0.1-py3.6.egg-info
    running install_scripts
    writing list of installed files to '/tmp/pip-_8hbltc6-record/install-record.txt'
    running customcommand
done
  Removing source in /tmp/pip-j57ovc7i-build
Successfully installed hack-0.1
person ayoon    schedule 25.03.2017