MesonによるCythonコンパイル

# はじめに
自分は普段、Pythonのlibrary, package製作をしているのですが、その際[Cython](https://cython.org)を多く用いています。
特に`.pyx`ファイルにcython言語で記述しているのですが、それらをbuildする際、今までは`setup.py`ファイルに`cython`と`setuptools`(内部では`distutils`)を用いてコンパイルし、shared library (`.so`ファイルや`.pyd`ファイル)を生成していました。
しかし、[PEP517](https://peps.python.org/pep-0517)や[PEP518](https://peps.python.org/pep-0518), [PEP621](https://peps.python.org/pep-0621)で記述されているとおり、`pyproject.toml`にbuildやmetadataの設定をすることが推奨されており、さらに、`distutils`がPython 3.10でdeprecateされ、3.12では完全に削除される予定です([PEP632](https://www.python.org/dev/peps/pep-0632/))。
実際、pythonの[scipy](https://docs.scipy.org/doc/scipy/)は[version1.9.0](https://github.com/scipy/scipy/releases/tag/v1.9.0)から[meson](https://mesonbuild.com)を用いた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://ninja-build.org/)というライブラリ動いて、コンパイルを最適化してくれるみたいです。
(この方が詳しく書かれています: 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  <------------- サブパッケージ１
    │   ├── __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`ファイルは以下の内容です:
```meson
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()`](https://mesonbuild.com/Reference-manual_functions.html#project)で基本的なビルドするソースに関する情報を載せます。
ここで`default_options`にcython[^2]やc言語に関するコンパイルオプションを指定しています。`c_std`や`buidtype`はmesonの[build-inオプション](https://mesonbuild.com/Builtin-options.html#builtin-options)で、この2つのオプションはwindowsなどのOSでコンパイルする場合は必要なようです。
さらに、コンパイルの際に必要な外部ライブラリを[`dpendency`オブジェクト](https://mesonbuild.com/Dependencies.html#dependencies)として定義しています。これはmesonで便利なツールの一つで、この`dep`オブジェクトをコンパイル時の引数に指定することで自動的にリンク付けがされます。meson側で対応していないライブラリ(`NumPy`など)は[`include_directories()`](https://mesonbuild.com/Reference-manual_functions.html#include_directories)関数からincludeディレクトリオブジェクトを作成し、そこから`dep`オブジェクトを作成できます。

また、[`run_command()`](https://mesonbuild.com/Reference-manual_functions.html#run_command)関数から`Python`などプログラムを直接実行することもできます。上では`NumPy`のメソッドを用いてincludeパスの文字列を取得しています。

最後に[`subdir`](https://mesonbuild.com/Reference-manual_functions.html#subdir)関数を用いて下の階層に移動しています。

## `./test_meson/meson.build`の内容
```meson
# 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`](https://mesonbuild.com/Syntax.html#foreach-statements)と[`py.extension_module`](https://mesonbuild.com/Python-module.html#extension_module)を用いて記述しています。この`foreach`文を用いたコンパイル法は上部で[要参考として載せたサイト](https://labs.quansight.org/blog/2021/07/moving-scipy-to-meson)を参考にさせていただきました。
引数の`dependency`に先ほど定義した`dep`オブジェクトをリスト形式で指定しています。必要となるライブラリだけでよいので、ここでは`omp_dep`は指定していません。

また、[`py.install_sources()`](https://mesonbuild.com/Python-module.html#install_sources)で`.py`ファイルを指定しているのは、今後`wheel`などのビルド済み配布物を制作する際に含める必要があるからです。

## `./test_meson/lib1/meson.build`の内容
```meson
# モジュール名と対応する.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()`](https://mesonbuild.com/Fs-module.html#copyfile)を用いて、buildディレクトリにコピーし、`dep`オブジェクトとして宣言する**ことです。コピーすることで、他のモジュールがこの`lib1.module`に依存していたとしてもcython側で適切に紐づけることができる様です。
また、`py.extension_module()`における`dependency`引数に`omp_dep`と`.pxd`ファイルから宣言された`_cython_tree`も加えられています。

さらに、`install_dir`をソースファイルがある階層と同じにします。`py.install_sources`における`subdir`も同様です。


# cythonソースコードの注意点
cythonのコーディングにおいて気をつける点は 内部モジュールを`cimport`する際は必ず[**相対インポート**](https://docs.python.org/ja/3/reference/import.html#package-relative-imports)をすることです。
例として`test_meson/lib2/module.pyx`の一部分を表示します:
```cython
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をセットアップします:
```bash
$ 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のセットアップが完了した場合は次のようにコンパイラを実行します:
```bash
$ 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コード例は以下の通りです:
```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](https://peps.python.org/pep-0518/)で決められています。今回のテストライブラリにおける`pyproject.toml`の設定の一部は以下のとおりです。
```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`](https://github.com/mesonbuild/meson-python)を利用している点です。このバックエンドはmesonシステムをpythonパッケージで利用するためのパッケージで現在なお開発が進められています。
また、ビルドの際必要となるパッケージは`requires`として予め登録しておく必要があります。これらのパッケージはビルド時に必要になるだけなので、ビルド時独立環境の中にのみインストールされ、ビルドが終わると削除されます。通常利用環境で依存パッケージがある場合は`[project]`テーブルの`dependencies`に記述しましょう。

# ビルド済み配布物の作成
ビルド済み配布物、いわゆるbdistやsdistを作成する際は、PyPAが提供している[`build`](https://github.com/pypa/build)というビルドフロントエンドツールを使用するのが最も簡単です。使用する際は次のようにコマンドを実行します。
```bash
$ 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](https://peps.python.org/pep-0660/)に載っています。
`meson-python`ではv0.13以降でeditableインストールに対応し、以下のコマンドで可能です:
```bash
$ python -m pip install --no-build-isolation --editable .
```
このコマンドでは`python`で`import`するたびに変更があればコンパイルされ、`.so`ファイルが`./build/cp12/`以下に配置され、パスが通るようになります。

詳しくは`meson-python`の[ドキュメント](https://meson-python.readthedocs.io/en/latest/how-to-guides/editable-installs.html#editable-installs)をご覧ください。


# dev.py CLIの活用
毎回、`meson compile`、`.so`ファイルのコピーを手打ちで実行するのは面倒なので、CLIを作成して楽にしましょう。
scipyの[dev.py CLI](https://docs.scipy.org/doc/scipy/dev/contributor/meson.html#the-dev-py-interface)を参考にして次のような`dev.py`を作成しました。
```python
"""
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`](https://github.com/ewels/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`などが採用していることから今後ますます利用されていくと思います。その時、この資料が少しでも皆様の参考になれば幸いです。

[^1]: https://github.com/scipy/scipy/issues/13615
[^2]: https://cython.readthedocs.io/en/latest/src/userguide/parallelism.html#compiling