PEP593のAnnotatedでwrapされた型ヒントからmetadataを取り出す

Annotated

PEP593で型ヒントにmetadataを付けられる様になった。

例えば、関数の型ヒントを見てCLIのコマンドを生成するようなフレームワークがあったとして、コマンドラインオプションのヘルプメッセージ用の文字列も保持したい。以下のコード上での --name オプションに対するヘルプメッセージをどうにか保持できないか悩んだりしたが、PEP593以前はできなかった。

def hello(name: str) -> None:
    print(f"hello {name}")

# このような形で呼び出せる
# $ python main.py --name=world"
# hello world

PEP593で提案されたAnnotatedを利用すると以下の様に書ける様になる(3.9から)。

from typing_extensions import Annotated


class Description:
    def __init__(self, description: str) -> None:
        self.description = description


def hello(*, name: Annotated[str, Description("the name of person")]) -> None:
    print(f"hello {name}")

typingモジュールではなくtyping_extensionsモジュールのものを使うことで3.9以前のpythonでも利用できるようになる。

mypyの解釈

mypyによる型チェックのときに、Annotatedが使われた引数がどのように解釈されているのかを見てみる。reveal_typeを使うと、その時に解釈された型がどのようなものであったかを確認できる。

--- 01hello-annotated.py 2020-07-29 04:59:29.000000000 +0900
+++ 02reveal-type.py  2020-07-29 04:59:06.000000000 +0900
@@ -7,4 +7,5 @@
 
 
 def hello(*, name: Annotated[str, Description("the name of person")]) -> None:
+    reveal_type(name)
     print(f"hello {name}")

nameはstrとして認識されていた。と、まぁそんなわけで、型チェック時には無視されるような値を型ヒントに埋め込むことができる。

$ mypy --strict 02*.py
02reveal-type.py:10: note: Revealed type is 'builtins.str'

metadataの抽出

mypyでは無視されるmetadata部分を取り出して利用したい。Annotatedを使って定義された関数からmetadataを取り出そうとしてみる。

pepの文章を見ると、typing.get_type_hints()がinclude_extrasというオプションを持つらしい。これを利用することでmetadataでwrapされた値が返ってくる(はず)。

from typing_extensions import get_type_hints

print(get_type_hints(hello))
# {'name': <class 'str'>, 'return': <class 'NoneType'>}

print(get_type_hints(hello, include_extras=True))
# {'name': typing_extensions.Annotated[str, <__main__.Description object at 0x10cd1e730>], 'return': <class 'NoneType'>}

なるほど。これで関数からmetadata付きの辞書が取れる様になった。通常genericsなどの型パラメーターを持った型の値を取り出すにはget_args()を使う。また、get_originを使うことで、特殊化した型の基底として使われている値を取る事ができる1

from typing_extensions import get_args

hints = get_type_hints(hello, include_extras=True)
print(get_args(hints["name"]))
# (<class 'str'>, <__main__.Description object at 0x106427730>)

print(get_origin(hints["name"]))
# <class 'typing_extensions.Annotated'>

これで、metadataを取り出してよしなにできるようになった。やりましたね。

3.8以前のpythonでは?

ところが話はもう少し面倒なことになる。

実は、get_args()などが追加されたのは3.8からで、それ以前のpythonはtypingモジュールがこれらの関数を持っていない。backportとしてtyping-inspectパッケージが用意されているが、まだこちらはAnnotatedに対応していない模様。

import typing_inspect

print(typing_inspect.get_args(hints["name"]))
# (<class 'str'>,)

仕方がないので、直接実装を覗いて無理やり取り出す。どうやら__metadata__というフィールドに情報を格納しているらしい。

print(hasattr(hints["name"], "__metadata__"))
# True

print(hints["name"].__metadata__)
# (<__main__.Description object at 0x104b9b730>,)

はい。

gist


  1. 正しくない表現かもしれない。Dict[str,str]に対して get_origin()を使うと dict が返るというような意味合いで使いたかった。