0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PyO3のpanicがPythonのexcept Exceptionで捕まらない理由

0
Posted at

PyO3のpanicがPythonのexcept Exceptionで捕まらない理由

PyO3で公開したRust関数がpanic!を起こすと、Python側にはPanicExceptionが現れます。しかし、except Exceptionでは捕まりません。

理由は、PanicExceptionがExceptionではなく、BaseExceptionを直接継承しているためです。

想定内の失敗はRustのResultから通常のPython例外へ変換し、panicは異常状態として区別する。この境界をPythonのテストで確認します。

RustのpanicをPythonから再現する

PythonからRust製のデータ変換関数を呼ぶ場面を考えます。

入力値が不正な場合、呼び出し側はValueErrorなどを想定しているかもしれません。しかし、Rust側でpanic!を使っていると、別の例外が返ります。

PyO3の公式ドキュメントでは、Pythonから呼び出されたRustコードのpanicを捕捉し、PanicExceptionへ変換する仕組みが説明されています。

panicをPython例外へ変換できることと、通常のエラーとして扱ってよいことは別です。

最小構成で再現します。

環境は次を想定します。

  • Python 3.12以降
  • Rustのstableツールチェーン
  • PyO3 0.27系
  • maturin 1系

以下のコードはPyO3 0.27系を前提にしています。

python -m venv .venv
source .venv/bin/activate
python -m pip install "maturin>=1,<2"
maturin init --bindings pyo3 --name panic_boundary

Windowsの場合は、仮想環境の有効化コマンドを環境に合わせて変更してください。

生成されたCargo.tomlの依存関係を確認します。

[lib]
name = "panic_boundary"
crate-type = ["cdylib"]

[dependencies]
pyo3 = { version = "0.27", features = ["extension-module"] }

続いてsrc/lib.rsを次の内容にします。

use pyo3::prelude::*;

#[pyfunction]
fn parse_count_bad(value: i32) -> i32 {
    if value < 0 {
        panic!("count must be non-negative");
    }
    value * 2
}

#[pymodule]
fn panic_boundary(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(parse_count_bad, m)?)?;
    Ok(())
}

負数を渡すとpanic!が実行されます。

これは問題を再現するためのコードです。入力値の検証にpanicを使う設計を推奨するものではありません。

拡張モジュールをビルドします。

maturin develop

Python側から呼び出します。

from panic_boundary import parse_count_bad

try:
    parse_count_bad(-1)
except Exception as exc:
    print("caught:", type(exc).__name__)

このコードではcaught:が表示されません。

Rust側のpanicはPyO3によってPanicExceptionとしてPythonへ伝わりますが、except Exceptionの対象外だからです。

未処理の例外となるため、通常はトレースバックが表示され、スクリプトは異常終了します。

なお、PyO3がpanicを捕捉できるのは、対応するPython呼び出し境界を通過する場合です。Rustプロセス全体のあらゆるpanicを安全に回復できるわけではありません。

PanicExceptionが通常の例外処理を通過する理由

PyO3のpanicがPythonのexcept Exceptionで捕まらない理由

Pythonの例外階層を確認します。

一般的な業務エラーはExceptionを継承しています。

print(ValueError.__mro__)

結果は次のとおりです。

(<class 'ValueError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)

一方、PyO3のPanicExceptionはBaseExceptionを直接継承します。

公式ドキュメントにも、通常のExceptionによって捕捉されないことが明記されています。

PythonにはKeyboardInterruptやSystemExitのように、通常のアプリケーション例外とは区別される例外があります。

これらもExceptionの外側に置かれています。

PyO3のpanicも、通常の入力エラーと同じ経路で処理されないように設計されています。

実際の継承関係をPythonから調べます。

from panic_boundary import parse_count_bad

try:
    parse_count_bad(-1)
except BaseException as exc:
    cls = type(exc)

    print(cls.__name__)
    print(cls.__mro__)
    print(isinstance(exc, Exception))
    print(isinstance(exc, BaseException))

例外型のモジュール表記は環境によって異なる場合がありますが、確認したい結果は次のとおりです。

PanicException
(<class 'pyo3_runtime.PanicException'>, <class 'BaseException'>, <class 'object'>)
False
True

ここで重要なのは、BaseExceptionなら捕捉できるという事実だけではありません。

except BaseExceptionを使えばよい、という結論にはならないことです。

広く捕捉すると、KeyboardInterruptやSystemExitまで対象になります。

さらに、panicを捕捉した後にRust側の状態が正常であるとは限りません。

複数の内部状態を更新する途中でpanicが発生した場合、更新が完了していない可能性があります。

PyO3による変換はPythonへの伝達方法を定義するものであり、処理のロールバックや内部状態の整合性まで保証するものではありません。

ResultからPyValueErrorへ変換する

負数が入力されることは、通常、プログラムの異常状態ではありません。

入力検証で予測できる失敗です。

Rust側ではResultで表現し、Pythonへ公開する境界でPyErrに変換します。

先ほどのsrc/lib.rsを修正します。

use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;

fn parse_count(value: i32) -> Result<i32, &'static str> {
    if value < 0 {
        return Err("count must be non-negative");
    }

    value
        .checked_mul(2)
        .ok_or("count is too large")
}

#[pyfunction]
fn parse_count_good(value: i32) -> PyResult<i32> {
    parse_count(value)
        .map_err(PyValueError::new_err)
}

#[pymodule]
fn panic_boundary(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(parse_count_good, m)?)?;
    Ok(())
}

処理を二段階に分けています。

内部関数parse_countはRustのResultを返します。

Pythonへの公開関数parse_count_goodでは、map_errによってPyValueErrorへ変換します。

PyResult<T>はResult<T, PyErr>の型エイリアスです。

PyO3は#[pyfunction]で公開された関数が返すPyErrを、Pythonの例外として伝えます。

これにより、Rust内部のエラー表現とPython側の例外契約を分離できます。

もう一つ、乗算にはchecked_mulを使用しました。

Rustの整数演算は、オーバーフロー時の挙動がビルド設定に依存します。デバッグビルドなどではpanicになるため、入力検証だけを修正しても異常終了の経路が残る可能性があります。

checked_mulなら、オーバーフローをNoneとして受け取り、通常のエラーに変換できます。

この例ではValueErrorに統一していますが、実際のAPIでは入力値の不正と計算範囲の超過を別の例外にする設計も考えられます。

修正後、再度ビルドします。

maturin develop

Python側では通常の例外処理で捕捉できます。

from panic_boundary import parse_count_good

try:
    parse_count_good(-1)
except Exception as exc:
    print(type(exc).__name__)
    print(str(exc))

出力は次のとおりです。

ValueError
count must be non-negative

Rust内部ではResult、Pythonへの公開境界ではPyResultという役割分担です。

内部処理をPythonの例外型に直接依存させる必要がないため、Rust側の関数を単体テストする場合にも扱いやすくなります。

Pythonのテストで例外契約を固定する

エラー設計は、Rust側のコードだけを確認しても十分ではありません。

Pythonから呼び出したときに、どの例外型が返るかをテストする必要があります。

問題のある実装と修正版を、同じPythonテストで比較します。

テスト用に、最初のparse_count_badも拡張モジュールへ残しておきます。

Cargo.tomlの依存関係は変えず、src/lib.rsに次の関数を追加します。

#[pyfunction]
fn parse_count_bad(value: i32) -> i32 {
    if value < 0 {
        panic!("count must be non-negative");
    }
    value * 2
}

#[pymodule]の登録処理にも追加します。

m.add_function(wrap_pyfunction!(parse_count_bad, m)?)?;

再ビルド後、pytestをインストールします。

python -m pip install pytest
maturin develop

test_boundary.pyを作成します。

import pytest

from panic_boundary import parse_count_bad, parse_count_good


def test_normal_error():
    with pytest.raises(ValueError) as exc:
        parse_count_good(-1)

    assert str(exc.value) == "count must be non-negative"
    assert isinstance(exc.value, Exception)


def test_panic_is_not_normal_exception():
    with pytest.raises(BaseException) as exc:
        parse_count_bad(-1)

    error = exc.value

    assert type(error).__name__ == "PanicException"
    assert not isinstance(error, Exception)
    assert isinstance(error, BaseException)


def test_success():
    assert parse_count_good(10) == 20


def test_overflow():
    with pytest.raises(ValueError):
        parse_count_good(2**30)

実行します。

python -m pytest -q

最後のtest_overflowは入力値の選び方に注意が必要です。

i32の最大値は2**31 - 1です。2**30を2倍するとその範囲を超えるため、checked_mulがNoneを返します。

一方、Pythonの整数には同じ固定幅の制限がありません。

Rustのi32へ変換できない値を渡した場合は、PyO3の引数変換段階でOverflowErrorになることがあります。

つまり、Pythonから見えるエラーは二つの境界で発生します。

  1. Pythonの値をRustの引数型へ変換する境界
  2. Rustの処理結果をPython例外へ変換する境界

入力型の変換失敗と業務ルール違反を同じ例外に統一するなら、公開関数の引数型や検証順序も含めて設計する必要があります。

また、pytest.raises(BaseException)はpanicの挙動を確認するために限定して使っています。

通常のアプリケーションコードですべての例外をBaseExceptionで捕捉することを勧めるものではありません。

Python側で期待する例外型をテストすることが、Rustとの境界におけるエラー契約の確認になります。

PanicExceptionを捕捉できても、そのまま処理を継続して安全とは限りません。共有状態を持つRust拡張では、panic後の状態を利用しない設計や、必要に応じたプロセス分離も検討対象になります。

今回の例は同期関数の入力検証に限っています。Rustの別スレッドで発生したpanic、panic=abortでビルドされたコード、FFI境界を越えるunwindについては同じ動作を前提にできません。プロセスの継続性が必要な処理では、例外変換だけでなく、異常をどの境界で隔離するかも確認する必要があります。

参考

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?