RustのコマンドラインツールとRust製Pythonモジュールをプロジェクト内に共存させる

## はじめに

maturin / PyO3 を使ってPythonの拡張モジュールをRustで実装する、ことをよくしていたが、コマンドラインツールとしてもビルドする必要が出てきたので、その方法をまとめる。

**要件：以下の２つを同じプロジェクトでできるようにする。**

- Rustのコマンドラインツールの実行バイナリ（`cargo build`でビルド）
- `pip install`できるPythonモジュール（`maturin build`でビルド）

**外部リンク：**

- 自作テンプレート（English version here too.）：
[https://github.com/k94-ishi/maturin-mixed-template](https://github.com/k94-ishi/maturin-mixed-template)
- 参考ページ：
  [How to Mix Rust and Python in Your Project](https://medium.com/@MatthieuL49/a-mixed-rust-python-project-24491e2af424)
  [Maturin User Guide: project_layout#mixed-rustpython-project](https://www.maturin.rs/project_layout.html?highlight=mixed#mixed-rustpython-project)

## Python側の環境準備

- `maturin`をインストール

```shell
pip install maturin
```

## プロジェクトの作成

単にPythonモジュールを作成する場合は`maturin new  --bindings pyo3 my_project`で作成するが、今回はRustのコマンドラインツールの実行バイナリもビルドできるようにしたいので、`--mixed`オプションを指定する。

- `maturin new --help`で`--mixed`オプションを確認

```plantext
--mixed
    Use mixed Rust/Python project layout
```

- Rust/Python混合プロジェクトを新規作成

```shell
maturin new --bindings pyo3 --mixed my_project
```

## `Cargo.toml`と`pyproject.toml`の修正

### `Cargo.toml`の修正

- `[features]`の追加

`cargo build`でPython関連のソースをビルドしようとしてエラーと成ることを防ぐ。

```toml
[features]
default = [] # デフォルトではPython関連の機能を無効化
python = ["dep:pyo3"] # `python` フィーチャーが有効な時のみ `pyo3` を使う
```

- `[lib]`の`crate-type`に`"rlib"`を追加

`src/lib.rs`をPython用とコマンドライン用それぞれの用途でビルドできるようにする。

```toml
[lib]
name = "my_project"
#  ["cdylib"]
crate-type = ["cdylib", "rlib"]
# "cdylib"はPython用、"rlib"はコマンドライン用
```

- `[dependencies]`の`pyo3`にオプション追加

ビルドソースを選んでビルドできるようにする。

```toml
[dependencies]
# pyo3 = "0.23.3"
pyo3 = { version = "0.23.3", features = ["extension-module"], optional = true }
```

### `pyproject.toml`の修正

- `[tool.maturin]`の`features`に`"python"`を追加

`maturin build`でのビルド対象の選択に`python`フィーチャーを使えるようにする。

```toml
# features = ["pyo3/extension-module"]
features = ["pyo3/extension-module, python"]
```

## ソースの修正

### `src/lib.rs`の修正

- コマンドラインでも使いたいソースと、Python用のみに使いたいソースを分ける。  
- `cargo build`でビルドしたくないPython用のソースには`#[cfg(feature = "python")]`を付ける

```rs
#[cfg(feature = "python")]
use pyo3::prelude::*;

pub fn sum_as_string_rs(a: usize, b: usize) -> String {
    (a + b).to_string()
}

#[cfg(feature = "python")]
#[pyfunction]
fn sum_as_string(a: usize, b: usize) -> PyResult<String> {
    Ok(sum_as_string_rs(a, b))
}

#[cfg(feature = "python")]
#[pymodule]
fn my_project(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(sum_as_string, m)?)?;
    Ok(())
}
```

### `src/main.rs`を追加

- プロジェクト作成時点では`src/main.rs`はないので追加で作成

```shell
touch src/main.rs
tree -L 3  # ディレクトリ構成の確認
```

ディレクトリ構成は下記のようになるはず。

```plaintext
.
├── Cargo.lock
├── Cargo.toml
├── pyproject.toml
├── python
│   ├── my_project
|   |   └── __init__.py
│   └── tests
│       └── test_all.py
└── src
    ├── lib.rs
    └── main.rs
```

`src/main.rs`を下記のように編集する。

```rs
use my_project::sum_as_string_rs;

fn main() {
    let text = sum_as_string_rs(1, 1);
    println!("1 + 1 = {}", text)
}
```

## ビルド＋実行＋`pip install`

```shell
cargo run
maturin build
pip install wheel
pip install ./target/wheels/my_project-*.whl
```

## Pythonコードの修正

`import`関連のコードが動かない状況なので、修正する。

### `python/my_project/__init__.py`の修正

```python
# from .my_project import *
from my_project import my_project
```

### `python/tests/test_all.py`の修正

```python
# import my_project
from my_project import my_project
```

この修正で`pytest`が動くはず。

```shell
pip install pytest
```

```shell
my_project $ pytest
=============================== test session starts ================================
platform darwin -- Python 3.12.3, pytest-8.3.5, pluggy-1.5.0
rootdir: /<directory>/my_project
configfile: pyproject.toml
collected 1 item                                                                   

python/tests/test_all.py .                                                   [100%]

================================ 1 passed in 0.04s ================================
```

## さいごに

- `features`をうまく使おう
- 詳しくは[Maturin User Guide](https://www.maturin.rs/)を読むべし