Pythonの型ヒント「Annotated」にDeepDiveしてみる

# はじめに
Python3.9から導入された型ヒント `Annotated` を使っていますか？

例えば、FastAPIを使っている人なら目にしたことがあると思いますが、以下のようにエンドポイントに対して依存性の注入をしたいときに使います。
```python
from typing import Annotated

from fastapi import Depends, FastAPI

app = FastAPI()


async def common_parameters(
    q: Union[str, None] = None, skip: int = 0, limit: int = 100
):
    return {"q": q, "skip": skip, "limit": limit}

# commons引数は辞書型であり、且つcommon_parametersのreturn値を受け取る
@app.get("/items/")
async def read_items(commons: Annotated[dict, Depends(common_parameters)]):
    return commons


@app.get("/users/")
async def read_users(commons: Annotated[dict, Depends(common_parameters)]):
    return commons
```

また、SQLAlchemyでは複数のモデル間で共通する列情報（例えば、日付型や主キーなど）を`Annotated`を用いて定義することができます。
※これによって個々のモデルで同じ列情報を繰り返し書く必要がなくなります。
```python
import datetime

from typing_extensions import Annotated

from sqlalchemy import func
from sqlalchemy import String
from sqlalchemy.orm import mapped_column

# 複数のモデルで利用する列情報をAnnotatedで定義する
intpk = Annotated[int, mapped_column(primary_key=True)]
timestamp = Annotated[
    datetime.datetime,
    mapped_column(nullable=False, server_default=func.CURRENT_TIMESTAMP()),
]
required_name = Annotated[str, mapped_column(String(30), nullable=False)]

class Base(DeclarativeBase):
    pass

# Annotatedで定義した列情報を使う
class UserClass(Base):
    __tablename__ = "user"

    id: Mapped[intpk]
    name: Mapped[required_name]
    created_at: Mapped[timestamp]
    
# Annotatedで定義した列情報を使う
class OrderClass(Base):
    __tablename__ = "order"

    id: Mapped[intpk]
    order_name: Mapped[required_name]
    created_at: Mapped[timestamp]
```

`Annotated` を知らない人が上記を見た場合、
「`Annotated` の最初の要素には型を指定して、2つ目の要素にクラスや関数を渡せばよしなに動作する」 ということがなんとなくわかるかと思います。

この `Annotated` について、そもそもどういうものなのか？どのようなメリットがあるのか？が気になり、少し調べたので本記事に残したいと思います。


# Annotatedとは？
Python3.9のtypingモジュールに実装された型ヒントです。

[Pythonドキュメント](https://docs.python.org/3/library/typing.html#typing.Annotated)の説明を見ると以下のように書いてあります。
> Add metadata x to a given type T by using the annotation Annotated[T, x]. Metadata added using Annotated can be used by static analysis tools or at runtime. At runtime, the metadata is stored in a `__metadata__` attribute.

説明にあるとおり、`Annotated` を使うと、型Tに対して任意のメタデータxを追加情報として持たせることができます。
これが何を意味するのか、まずは簡単な例を見てみます。

## 型Tに対して説明を追加する
以下の例では、dataclassの `age` 変数に対して、int型で且つ「入力できる数値の範囲は 0〜120 です」というメタデータを `Annotated` で付与しています。
```python
from dataclasses import dataclass, field
from typing import Annotated

@dataclass
class User():
    age: Annotated[int, "入力できる数値の範囲は 0〜120 です"] = field()
```

なお、メタデータは１つだけでなく複数追加することができます。
```python
@dataclass
class User():
    age: Annotated[int, "年齢", "入力できる数値の範囲は 0〜120 です"] = field()
```

上記の通り、型ヒントに追加の説明を組み込むことができます。
型と文脈がセットになっていることで、他の開発者がコードを読む際に理解しやすくなることがメリットの1つです。

### メタデータにアクセスする方法
では、追加したメタデータにはどのようにアクセスするのでしょうか。
その場合は、typingモジュールの `get_type_hints()` と `__metadata__`属性 を使います。
```python
from dataclasses import dataclass, field
from typing import Annotated, get_type_hints

@dataclass
class User:
    age: Annotated[int, "年齢", "入力できる数値の範囲は 0〜120 です"] = field()

# Userクラス自体の型ヒントを取得
type_hints = get_type_hints(User, include_extras=True)
# Userクラスに含まれるage変数のメタデータを取得
print(type_hints['age'].__metadata__)


#...実行結果
('年齢', '入力できる数値の範囲は 0〜120 です')
# -> 追加情報として付与したメタデータがタプルに格納されて返却される
```

`get_type_hints(User, include_extras=True)`について補足すると、
この関数は、指定されたクラスまたは関数の型ヒントを辞書形式で返すものです。

なお、`include_extras` を `True` にしないと、 `Annotated` を通じて付与された追加のメタデータを取得することができないため注意です。

#### `age` に範囲外の値を入れると？
当然、上記のメタデータは単なる説明なので範囲外の数値を代入することはできてしまいます。
```bash
>>> user = User(age=130)
>>> user.age
130
```




## 型Tに対してバリデーションを仕込みたいよね
ここで、入力できる数値の範囲を 0〜120　にしたいのであれば、バリデーションを実装して範囲内の数値のみ代入を許すように強制したくなります。

ちなみに、O'Reillyの[「ロバストPython」](https://www.oreilly.co.jp/books/9784814400171/)には以下のように書いてあります。
> **4.4 Annotated型**
> 
>   x: Annotated[int, ValueRange(3, 5)]
>   y: Annotated[str, MatchsRegex("[0-9]{4}")]
>
> ValueRangeやMatchsRegexは組み込みのデータ型ではなく、適当な式なので上のコードは使えない。Annotated変数の一部として独自のメタデータを書く必要がある。

メタデータとして追加できるのは単なる説明だけでなく、上記のようなバリデーション関数も追加できるということです。

これは変数に対して、型だけでなく**制約**を追加できるという意味です。

では、制約はどのように実装すればいいのでしょうか。

一例とはなりますが、これを実現するには前述した`get_type_hints()` と `__metadata__`属性を使って以下のように実装できます。
```python
from dataclasses import dataclass, field
from typing import Annotated, get_type_hints

def validate_age(min_val: int, max_val: int):
    def validator(value):
        if not (min_val <= value <= max_val):
            raise ValueError(f"入力できる数値の範囲は {min_val} 〜 {max_val} です")
        return value
    return validator

@dataclass
class User:
    age: Annotated[int, validate_age(0, 120)] = field()

    def __post_init__(self):
        type_hints = get_type_hints(self, include_extras=True)
        for name, type_hint in type_hints.items():
            if hasattr(type_hint, '__metadata__'):
                for metadata in type_hint.__metadata__:
                    if callable(metadata):
                        setattr(self, name, metadata(getattr(self, name)))
```

ごちゃごちゃやってますがロジックは単純です。
- `validate_age`関数
  - この関数は引数として最小値(`min_val`)と最大値(`max_val`)を受け取り、それをもとに範囲を検証する内部関数`validator`を生成して返しています。

- `__post_init__`メソッド 
  - dataclassのインスタンス化が完了した後に自動的に呼ばれる特殊メソッドです。
  - このメソッド内で、クラスに定義された属性の型ヒントを`get_type_hints`を用いて取得し、それらの型ヒントに含まれるメタデータを検証します。
  - 各属性に`__metadata__`が存在する場合、そのメタデータ内で実行可能な（callableな）ものがあれば、callableオブジェクトを実行して属性の値を設定しています。


では上記のコードを実行し、正しく動作するか検証します。
```bash
# 成功パターン
>>> user = User(age=120)
>>> user.age
120

# エラーパターン
>>> user = User(age=130)
File "/User/test/main.py", line 92, in validator
     raise ValueError(f"入力できる数値の範囲は {min_val} 〜 {max_val} です")
 ValueError: 入力できる数値の範囲は 0 〜 120 です
```

期待どおり動きました。

上記の通り、`Annotated` のメタデータに追加した関数やクラスは、`get_type_hints()` と `__metadata__`属性を使って実行できることがわかったかと思います。

なお、上記の例は単一のバリデーションだけなのであまり旨味がないですが、他にも以下のような用途で使えると思います。
- データのシリアライズ化
- データのフォーマット
- メタデータを2つ追加して異なるバリデーションロジックを実行

などなど、メタデータを利用すれば型に加えて制約や変換など任意のカスタムロジックを実行できます。

よって、冒頭に紹介したFastAPIの`Depends()`やSQLAlcemyの`mapped_column()`はAnnotatedのメタデータであり、これらを内部的に呼び出してT型の値を返しているということです。

https://fastapi.tiangolo.com/tutorial/dependencies/#first-steps

https://docs.sqlalchemy.org/en/20/orm/declarative_tables.html#mapping-whole-column-declarations-to-python-types


また、Pydanticも `Annotated` のメタデータを利用して制約やデフォルト値の代入などを簡潔に定義できるので、興味のある方は見てみてください。

https://docs.pydantic.dev/latest/concepts/types/#adding-validation-and-serialization

:::note info
今回紹介した `Annotated` を用いた数値範囲のバリデーションはPydanticだと1行で終わります(笑）
```python
from pydantic import BaseModel, Field

class User(BaseModel):
    age: Annotated[int, Field(gt=0, lt=120)]

u2 = User(age=130)

# pydantic_core._pydantic_core.ValidationError: 1 validation error for User
# age
#  Input should be less than 120 [type=less_than, input_value=130, input_type=int]
#    For further information visit https://errors.pydantic.dev/2.7/v/less_than
```

:::

## 静的解析（mypy）でメタデータのバリデーションは解釈できるのか？
できません。
mypyはあくまでも型をチェックするだけなので、以下のように範囲外の値を代入してもエラーとはなりません。
```python
user = User(age=30)    # OK
user = User(age=130)   # OK
user = User(age="130") # str型なのでNG
```


# まとめ

最後に、`Annotated`のメリットをまとめます。一言でいうと「型ヒントの拡張」ですね。
- 型に説明を追加できる
    - 型に加えて追加の文脈が追加できるので、これによって可読性が向上し、他の開発者がコードを理解しやすくなる
- 型のメタデータに追加した独自のクラスや関数を実行できる
    - カスタムバリデーションやシリアライズ化、動的なデフォルト値の生成など、柔軟なデータ処理が可能になる


また普段、主要なライブラリを使う分には `Annotated` メタデータの内部構造を意識することなく利用できていますが、この仕組みを知っていれば、例えば独自の機能やフレームワークを作成する場合にAnnotatedを活用してより高度な型ヒントを提供することができると思います。


# 参考
https://peps.python.org/pep-0593/

https://peps.python.org/pep-3107/#id6