0
点赞
收藏
分享

微信扫一扫

setuptools库:构建自己的python包

Silence潇湘夜雨 2022-03-11 阅读 37
python

setuptools基本用法的清晰介绍,好文
MANIFEST.in文件的官方讲解
根目录setup.py, MANIFEST.in, README.md等文件会自动加入包里,不需要指定什么。
python setup.py clean --all 清除之前编译时的中间结果(也即build目录),如果没有清理,修改setup.py, MANIFEST.in文件中的内容后重新编译很可能不会生效,程序依然直接根据之前编译的中间结果去打包文件。(后记:不过实测出来光clean还不够,还要rm -rf *.egg-info/,否则还是会打包之前指定的py文件)

开局一句from setuptools import setup写在前面,
关于setup函数的参数,我们一个一个来讲(讲某个参数的时候其他参数忽略不写):
setup(name="demo", version="1.0") 包里只有根目录下的setup.py, MANIFEST.in, README.md等文件
setup(packages=["mod1", "mod2"] 假设根目录下面有很多包,我们只需要mod1mod2包,那么把它们加进packages参数,则这些目录下的所有.py文件都会被打包
setup(include_package_data=True) 根据MANIFEST.in的内容精细化指定需要和不需要打包的文件
setup(exclude_package_data={'mod1':['.gitignore']}) 排除mod1目录下的.gitignore文件,如果'mod1'换成''表示排除所有目录下的.gitignore文件
setup(package_data={"": ["*.txt", "*.pth"]})exclude_package_data的用法基本一致,需要注意的是它只对在packages中的目录进行检查,而不会添加其他目录下的数据文件,即使这里写的''看起来像是整个根目录下的所有目录。package_data感觉没有MANIFEST.in用起来方便。

python setup.py install是直接将当前目录下的内容安装成pip list里头的一个包,而python setup.py develop更适合处于调试阶段的代码,它并没有在site-packages目录下生成一个包,而是拉了一条软链到当前目录,这样我们在当前目录下修改代码,都能立即反映上去,不需要每次都重复python setup.py install

python setup.py sdist可以创建一个源码包,python setup.py bdist_wheel创建一个wheel发布包
更多命令可以通过python setup.py --help-commands查看。
python setup.py install/develop会在当前目录下创建一个module_name.egg-info/目录,后续如果python setup.py sdist && pip install dist/module_name-version.tar.gz就会报一些奇奇怪怪的错误,这时把module_name.egg-info/目录删掉可以正常编译安装了。

更进一步:在打包时添加c++文件拓展
首先是自己制作可被python调用的C++动态链接库也就是so文件,有几种常见的技术路径比如ctypes、Boost和pybind11(参考链接1,参考链接2,参考链接3),这里选择pybind11进行讲解。

复习一下编译和链接的关系:看这里。编译是把我们自己写的代码转换成二进制目标文件.o.obj,链接是将所有的目标文件以及系统组件组合成一个可执行文件.exe
使用cmake编译纯C++代码可以看我的另一篇文章。

首先安装pybind11,源码安装跟pip install安装的结果还不太一样,后者可以import pybind11前者不行,把usr/local/include/pybind11加入PYTHONPATH也无济于事,前者可以找到pybind11-config.cmake文件后者不行,wheel包就是不包含这个文件,所以这里分开讲用法。pip安装:pip install pybind11
然后用C++写一个简单的求和函数,命名为example.cpp(从这里拷过来的):

#include <pybind11/pybind11.h>
namespace py = pybind11;

int add(int i, int j)
{
    return i + j;
}

PYBIND11_MODULE(test, m)
{
    // optional module docstring
    m.doc() = "pybind11 example plugin";
    // expose add function, and add keyword arguments and default arguments
    m.def("add", &add, "A function which adds two numbers", py::arg("i")=1, py::arg("j")=2);

    // exporting variables
    m.attr("the_answer") = 42;
    py::object world = py::cast("World");
    m.attr("what") = world;
}

然后进行编译:c++ -O3 -Wall -shared -std=c++11 -fPIC $(python -m pybind11 --includes) example.cpp -o test$(python3-config --extension-suffix),超长的命令,官网的示例命令就是这么长,离谱。
各参数的含义为(参考链接):
-O3:优化等级设为第3级
-Wall:启用最大程度的警告信息
-shared:生成一个共享库文件;
-fPIC:生成位置无关目标代码,适用于动态连接;
-L path:将path库文件搜索路径列表;
-I path:将path加入头文件搜索路径列表;
-o file:指定输出文件名,也就是so文件名字的前半段,以及python import时候的包名,file必须与PYBIND11_MODULE()的第一个参数相同;
其他参数可以通过c++ -v --help查看。
python -m pybind11 --includes会给出pybind11用到的库文件的搜索路径,形如"-I/usr/include/python3.9 -I/usr/local/lib/python3.9/dist-packages/pybind11/include"
python3-config --extension-suffix给出当前环境下so文件的后缀,形如".cpython-39-x86_64-linux-gnu.so"
所以如果把$()包着的两个参数解开,整一条命令就是:c++ -O3 -Wall -shared -std=c++11 -fPIC -I/usr/include/python3.9 -I/usr/local/lib/python3.9/dist-packages/pybind11/include example.cpp -o test.cpython-39-x86_64-linux-gnu.so

如果so文件已经生成好了,在python里头import却说找不到,这时可以通过下面的命令看看当前python解释器支持哪些后缀的so文件的导入(参考链接):

import importlib.machinery
print(importlib.machinery.all_suffixes())

如果发现so文件名称不对,例如so文件中的python版本数字本来应该是39结果变成了38,就要去确认一下python3python3-config命令软链的目标是否跟预期一致。
到这一步,cpp文件目录下就应当有一个test开头的so文件,且可以在python中执行下列命令:

import test
test.add(1, 2)

至此一个基于pybind11和c++编译命令的python和C++混合用例就完成了~


接下来使用cmake进行编译,与cpp文件同目录的CMakeLists.txt内容如下:

# 给定cmake版本最低要求
cmake_minimum_required(VERSION 3.10) 

# 设置项目名
project(example)

# 添加头文件的搜索路径
include_directories("/usr/include/python3.9")
include_directories("/usr/local/lib/python3.9/dist-packages/pybind11/include")

# 设置变量,这部分与单纯用`c++`命令编译时的参数一致
SET(CMAKE_CXX_FLAGS "-std=c++11 -O3")

# 把一或多个源文件编译成库文件
add_library(test SHARED example.cpp)

对cmake命令的更详细介绍请看这里。
然后执行cmake -B build/ && cd build/ && make,就会得到一个libtest.so文件。这里我没有找到自动去掉前面的lib前缀的方法,但是这个so文件里头其实是对应着模块名test,所以还需要手动改回test.so,用其他名字都无法import成功。
mv libtest.so test.so,最后检验一下导入是否成功:

import test
test.add(1, 2)

至此一个基于pybind11和cmake的python和C++混合用例就完成了!


接下来讲一下源码安装方式,网上大多都是用这种方式安装pybind11

#有一些python跟C++的混合项目编译用的是eigen,也要装一下
git clone git@github.com:pybind/pybind11.git \
 && mkdir pybind11/build && cd pybind11/build \
 && cmake .. \
 && make -j12 \
 && make install \
 && apt-get install libeigen3-dev \
 && ln -s /usr/include/eigen3/Eigen /usr/include/Eigen \
 && ln -s /usr/include/eigen3/unsupported /usr/include/unsupported
 && export PYTHONPATH=$PYTHONPATH:/usr/local/include/pybind11

此时,CMakeLists.txt应该这样写:

# 给定cmake版本最低要求
cmake_minimum_required(VERSION 3.10)

# 设置项目名,此后可以通过 ${PROJECT_NAME} 使用项目名
project(example)

# 寻找pybind11,此时应有 /usr/local/include/pybind11 目录,否则程序无法工作
find_package(pybind11 REQUIRED)

# 将example.cpp文件中的内容添加到输出文件test中,最后将生成一个以test开头的so文件供python导入
# example.cpp中应有PYBIND11_MODULE函数,且它的第一个参数应于这里传给pybind11_add_module的第一个参数一致,
# 否则后续会报错ImportError: dynamic module does not define module export function
pybind11_add_module(test example.cpp)

然后执行cmake -B build/ && cd build/ && make,就会得到一个test.xxx.so文件,最后检验一下导入是否成功:

import test
test.add(1, 2)

完成!

下面展示一个引入c++文件的setup.py:

import os
import subprocess
from setuptools import setup, Extension
from setuptools.command.build_ext import build_ext


project_name = 'projection_render'

class CMakeExtension(Extension):

    def __init__(self, name, sourcedir=''):
        Extension.__init__(self, name, sources=[])
        self.sourcedir = os.path.abspath(sourcedir)
        if not os.path.exists(self.sourcedir):
            os.makedirs(self.sourcedir)


class CMakeBuild(build_ext):
    r"""
    During the process of `python setup.py install`, it will automatically call `python setup.py build_ext`
    to build C/C++ extensions, so we need to rewrite the event of build_ext command
    """

    def run(self):
        for ext in self.extensions:
            self.build_extension(ext)

    def build_extension(self, ext):
        if not os.path.exists(self.build_temp):
            os.makedirs(self.build_temp)

        extdir = self.get_ext_fullpath(ext.name)
        if not os.path.exists(extdir):
            os.makedirs(extdir)

        # This is the temp directory where your build output should go
        install_prefix = os.path.abspath(os.path.dirname(extdir))
        if not os.path.exists(install_prefix):
            os.makedirs(install_prefix)
        cmake_list_dir = os.path.join(install_prefix, project_name)
        # run cmake to build a .so file according to CMakeLists.txt
        subprocess.check_call(['cmake', cmake_list_dir], cwd=self.build_temp)
        subprocess.check_call(['cmake', '--build', '.'], cwd=self.build_temp)


setup(
    name=project_name,
    # 将include_package_data设为True,通过MANIFEST.in文件将非.py的编译所需文件(如.cpp)打包
    include_package_data=True,
    # 没有写__init__.py但又需要打包的.py文件目录,写在packages里头
    packages=[
        'projection_render', 'projection_render.include',
        'projection_render.src', 'projection_render.cuda_renderer'
    ],
    version="0.1",
    ext_modules=[CMakeExtension('projection_render')],
    # 含C++包的需要重写python setup.py build_ext命令,在里头编译cpp文件
    cmdclass={
        "build_ext": CMakeBuild,
    },
    description='python verison of projection_render',
)

MANIFEST.in文件的内容为:

include projection_render/README.md
include projection_render/cuda_renderer/*
include projection_render/include/*
graft projection_render/pybind/
include projection_render/src/*
include projection_render/CMakeLists.txt

如果一个顶层目录下有多个C++模块,各有各的CMakeLists.txt,情况又有不同。
引入C++模块最简单的写法是使用ext_modules参数,通过sources指定参与编译的源文件,通过name指定编译成的文件名(参考链接)。但在需要比较多的源文件参与编译的时候,这个参数就显得有点乏力了。这时我们需要使用更强大的工具,通过cmdclass参数重写build_ext函数。
setup(cmdclass={"build_ext": MyCommand}) 这个MyCommand必须继承自distutils.core.Command类,setuptools已经包了一层,一般是继承setuptools包好的类,比如build_ext命令对应的MyCommand就继承自setuptools.command.build_ext.build_ext类,我们如果不做重写,python setup.py build_ext命令运行的就是它这个类。
重写主要是重写run()函数,在进入run()的时候,self.extensions已经被赋值为输入setup()ext_modules,可以被MyCommand实例使用了,暂时没有搞明白到底是在哪个阶段赋值的,不管它,先用了再说
不管运行的命令是install, develop还是build_ext还是啥,setup()里头的参数都会在Command实例get_finalized_command()的时候被解析出来,其中就包括ext_modules
这里再给出一个多模块的setup.py例子:

import os
import subprocess
from setuptools import setup, Extension
from setuptools.command.build_ext import build_ext


project_name = "algo_utils"

# 因为编译所需的文件由MANIFEST.in指定,具体的操作由CMakeLists.txt指定,所以Extension的sources参数就没有用了,所以这里才直接赋了个固定空列表
# 不加makedirs的操作会报CMake Error: The source directory "xxx" does not exist,原因不明
class CMakeExtension(Extension):

    def __init__(self, name, sourcedir=""):
        Extension.__init__(self, name, sources=[])
        self.sourcedir = os.path.abspath(sourcedir)
        if not os.path.exists(self.sourcedir):
            os.makedirs(self.sourcedir)


class CMakeBuild(build_ext):
    r"""
    During the process of `python setup.py install`, it will automatically call `python setup.py build_ext`
    to build C/C++ extensions, so we need to rewrite the event of build_ext command
    """

    def run(self):
        for ext in self.extensions:
            self.build_extension(ext)

    def build_extension(self, ext):
        if not os.path.exists(self.build_temp):
            os.makedirs(self.build_temp)

        extdir = self.get_ext_fullpath(ext.name)
        if not os.path.exists(extdir):
            os.makedirs(extdir)

        # This is the temp directory where your build output should go
        install_prefix = os.path.abspath(os.path.dirname(extdir))
        if not os.path.exists(install_prefix):
            os.makedirs(install_prefix)
        cmake_list_dir = os.path.join(install_prefix, project_name, ext.name)
        # run cmake to build a .so file according to CMakeLists.txt
        subprocess.check_call(["cmake", cmake_list_dir], cwd=self.build_temp)
        subprocess.check_call(["cmake", "--build", "."], cwd=self.build_temp)

setup(
    name=project_name,
    include_package_data=True,
    version="0.1",
    # 二级模块名写在这里
    ext_modules=[
        CMakeExtension("projection_render"),
    ],
    cmdclass={
        "build_ext": CMakeBuild,
    },
    description="algorithm utility package containing .cpp file",
)

安装好之后检查一下:

from algo_utils import projection_render

没有问题就完成了。

进阶:多个包共用一个命名空间
随着代码仓库规模扩大,有时候我们会有将某些功能模块独立出来单独进行版本控制的需求,分离出来的模块以一个单独的python包的形式存在,也需要安装,有自己的版本号。但是简单的分离成一个独立的库,在导入时跟之前的写法就不一样了,有很多import语句需要更改。setuptools给出了一种解决方案,通过namespace_packages参数指出命名空间,详情请看参考链接和示例项目(还有一个谷歌的项目也可以参考)。简单地说,就是在setup.py中的setup()函数添加一个参数namespace_packages=["顶层模块名"],然后按示例项目的写法在对应的__init__.py中写上__import__("pkg_resources").declare_namespace(__name__),这句话的含义是临时导入pkg_resources包,调用它的declare_namespace()方法将顶层模块指定为命名空间(参考链接)。需要注意的是原有的主仓库跟新分离出来的子仓库都要对setup()__init__.py做上述处理,如果只是子仓库做了主仓库没做,在python setup.py develop之后主仓库的命名空间会被子仓库所覆盖,import 顶层模块名只能找到子仓库,主仓库找不到了,这点一定要注意。如果书写正确,代码分离前后import命令是不需要改动的。
另一种方案是基于pkgutils库,跟上面的pkg_resources方法类似,在对应的__init__.py里写上__path__ = __import__('pkgutil').extend_path(__path__, __name__),然后setup()函数不需要加namespace_packages参数。根据文档的说法,这个方案对Python2和Python3的兼容性更强,个人觉得pkg_resources方法有namespace_packages参数可以直截了当地看出顶层包名,是比较方便的,在不需要兼容Python2的情况下选用pkg_resources方案应该更合适。

其他
扩充setup.py支持的命令:需要使用cmdclass参数
setup(cmdclass={"build_ext": CMakeBuild})新增一个build_ext命令,通过python setup.py build_ext调用,这里的CMakeBuild是一个父类为setuptools.command.build_ext.build_ext的类,需要重写run方法,在运行python setup.py build_ext的时候实际上就是在调用CMakeBuild类的run方法。

举报

相关推荐

0 条评论