delhi09の勉強日記

技術トピック専用のブログです。自分用のメモ書きの投稿が多いです。あくまで「勉強日記」なので記事の内容は鵜呑みにしないでください。

Pythonの例外の仕様についてちゃんと調べた

概要

今までPythonでコードを書くときに例外をなんとなく雰囲気で使っていたので、ちゃんと仕様を調べてみた。

検証したこと

1. Exceptionはコンストラクタ引数をargsフィールドに保持している

以下のようなコードを例にする。

def hoge():
    raise Exception("hoge")

try:
    hoge()
except Exception as e:
    print(e.args)

実行すると以下のような結果になる。

$ python main.py
('hoge',)
$

次にExceptionの引数を以下のように変えて実行してみる。

raise Exception("hoge", "fuga")

実行すると以下のような結果になる。

$ python main.py
('hoge', 'fuga')
$

以上から、Exceptionはコンストラクタ引数をフィールドにTupleで保持していることが分かる。

公式ドキュメントの該当箇所

公式ドキュメントでは以下にargsの記載がある。
docs.python.org

これを読むと、argsはExceptionのさらに親クラスのBaseExceptionに定義されていることが分かる。

キーワード引数はargsフィールドには保存されない

まず前提として、元のExceptionクラス自体はキーワード引数を取ることができない。

例えば、以下のようなコードを実行するとTypeError: Exception() takes no keyword argumentsが送出される。

def hoge():
    raise Exception(message="hoge")

hoge()

次に、以下のように、独自例外を定義してキーワード引数を受け取る場合を検証する。

class HogeException(Exception):
    def __init__(self, message1, message2):
        pass

def hoge():
    raise HogeException("hoge", message2="fuga")

try:
    hoge()
except Exception as e:
    print(e.args)

上記のコードを実行すると以下のような結果になる。

$ python main.py
('hoge',)
$

以上から、キーワード引数はargsには保存されないことが分かる。

2. スタックトレースにはargsに保持している値が出力される

次に、スタックトレースには何が表示されるのかということをみていく。

以下のようなコードを例にする。

import logging

logger = logging.getLogger(__name__)

class HogeException(Exception):
    pass

def hoge():
    raise HogeException("hoge_message")

try:
    hoge()
except Exception:
    logger.exception("error!")

logger.exceptionは巷のサンプルコードによく使い方の誤りがあるので、以下の記事を読んだ方がよい。
qiita.com


実行すると以下のようにHogeException: hoge_messageが出力される。

$ python main.py
error!
Traceback (most recent call last):
  File "/path/to/main.py", line 15, in <module>
    hoge()
  File "/path/to/main.py", line 11, in hoge
    raise HogeException("hoge_message")
HogeException: hoge_message
$

次に以下のように引数を追加してみる。

raise HogeException("hoge_message", "fuga_message")

今度は結果は以下のようになる。

error!
...
HogeException: ('hoge_message', 'fuga_message')

上記の実行結果より、スタックトレースには以下の仕様でエラーが表示されていると推測される。

  • 「${例外クラス名}: ${args}」が表示される。
  • argsのTulpleの要素が1個か2個以上かで表示が少し変わる。(括弧が付くか否か)

一応、検証としてargsの値を書き変えた場合の実行結果を確認してみる。

def hoge():
    raise HogeException("hoge_message")

try:
    hoge()
except Exception as e:
    e.args = ("fuga_message",)
    logger.exception("error!")

実行するとHogeException: fuga_messageが出力されたので、推測は当たっているようである。

3. スタックトレースにエラーメッセージを出力する際は__str__が呼ばれている

2.にて、スタックトレースにはargsの値が出力されることが分かった。

公式ドキュメントを読むと、args__str__を呼ぶことでも出力できるということが記載されている。

例外インスタンスには __str__() が定義されており、 .args を参照しなくても引数を直接印字できるように利便性が図られています。

docs.python.org

この時に、スタックトレースにエラーメッセージを出力する際も直接argsを参照しているのではなく、__str__を呼んでいるのではないかと思った。

検証のため、以下のコードを実行してみる。

import logging

logger = logging.getLogger(__name__)

class HogeException(Exception):
    def __init__(self, message1, message2):
        self.message1 = message1
        self.message2 = message2

    def __str__(self) -> str:
        return f"==={self.message2}==="

def hoge():
    raise HogeException("hoge_message", message2="fuga_message")

try:
    hoge()
except Exception as e:
    logger.exception("error!")

結果は予想通り、HogeException: ===fuga_message===が表示された。

以上より、スタックトレースにエラーメッセージを出力する際は__str__経由でargsが出力されることが分かった。

__repr__は呼ばれない

念のため、上のコードで__str__の代わりに__repr__を定義した場合も検証してみた。

class HogeException(Exception):
    def __init__(self, message1, message2):
        self.message1 = message1
        self.message2 = message2

    def __repr__(self) -> str:
        return f"==={self.message2}==="

この場合はHogeException: hoge_messageが表示されたので、__repr__は呼ばれていないということが分かった。

4. 独自のエラーメッセージを出力する方法

1.〜3.を踏まえて、スタックトレースに独自のエラーメッセージを出力する方法を検討する。

以下の2つのやり方があることが分かる。

  1. 親の例外クラスのコンストラクタに渡す。
  2. __str__を定義する。
1. 親の例外クラスのコンストラクタに渡す。

という仕様を使って、親の例外クラスのコンストラクタに独自のエラーメッセージを渡すことで、実現することができる。

コードは以下のようになる。

import logging

logger = logging.getLogger(__name__)

class HogeException(Exception):
    def __init__(self, message):
        super().__init__(f"==={message}===")

def hoge():
    raise HogeException(message="hoge_message")

try:
    hoge()
except Exception as e:
    logger.exception("error!")

実行すると想定通り以下のような結果になる。

error!
Traceback (most recent call last):
  File "/path/to/main.py", line 16, in <module>
    hoge()
  File "/path/to/main.py", line 12, in hoge
    raise HogeException(message="hoge_message")
HogeException: ===hoge_message===

例えば、urllib3の独自例外はこの方法を使っている。

class PoolError(HTTPError):
    """Base exception for errors caused within a pool."""

    pool: "ConnectionPool"

    def __init__(self, pool: "ConnectionPool", message: str) -> None:
        self.pool = pool
        super().__init__(f"{pool}: {message}")

github.com

2. __str__を定義する。

こちらは検証時にコードも示しているので、コードは省略する。

例えば、SQLAlchemyの独自例外はこの方法を使っている。

class SQLAlchemyError(HasDescriptionCode, Exception):
    """Generic error class."""

    def _message(self, as_unicode=compat.py3k):
        if len(self.args) == 1:
            text = self.args[0]

            if as_unicode and isinstance(text, compat.binary_types):
                text = compat.decode_backslashreplace(text, "utf-8")
            elif compat.py3k or not as_unicode:
                text = str(text)
            else:
                text = compat.text_type(text)

            return text
        else:
            return str(self.args)

    def _sql_message(self, as_unicode):
        message = self._message(as_unicode)

        if self.code:
            message = "%s %s" % (message, self._code_str())

        return message

    def __str__(self):
        return self._sql_message(compat.py3k)

    def __unicode__(self):
        return self._sql_message(as_unicode=True)

github.com

どちらの方法でも良さそうだが、状況に応じてどっちの方がベターとかあるのであれば知りたい。

以上