【Python】独自例外とraise fromで「何に失敗したか」を伝える

PythonのTopに戻る

利用者向けの文脈と元の原因を両方残す

設定を読む関数でFileNotFoundErrorやValueErrorがそのまま出ると、呼び出し側には何の設定を読もうとしたのか分かりにくいことがある。用途に沿った独自例外へ変換すると、呼び出し側は「設定の読み込みに失敗した」とまとめて扱える。その際、raise … from errorを使えば元の原因とのつながりを残せる。

独自例外はエラーを隠すためのものではない。公開する関数の境界で意味を付け足し、低レベルの原因は調査できるように保持する。以下では、一つの設定ファイルから正の有限な係数を読む。読み込みと数値化に失敗した場合をSettingsErrorにまとめ、必要な条件を満たさない値も同じ用途の例外で知らせる。

Exceptionを継承し、原因をfromで渡す

example.py

from pathlib import Path
from tempfile import TemporaryDirectory
import math
import traceback


class SettingsError(Exception):
    pass


def load_scale(path):
    path = Path(path)
    try:
        text = path.read_text(encoding="utf-8")
    except (OSError, UnicodeError) as error:
        raise SettingsError(f"cannot read settings: {path.name}") from error
    try:
        value = float(text.strip())
    except ValueError as error:
        raise SettingsError(f"invalid numeric setting: {path.name}") from error
    if not math.isfinite(value) or value <= 0:
        raise SettingsError("scale must be finite and positive")
    return value


with TemporaryDirectory() as temporary:
    root = Path(temporary)
    (root / "ok.txt").write_text("2.5\n", encoding="utf-8")
    (root / "bad.txt").write_text("not a number\n", encoding="utf-8")
    assert load_scale(root / "ok.txt") == 2.5
    for name, cause_type in (("missing.txt", FileNotFoundError), ("bad.txt", ValueError)):
        try:
            load_scale(root / name)
        except SettingsError as error:
            assert isinstance(error.__cause__, cause_type)
            report = "".join(traceback.format_exception(error))
            assert "direct cause" in report
            print(str(error), "->", type(error.__cause__).__name__)
        else:
            raise AssertionError("expected SettingsError")

実行結果

cannot read settings: missing.txt -> FileNotFoundError
invalid numeric setting: bad.txt -> ValueError

独自例外の粒度を決める

SettingsErrorは通常の例外と同じようにExceptionを継承する。ここでは追加属性が不要なのでpassだけでよい。ファイル読み込みが失敗したのか、数値として読めなかったのかはメッセージで区別する。呼び出し側が両者に対して異なる回復方法を持つなら、SettingsReadErrorとSettingsValueErrorのような派生型を追加する設計もある。

例外のクラスを細かく増やすこと自体が目的ではない。呼び出し側が対応を変える必要があるかを基準にする。例えばファイルが存在しないときだけ既定値へ戻すなら、SettingsErrorを全部捕まえると不正な値まで見逃してしまう。原因の種類を調べるか、区別可能な例外型を公開する。

数値が負や非有限である場合は、下位の関数から例外が出たわけではなく、この関数自身が制約違反を検出している。そのためfromを付ける相手がない。常に何かを原因として付けるのではなく、実際に起きた失敗のつながりを表す。

__cause__とトレースバックを見る

raise SettingsError(…) from errorで設定した元の例外は__cause__から取得できる。未処理のまま表示すると、元の例外、直接の原因であることを示す説明、新しい例外の順に情報が並ぶ。例では全文を表示せず、__cause__の型と、生成したトレースバックに因果関係が含まれることをassertで確認している。

except内で別の例外を送出しただけでも例外の文脈が残る場合があるが、fromで明示すると意図した直接の原因が読み取りやすい。raise … from Noneは元の文脈の表示を抑えるために使えるが、調査に必要な原因まで見えなくしないようにする。分かりやすいメッセージへ変えることと、原因を消すことは別である。

同じ例外をそのまま外へ伝えるだけなら、exceptの中で引数なしのraiseを使う。新しい例外を何層でも付ければ分かりやすくなるとは限らない。モジュールの公開境界など、利用者にとって意味のある場所で文脈を追加するのがよい。

メッセージへ何を含めるか

ファイル名は含めたが、設定ファイルの内容はメッセージへ入れていない。設定には接続情報などが含まれることがあるため、失敗した値をそのままログへ出す設計には注意が必要である。元の例外のトレースバックにも完全なパスなどが含まれうるので、原因を保持したまま利用者へ見せる範囲を選ぶ。

テストでは「SettingsErrorが出る」だけでなく、FileNotFoundErrorとValueErrorのどちらを原因として保持したかまで確認した。例外変換を修正したときに、原因が失われたり違う例外までまとめて捕まえたりしていないかを見つけられる。成功時の値も一緒に確認し、異常系だけに偏らないテストにする。

実行環境と関連情報

掲載コードはLinux上のCPython 3.12.14で実行した。OS固有のファイル操作や対話環境の違いは、本文に記した条件に従って扱う。

関連:例外を握りつぶさないtry・except・else・finallyの使い分け / pytestで正常系と異常系の自動テストを始める

PythonのTopに戻る