はじめに
自分は普段、Pythonのlibrary, package製作をしているのですが、その際Cythonを多く用いています。
特に.pyx
ファイルにcython言語で記述しているのですが、それらをbuildする際、今まではsetup.py
ファイルにcython
とsetuptools
(内部ではdistutils
)を用いてコンパイルし、shared library (.so
ファイルや.pyd
ファイル)を生成していました。
しかし、PEP517やPEP518, PEP621で記述されているとおり、pyproject.toml
にbuildやmetadataの設定をすることが推奨されており、さらに、distutils
がPython 3.10でdeprecateされ、3.12では完全に削除される予定です(PEP632)。
実際、pythonのscipyはversion1.9.0からmesonを用いたbuild systemに移行しています1。
(要参考: https://labs.quansight.org/blog/2021/07/moving-scipy-to-meson)
しかし、mesonのドキュメントは例文が少ないためわかりにくく、未だに日本語での解説も少ないため備忘録としてPython+Cython libraryにおける設定の仕方をここにまとめておこうと思います。
Mesonとは
Meson is an open source build system meant to be both extremely fast, and, even more importantly, as user friendly as possible.
The main design point of Meson is that every moment a developer spends writing or debugging build definitions is a second wasted. So is every second spent waiting for the build system to actually start compiling code.
-- https://mesonbuild.com 引用
つまり高速でコンパイルできるbuildシステムを提供してくれるツールです。CMakeといったツールと近いみたいです。自分はC++を扱ったことがほとんどないのでそのあたりには詳しくないです...
Meson自体はPythonで記述されており、内部ではNinjaというライブラリ動いて、コンパイルを最適化してくれるみたいです。
(この方が詳しく書かれています: https://qiita.com/turenar/items/c727834fbf701beb47ef)
テストPython library構成
ソースコードについてはGitHubにて公開しております: https://github.com/munechika-koyo/test_meson
.
├── pyproject.toml
├── setup.py <------------- setuptools developモードインストール用
├── dev.py <-------------- 様々なbuild用CLI
├── meson.build
└── test_meson <----------- ソースディレクトリ
├── __init__.py
├── meson.build
├── cmain.pyx
├── main.py
├── lib1 <------------- サブパッケージ1
│ ├── __init__.pxd <- module.pxyを他のcythonコードでも使用するために必要
│ ├── __init__.py
│ ├── meson.build
│ ├── module.pxd <--- TestClass1を他のクラスで継承するために必要
│ └── module.pyx <--- TestClass1の定義など
└── lib2 <------------- サブパッケージ2
├── __init__.py
├── meson.build
└── module.pyx <--- TestClass1を継承したTestClass2の定義
テスト環境
OS: Ubuntu 22.04.2
Python環境
- Python 3.12.0
- Cython 3.0.6
- meson 1.3.0
- ninja 1.11.1
- pip 23.3.1
- build 0.7.0
- meson-python 0.15.0
Cコンパイラ関連
- gcc 11.4.0
- pkg-config 1.8.0
conda環境にて上記を実現していますが、他の環境(pyenvなど)でも実現できると思います。
まず特徴として、ルート階層とソースコードのスクリプトファイル(*.py
, *.pyx
)がある階層にすべてにmeson.build
ファイルが置かれていることです。
ここに、cythonやC言語によるコンパイル方法やincludeパス、dependency、ビルド済み配布物に含めるソースファイルなど全てを記述していきます。これが非常に冗長的で面倒な点ですが、逆にいうと非常に厳密的であると言えると思います。以下にそれぞれのmeson.build
ファイルで何を記述しているか説明していきます。
meson.buildファイルの内容
./meson.build
の内容
まずルート階層にあるmeson.build
ファイルは以下の内容です:
project(
'test-meson', # project名 (普通はpythonのパッケージ名と同じ)
# === 以下に使用するプログラミング言語を列挙する ===
'c', # cythonを使う場合はcコンパイラも用いるため、列挙しといた方がよい。
'cython',
# ===
version: '0.1', # パッケージバージョン (ファイルに記述してある場合はそれを指定することも可能)
meson_version: '>= 1.3',
default_options: [ # ここにコンパイルする際、必ず使用するoptionを'key=value'で指定
'cython_args=-3', # cythonのコンパイルオプション
'c_std=c99', # 使用するC言語規格 - c11にするとwindowsでcomplexに関するエラーが起こる
'buildtype=debugoptimized', # mesonのbuild-in オプション -Ddebug=true -Doptimization=2と等価
]
)
# filesystemモジュール - ファイルをbuildディレクトリにコピーするのに用いる
fs = import('fs')
# Pythonモジュール - pythonプログラムを実行したりするのに用いる
py = import('python').find_installation(pure: false) # pure: false でplatform依存のwheelを作成できる
# ======================================================================
# 以下にコンパイラに必要な外部ライブラリ(external dependency)について定義する
# ======================================================================
# Python dependency
py_dep = py.dependency()
# OpenMP dependency
omp_dep = dependency('openmp', required: true)
# NumPy dependency
incdir_numpy = run_command(
py, # Pythonでプログラムを実行
[
'-c',
'import numpy; print(numpy.get_include())'
],
check: true
).stdout().strip()
inc_np = include_directories(incdir_numpy)
np_dep = declare_dependency(include_directories: inc_np) # includeディレクトリからdependencyを作成
# =======================================================================
# 次の下階層へ
subdir('test_meson')
まずproject()
で基本的なビルドするソースに関する情報を載せます。
ここでdefault_options
にcython2やc言語に関するコンパイルオプションを指定しています。c_std
やbuidtype
はmesonのbuild-inオプションで、この2つのオプションはwindowsなどのOSでコンパイルする場合は必要なようです。
さらに、コンパイルの際に必要な外部ライブラリをdpendency
オブジェクトとして定義しています。これはmesonで便利なツールの一つで、このdep
オブジェクトをコンパイル時の引数に指定することで自動的にリンク付けがされます。meson側で対応していないライブラリ(NumPy
など)はinclude_directories()
関数からincludeディレクトリオブジェクトを作成し、そこからdep
オブジェクトを作成できます。
また、run_command()
関数からPython
などプログラムを直接実行することもできます。上ではNumPy
のメソッドを用いてincludeパスの文字列を取得しています。
最後にsubdir
関数を用いて下の階層に移動しています。
./test_meson/meson.build
の内容
# local modules for .pyx file
pyx_files = [
['cmain', 'cmain.pyx'], # [モジュール名, ファイル名]のリスト
]
# for文でpythonの外部モジュールをファイルごとに指定。ここで cython-> c -> .so/.pyd という流れを自動で行ってくれる。
foreach pyx_file : pyx_files
py.extension_module(
pyx_file[0], # モジュール名
pyx_file[1], # ファイル名
c_args: [numpy_nodepr_api],
dependencies: [py_dep, np_dep], # コンパイラに必要な外部ライブラリをここで指定する
install: true, # install_dir 先にインストールする
install_dir: py.get_install_dir() / 'test_meson', # .so/.pydファイルをインストールするディレクトリパスを指定
)
endforeach
# pure pythonのソースコード
python_sources = [
'__init__.py',
'main.py',
]
# インストールする際のソースコードの指定 ビルド済み配布物に含める場合に必要
py.install_sources(
python_sources,
subdir: 'test_meson', # インストールする際のディレクトリパス
)
# move to subdirectories
subdir('lib1')
subdir('lib2')
ここから本格的にcythonファイルのコンパイル(shared object作成等)の命令を記述していきます。
pyx_files
にモジュール名と元となるモジュールファイル名との対応を配列として静的に記述し、foreach
とpy.extension_module
を用いて記述しています。このforeach
文を用いたコンパイル法は上部で要参考として載せたサイトを参考にさせていただきました。
引数のdependency
に先ほど定義したdep
オブジェクトをリスト形式で指定しています。必要となるライブラリだけでよいので、ここではomp_dep
は指定していません。
また、py.install_sources()
で.py
ファイルを指定しているのは、今後wheel
などのビルド済み配布物を制作する際に含める必要があるからです。
./test_meson/lib1/meson.build
の内容
# モジュール名と対応する.pxdファイルをbuildディレクトリにコピーし、dependecyとして宣言する
_cython_tree = declare_dependency(sources: [
fs.copyfile('__init__.pxd'),
fs.copyfile('module.pxd'),
])
pyx_files = [
['module', 'module.pyx'],
]
foreach pyx_file : pyx_files
py.extension_module(
pyx_file[0],
pyx_file[1],
c_args: [numpy_nodepr_api],
dependencies: [py_dep, omp_dep, np_dep, _cython_tree], # omp_depと_cython_treeを追加
install: true,
install_dir: py.get_install_dir() / 'test_meson/lib1', # install先パスが異なることに注意
)
endforeach
python_sources = [
'__init__.py',
'__init__.pxd', # .pxdファイルは配布物として追加する
'module.pxd',
]
py.install_sources(
python_sources,
subdir: 'test_meson/lib1', # 同じくパスが異なることに注意
)
ここのサブパッケージにはcythonソースが記述されたmodule.pyx
と、様々な宣言が記述されたmodule.pxd
ファイルがあります。ここで重要なのは、.pxd
ファイルもfs.copyfile()
を用いて、buildディレクトリにコピーし、dep
オブジェクトとして宣言することです。コピーすることで、他のモジュールがこのlib1.module
に依存していたとしてもcython側で適切に紐づけることができる様です。
また、py.extension_module()
におけるdependency
引数にomp_dep
と.pxd
ファイルから宣言された_cython_tree
も加えられています。
さらに、install_dir
をソースファイルがある階層と同じにします。py.install_sources
におけるsubdir
も同様です。
cythonソースコードの注意点
cythonのコーディングにおいて気をつける点は 内部モジュールをcimport
する際は必ず相対インポートをすることです。
例としてtest_meson/lib2/module.pyx
の一部分を表示します:
from libc.stdio cimport printf
from ..lib1.module cimport TestClass1
cdef class TestClass2(TestClass1):
:
:
ここではtest_meson/lib1/module.pyx
で定義してあるTestClass1
を相対cimport
しています。
mesonビルド方法
meson
とninja
をインストールし、ルート階層でまず次のコマンドをコマンドラインで実行し、mesonをセットアップします:
$ meson setup build # "build"ディレクトリに設定
成功すると標準出力に次のような内容が記述されます:
The Meson build system
Version: 1.3.0
Source dir: /home/koyo/Documents/test_meson
Build dir: /home/koyo/Documents/test_meson/build
Build type: native build
Project name: test-meson
Project version: 0.1
C compiler for the host machine: cc (gcc 11.4.0 "cc (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0")
C linker for the host machine: cc ld.bfd 2.38
Cython compiler for the host machine: cython (cython 3.0.6)
Host machine cpu family: x86_64
Host machine cpu: x86_64
Program python3 found: YES (/home/koyo/.conda/envs/test-meson/bin/python3.12)
Found pkg-config: YES (/usr/bin/pkg-config) 1.8.0
Run-time dependency python found: YES 3.12
Run-time dependency OpenMP found: YES 4.5
Build targets in project: 5
Found ninja-1.11.1 at /home/koyo/.conda/envs/test-meson/bin/ninja
ここで、Cコンパイラやリンカ、depndencyが見つからないとエラーを吐きます。その場合は適切に環境変数(CC
やPATH
など)を設定してください。やり直す場合は --wipe
オプション付けてmeson setup
を再び実行します。
適切にmesonのセットアップが完了した場合は次のようにコンパイラを実行します:
$ meson compile -C build
結果は以下の通りです:
INFO: autodetecting backend as ninja
INFO: calculating backend command to run: /home/koyo/.conda/envs/test-meson/bin/ninja -C /home/koyo/Documents/test_meson/build
ninja: Entering directory `/home/koyo/Documents/test_meson/build'
[11/11] Linking target test_meson/lib1/module.cpython-312-x86_64-linux-gnu.so
-j
オプションでコア数を指定すると並列に処理してくれます。
コンパイルが成功すると./build
ディレクトリ以下に.so
ファイルや様々な設定ファイルなどが作成されます。
これでmesonによるコンパイルは完了ですが、pythonからimport
文で呼び出すには.build
にもパスを通す必要があり、少し面倒です。そこで作成された.so/.pyd
ファイルをもとのソースツリー内にコピーしてあげましょう。その時のpythonコード例は以下の通りです:
from pathlib import Path
import sys
import shutil
BUILD_DIR = Path("build")
ext = ".pyd" if sys.platform == "win32" else ".so"
for so_path in BUILD_DIR.glob(f"**/*{ext}"):
src = so_path.resolve()
dst = so_path.relative_to(BUILD_DIR)
shutil.copy(src, dst)
print(f"copy {src} into {dst}")
カレントディレクトリがルート階層の場合のコードです。
pyproject.tomlの設定
pythonパッケージとして成立させるためにはpyproject.toml
ファイルを作成し、そこに必要最小限のビルド設定を載せることがPEP518で決められています。今回のテストライブラリにおけるpyproject.toml
の設定の一部は以下のとおりです。
[build-system]
build-backend = "mesonpy"
requires = ["cython >= 3.0", "numpy", "meson-python", "ninja >= 1.8.2"]
[project]
name = "test-meson"
description = "Test for Meson with Cython"
version = "0.1"
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.9"
dependencies = ["numpy"]
ここで重要なのはbuild-backend
として"mesonpy"
つまりmeson-python
を利用している点です。このバックエンドはmesonシステムをpythonパッケージで利用するためのパッケージで現在なお開発が進められています。
また、ビルドの際必要となるパッケージはrequires
として予め登録しておく必要があります。これらのパッケージはビルド時に必要になるだけなので、ビルド時独立環境の中にのみインストールされ、ビルドが終わると削除されます。通常利用環境で依存パッケージがある場合は[project]
テーブルのdependencies
に記述しましょう。
ビルド済み配布物の作成
ビルド済み配布物、いわゆるbdistやsdistを作成する際は、PyPAが提供しているbuild
というビルドフロントエンドツールを使用するのが最も簡単です。使用する際は次のようにコマンドを実行します。
$ python -m build
実行が完了するとルート階層にdist
というディレクトリが作成され、その下にビルド済み配布物が作成されます。
.
├── dist
│ ├── test_meson-0.1-cp312-cp312-linux_x86_64
│ └── test_meson-0.1.tar.gz
wheelファイルの中身をless
コマンドを利用して見ると、
前半省略
Archive: dist/test_meson-0.1-cp312-cp312-linux_x86_64.whl
Length Method Size Cmpr Date Time CRC-32 Name
-------- ------ ------- ---- ---------- ----- -------- ----
3300 Defl:N 1435 57% 2023-12-03 08:52 421158e4 test_meson-0.1.dist-info/METADATA
88 Defl:N 85 3% 2023-12-03 08:52 8d43aa0c test_meson-0.1.dist-info/WHEEL
1071 Defl:N 632 41% 2023-11-14 06:00 028f879f test_meson-0.1.dist-info/LICENSE
53528 Defl:N 17186 68% 2023-12-03 08:52 5f2369a9 test_meson/cmain.cpython-312-x86_64-linux-gnu.so
260488 Defl:N 104417 60% 2023-12-03 08:52 393f4ffe test_meson/lib1/module.cpython-312-x86_64-linux-gnu.so
100792 Defl:N 37501 63% 2023-12-03 08:52 fbf1c06d test_meson/lib2/module.cpython-312-x86_64-linux-gnu.so
22 Defl:N 24 -9% 2023-11-14 06:00 dce6f0a3 test_meson/__init__.py
356 Defl:N 220 38% 2023-11-14 06:00 774030ef test_meson/main.py
22 Defl:N 24 -9% 2023-11-14 06:00 40344f1a test_meson/lib1/__init__.py
22 Defl:N 24 -9% 2023-11-14 06:00 b2f6c4ff test_meson/lib1/__init__.pxd
280 Defl:N 172 39% 2023-11-14 06:00 2563b6d8 test_meson/lib1/module.pxd
57 Defl:N 51 11% 2023-11-14 06:00 0d7ef4b7 test_meson/lib2/__init__.py
1110 Defl:N 623 44% 2023-12-03 08:52 9f8e2634 test_meson-0.1.dist-info/RECORD
-------- ------- --- -------
421136 162394 61% 13 files
となっており、meson.build
で指定したファイルと.so
ファイルが梱包されていることがわかります。
editableインストールの活用
ライブラリ開発を行う場合はpip install -e .
というeditableモードでのパッケージインストールが便利です。
これはソースコードを普通のインストール先(多くは../lib/python{version}/site-package
ディレクトリ)にインストールすることなく、python実行の際にソースコードへパスを通してくれるインストール法です。詳細はPEP660に載っています。
meson-python
ではv0.13以降でeditableインストールに対応し、以下のコマンドで可能です:
$ python -m pip install --no-build-isolation --editable .
このコマンドではpython
でimport
するたびに変更があればコンパイルされ、.so
ファイルが./build/cp12/
以下に配置され、パスが通るようになります。
詳しくはmeson-python
のドキュメントをご覧ください。
dev.py CLIの活用
毎回、meson compile
、.so
ファイルのコピーを手打ちで実行するのは面倒なので、CLIを作成して楽にしましょう。
scipyのdev.py CLIを参考にして次のようなdev.py
を作成しました。
"""
Developer CLI: building (meson) sources in place, document, etc.
"""
import os
import shutil
import subprocess
import sys
from pathlib import Path
import rich_click as click
BASE_DIR = Path(__file__).parent.resolve()
BUILD_DIR = BASE_DIR / "build"
SRC_PATH = BASE_DIR / "test_meson"
ENVS = dict(os.environ)
N_CPUs = os.cpu_count()
@click.group()
def cli():
"""Developer CLI: building (meson) sources in place, document, etc."""
pass
@cli.command()
@click.option("--build-dir", default=str(BUILD_DIR), help="Relative path to the build directory")
@click.option(
"-j",
"--parallel",
default=N_CPUs,
show_default=True,
help="Number of parallel jobs for building.",
)
def build(build_dir, parallel):
"""Build & Install package using Meson build tool.
\b
```python
Examples:
$ python dev.py build
```
"""
# === setup build ===============================================
cmd = ["meson", "setup", build_dir]
if Path(build_dir).exists():
cmd += ["--wipe"]
click.echo(" ".join([str(p) for p in cmd]))
ret = subprocess.call(cmd, env=ENVS, cwd=BASE_DIR)
if ret == 0:
print("Meson build setup OK")
else:
print("Meson build setup failed!")
sys.exit(1)
# === build project =============================================
cmd = ["meson", "compile", "-C", build_dir, "-j", str(parallel)]
click.echo(" ".join([str(p) for p in cmd]))
ret = subprocess.call(cmd)
if ret == 0:
print("Build OK")
else:
print("Build failed!")
sys.exit(1)
# === install .so/.pyd files in source tree ==========================
ext = ".pyd" if sys.platform == "win32" else ".so"
for so_path in BUILD_DIR.glob(f"**/*{ext}"):
src = so_path.resolve()
dst = BASE_DIR / so_path.relative_to(BUILD_DIR)
shutil.copy(src, dst)
print(f"copy {src} into {dst}")
print("Install .so files in place.")
rich_click
を用いてCLIを作成しています。ビルドを実行する際はpython dev.py build
とコマンド実行すれば良いです。
mesonビルドで注意すべき点
name-space パッケージのビルド
次のような__init__.py
ファイルが最初の階層に無いパッケージのことをnamespaceパッケージと呼び、トップのライブラリ名を共通として複数のサブパッケージを用意する際に用いられます。
package/
subpackageA/
__init__.py
modules.py
subpackageB/
__init__.py
modules.py
この場合、cython v3.0以降では標準で対応しているのですが、mesonを用いた場合ではC言語ファイルを作成する際にトップのpackage
を認識できないようです。
ですので、この場合はdev.py
CLIにpackage/__init__.py
を作成した上でビルドし、その後削除するようなコードを作成する応急手当が必要となるようです。
最後に
meson + cython の pythonパッケージは、numpy
やscipy
、scikit-image
などが採用していることから今後ますます利用されていくと思います。その時、この資料が少しでも皆様の参考になれば幸いです。