LoginSignup
2
3

はじめに

Python3.9から導入された型ヒント Annotated を使っていますか?

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

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を用いて定義することができます。
※これによって個々のモデルで同じ列情報を繰り返し書く必要がなくなります。

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ドキュメントの説明を見ると以下のように書いてあります。

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 で付与しています。

from dataclasses import dataclass, field
from typing import Annotated

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

なお、メタデータは1つだけでなく複数追加することができます。

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

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

メタデータにアクセスする方法

では、追加したメタデータにはどのようにアクセスするのでしょうか。
その場合は、typingモジュールの get_type_hints()__metadata__属性 を使います。

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_extrasTrue にしないと、 Annotated を通じて付与された追加のメタデータを取得することができないため注意です。

age に範囲外の値を入れると?

当然、上記のメタデータは単なる説明なので範囲外の数値を代入することはできてしまいます。

>>> user = User(age=130)
>>> user.age
130

型Tに対してバリデーションを仕込みたいよね

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

ちなみに、O'Reillyの「ロバストPython」には以下のように書いてあります。

4.4 Annotated型

x: Annotated[int, ValueRange(3, 5)]
y: Annotated[str, MatchsRegex("[0-9]{4}")]

ValueRangeやMatchsRegexは組み込みのデータ型ではなく、適当な式なので上のコードは使えない。Annotated変数の一部として独自のメタデータを書く必要がある。

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

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

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

一例とはなりますが、これを実現するには前述したget_type_hints()__metadata__属性を使って以下のように実装できます。

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オブジェクトを実行して属性の値を設定しています。

では上記のコードを実行し、正しく動作するか検証します。

# 成功パターン
>>> 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型の値を返しているということです。

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

今回紹介した Annotated を用いた数値範囲のバリデーションはPydanticだと1行で終わります(笑)

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はあくまでも型をチェックするだけなので、以下のように範囲外の値を代入してもエラーとはなりません。

user = User(age=30)    # OK
user = User(age=130)   # OK
user = User(age="130") # str型なのでNG

まとめ

最後に、Annotatedのメリットをまとめます。一言でいうと「型ヒントの拡張」ですね。

  • 型に説明を追加できる
    • 型に加えて追加の文脈が追加できるので、これによって可読性が向上し、他の開発者がコードを理解しやすくなる
  • 型のメタデータに追加した独自のクラスや関数を実行できる
    • カスタムバリデーションやシリアライズ化、動的なデフォルト値の生成など、柔軟なデータ処理が可能になる

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

参考

2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3