😫

[python] __future__.annotations をインポートすると__annotations__が文字列になる

2023/08/02に公開

ちょっとはまったので備忘録.

環境: windows python 3.11.4, 3.10.5

背景

from dataclasses import dataclass


@dataclass
class A:
    f1: str


@dataclass
class B:
    f2: A
    f3: int | None


if __name__ == "__main__":
    print(B.__annotations__)

上記コードを実行すると以下のような出力になる

{'f2': <class '__main__.A'>, 'f3': int | None}

ちゃんとクラスAの情報やUnionTypeの型情報が取れてる。正しい。
では元のコードにfrom __future__ import annotationsを付け加えて実行してみる。すると以下のような出力になる。

{'f2': 'A', 'f3': 'int | None'}

クラスではなく文字列になってる。なんで???

なんで????

https://docs.python.org/3/library/typing.html#introspection-helpers

注釈 get_type_hints() does not work with imported type aliases that include forward references. Enabling postponed evaluation of annotations (PEP 563) may remove the need for most forward references.

とあり、さらにPEP563をみると

https://peps.python.org/pep-0563/#abstract

This PEP proposes changing function annotations and variable annotations so that they are no longer evaluated at function definition time. Instead, they are preserved in __annotations__ in string form.
このPEPは、関数アノテーションと変数アノテーションを変更して、関数定義時に評価されないようにすることを提案しています。代わりに、それらは文字列形式で __annotations__ に保存されます。(google翻訳)

This change is being introduced gradually, starting with a __future__ import in Python 3.7.

とあるので、from __future__ import annotationsをimportすると、PEPで定められているように関数/変数アノテーションは__annotations__文字列で格納される

https://peps.python.org/pep-0563/#resolving-type-hints-at-runtime

To resolve an annotation at runtime from its string form to the result of the enclosed expression, user code needs to evaluate the string.

For code that uses type hints, the typing.get_type_hints(obj, globalns=None, localns=None) function correctly evaluates expressions back from its string form. Note that all valid code currently using annotations should already be doing that since a type annotation can be expressed as a string literal.

For code which uses annotations for other purposes, a regular eval(ann, globals, locals) call is enough to resolve the annotation.

__annotations__に格納された文字列の型アノテーション情報をクラスとして使いたいなら文字列を評価する必要があり、

  • typing.get_type_hints()を使う
  • eval(ann, globals, locals)を呼び出す

などすればよい

回避策: get_type_hints()を使う

obj.__annotations__を直接取るのではなく、typing.get_type_hints()を使う。

from __future__ import annotations

from dataclasses import dataclass
from typing import get_type_hints


@dataclass
class A:
    f1: str


@dataclass
class B:
    f2: A
    f3: int | None


if __name__ == "__main__":
    print(B.__annotations__)
    print(get_type_hints(B))
出力
{'f2': 'A', 'f3': 'int | None'}
{'f2': <class '__main__.A'>, 'f3': int | None}

注意: 継承してる場合

get_type_hints()は対象のobjectのmroを遡ってすべての__annotations__を統合して出力する

from __future__ import annotations

from dataclasses import dataclass
from typing import get_type_hints


@dataclass
class A:
    f1: str


@dataclass
class B(A):
    f2: A
    f3: int | None


if __name__ == "__main__":
    print(B.__annotations__)
    print(get_type_hints(B))
出力結果
{'f2': 'A', 'f3': 'int | None'}
{'f1': <class 'str'>, 'f2': <class '__main__.A'>, 'f3': int | None}

なので対象のオブジェクトの__annotations__だけ出力しようとすると追加の処理が必要

def get_obj_type_hints(obj):
    return {
        key: value
        for key, value in get_type_hints(obj).items()
        if key in getattr(obj, "__annotations__", {})
    }


if __name__ == "__main__":
    print(B.__annotations__)
    print(get_type_hints(B))
    print(get_obj_type_hints(B))
出力結果
{'f2': 'A', 'f3': 'int | None'}
{'f1': <class 'str'>, 'f2': <class '__main__.A'>, 'f3': int | None}
{'f2': <class '__main__.A'>, 'f3': int | None}

注意その2: アノテーションにNoneを指定した場合

型アノテーションにNoneを付けた場合(そんなことするケースがあるか知らないが)、
__annotations__get_type_hints()で出力される値が違う

from dataclasses import dataclass
from typing import get_type_hints


@dataclass
class A:
    f1: None
    f2: int


def get_obj_type_hints(obj):
    return {
        key: value
        for key, value in get_type_hints(obj).items()
        if key in getattr(obj, "__annotations__", {})
    }


if __name__ == "__main__":
    print(A.__annotations__)
    print(get_type_hints(A))
出力
# from __future__ import annotations を付けた場合
{'f1': 'None'}
{'f1': <class 'NoneType'>}
# from __future__ import annotations を付けない場合
{'f1': None}
{'f1': <class 'NoneType'>}

型アノテーションなのでNoneTypeになってるほうが正しい気がする・・・が正確に__annotations__の情報が欲しいのであればeval()で評価してやる必要がある

from __future__ import annotations

from dataclasses import dataclass
from typing import get_type_hints


@dataclass
class A:
    f1: None
    f2: int


def get_hints(obj):
    annotations = getattr(obj, "__annotations__", {})
    for key, value in annotations.items():
        if isinstance(value, str):
            annotations[key] = eval(value)
        else:
            continue
    return annotations


if __name__ == "__main__":
    print(A.__annotations__)
    print(get_type_hints(A))
    print(get_hints(A))
出力
# __future__をimportした時の__annotations__
{'f1': 'None', 'f2': 'int'}
# get_type_hints()した結果
{'f1': <class 'NoneType'>, 'f2': <class 'int'>}
# __annotations__の値をeval()した結果
{'f1': None, 'f2': <class 'int'>}

ちなみに__future__のインポートなしの場合以下のように出力される

出力(__future__のインポートなし)
{'f1': None, 'f2': <class 'int'>}
{'f1': <class 'NoneType'>, 'f2': <class 'int'>}
{'f1': None, 'f2': <class 'int'>}

ただしクラスが読み込まれてない場合などevalするとそんなクラスないよとエラーが返る場合があるので注意が必要。
get_type_hints()するとその辺ちゃんと解決してくらたりするので、eval呼ぶより素直にget_type_hints()するほうが良いと思います。

終わりに

typingモジュールにはほかにgenericalias.__origin__を取ってくるtyping.get_origin()とかgenericalias.__args__を取ってくるtyping.get_args()とかがあって、必要か?これ。という感想だったがこういうことがあると直接dunder変数取るのではなく、こういう組み込みの関数呼んだほうがいい気がするので気を付けましょう。でもはまりどころもあったので注意してね。

Discussion