pythonで**kwargsにもう少し細かく型を付けたい

例えば以下の様な関数helloがあるとする。可変長引数を使って定義されている。とてもtrivialな例ではあるけれど説明用なので。。

from typing import Any


def greet(prefix: str, *, name: str) -> None:
    print(f"{prefix}, {name}")


def hello(**params: Any) -> None:
    greet("hello", **params)

ここでhelloを呼び出す際に以下のようにtypoしたとする。これをmypyなどの静的解析で検知したい。

# TypeError: greet() got an unexpected keyword argument 'nam'
hello(nam="foo")

TypedDict

仮に可変長引数の部分全体をDictとして見るような定義だったら上手くいく1。辞書に限って考えれば、typing.TypedDictを利用することで取りうる値の範囲に制限を加えられる。

from typing import TypedDict

class ParamsDict(TypedDict):
    name: str

# error: Extra key 'nam' for TypedDict "ParamsDict"
params : ParamsDict = {"nam": "foo"}

このTypedDictを上手く流用することで可変長引数も同様の形で制限できないか?というようなissueがmypyにも存在した。

まだできるようにはなっていないが要望はあるらしい。Expandというgeneric typeを用意して以下のように書けるようにするという方針らしい。

from typing import TypedDict
from mypy_extensions import Expand

# https://github.com/python/mypy/issues/4441


class ParamsDict(TypedDict):
    name: str


def greet(prefix: str, *, name: str) -> None:
    print(f"{prefix}, {name}")


def hello(**params: Expand[ParamsDict]) -> None:
    greet("hello", **params)


hello(nam="foo")

もちろん、現状では動かない。

work-around

それでは現在利用できる範囲でもう少し正確に型を付けたいときにはどうすれば良いかというと、リンク先のissueでも言及されていたがtyping.overloadを乱用することでごまかせるかもしれない。単に既存の型定義を上書きするだけといえばだけだけれど。定義が複数ないと怒られてしまうのでダミー的な定義も追加しておく。

from typing import overload, Any, TYPE_CHECKING


def greet(prefix: str, *, name: str) -> None:
    print(f"{prefix}, {name}")


@overload
def hello(*, name: str) -> None:
    ...


# suppress "Single overload definition, multiple required"
@overload
def hello(*, _: object = ...) -> None:
    ...


def hello(**params: Any) -> None:
    greet("hello", **params)


hello(name="foo")
hello(nam="x")

一応動く。

$ mypy --strict --pretty 02overload.py
02overload.py:24: error: No overload variant of "hello" matches argument type "str"
    hello(nam="x")
    ^
02overload.py:24: note: Possible overload variants:
02overload.py:24: note:     def hello(*, name: str) -> None
02overload.py:24: note:     def hello(*, _: object = ...) -> None

余談

まぁ、そもそもあんまり可変長引数を乱用するのもどうかと思うし、実装に触れるならこんなまどろっこしい事をせずに素直に全部の引数定義を明示的に書いてしまえば良いと言う話はある。なので、この方法は既存のライブラリに対するstubを作るようなときだけに使いたくなる方法かもしれない。

def greet(prefix: str, *, name: str) -> None:
    print(f"{prefix}, {name}")


def hello(*, name:str) -> None:
    greet("hello", name=name)

あとデコレーター用の関数などにはこの方法は適さない。

gist


  1. まぁご存知の通り、Dictの値部分だけの型を指定する事になっている。 https://mypy.readthedocs.io/en/latest/getting_started.html?highlight=var-args#more-function-signatures