Python Monthly Topics

O/Rマッパーの型チェックを強化できるPython 3.11の新機能 Data Class Transforms(PEP 681)

筒井@ryu22eです。2023年最初の「Python Monthly Topics」は、Python 3.11の新機能Data Class Transforms(PEP 681)について解説します。

PEP 681についての公式ドキュメントは以下を参照してください。

PEP 681 – Data Class Transforms | peps.python.org

Pythonには、データクラスと似た構造を持つクラスを扱うライブラリがいくつかあります。たとえば、attrspydantic、O/Rマッパー[1]SQLAlchemyDjango内蔵のO/Rマッパー)などです。Data Class Transforms(PEP 681)でtypingモジュールに追加されたdataclass_transformデコレーターは、これら(O/Rマッパー、attrs、pydandic)などを使う際の型チェックをより便利にしてくれます。

以下ではO/Rマッパーを使ったサンプルコードを交えて、具体的にdataclass_transformデコレーターが力を発揮する場面を説明します。

PythonのO/Rマッパーを使っていて困ること

ここでは、PythonのO/Rマッパーを使っていると遭遇する型チェックに関する問題について説明します。

サンプルコードで使用するO/Rマッパーは、説明の便宜上、O/Rマッパーのよくあるインターフェースをシンプルに再現しました。以下のコードをorm.pyとしてカレントディレクトリに置いて使う前提とします。

orm.py
class Base:
    """リレーショナルデータベースとマッピングさせるクラスの基底クラス"""
    def __init__(self, **kwargs):
        # 具体的な処理内容は省略
        print("Baseクラスの初期化処理")

class String:
    """文字列フィールド用のクラス"""
    pass

class Integer:
    """整数フィールド用のクラス"""
    pass

また、型チェッカーは現時点(2022年12月15日)でPEP 681に対応しているPyright 1.1.284を使います。PyrightはNode.js版とPython版がありますが、Node.js版を使います[2]

O/Rマッパーでは初期化処理の型チェックができない

O/Rマッパーを使っていて、型ヒントの恩恵を受けられない場面について説明します。以下のコードを見てください。orm.pyを使って書籍を表すBookクラスを定義しています。

books.py
from orm import Base, Integer, String

class Book(Base):
    title = String()
    price = Integer()

book = Book(
    title="Python実践レシピ",
    # priceは整数型なのでこれは間違っている
    price="定価2,970円(本体2,700円+税10%)",
)

BookクラスはPythonのデータクラスによく似ていますが別物です。データクラスと違って属性の型に関する情報がありません。books.pyの中には、以下のようなBookクラスを初期化するコードが書いてあります。

book = Book(
    title="Python実践レシピ",
    # priceは整数型なのでこれは間違っている
    price="定価2,970円(本体2,700円+税10%)",
)

上記のコードはpriceに指定した値の型が明らかに間違っていますが、型チェッカーMypyPyrightなど)では型エラーを検出できません。なぜなら、Bookクラスを初期化する際に呼ばれるBook.__init__メソッドの引数に型情報がないためです。Pyrightで型チェックを実行してみましょう。以下のようにエラーメッセージは発生しません。

$ pyright books.py
(省略)
0 errors, 0 warnings, 0 informations
Completed in 0.512sec
✨  Done in 0.86s.

Book.__init__メソッドの定義も見ておきましょう。以下のように、引数が**kwargsとなっており、型情報がないことがわかります。

>>> from books import Book
Baseクラスの初期化処理
>>> help(Book.__init__)
Help on function __init__ in module orm:

__init__(self, **kwargs)
    Initialize self.  See help(type(self)) for accurate signature.
(END)

今回はO/Rマッパーに独自のコードorm.pyを使用しましたが、SQLAlchemy、Djangoを使ってもBook.__init__メソッドの定義は型情報がありません。

一方、データクラスの初期化処理では型チェックができる

一方、データクラスで同様のコードを書いた場合はどうなるのか見てみましょう。

データクラスはクラスに定義した型アノテーションを元に、dataclasses.dataclassデコレーターによって属性を自動生成したクラスです。

前述のBookクラスに近い構造のデータクラスを以下のように定義しました。

dataclass_books.py
from dataclasses import dataclass

@dataclass
class Book:
    title: str
    price: int

book = Book(
    title="Python実践レシピ",
    # priceは整数型なのでこれは間違っている
    price="定価2,970円(本体2,700円+税10%)",
)

データクラスで定義したBookクラスの__init__メソッドには型情報があるので、Pyrightで型チェックを実行するとエラーが出力されます。

$ pyright dataclass_books.py
(省略)
/***/dataclass_books.py
  /***/dataclass_books.py:11:11 - error: Argument of type "Literal['定価2,970円(本体2,700円+税10%)']" cannot be assigned to parameter "price" of type "int" in function "__init__"
    "Literal['定価2,970円(本体2,700円+税10%)']" is incompatible with "int" (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations
Completed in 0.448sec
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

こちらもBook.__init__メソッドの定義を確認してみましょう。books.pyに定義したBookクラスとは異なり、titleにはstrpriceにはintの型情報を持っていることがわかります。

>>> from dataclass_books import Book
>>> help(Book.__init__)
Help on function __init__ in module dataclass_books:

__init__(self, title: str, price: int) -> None
    Initialize self.  See help(type(self)) for accurate signature.

O/Rマッパーのクラスをデータクラス化するとどうなるか

データクラスなら初期化処理の型チェックができるというなら、O/Rマッパーのクラスをデータクラス化してしまえばいいと思うかもしれません。しかし、この試みには問題があります。以下のコードを見てください。books.pyBookクラスに定義したフィールドtitlepriceを型アノテーションにし、dataclasses.dataclassデコレーターも付けました。

books2.py
from dataclasses import dataclass

from orm import Base

@dataclass
class Book(Base):
    title: str
    price: int

book = Book(
    title="Python実践レシピ",
    # priceは整数型なのでこれは間違っている
    price="定価2,970円(本体2,700円+税10%)",
)

上記のBookクラスはデータクラスなので、型チェックを行うことはできます。

$ pyright books2.py
(省略)
/***/books2.py
  /***/books2.py:13:11 - error: Argument of type "Literal['定価2,970円(本体2,700円+税10%)']" cannot be assigned to parameter "price" of type "int" in function "__init__"
    "Literal['定価2,970円(本体2,700円+税10%)']" is incompatible with "int" (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations
Completed in 0.454sec
error Command failed with exit code 1.

しかし、基底クラスBase__init__メソッドを呼ぶことができなくなります。まず、正しい挙動を確認するためにbooks.pyを実行します。以下のようにBaseクラスの__init__メソッドに書いたprint関数が呼ばれ、Baseクラスの初期化処理が表示されます。

$ python books.py
Baseクラスの初期化処理

次に、books2.pyを実行します。今度はBaseクラスの初期化処理が表示されません。dataclasses.dataclassデコレーターによって、型情報付きのBook.__init__メソッドが自動生成されるためです。

$ python books2.py  # Base.__init__メソッドの処理が呼ばれない

以下のようにdataclasses.dataclassデコレーターを使わず型アノテーションだけ定義した場合も考えてみましょう。

books3.py
from orm import Base

class Book(Base):
    title: str
    price: int

book = Book(
    title="Python実践レシピ",
    # priceは整数型なのでこれは間違っている
    price="定価2,970円(本体2,700円+税10%)",
)

今度は基底クラスBase__init__メソッドを呼び出せます。

$ python books3.py
Baseクラスの初期化処理

しかし、型情報付きのBook.__init__が作られないので、型チェックを行うことができません。

$ pyright books3.py
(省略)
0 errors, 0 warnings, 0 informations
Completed in 0.446sec
✨  Done in 0.80s.

どうやら、O/Rマッパーのクラスをデータクラス化するアプローチは茨の道のように見えます。

typingモジュールの dataclass_transform デコレーターならO/Rマッパーのクラスをデータクラスのように扱える

この状況を改善してくれるのが、typingモジュールのdataclass_transformデコレーターです。dataclass_transformデコレーターは、データクラスではないクラスにデータクラスで行っている型チェックの一部を導入する機能です。dataclass_transformデコレーターの使い方はいくつかありますが、今回はクラスデコレーターとして機能する独自の関数を定義して組み合わせる方法を紹介します[3]

まず、my_orm.pyをカレントディレクトリに作成し、中にBookクラスに適用するデコレーターcreate_modelを定義します。create_modelデコレーターにはdataclass_transformデコレーターを使います。また、内部の処理ではクラスの型アノテーションを元にフィールドを追加する処理を書いておきます。実際のO/Rマッパーはstrint以外の型や初期値を指定した場合などの対応が必要なので、もっと複雑なコードになりますが、今回はBookクラスの定義に必要なコードのみを書いています。

my_orm.py
from typing import TypeVar, dataclass_transform

from orm import Integer, String

T = TypeVar("T")

@dataclass_transform()
def create_model(cls: type[T]) -> type[T]:
    """Bookクラスに適用するデコレーター"""
    # クラスの型アノテーションを元にフィールドを追加
    for key, value in cls.__annotations__.items():
        if value is str:
            setattr(cls, key, String())
        elif value is int:
            setattr(cls, key, Integer())
    return cls

次に、前述のbooks3.pyBookモデルにcreate_modelデコレーターを使うようにします。

books4.py
from my_orm import create_model
from orm import Base

@create_model
class Book(Base):
    title: str
    price: int

book = Book(
    title="Python実践レシピ",
    # priceは整数型なのでこれは間違っている
    price="定価2,970円(本体2,700円+税10%)",
)

このコードに対してPyrightで型チェックを実行すると、データクラスのように型情報付きのBook.__init__があるものとして扱ってくれます。

Pyrightの実行結果を以下に載せます。priceに指定した型が間違っているので、エラーを検出してくれています。

$ pyright books4.py
(省略)
/***/books4.py
  /***/books4.py:12:11 - error: Argument of type "Literal['定価2,970円(本体2,700円+税10%)']" cannot be assigned to parameter "price" of type "int" in function "__init__"
    "Literal['定価2,970円(本体2,700円+税10%)']" is incompatible with "int" (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations
Completed in 0.452sec
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

また、Bookクラスはデータクラスではないので、実行時に基底クラスBase__init__メソッドを呼び出せます。

$ python books4.py
Baseクラスの初期化処理

dataclass_transform デコレーターの内部では何をやっているのか

dataclass_transformデコレーターの実装はとてもシンプルです。CPython 3.11のtypingライブラリのコード(typing.py)を以下に引用します(docstringは省略⁠⁠。

def dataclass_transform(
    *,
    eq_default: bool = True,
    order_default: bool = False,
    kw_only_default: bool = False,
    field_specifiers: tuple[type[Any] | Callable[..., Any], ...] = (),
    **kwargs: Any,
) -> Callable[[T], T]:
    def decorator(cls_or_fn):
        cls_or_fn.__dataclass_transform__ = {
            "eq_default": eq_default,
            "order_default": order_default,
            "kw_only_default": kw_only_default,
            "field_specifiers": field_specifiers,
            "kwargs": kwargs,
        }
        return cls_or_fn
    return decorator

dataclass_transformデコレーターを使ったクラスは__dataclass_transform__属性が追加されます。これ自体は実行時に何かに使われるものではありません。あくまで型チェッカーに渡すための情報です。型チェッカーは、__dataclass_transform__属性があるクラスはデータクラスのように型アノテーションの情報を元にした型チェックを行ってくれます。たとえば、前述のbooks4.pyであれば、型チェッカーはtitleの型をstrpriceの型をintとして型チェックを行います。

主要な型チェッカーのPEP 681への対応状況

前述したとおり、dataclass_transformデコレーターはクラスに型チェッカー用の属性を追加する機能しかありません。つまり、型チェッカーがPEP 681に対応していないと、まったく意味がありません。以下で主要な型チェッカーのPEP 681への対応状況を紹介します。

型チェッカー名 2022年12月15日
時点の
最新バージョン
PEP 681への対応状況
Pyright 1.1.284 対応済み
Mypy 0.991 未対応(Issue #12840で対応予定)
Pyre 0.9.17 0.9.11のリリースノートによると「Basic support for PEP 681 (dataclass transforms).」と書かれているが、動作確認したところ、まだ対応されていなかった。PEP 681対応関連のIssueは見当たらなかった
pytype 2022.12.9 未対応(Python 3.11自体に未対応。PEP 681対応関連のIssueは見当たらなかった)

「データクラスと似た構造を持つクラスを扱うライブラリ」のPEP 681への対応状況

冒頭で紹介した「データクラスと似た構造を持つクラスを扱うライブラリ」のPEP 681への対応状況についても以下で紹介します。Django以外は対応済みなので、PEP 681の恩恵を受けることができます。

ライブラリ名 2022年12月15日
時点の
最新バージョン
PEP 681への対応状況
attrs 22.1.0 対応済み。attr.defineデコレーター、attr.s(auto_attribs=True)デコレーターがdataclass_transformデコレーターと同じ機能を持つ
pydantic 1.10.2 対応済み。pydantic.BaseModelモデルがdataclass_transformデコレーターと同じ機能を持つ
SQLAlchemy 1.4.45 対応済み。attrsを使ったクラスをSQLAlchemy用のクラスにする機能を使うとdataclass_transformデコレーターと同じことができる。また、データクラスそのものも利用できる。詳細はIntegration with dataclasses and attrsを参照
Django内蔵のO/Rマッパー 4.1.4 未対応。Django IssuesDjango Enhancement Proposals(DEPs)[4]にはPEP 681対応関連のIssueは見当たらなかった

まとめ

dataclass_transformデコレーターの登場によって、より型ヒントを活用できる場面が増えてきそうです。まだ対応していない型チェッカーがあるので、今後に期待ですね!

おすすめ記事

記事・ニュース一覧