Python setuptools/distutils 使用 Makefile 自定义构建 `extra` 包

Posted

技术标签:

【中文标题】Python setuptools/distutils 使用 Makefile 自定义构建 `extra` 包【英文标题】:Python setuptools/distutils custom build for the `extra` package with Makefile 【发布时间】:2017-05-01 09:12:14 【问题描述】:

序言: Python setuptools 用于包分发。我有一个 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 针对不同平台编译进行了优化,所以只需要在构建过程运行时在my_package 中的X 库的相应目录中运行make .

问题 #1:如何在包的构建过程中使用 setuptools/distutils 运行终端命令(在我的例子中是 make)?

问题#2:如何确保只有在安装过程中指定了对应的extra1 时才会执行这样的终端命令?

例子:

    如果有人运行pip install my_package,则不会发生库X 的此类额外编译。 如果有人运行pip install my_package [extra1],则需要编译模块X,因此将创建相应的二进制文件并在目标机器上可用。

【问题讨论】:

How can I run a Makefile in setup.py 的可能重复项? 不完全是。它 a) 没有针对某种情况的答案,当需要这种安装时,只有在涉及“extra1”时。 b)它不是真正的信息/详细。如果能提供更详细的答案,我将不胜感激,我相信如果提供了相当详细的答案,这将对社区提供非常丰富的信息。 X 是否有 setup.py,因此是常规 Python 包? 可以,但是很难。我建议将 X 作为非 Python 依赖项处理,不能使用 pip 安装。 IE。您(和您的用户)必须使用操作系统包管理器或手动安装X。请注意,您甚至不能希望在所有平台上都有一个像样的make 你能编译二进制文件并将你的项目作为一个***而不是(或附加于)源包分发吗? 【参考方案1】:

在我两年前评论这个问题很久之后,这个问题又困扰了我很久!最近我自己也遇到了几乎同样的问题,而且我发现文档非常稀缺,我想你们中的大多数人一定都经历过。因此,我尝试研究了setuptools 和distutils 的一些源代码,看看是否可以找到一个或多或少标准的方法来解决您提出的两个问题。


你问的第一个问题

问题 #1:如何在包的构建过程中使用 setuptools/distutils 运行终端命令(在我的例子中是 make)?

有很多方法,所有方法都涉及在调用setup 时设置cmdclasssetup 的参数 cmdclass 必须是根据发行版的构建或安装需要执行的命令名称与继承自 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.setupdistutils.setupsetup 将运行它found in the command line 的命令,假设它只是一个普通的install。在setuptools.setup 的情况下,这将触发一系列测试,看是否诉诸于对distutils.install 命令类的简单调用,如果没有发生,它将尝试运行bdist_egg。反过来,这个命令做了很多事情,但关键是决定是否调用build_clibbuild_py 和/或build_ext 命令。 distutils.install 只需运行 build,如有必要,它也运行 build_clibbuild_py 和/或 build_ext。这意味着无论您使用setuptools还是distutils,如果需要从源代码构建,命令build_clibbuild_py和/或build_ext都会运行,所以这些是我们想要用cmdclasssetup 重载,问题就变成了三者中的哪一个。

build_py 用于“构建”纯 python 包,因此我们可以放心地忽略它。 build_ext 用于构建声明的扩展模块,这些模块通过调用setup 函数的ext_modules 参数传递。如果我们希望重载这个类,构建每个扩展的主要方法是build_extension(或者对于distutils 是here) build_clib 用于构建声明的库,这些库通过调用setup 函数的libraries 参数传递。在这种情况下,我们应该用派生类重载的主要方法是 build_libraries 方法(here 代表 distutils)。

我将分享一个示例包,它使用setuptoolsbuild_ext 命令通过Makefile 构建一个玩具c 静态库。该方法可以适应使用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: \n0'.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

在这个例子中,我想使用自定义 Makefile 构建的 c 库只有一个函数,它将"Hello from testlib_fun!\n" 打印到标准输出。 test.c 脚本是 python 和这个库的单个函数之间的一个简单接口。这个想法是我告诉setup我想构建一个名为test_pack_opt.test_ext的ac扩展,它只有一个源文件:test.c接口脚本,我还告诉扩展它必须链接到静态库libtestlib.a。主要是我使用specialized_build_ext(build_ext, object) 重载了build_ext cmdclass。仅当您希望能够调用 super 以调度到父类方法时,才需要从 object 继承。 build_extension 方法将 Extension 实例作为其第二个参数,为了与需要 build_extension 的默认行为的其他 Extension 实例一起工作,我检查此扩展是否具有特殊的名称和如果不是,我调用superbuild_extension 方法。

对于特殊库,我只用subprocess.Popen('make static ...') 调用Makefile。传递给 shell 的命令的其余部分只是将静态库移动到某个默认位置,在该位置应该找到该库以便能够将其链接到编译扩展的其余部分(也只是使用 @ 编译987654416@的build_extension方法)。

正如您可以想象的那样,您可以通过多种方式以不同的方式组织此代码,因此将它们全部列出是没有意义的。我希望这个示例能够说明如何调用 Makefile,以及在标准安装中应该重载哪个 cmdclassCommand 派生类以调用 make


现在,进入问题 2。

问题#2:如何保证这样的终端命令只有在安装过程中指定了对应的extra1时才会执行?

这可以通过 setuptools.setup 的已弃用 features 参数实现。标准方法是尝试根据满足的要求安装软件包。 install_requires 列出强制要求,extras_requires 列出可选要求。例如来自setuptools documentation

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

您可以通过调用 pip install Project-A[PDF] 来强制安装可选的必需软件包,但如果出于某种原因事先满足了名为 extra 的 'PDF' 的要求,pip install Project-A 最终会得到相同的 "Project-A"功能。这意味着“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 源代码中推断出这一点。

【讨论】:

sources_path = os.path.commonprefix(sources) 这条线路应该调用os.path.commonpath 吗? commonprefix 不一定返回有效路径,只是一个字符串前缀。例如,os.path.commonprefix(["/existing1", "/existing2"]) == "/existing"【参考方案2】:

不幸的是,关于 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 install 输出:

(.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

【讨论】:

以上是关于Python setuptools/distutils 使用 Makefile 自定义构建 `extra` 包的主要内容,如果未能解决你的问题,请参考以下文章

Python代写,Python作业代写,代写Python,代做Python

Python开发

Python,python,python

Python 介绍

Python学习之认识python

python初识