CSVを読み込むバッチでValueError: invalid literal for int() with base 10: 'lots'とだけ出て落ちると、どのファイルの何行目かがわからない。
例外を捕まえて別の例外をraise ... from eで投げ直すと情報は足せるが、例外の型が変わり、トレースバックも2つに分かれて長くなる。3.11で入ったBaseException.add_note()(PEP 678)を使うと、元の例外の型もトレースバックもそのままで、メモの行だけを足せる。
使い方
def load(name: str) -> list[tuple[int, int]]:
out = []
for lineno, row in enumerate(csv.DictReader(open(name)), start=2):
try:
out.append(parse_row(row))
except ValueError as e:
e.add_note(f"file={name} line={lineno} row={row}")
raise
return out File "basic.py", line 7, in parse_row
return int(row["id"]), int(row["qty"])
~~~^^^^^^^^^^^^
ValueError: invalid literal for int() with base 10: 'lots'
file=orders-2.csv line=3 row={'id': '2', 'qty': 'lots'}メモは例外メッセージの次の行に出る。追加したメモはe.__notes__(文字列のリスト)に入っていて、何度でも足せる。
raise ... from eとの違いを、同じ情報を足した場合で比べた。
| 外に出る例外の型 | トレースバックの行数 | |
|---|---|---|
raise RuntimeError(...) from e | RuntimeError | 16行 |
e.add_note(...); raise | ValueErrorのまま | 9行 |
型が変わらないので、呼び出し元のexcept ValueErrorもそのまま効く。「原因を別の意味の例外に変換したい」ならfromを使い、「状況を書き足したいだけ」ならadd_note()を使う、と使い分けられる。
コンテキストマネージャーにする
tryとexceptを毎回書くのは面倒なので、小さなヘルパーにしておくと便利だ。入れ子にすると、内側のメモから順に積まれる。
from contextlib import contextmanager
@contextmanager
def note(msg: str):
try:
yield
except Exception as e:
e.add_note(msg)
raise
for name, rows in files.items():
with note(f"file={name}"):
for lineno, qty in enumerate(rows, start=1):
with note(f"line={lineno} qty={qty!r}"):
int(qty)ValueError: invalid literal for int() with base 10: 'lots'
line=2 qty='lots'
file=b.csvハマりどころ
str(e)にはメモが含まれない。ログをlog.error(f"failed: {e}")のように書いていると、せっかく足したメモが消える。
str: invalid literal for int() with base 10: 'lots'
ERROR failed: invalid literal for int() with base 10: 'lots'メモまで出すには、log.exception()かexc_info=Trueでトレースバックごと出すか、traceback.format_exception_only(e)を使う。後者は["ValueError: ...\n", "file=orders-2.csv line=3\n"]のように、メモを含んだ行のリストを返す。
もう1つ気をつけたいのは、メモが例外オブジェクトそのものを書き換えることだ。同じ例外インスタンスが何度も投げられる場面では、メモが積み重なる。
fut = executor.submit(boom, 1)
for attempt in range(3):
try:
fut.result() # 毎回同じ例外インスタンスが投げられる
except ValueError as e:
e.add_note(f"attempt={attempt}")
fut.exception().__notes__
# ['attempt=0', 'attempt=1', 'attempt=2']asyncioでも、同じタスクを2か所でawaitしてそれぞれメモを足すと、['seen by waiter 1', 'seen by waiter 2']のように両方のメモが1つの例外に残る。リトライのたびにメモを足すコードや、結果を複数の待ち手で共有するコードでは、メモが重複していないか確かめた方がよい。
そのほかに確かめた挙動は次のとおり。
ProcessPoolExecutorのワーカーで足したメモは、pickleを通っても呼び出し元まで残る。ExceptionGroupに足したメモは、split()やsubgroup()で分けたあとの両方のグループにコピーされる。add_note()に文字列以外を渡すとTypeErrorになる。ただしe.__notes__に直接代入するとその検査はなく、文字列を代入すると、トレースバックにはそのreprの'oops'が1行として出る。
関連
- TaskGroup.cancel()で一番早い応答だけを採用する:
TaskGroupとexcept*で複数のタスクの例外を扱う話