筒井@ryu22eです。2023年9月の
PEP 692は**kwargs
引数
PEP 692の本文については以下を参照してください。
なお、本記事の執筆時点
型チェッカーはMypy 1.
Python 3.11以前の**kwargs引数にまつわる2つの問題
まず最初に、PEP 692登場前、つまりPython 3.**kwargs
引数の型にまつわる問題について説明します。
【問題点1】各キーワード引数に同じ型しか指定できない
Python 3.**kwargs
引数での型ヒントでは、どのキーワード引数にも同じ型しか指定できません。
以下の例では、example
関数の**kwargs
引数にstr型を指定しているため、example
関数呼び出し時に指定したキーワード引数はどれもstr型の値しか受け付けません。foo
引数はstr型、bar
引数はint型にしたい、といったケースには対応できません。
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
引数で認証情報を渡す仕様です。
class Auth:
"""認証情報"""
...
def request(url: str, auth: Auth | None = None) -> None:
"""url引数で指定したURLにリクエストを送る。認証が必要な場合はauth引数に認証情報を指定"""
...
上記の関数に、auth
引数を省略した場合と明示的にauth=None
を指定した場合で違う処理を実行する仕様が加わったとします。上記コードをどう変更すればこの仕様を実現できるでしょうか?
デフォルト値付きキーワード引数では**kwargs
引数が有効です。
以下のコードはexample_
を編集して、auth
引数を**kwargs
引数にしました
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_
に型情報を付けなかったのはこのためです)。そのため、型を書いているプログラムでこの案を適用するのは困難となります。
なお、この問題はHTTPXというライブラリで実際に議論の対象になったIssueを元にしています[1]。実際の議論の内容は以下URLを参照してください。
【問題点2】関数、メソッドの仕様にはない引数を指定しても型エラーにならない
以下の関数は**kwargs
引数を定義していますが、キーワード引数にfirst_
、last_
を指定した場合にprint
関数で値を出力する仕様になっています。
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_
引数をlast_
と間違えています。**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.**kwargs
引数に型を付けられるようになりました。
以下のコードはadd_
関数に**kwargs
引数を定義していますが、キーワード引数としてstr型のtitle
引数、int型のprice
引数を受け付けるようにしています。
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_
はエラーになりません--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を使います。NotRequired
はTypedDict
の省略可能なキーに指定する型で、Python 3.isbn
引数は指定してもしなくてもエラーにはなりません。
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.
【問題点1】**kwargs
引数にTypedDict
を使えるようになったので各キーワード引数に異なる型を指定できるようになりました。
example_
をPEP 692に対応した例を以下に示します。foo
引数はstr型、bar
引数はint型」
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_
をPEP 692に対応した例も以下に示します。auth
引数は省略可能なのでNotRequired
を使っています。
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://
で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_
をPEP 692に対応した例を以下に示します。
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_
でPerson
クラスに定義されていないlast_
引数を指定しているため、エラーになります。
$ 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.