maturinによるPythonとRustの連携

前置き

sqlglotというSQLパーサーをいじっていたらバグを発見しました。
github.com

下記のように全角スペースを含むSQLをパースしようとするとパース出来ないエラーとなりました。(SELECTの後に全角スペースがあります。)

SELECT * FROM tbl;

しかし、以前試したときは特にエラーが起きないことを確認していたので違和感を覚えました。
verによる違いかなと考えましたが、changelogを見てもそれらしき差分が無かったためコードを深堀してみました。

結論としては、sqlglotのtokenizerは、Python実装のものとRust実装のものがあり、Rust実装のものは全角スペースを変数tokenと認識してしまっていたものによるものでした。
tokenizeの部分はPythonもRustもエラーを起こさずに通過しますが、Parseの部分でRustのtokenizerを通過したtokenはエラーとなるようです。
以前はPython実装のものを使っていたためエラーが出なかったようです。(インストールをGitHubに書かれているやり方ではなくPyPIでやったためこのような状況になったようです。)

Issueにあるようにvenvで環境を切り替えずともSQLGLOTRS_TOKENIZER環境変数で制御できるようです。
github.com



私がこの件で興味を持ったのは、PythonからRustを呼ぶ仕組みです。
この仕組みを習得すれば、Python言語のメリット(ライブラリが充実していること、簡単に書けること)を全面的に採用しつつ、パフォーマンスが要求される部分のみRust言語で書くという選択肢を得ることができそうです。

maturinの仕組み

調べたところmaturinというツールを使うことでPythonとRustを連携できるようです。
maturinとは、Rustで書かれたコードをPythonの拡張モジュールとしてビルド・配布するためのツールのようです。

こちらの記事にわかりやすい説明が記載されていました。
gihyo.jp

PyO3は、PythonとRustをバインディングするためのツールであり、maturinはそのPyO3を内部で利用し、RustプログラムをビルドしてPythonパッケージ化するツールという位置付けのようです。
また、Polarsやruffといった高速化を特徴とするツール(従来のpandasやblackに対抗するもの)も、PyO3を活用してRustとの連携が図られ、開発されているようです。

maturinの検証

venv環境を作成します。

$ cd project
$ python -m venv .venv
$ source .venv/bin/activate
$ pip install maturin

次にmaturinでプロジェクトの初期化を行います。

$ maturin init
$ tree
.
├── Cargo.toml
├── README.md
├── pyproject.toml
└── src
    └── lib.rs

1 directory, 4 files

src/lib.rsは下記のようになっています。

use pyo3::prelude::*;

/// Formats the sum of two numbers as string.
#[pyfunction]
fn sum_as_string(a: usize, b: usize) -> PyResult<String> {
    Ok((a + b).to_string())
}

/// A Python module implemented in Rust.
#[pymodule]
fn prime(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(sum_as_string, m)?)?;
    Ok(())
}

続いてRustプログラムをビルドします。
ビルドにはdevelopを使い、リリース時には--releaseフラグをつけることでデバッグ情報などを除外した最適化された生成物をリリースできるようです。
この操作により、.venvにバイナリがインストールされるようです。

$ maturin develop
~~

$ ls .venv/lib/python3.13/site-packages/prime/
 __init__.py  󰌠 __pycache__   prime.cpython-313-x86_64-linux-gnu.so

ちなみにprime.cpython-313-x86_64-linux-gnu.soのファイル形式の見方としては、

prime      .cpython-313     -x86_64     -linux-gnu     .so
──────      ───────────     ───────     ──────────     ───
モジュール  CPythonの       CPU         OS            共有オブ
名          バージョン      アーキ       環境           ジェクト
            (Python 3.13) テクチャ     (Linux)      ファイル
  • prime: Pythonモジュールとしてimport primeのように使用可能
  • .cpython-313: Pythonのバージョン
  • -x86_64: 64ビットのx86アーキテクチャ向けにコンパイルされていることを示す
  • -linux-gnu: LinuxのGNU Cライブラリ(glibc)を使用する環境向けであることを示す
  • .so: 共有オブジェクトファイル(Shared Object)の拡張子

プログラムサイズの削減やライブラリ更新の一元化を目的として、プログラムが実行時に動的にライブラリを読み込んで使用する方式を動的リンクと呼びます。
また、この動的リンクされた形式のファイルは共有オブジェクトファイルと呼ばれます。

次のようなmain.pyを作るとRustで記述したモジュールを呼ぶことができました。

import prime

if __name__ == "__main__":
    print(prime.sum_as_string(1, 2))

少し複雑なメソッドを追加するにはこのようにすると出来ました。

use pyo3::prelude::*;

/// Formats the sum of two numbers as string.
#[pyfunction]
fn sum_as_string(a: usize, b: usize) -> PyResult<String> {
    Ok((a + b).to_string())
}

#[pyfunction]
fn is_prime(n: u64) -> PyResult<bool> {
    // 2未満の数は素数ではない
    if n < 2 {
        return Ok(false);
    }
    
    // 2は素数
    if n == 2 {
        return Ok(true);
    }
    
    // 偶数は2以外素数ではない
    if n % 2 == 0 {
        return Ok(false);
    }
    
    // 3から数の平方根までの奇数で割り切れるかチェック
    let sqrt = (n as f64).sqrt() as u64;
    for i in (3..=sqrt).step_by(2) {
        if n % i == 0 {
            return Ok(false);
        }
    }
    
    Ok(true)
}

/// A Python module implemented in Rust.
#[pymodule]
fn prime(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(sum_as_string, m)?)?;
    m.add_function(wrap_pyfunction!(is_prime, m)?)?;
    Ok(())
}
import prime

if __name__ == "__main__":
    print(prime.sum_as_string(1, 2))
    print(prime.is_prime(7))  # True
    print(prime.is_prime(8))  # False