使用meson构建打包Python包

Python meson

This article was last updated on <span id="expire-date"></span> days ago, the information described in the article may be outdated.

最近在重构组里的代码上遇到一件稍微棘手的事情。导师的旧代码是使用Fortran编写的,其对一些Python无法方便读取的二进制数据做了非常精细的操作,导致将代码完全Python化的难度直线上升。折衷的方案是通过使用numpy.f2py工具,将Fortran代码转换成Python扩展,这样就引入了新的问题,如何将扩展的编译融合进Python包的打包和安装过程中。

meson构建系统是我在几个月前无意中了解到的新工具,其包含了能够帮助编译和打包的Python模块,并且现有的一些项目都已完全将meson作为后端,例如scipynumpy。因此我决定将这作为一个练手的机会,使用meson来帮助构建这个Python包。

meson的简单介绍

meson是一个使用Python实现的项目构建系统,作用类似于Makefile或者CMake,提供了对多种平台、多种语言的支持。如果你的主要工作语言是Python,那么安装meson会变得非常方便,通过pip命令就能安装meson以及其默认的编译后端ninja。

1
pip install meson ninja

如果你的Python项目需要编译扩展,则你需要自己再安装对应的编译器,如gcc,gfortran等。

meson的配置文件是名为meson.build的文件,该文件类似于CMakeLists.txt,里面定义了项目的信息以及编译的信息等。

1
2
3
project('simple', 'c')
src = ['source1.c', 'source2.c', 'source3.c']
executable('myexe', sources : src)

使用meson来编译其他语言不是这篇文章的重点,如有需要可以直接去meson官网手册查看。

Python项目的meson配置

无扩展

假设你有以下这一个Python项目。

1
2
3
4
5
6
7
8
9
10
.
├── main.py
└── mypyproj # 真正的Python包内容。
├── __init__.py
├── c.py
├── core
│ ├── __init__.py
│ ├── a.py
│ └── b.py
└── utils.py

mypyproj目录内是真正的Python包代码。Python并不是编译型语言,该项目也没有需要编译的扩展,则完全用不到meson的编译功能,但是你依然可以将meson作为其打包的后端。首先在mypyproj同级目录添加pyproject.toml,配置项目信息、后端和依赖。

1
2
3
4
5
6
7
[build-system]
build-backend = "mesonpy"
requires = ["meson-python"]

[project]
name = "mypyproj"
version = "0.0.1"

build-backend字段指定了使用meson作为后端,但是由于meson并不会随Python一起安装,所以需要在requires中指定依赖,这样打包和安装时会先安装meson-pythonpyproject.toml的配置完成后,我们需要再配置meson,指定需要打包的文件以及安装的位置。不同于setuptools,meson不会自动找寻和检测Python包的代码,而是需要我们在配置文件中详细列出需要包含的文件。这样的做法比setuptools更繁琐一些,但是能让我们更细粒度控制Python包内包含的东西。

pyproject.toml的同级目录下添加meson.build,同样写入项目配置。

1
2
3
4
5
6
7
8
9
10
11
project(
'mypyproj',
version: '0.0.1',
license: 'BSD-3',
meson_version: '>=0.64.0',
default_options: ['warning_level=2'],
)

py3 = import('python').find_installation(pure: false)

subdir('mypyproj')

project()定义项目的名称,版本号,授权证书,meson版本等。py3 = import('python').find_installation(pure: false)导入Python模块,并找到安装的Python。subdir()指定了子目录,随后meson会去子目录下寻找子目录的meson.build,并再读取相应的配置。可以看到当前的meson.build并没有配置Python包的内容,这些是由子目录下的meson.build来配置的。

添加pyproject.tomlmeson.build后的目录结构。

1
2
3
4
5
6
7
8
9
10
11
12
.
├── main.py
├── meson.build
├── mypyproj
│ ├── __init__.py
│ ├── c.py
│ ├── core
│ │ ├── __init__.py
│ │ ├── a.py
│ │ └── b.py
│ └── utils.py
└── pyproject.toml

mypyproj下添加新的meson.build,该文件将用于定义Python包的内容。

1
2
3
4
5
6
7
8
9
10
py3.install_sources(
[
'__init__.py',
'c.py',
'utils.py',
],
subdir: 'mypyproj'
)

subdir('core')

py3.install_sources()定义了哪些文件会被包含到Python包中,以及他们在包中对应的位置subdirpy3是上一级的meson.build中获取,通过使用subdir()包含,我们可以在下级的meson.build中使用上级中的变量。由于我们的项目还包含了子目录core,所以我们进一步使用subdir()包含了core。同样的道理,在core目录下,我们同样需要添加meson.build,并定义需要被包含的文件。

1
2
3
4
5
6
7
8
py3.install_sources(
[
'__init__.py',
'a.py',
'b.py',
],
subdir: 'mypyproj/core'
)

完整的目录结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.
├── main.py
├── meson.build # 定义项目信息,subdir()包含下级meson.build
├── mypyproj
│ ├── __init__.py
│ ├── c.py
│ ├── core
│ │ ├── __init__.py
│ │ ├── a.py
│ │ ├── b.py
│ │ └── meson.build # 定义被包含的文件
│ ├── meson.build # 定义被包含的文件,subdir()包含下级meson.build
│ └── utils.py
└── pyproject.toml

至此,meson的配置我们就完成了,每一个目录下都包含有对应的meson.build,他们分别用于定义项目信息、Python包包含的文件,并通过subdir()互相联系起来。

完成pyproject.tomlmeson.build的配置后,我们就可以使用build来打包Python了。

1
python -m build
image-20241002151555900

呃,好吧。除了配置pyproject.tomlmeson.build外,你还需要使用Git来配置一下项目,将需要打包的文件添加到版本控制系统,这是使用meson打包文件时meson要求必须做的工作。需要注意的是,meson进行源代码打包时,会将Git中包含的所有文件打包进来。你可以使用.gitattributes文件来排除不想打包的东西:

1
2
.gitea/ export-ignore
images/ export-ignore

将文件都添加到Git后,再次build,就可以在dist/下看到打包好的Python包了。

有扩展

现在我们想添加一个使用Fortran编写的扩展d.f90,它位于mypyproj/core/d.f90

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.
├── main.py
├── meson.build
├── mypyproj
│ ├── __init__.py
│ ├── c.py
│ ├── core
│ │ ├── __init__.py
│ │ ├── a.py
│ │ ├── b.py
│ │ ├── d.f90
│ │ └── meson.build
│ ├── meson.build
│ └── utils.py
└── pyproject.toml

对于Fortran扩展的编译,我们首先需要使用numpy.f2py对其处理,生成C语言的wrap代码,然后再将生成的C wrap代码与原始Fortran文件一起编译,生成Python可直接import的扩展库。除了C wrap代码的生成需要我们额外进行控制,代码的编译过程直接由meson接管。

由于编译的过程需要使用numpy,我们还需要对pyproject.toml文件进行修改,在requires中添加numpy

1
2
3
4
5
6
7
[build-system]
build-backend = "mesonpy"
requires = ["meson-python", "numpy<2.0.0"]

[project]
name = "mypyproj"
version = "0.0.1"

编译的相关设置则都需要在mypyproj/core/meson.build文件中进行,因为d.f90扩展在mypyproj/core/meson.build所控制的目录下。首先调用numpy.f2py的配置如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
add_languages('fortran')
add_languages('c')

d = custom_target(
'dmodule',
input: ['d.f90'],
command: [
py3,
'-m', 'numpy.f2py',
'@INPUT@',
'-m', 'd',
'--lower',
'--build-dir',
'mypyproj/core',
],
output: ['dmodule.c', 'd-f2pywrappers2.f90'],
)

add_languages()设置了项目语言包含Fortran和C,然后使用custom_target()命令定义自定义构建目标。这里我们定义输入文件为['d.f90'],自定义的构建命令是numpy.f2py的构建命令,只不过将Python解释器替换为上级meson.build找到的py3,输入文件名设置为@INPUT@@INPUT@是meson的语法,meson会自动将@INPUT@替换为正确的输入文件名。我们使用了--build-dir来指定了生成文件存放的目录,保证生成的文件位于d.f90同目录下。output参数表示该构建目标的输出,输出文件的命名是numpy.f2py固定的命名格式。

由于我们是使用numpy.f2py来生成扩展,这部分涉及到numpy的C-API,因此需要知道numpynumpy.f2py的头文件路径。可以使用meson的run_command函数来执行Python命令,获取头文件目录路径。然后使用include_directories()来设置包含路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
incdir_numpy = run_command(
py3,
[
'-c', 'import os; os.chdir(".."); import numpy; print(numpy.get_include())',
],
check: true,
).stdout().strip()

incdir_f2py = run_command(
py3,
[
'-c', 'import os; os.chdir(".."); import numpy.f2py; print(numpy.f2py.get_include())',
],
check: true,
).stdout().strip()

inc_np = include_directories(incdir_numpy, incdir_f2py)

同样,我们需要知道编译Python扩展所依赖的Python链接库,以及编译链接的参数等,这里使用py3.dependency()来获取相关的设置。以上的准备工作做完以后,就可以使用py3.extension_module()来真正设置编译扩展。

1
2
3
4
5
6
7
8
py3.extension_module(
'd',
['d.f90', d, incdir_f2py / 'fortranobject.c'],
include_directories: inc_np,
dependencies: py_dep,
install: true,
subdir: 'mypyproj/core',
)

第一个参数'd'是设置的独一无二的构建目标名称,['d.f90', d, incdir_f2py / 'fortranobject.c']是输入文件,包含了原始Fortran文件,通过custom_target()编译得到的文件,以及numpy.f2py中定义的文件。include_directories设置头文件路径,dependencies设置链接库路径,install: true设置编译好后将扩展放入subdir指定的路径中。

到现在为止mypyproj/core/meson.build文件更改完毕,所有内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
py3.install_sources(
[
'__init__.py',
'a.py',
'b.py',
],
subdir: 'mypyproj/core'
)

add_languages('fortran')
add_languages('c')

d = custom_target(
'dmodule',
input: ['d.f90'],
command: [
py3,
'-m', 'numpy.f2py',
'@INPUT@',
'-m', 'd',
'--lower',
'--build-dir',
'mypyproj/core',
],
output: ['dmodule.c', 'd-f2pywrappers2.f90'],
)

incdir_numpy = run_command(
py3,
[
'-c', 'import os; os.chdir(".."); import numpy; print(numpy.get_include())',
],
check: true,
).stdout().strip()

incdir_f2py = run_command(
py3,
[
'-c', 'import os; os.chdir(".."); import numpy.f2py; print(numpy.f2py.get_include())',
],
check: true,
).stdout().strip()

inc_np = include_directories(incdir_numpy, incdir_f2py)

py_dep = py3.dependency()

py3.extension_module(
'd',
['d.f90', d, incdir_f2py / 'fortranobject.c'],
include_directories: inc_np,
dependencies: py_dep,
install: true,
subdir: 'mypyproj/core',
)

可以先单独运行meson的命令来测试扩展是否可以编译成功。

1
2
meson setup build
meson compile -C build

如果没有问题,则可以将所有更改提交到Git,然后运行打包命令打包,打包完成后就可以看到含有扩展的.whlPython包了。

image-20241003152907462

Author: Syize

Permalink: https://blog.syize.cn/2024/10/03/build-python-package-with-meson/

本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Syizeのblog

Comments