Python Monthly Topics

Python 3.12の新機能「PEP 692: Using TypedDict for more precise **kwargs typing」紹介

筒井@ryu22eです。2023年9月の「Python Monthly Topics」は、Python 3.12の新機能「PEP 692 – Using TypedDict for more precise **kwargs typing」について紹介します。

PEP 692は**kwargs引数(任意のキーワード引数を辞書型で受け取れる)への型チェックを強化してくれる機能です。本記事ではPEP 692登場前にどんな問題があったのか、PEP 692がそれらをどう解決するのかを説明します。

PEP 692の本文については以下を参照してください。

なお、本記事の執筆時点(2023年8月27日)では、Python 3.12はリリース候補版です(正式版は2023年10月2日リリース予定⁠⁠。本記事に掲載されたサンプルコードの動作確認にはPython 3.12.0rc1を使っています。Python 3.12のリリース計画について詳細は以下を参照してください。

型チェッカーはMypy 1.4.1を使用します。

Python 3.11以前の**kwargs引数にまつわる2つの問題

まず最初に、PEP 692登場前、つまりPython 3.11以前の**kwargs引数の型にまつわる問題について説明します。

【問題点1】各キーワード引数に同じ型しか指定できない

Python 3.11以前の**kwargs引数での型ヒントでは、どのキーワード引数にも同じ型しか指定できません。

以下の例では、example関数の**kwargs引数にstr型を指定しているため、example関数呼び出し時に指定したキーワード引数はどれもstr型の値しか受け付けません。foo引数はstr型、bar引数はint型にしたい、といったケースには対応できません。

example_problem1_1.py
def example(**kwargs: str) -> None:
    ...

example(foo="test1", bar="test2")  # すべてのキーワード引数が文字列なのでOK
example(foo="test1", bar=2)  # bar引数が整数値なのでNG

上記のコードをMypyで型チェックすると、最後の行のexample関数の呼び出しでbar引数に整数値を指定しているため、以下のエラーが表示されます。

$ mypy example_problem1_1.py
example_problem1_1.py:5: error: Argument "bar" to "example" has incompatible type "int"; expected "str"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

また、この問題に関連して起こるキーワード引数に関する別の問題についても紹介します。

以下のコードではurl引数で指定したURLにリクエストを送るrequest関数を定義しています。認証が必要なURLはauth引数で認証情報を渡す仕様です。

example_problem1_2.py
class Auth:
    """認証情報"""
    ...

def request(url: str, auth: Auth | None = None) -> None:
    """url引数で指定したURLにリクエストを送る。認証が必要な場合はauth引数に認証情報を指定"""
    ...

上記の関数に、auth引数を省略した場合と明示的にauth=Noneを指定した場合で違う処理を実行する仕様が加わったとします。上記コードをどう変更すればこの仕様を実現できるでしょうか?

デフォルト値付きキーワード引数では「指定を省略した場合」「明示的にデフォルト値を指定した場合」の区別ができません。こういうケースでは**kwargs引数が有効です。

以下のコードはexample_problem1_2.pyを編集して、auth引数を**kwargs引数にしました(型情報が付いていない理由は後で説明します⁠⁠。

example_problem1_3.py
def request(url, **kwargs):
    if "auth" not in kwargs:
        print("auth引数が省略された場合の処理が呼ばれた")
    elif "auth" in kwargs and kwargs["auth"] is None:
        print("auth引数に明示的にNoneを渡した場合の処理が呼ばれた")

print("---auth引数を省略した関数呼び出し---")
request("https://example.com")
print("---auth引数に明示的にNoneを渡した関数呼び出し---")
request("https://example.com", auth=None)

上記のコードはキーワード引数authが明示的に指定されていればkwargsのキーに"auth"が含まれるので、判定式を書くことができます。上記のコードを実行すると、auth引数を省略した場合、明示的にNoneを指定した場合を区別していることがわかります。

$ python example_problem1_3.py
---auth引数を省略した関数呼び出し---
auth引数が省略された場合の処理が呼ばれた
---auth引数に明示的にNoneを渡した関数呼び出し---
auth引数に明示的にNoneを渡した場合の処理が呼ばれた

ところが、前述の通り**kwargs引数には各キーワード引数に同じ型しか指定できない問題がありますexample_problem1_3.pyに型情報を付けなかったのはこのためです⁠⁠。そのため、型を書いているプログラムでこの案を適用するのは困難となります。

なお、この問題はHTTPXというライブラリで実際に議論の対象になったIssueを元にしています[1]。実際の議論の内容は以下URLを参照してください。

【問題点2】関数⁠メソッドの仕様にはない引数を指定しても型エラーにならない

以下の関数は**kwargs引数を定義していますが、キーワード引数にfirst_namelast_nameを指定した場合にprint関数で値を出力する仕様になっています。

example_problem2.py
def example(**kwargs: str) -> None:
    first_name = kwargs.get("first_name")
    if first_name:
        print(f"First name: {first_name}")
    last_name = kwargs.get("last_name")
    if last_name:
        print(f"Last name: {last_name}")

example(first_name="Taro", last_name="Yamada")  # OK
example(first_name="Ichiro", last_neme="Suzuki")  # last_nemeはtypo

最後の行の関数呼び出しはlast_name引数をlast_nemeと間違えています。**kwargs引数はどんなキーワード引数も受け取れるので、以下のように実行時も型チェック時もエラーにはなりません。

$ python example_problem2.py
First name: Taro
Last name: Yamada
First name: Ichiro
$ mypy example_problem2.py
Success: no issues found in 1 source file

Python 3.12の新機能「PEP 692 – Using TypedDict for more precise kwargs typing」kwargs引数にTypedDictで型を付けられる

Python 3.12で追加されたPEP 692により、TypedDictUnpackを組み合わせることで**kwargs引数に型を付けられるようになりました。

以下のコードはadd_book関数に**kwargs引数を定義していますが、キーワード引数としてstr型のtitle引数、int型のprice引数を受け付けるようにしています。

example_pep692_1.py
from typing import TypedDict, Unpack, assert_type

class Book(TypedDict):
    title: str
    price: int

def add_book(**kwargs: Unpack[Book]) -> None:
    assert_type(kwargs, Book)  # エラーにならない

add_book(title="Python実践レシピ", price=2790)
add_book(title="Python実践レシピ", price="2,970円(本体2,700円+税10%)")

上記のコードをMypyで型チェックしてみましょう。最後の行のprice引数の型が整数値ではなく文字列になっているので、エラーとして検出しています。また、型チェッカーは**kwargs引数をTypedDictとして扱うため、assert_type(kwargs, Book)はエラーになりません(Mypy 1.4.1時点では型チェックするために--enable-incomplete-feature=Unpackオプションが必要です⁠⁠。

$ mypy --enable-incomplete-feature=Unpack example_pep692_1.py
example_pep692_1.py:11: error: Argument "price" to "add_book" has incompatible type "str"; expected "int"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

また、必須ではないキーワード引数を定義したい場合は、NotRequiredを使います。NotRequiredTypedDictの省略可能なキーに指定する型で、Python 3.11で追加されました。以下のコードはisbn引数は指定してもしなくてもエラーにはなりません。

example_pep692_2.py
from typing import TypedDict, Unpack, assert_type, NotRequired

class Book(TypedDict):
    title: str
    price: int
    isbn: NotRequired[str]

def add_book(**kwargs: Unpack[Book]) -> None:
    assert_type(kwargs, Book)  # エラーにならない

add_book(title="Python実践レシピ", price=2790)
add_book(title="Python実践レシピ", price=2790, isbn="978-4-297-12576-9")

PEP 692により、前述のPython 3.11以前の**kwargs引数にまつわる2つの問題はすべて解決しています。

【問題点1】**kwargs引数にTypedDictを使えるようになったので各キーワード引数に異なる型を指定できるようになりました。

example_problem1_1.pyをPEP 692に対応した例を以下に示します。foo引数はstr型、bar引数はint型」という仕様にしています。

improved_example_problem1_1.py
from typing import TypedDict, Unpack

class Example(TypedDict):
    foo: str
    bar: int

def example(**kwargs: Unpack[Example]) -> None:
    ...

example(foo="test1", bar=2)  # OK
example(foo="test1", bar="test2")  # bar引数の型を間違えているのでエラー

上記のコードをMypyで型チェックすると、最後の行example(foo="test1", bar="test2")の部分でbar引数の型を間違えているため、エラーになります。

$ mypy --enable-incomplete-feature=Unpack improved_example_problem1_1.py
improved_example_problem1_1.py:11: error: Argument "bar" to "example" has incompatible type "str"; expected "int"  [arg-type]
Found 1 error in 1 file (checked 1 source file

また、example_problem1_3.pyをPEP 692に対応した例も以下に示します。auth引数は省略可能なのでNotRequiredを使っています。

improved_example_problem1_3.py
from typing import TypedDict, Unpack, NotRequired

class Auth:
    ...

class Option(TypedDict):
    auth: NotRequired[Auth | None]

def request(url, **kwargs: Unpack[Option]) -> None:
    if "auth" not in kwargs:
        print("auth引数が省略された場合の処理が呼ばれた")
    elif "auth" in kwargs and kwargs["auth"] is None:
        print("auth引数に明示的にNoneを渡した場合の処理が呼ばれた")

print("---auth引数を省略した関数呼び出し---")
request("https://example.com")
print("---auth引数に明示的にNoneを渡した関数呼び出し---")
request("https://example.com", auth=None)
print("---auth引数の型を間違えている関数呼び出し---")
request("https://example.com", auth="invalid value")

上記のコードをMypyで型チェックすると、最後の行request("https://example.com", auth="invalid value")auth引数の型を間違えているため、エラーになります。

$ mypy  --enable-incomplete-feature=Unpack improved_example_problem1_3.py
improved_example_problem1_3.py:20: error: Argument "auth" to "request" has incompatible type "str"; expected "Auth | None"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

【問題点2】TypedDictに定義されていないキーワード引数を指定すると型チェックでエラーになるのでtypoがあっても早期発見ができます。

example_problem2.pyをPEP 692に対応した例を以下に示します。

improved_example_problem2.py
from typing import TypedDict, Unpack, NotRequired

class Person(TypedDict):
    first_name: NotRequired[str]
    last_name: NotRequired[str]

def example(**kwargs: Unpack[Person]) -> None:
    first_name = kwargs.get("first_name")
    if first_name:
        print(f"First name: {first_name}")
    last_name = kwargs.get("last_name")
    if last_name:
        print(f"Last name: {last_name}")

example(first_name="Taro", last_name="Yamada")  # OK
example(first_name="Ichiro", last_neme="Suzuki")  # last_nemeはtypo

上記のコードをMypyで型チェックすると、最後の行example(first_name="Ichiro", last_neme="Suzuki")Personクラスに定義されていないlast_neme引数を指定しているため、エラーになります。

$ mypy --enable-incomplete-feature=Unpack improved_example_problem2.py
Success: no issues found in 1 source file
root@91214d54f122:/tmp# mypy --enable-incomplete-feature=Unpack improved_example_problem2.py
improved_example_problem2.py:7: note: "example" defined here
improved_example_problem2.py:16: error: Unexpected keyword argument "last_neme" for "example"; did you mean "last_name"?  [call-arg]
Found 1 error in 1 file (checked 1 source file)

最後に

個人的に**kwargs引数は時々必要な場合があるものの、型を付けにくくて困ることがあったのですが、PEP 692でかなり改善されそうですね。

冒頭にも書いた通り、Python 3.12の正式版は2023年10月2日リリース予定です。リリースされたらぜひ試してみてください!

おすすめ記事

記事・ニュース一覧