rcmdnk's blog

20231121_pythonkeyword_200_200

mypy 1.7.0で正式に実装されたキーワード引数を展開する時に アノテーションを付ける方法について。

以前までの展開されるキーワード引数に対するアノテーション

以前までは関数の引数で辞書を展開する形で書く場合、 以下のようなアノテーションのみが可能でした。

1
2
def keyword(**kw: str) -> None:
    ...

このように書くと、kw自体はdict[str, str]の型とみなされ、 適当なキーワード変数に対してすべてstrであるとされる状態でした。

つまり、全て同じ型にすることしか出来ません。

かわりにstr | intとしたりAnyとしたりすればすべての変数でいずれかでOKとされますが、 そうすると今度は逆に関数の中でそれぞれを扱うのが面倒になります。

TypedDictとUnpackを使ったアノテーション

今月リリースされたmypy 1.7.0で TypedDictUnpack を使って個別に型を定義できるようにした場合でも解釈してくれるようになりました。

The Mypy Blog: Mypy 1.7 Released

Python 3.10以降で 以下のように書くことが出来ます。

1
2
3
4
5
6
7
8
9
10
11
12
from typing import TypedDict, Unpack

class Params(TypedDict):
    a: int
    b: str


def keyword(**kw: Unpack[Params]) -> None:
    print(kw)


keyword(a=1, b='a')

これをmypyでチェックすると通りますが、

1
keyword(a=1, b=1)

のようにして型を間違えると

1
keyword.py:11: error: Argument "b" to "keyword" has incompatible type "int"; expected "str"  [arg-type]

と言った形でエラーを出します。

また、これがmypy 1.7より前だと

1
keyword.py:8: error: "Unpack" support is experimental, use --enable-incomplete-feature=Unpack to enable  [misc]

といった感じのエラーが出ます。

ただ、実はここにあるように、--enable-incomplete-feature=Unpackを付ければ1.7以降と同様にUnpack部分も解釈してくれます。

これは0.981で実装されています(1年前)。

The Mypy Blog: Mypy 0.981 Released

なので今回から始めて使える様になった、というわけではありませんが デフォルトとして正式に導入された形になります。

typing-extensions

上のTypedDictはpython 3.8で実装されていますが、 Unpackの方は3.10で実装されました。

なので3.9以前のものだと上のようにfrom typing ...の形で書くことは出来ません。

ですが、これらのtypingの新しいモジュールを古いPythonでも使えるようにしてくれる typing-extensions というライブラリがあります。

これをpipとかでインストールすると、Python 3.9で

1
2
from typing_extensions import TypedDict, Unpack
...

と書き換えれば実行でき、mypyでもOKになります。

この書き方は3.10以降でも同じ様にtyping_extensionsを使っても書けるので 複数のPythonバージョンに対応したものを書きたい場合にはtyping_extensionsを使うとより広い範囲をカバーできます。

このtyping_extensionsですが、 mypyやformatterのblack が依存関係として持っているので、これらのツールをpipで入れるとついでに入るので 使っている環境ですでに入っていることもあるかもしれません。

Poetryとかを使っている場合、開発用にのみインストールするものと通常インストールするものを分けている場合もあるかと思います。

mypyとかは開発時のみにインストールするようにするのが通常だと思いますが、 その場合、typing_extensionsも開発時のみにして

pyproject.toml
1
2
3
4
5
6
7
8
...
[tool.poetry.dependencies]
python = "^3.8.1"
...

[tool.poetry.group.dev.dependencies]
typing-extensions = "^4.8.0"
...

みたいな感じの設定だと、 開発関連のパッケージが入っていない実行環境ではtyping_extensionsがなくてエラーが起こります。

ただ実行時には実際に必要ないものなのでやはり開発時のみに入れておきたいところ。

これを実現するには以下のようにします。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from __future__ import annotations
from typing import TYPE_CHECKING


if TYPE_CHECKING:
    from typing_extensions import TypedDict, Unpack

    class Params(TypedDict):
        a: int
        b: str


def keyword(**kw: Unpack[Params]) -> None:
    print(kw)


keyword(a=1, b='a')

TYPE_CHECKINGを使って型チェックするときにだけtyping_extensionsimportするようにすれば 通常実行時には使わないのでインストールして無くてもエラーになりません。

注意として、Unpackを使って型付けをしている部分がそのままだと'Unpack' is not definedのエラーを出してしまうので、 これを回避するために from __future__ import annotations を入れて実行時には見ない様にする必要があります。

NotRequired

TypedDictで作られた上のParamではab両方とも必ずあるべきものになっています。

1
2
3
4
5
6
7
8
9
10
11
12
from typing import TypedDict, Unpack

class Params(TypedDict):
    a: int
    b: str


def keyword(**kw: Unpack[Params]) -> None:
    print(kw)


keyword(a=1)

とすれば、

1
keyword.py:11: error: Missing named argument "b" for "keyword"  [call-arg]

と言ったエラーを吐きます。

無くてもよくするためには NotRequired を使います。

1
2
3
4
5
6
7
8
9
10
11
12
from typing import TypedDict, Unpack, NotRequired

class Params(TypedDict):
    a: int
    b: NotRequired[str]


def keyword(**kw: Unpack[Params]) -> None:
    print(kw)


keyword(a=1)

これだとOKです。

ただし、NotRequiredはPython 3.11で追加されているので 3.10以前ではtyping_extensionsを使う必要があります。

上と同じことを Required を使って書くことも出来ます。

1
2
3
4
5
6
7
8
9
10
11
12
from typing import TypedDict, Unpack, Required

class Params(TypedDict, total=False):
    a: Required[int]
    b: str


def keyword(**kw: Unpack[Params]) -> None:
    print(kw)


keyword(a=1)

こちらの場合では必須なものにRequiredをつけていますが、 TypedDictを使う際にtotal=Falseオプションを付けていて、これによって デフォルトではすべてがNotRequiredな状態になります。

RequiredはPython3.8から導入されているのでこちらであればtype_extensionsを使わずとも古いバージョンでも使えます。

必須なものとそうでないものの数を見ながらスッキリする方で書けばよいかと。

TypedDictクラスを直接使う方法

TypedDictを使った型を作る際、 上の様にTypedDictを継承する形以外に以下の様な形でも同じ意味になります。

1
2
3
4
5
6
7
8
9
10
11
from typing import TypedDict

class Params(TypedDict):
    a: int
    b: str


Params = TypedDict('Params', {'a': int, 'b': str})


Params = TypedDict('Params', a=int, b=str)

第二引数に辞書としてキーワードと型を与えるか、第二引数以降にキーワード引数として 変数とその型を渡すか。

ただし、後者の方はPython 3.11ですでに非推奨で3.13では削除予定なので今からは使うべきではありません。

class typing.TypedDict(dict)

Sponsored Links
Sponsored Links

« inherit-docstring: Pythonでdocstringの一部を継承するdecorator numpydocでPythonのdocstringをチェック »

}