🧩

mypyでPythonの関数定義を型チェック、できるけど...

2023/02/17に公開

関数定義をチェックするimplementsを作ってみた

モチベーション

dependency_injector を使って関数をDIしていくコンテナを作っていたんですが、Djangoを使っている都合上モデルのimportが発生するコードをDIコンテナ定義ファイルからimportできないという制約がありました。そこで一旦providers.CallableでごまかしておいてAppConfigreadyフック内で関数の実態を詰め込んでたんですが、dependency_injectorの型が結構ルーズなので実際に走らせてみないと関数シグネチャの不一致を見落としたり、そもそもdependency_injectorがかなりcrypticなメッセージと共に落ちる(コンテナの書き方を間違えると普通にセグフォルで落ちたりします、このご時世に?)のでなんとかしたいという思いが発端でした。

そんなこんなで「これくらいならmypyでなんとかできるのでは?」と思い立ったのがきっかけでした。結構すんなり実装できてそれっぽいことはできる...

implementsの実装

from functools import wraps
from typing import Callable, ParamSpec, Protocol, Type, TypeVar

Params = ParamSpec('Params')
Return = TypeVar('Return', covariant=True)


class CallableProtocol(Protocol[Params, Return]):
    def __call__(self, *args: Params.args, **kwargs: Params.kwargs) -> Return: ...


T = TypeVar('T')


def implements(protocol: Type[CallableProtocol[Params, Return]]) -> Callable[[CallableProtocol[Params, Return]], CallableProtocol[Params, Return]]:
    def decorator(func: CallableProtocol[Params, Return]) -> CallableProtocol[Params, Return]:
        assert isinstance(func, protocol)

        @wraps(func)
        def wrapper(*args: Params.args, **kwargs: Params.kwargs) -> Return:
            return func(*args, **kwargs)
        return wrapper

    return decorator

...んですがまだなんか色々イケてないです。

implementsの使用例

from typing import Protocol, runtime_checkable


@runtime_checkable
class IntTransform(Protocol):
    def __call__(self, a: int) -> int: ...


# Argument 1 has incompatible type "Callable[[int], str]"; expected "Callable[[int], int]"  [arg-type]mypy(error)
@implements(IntTransform)
def hoge(a: int) -> str:
    return str(a)

イケてないポイント

runtime_checkableなんかダサい

別にこのユースケースに限った話ではないんですが、なんか「runtime_checkableついてるとダサいなぁ」というのが拭えないんですよね。どうせならデフォルトでruntime_checkableでいいのではないか... という気もしますがパフォーマンスに影響がありそうな気もするのであまり深く考えないことにしました。

エラーが@implementsのところに出る

本来hogeの引数ないし返り値が一致していないので該当する部分にエラーが出てほしいところですが@implementsにデコレートされる関数が引数としておかしいという判定になります。そのため@implementsのところにエラーがあるという表示が出てきてしまいなんともしっくりきません。具体的にどこの部分が間違っているのかはエラーメッセージを読まないと行けず二度手間なのが正直若干面倒です。これでも出ないよりは全然マシですけどね。

エラー文が読みにくい

常々思うことなのですが「got: X, expected Y」系のエラーってものすごく読みにくくないですか。どっちがどっちだっけって毎回なる上に今回のケースのようにCallableの型パラメータのどこか一箇所だけが違うみたいな場合には間違い探しが大変に厄介です。その点Protocolの不一致の場合はパラメータの名前まで出してくれるので便利なんですが残念ながら私の理解の範囲ではCallableを使わざるを得ず読みにくいエラーメッセージのままです。

今後の課題

プラグインを書くのが一番真っ当な未知なんだろうな〜と思いつつも公式がプラグインを書くのをあまり推奨していないのでわざわざ勉強してまで書く気があまり起きていません。そもそも事前に型エラーが発生することをチェックできるようになっただけでかなりの前進なのでとりあえずこれで満足しておこうかなあという気持ちです。もしmypy強者の方でうまいやり方をご存知の方がいらしたらぜひご教示ください。

更新履歴

2023-03-23

概ね問題なく使えていたのですがmypyが変な型を返してくるので修正しました。

Discussion