Python Monthly Topics

Python 3.10から導入されたBetter error messagesの深掘り

鈴木たかのりです。今月のPython Monthly Topicsでは、Python 3.10から導入されたBetter error messagesについて紹介します。Better error messagesがどういったものであるかの紹介と、どのようにこのエラーメッセージが出力されているか、Python内部の処理についても触れようと思います。

Better error messagesとは

Python 3.10からBetter error messagesという機能が追加されました。この機能は名前のとおり「エラーメッセージを改善」するものです。⁠エラーメッセージの改善」により以前よりもわかりやすいエラーメッセージが出力され、ユーザーがエラーの意味に気づきやすくなりました。

例として、以下のようなリストの閉じカッコ]を忘れたコードを実行し、出力されるエラーメッセージを見比べてみます。

example.py
nums = [1, 3, 2, 5, 4, 8, 10
min(nums)

このコードをPython 3.9以前で実行すると、エラーメッセージとしては以下のように構文エラーSntaxErrorであることを示しますが、エラーの場所としては2行目の min(nums) を指し示します。このエラーメッセージを見てもmin(nums)自体には問題がないため、とくに初心者は混乱しやすいと思います。

$ python3.9 example.py 
  File "example.py", line 2
    min(nums)
    ^
SyntaxError: invalid syntax

同様のコードをPython 3.10で実行すると、以下のようなエラーメッセージとなります。エラーの箇所としてリストの開きカッコ[の位置を指し示しており、メッセージも'[' was never closedとなり「カッコが閉じられていない」ということがわかりやすくなっています。

$ python3.10 example.py 
  File "example.py", line 1
    nums = [1, 3, 2, 5, 4, 8, 10
           ^
SyntaxError: '[' was never closed

このようにBetter error messagesによって、エラー発生時のエラーメッセージが「どこが間違っているか」をよりわかりやすく伝えるようになりました。この機能はとくにPythonプログラミングの初心者に有効だと思います。

以下では、いくつかのサンプルコードを使用して、Python 3.10以降でどのようにエラーメッセージが改善されたかを示します。

また、後半ではBetter error messagesがどのように実現されているかについて解説します。

Better error messagesの例

ここではいくつかBetter error messagesで改善されたエラーメッセージの例を紹介します。同じコードをPython 3.9とPython 3.10で実行して、改善されたエラーメッセージを確認します。

コロン:を忘れた場合

以下のような、forif文などの末尾のコロン:を忘れたコードを実行します。

no_colon.py
for num in range(10)
    print(num)

Python 3.9では単純に「無効なシンタックスです」というメッセージでした。Python 3.10では「ここはコロンが期待されている」というエラーメッセージが出力され、わかりやすくなっています。

$ python3.9 no_colon.py
  File "no_colon.py", line 1
    for num in range(10)
                        ^
SyntaxError: invalid syntax
$ python3.10 no_colon.py
  File "no_colon.py", line 1
    for num in range(10)
                        ^
SyntaxError: expected ':'

===を間違えた

if文の条件で===に間違えたコードを実行します。

single_equal.py
if beer = "IPA":
    print("I like it")

Python 3.9では1つ前の例と同様「無効なシンタックスです」というメッセージです。Python 3.10では「もしかして=ではなくて==:=じゃないですか?」と提案されていて、とても丁寧です。また、エラーの範囲も代入文となっている全体を示して、わかりやすくなっています。

% python3.9 single_equal.py
  File "single_equal.py", line 1
    if beer = "IPA":
            ^
SyntaxError: invalid syntax
$ python3.10 single_equal.py
  File "single_equal.py", line 1
    if beer = "IPA":
       ^^^^^^^^^^^^
SyntaxError: invalid syntax. Maybe you meant '==' or ':=' instead of '='?

インデントを入れ忘れた

if文などのブロックでインデントを忘れることがあります。

no_indent.py
def is_beer(style):
    if style in ("IPA", "Hazy", "Pilsner"):
    print("This is beer")

Python 3.9でも「インデントされたブロックが期待される」というメッセージが出力されます。Python 3.10では加えて「2行目のif文の後の」と具体的な場所が示されて、より親切になっています。

$ python3.9 no_indent.py
  File "no_indent.py", line 3
    print("This is beer")
    ^
IndentationError: expected an indented block
$ python3.10 no_indent.py
  File "/Users/takanori/Books/gihyo-python-monthly/source/202212/no_indent.py", line 3
    print("This is beer")
    ^
IndentationError: expected an indented block after 'if' statement on line 2

変数名などのtypo

変数名、関数名などの綴りの入力を間違える、いわゆるtypoはとてもよくあります。以下のように、宣言した変数名を間違えている場合を考えます。

typo_var_name.py
bear = "IPA"
print(beer)

Python 3.9のエラーメッセージはbeerという名前は定義されていません」という意味です。そのため、ユーザーはよく「beerはその前に宣言しているはずなのになんで!?」と思い込んで1行目の間違えに気づかないことがあります。

Python 3.10ではbearのことですか?」とメッセージを補足することで、beerbearを間違えていることに気づきやすくなっています。

$ python3.9 typo_var_name.py
Traceback (most recent call last):
  File "typo_var_name.py", line 2, in <module>
    print(beer)
NameError: name 'beer' is not defined
$ python3.10 typo_var_name.py
Traceback (most recent call last):
  File "typo_var_name.py", line 2, in <module>
    print(beer)
NameError: name 'beer' is not defined. Did you mean: 'bear'?

このエラーメッセージは名前が似ている変数、関数などを探して提案しています。そのため、似た名前の変数や関数が存在しない場合には「Did you mean」のメッセージは出力されません。

以下の例ではPythonの対話モードで同じprint(beer)を実行していますが、bear変数を定義する前後で出力されるエラーメッセージが変わっていることがわかります。

>>> print(beer)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'beer' is not defined
>>> bear = "IPA"  # typoしたbear変数を定義
>>> print(beer)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'beer' is not defined. Did you mean: 'bear'?

Better error messagesをどう実現しているか

ここではBetter error messagesがどう実現されいてるかを説明しますが、その前にPythonのパーサーが変更された件について解説します。

LL(1)パーサーとPEGパーサー

PythonのパーサーとはPythonのプログラムを文法的に解析し、AST(Abstract Syntax Tree:抽象構文木)という中間状態を作成するものです。このパーサーはPythonの開発当初からLL(1)ベースのパーサーが使用されていました。このパーサーがPEP 617 – New PEG parser for CPythonの提案により、Python 3.9から新しいPEGパーサーに変更されました。まずはLL(1)パーサーとPEGパーサーの違いについて簡単に説明します。

LL(1)パーサーは左から順番に読んでいく構文解析器です。(1)は先読みするトークン(キーワード、識別子、演算子など)の数を表しており、1つ先読みするという意味になります。しかし、Pythonでは言語使用の拡張によって一部はLL(1)ではないルールが存在するようになり、そういう箇所は独自の実装となっているようです。また、LL(1)パーサーでは左再帰ができないため、解析ツリーが不自然な形状となるようです。

これらの問題に対処するために、上記のPEP 617でPEGパーサーの導入が提案されました。PEGパーサーではあいまいな処理は行われず、解析ツリーは必ず1通りとなります。

Python 3.9でPEGパーサーが導入されました。なお、Python 3.9のみオプション -X oldparser でLL(1)パーサーが使用できるようになっています。Python 3.10以降ではLL(1)パーサーは削除されたため、このオプションを指定してもパーサーは切り替わりません。新しいパーサーの導入については以下のドキュメントも参考にしてください。

また、LL(1)パーサー、PEGパーサー、左再帰については以下のWikipediaのドキュメントが参考になります。

PythonのPEGパーサーについては、以下の開発者向けガイドに詳しい情報が書いてあります。

Python 3.10で導入されたParenthesized context managersと構造化パターンマッチング(ソフトキーワードの導入)は、PEGパーサーの導入によって実現されました。今後もこのように、PEGパーサーによってより便利な文法がPythonに採用されるかも知れません。

Parenthesized context managers。カッコの中に複数のコンテキストマネージャーを入れられる
with (
    CtxManager1(),
    CtxManager2(),
):
    ....
構造化パターンマッチング
match = "IPA"  # ソフトキーワードなので変数に使用可能
match match:  # Pilsner, IPA and others
    case "Pilsner":
        result = "First drink"
    case "IPA":
        result = "I like it"
    case _:  # Wildcard
        result = "I like most beers"

構造化パターンマッチングの詳細については、以下のPython Monthly Topicsの過去記事も参考にしてください。

Better error messagesの動作(リストの閉じカッコ忘れ)

話をBetter error messagesに戻します。このエラーメッセージの改善も、PEGパーサーによって実現できるようになりました。

最初の例にも出てきた、リストの閉じカッコ]を忘れたコードを例に、どのように動作しているかをCPythonのコードで見てみましょう。

nums = [1, 3, 2, 5, 4, 8, 10
min(nums)

まず、このエラーメッセージの改善は以下のGitHub Issueで対応しています。What's New In Python 3.10の中にbpo-42864というリンクがあり、ここをクリックすると該当するIssueが確認できます。

このIssueでは3つPR(プルリクエスト)がありますが、メイン実装となる以下のPRを見てみます。

以下のコードParser/pegen.cの1191行目を見るとトークン化でエラーが発生し、エラーの行番号が現在のエラー行番号より前にある場合は、raise_unclosed_parentheses_error(p);でカッコが閉じていないことを示すエラーを生成しています。

Paser/pagen.c
_PyPegen_check_tokenizer_errors(Parser *p) {
    〈省略〉
        switch (PyTokenizer_Get(p->tok, &start, &end)) {	
            case ERRORTOKEN:
                if (p->tok->level != 0) {
                    int error_lineno = p->tok->parenlinenostack[p->tok->level-1];
                    if (current_err_line > error_lineno) {
                        raise_unclosed_parentheses_error(p);
                        return -1;
                    }
                }
                break;

raise_unclosed_parentheses_error(p) の中身は以下のようなコードPaser/pagen.cの268行目で、シンタックスエラーであること、エラーの行番号、列名、メッセージとして"'%c' was never closed"などを渡しています。

Paser/pagen.c
static inline void
raise_unclosed_parentheses_error(Parser *p) {
       int error_lineno = p->tok->parenlinenostack[p->tok->level-1];
       int error_col = p->tok->parencolstack[p->tok->level-1];
       RAISE_ERROR_KNOWN_LOCATION(p, PyExc_SyntaxError,
                                  error_lineno, error_col,
                                  "'%c' was never closed",
                                  p->tok->parenstack[p->tok->level-1]);
}

その結果、エラーメッセージとして正しく開きカッコの位置を差し示して、以下のようにエラーメッセージがわかりやすく出力されるようになりました。

$ python3.10 example.py 
  File "example.py", line 1
    nums = [1, 3, 2, 5, 4, 8, 10
           ^
SyntaxError: '[' was never closed

このPRではテストコードを追加Lib/test/test_syntax.pyの990行目して、エラーメッセージが正しく出力されることを確認しています。

Lib/test/test_syntax.py
def test_error_parenthesis(self):
    for paren in "([{":
        self._check_error(paren + "1 + 2", f"\\{paren}' was never closed")

    for paren in ")]}":
        self._check_error(paren + "1 + 2", f"unmatched '\\{paren}'")

Better error messagesの動作(属性名のtypo)

もう1つのBetter error messagesの動作例として、以下のようなコードでsplit()メソッドの綴りを間違えた例を見てみます。

Lib/test/test_syntax.py
s = "i like ipa"

print(s.sprit())

このコードを実行すると、AttributeErrorのメッセージに間違えたと思われるメソッド名splitが提案されます。

$ python3.10 attribute_error.py
Traceback (most recent call last):
  File "attribute_error.py", line 3, in <module>
    l = s.sprit()
AttributeError: 'str' object has no attribute 'sprit'. Did you mean: 'split'?

この改善に関するGitHub Issueはbpo-38530で、以下のものです。

Issueのタイトルの通りAttributeErrorとNameErrorで名前の提案をするというものです。

このIssueにもたくさんのPRがありますが、最初にAttributeErrorに対して名前の提案機能が追加された以下のPRを見てみます。

まず、例外を出力するprint_exception()関数の中に、名前の提案suggestions変数)を受け取り、Did you meanを出力する処理が追加されていますPython/pythonrun.cの956行目⁠。

Python/pythonrun.c
static void
print_exception(PyObject *f, PyObject *value)
{
    〈省略〉
    PyObject* suggestions = _Py_Offer_Suggestions(value);
    if (suggestions) {
        // Add a trailer ". Did you mean: (...)?"
        err = PyFile_WriteString(". Did you mean: ", f);

_Py_Offer_Suggestions(value)関数の中身は以下の通りで、ここでは例外がAttributeErrorの場合にのみ、offer_suggestions_for_attribute_error()関数が実行されますPython/suggestions.cの137行目⁠。

Python/suggestions.c
PyObject *_Py_Offer_Suggestions(PyObject *exception) {
    PyObject *result = NULL;
    assert(!PyErr_Occurred()); // Check that we are not going to clean any existing exception
    if (PyErr_GivenExceptionMatches(exception, PyExc_AttributeError)) {
        result = offer_suggestions_for_attribute_error((PyAttributeErrorObject *) exception);
    }
    assert(!PyErr_Occurred());
    return result;
}

offer_suggestions_for_attribute_error()関数の中では、指定された名前をname変数に、dir変数に現在のスコープの名前空間を取り出して、calculate_suggestions(dir, name)関数を呼び出しますPython/suggestions.cの115行目⁠。

Python/suggestions.c
static PyObject *
offer_suggestions_for_attribute_error(PyAttributeErrorObject *exc) {
    PyObject *name = exc->name; // borrowed reference
    PyObject *obj = exc->obj; // borrowed reference
    〈省略〉
    PyObject *dir = PyObject_Dir(obj);
    〈省略〉
    PyObject *suggestions = calculate_suggestions(dir, name);
    Py_DECREF(dir);
    return suggestions;
}

そしてcalculate_suggestions(dir, name)関数の中では、dir変数から1つずつ名前を取り出し、name変数と似ているかをlevenshtein_distance()関数で調べますPython/suggestions.cの79行目⁠。これはレーベンシュタイン距離Wikipediaという、何回書き換えるとその文字列になるかという数で2つの文字列が似ているかを表すものです。

レーベンシュタイン距離がMAX_DISTANCE(3)以下で最小のitemを、suggestionに代入して提案に採用します。そのため、似た名前がない場合はDid you meanのメッセージは表示されません。なお、コード中の日本語のコメントは筆者が追加したものです。

Python/suggestions.c
static inline PyObject *
calculate_suggestions(PyObject *dir,
                      PyObject *name) {
    〈省略〉
    Py_ssize_t suggestion_distance = PyUnicode_GetLength(name);
    PyObject *suggestion = NULL;
    for (int i = 0; i < dir_size; ++i) {
        PyObject *item = PyList_GET_ITEM(dir, i);  // 名前を1つ取り出し
        〈省略〉
        // nameとitemのレーベンシュタイン距離を計算
        Py_ssize_t current_distance = levenshtein_distance(PyUnicode_AsUTF8(name), PyUnicode_AsUTF8(item));
        // 距離が0またはMAX_DISTANCE(3)より大きければ無視
        if (current_distance == 0 || current_distance > MAX_DISTANCE) {
            continue;
        }
        // suggestionが空または距離が他よりも小さければitemを提案として採用
        if (!suggestion || current_distance < suggestion_distance) {
            suggestion = item;
            suggestion_distance = current_distance;
        }
    }
    〈省略〉
    return suggestion;
}

このように、エラーメッセージを改善するために、エラーの中身を意味的に解釈していることがわかると思います。

まとめとさらなるエラーメッセージの改善

Python 3.10ではさまざまなエラーメッセージが改善されており、初心者により優しいプログラミング言語になっていると感じます。しかし、エラーメッセージを改善するためには、PEGパーサーへの変更とエラーの状態を取得して適切なメッセージを出力するという細やかな対応がされています。

エラーメッセージの改善は継続しており、Python 3.11ではPEP 657 – Include Fine Grained Error Locations in Tracebacksによって、どの部分でエラーが発生したのかを明確に示すようになりました。詳細は以下のドキュメントを参照してください。

これらの改善は、Python 3.10、3.11のリリースマネージャーであり、PythonのSteering CouncilでもあるPablo Galindo Salgado氏@pyblogsalが中心となって実装されました。地道な改修を続けていて、非常にありがたいと個人的に思っています。

これらの改修の背景や内部的にどのように実装しているかについて、本人によるEuroPython 2022でのトークがあるので、興味がある方はそちらも参照してみてください。

また、このトークについても触れているEuroPythonのレポート記事が、gihyo.jp上にあります。そちらも見てもらえるとうれしいです(筆者によるレポート記事です⁠⁠。

おすすめ記事

記事・ニュース一覧