2023年1月: 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には、データクラスと似た構造を持つクラスを扱うライブラリがいくつかあります。 たとえば、attrs、pydantic、O/Rマッパー[1](SQLAlchemy、Django内蔵の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
としてカレントディレクトリに置いて使う前提とします。
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
クラスを定義しています。
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
に指定した値の型が明らかに間違っていますが、型チェッカー(Mypy、Pyrightなど)では型エラーを検出できません。なぜなら、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
クラスに近い構造のデータクラスを以下のように定義しました。
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
にはstr
、price
には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.py
のBook
クラスに定義したフィールドtitle
、price
を型アノテーションにし、dataclasses.dataclass
デコレーターも付けました。
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
デコレーターを使わず型アノテーションだけ定義した場合も考えてみましょう。
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マッパーはstr
、int
以外の型や初期値を指定した場合などの対応が必要なので、もっと複雑なコードになりますが、今回はBook
クラスの定義に必要なコードのみを書いています。
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.py
のBook
モデルにcreate_model
デコレーターを使うようにします。
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
の型をstr
、price
の型をint
として型チェックを行います。
主要な型チェッカーのPEP 681への対応状況¶
前述したとおり、dataclass_transform
デコレーターはクラスに型チェッカー用の属性を追加する機能しかありません。つまり、型チェッカーがPEP 681に対応していないと、まったく意味がありません。以下で主要な型チェッカーのPEP 681への対応状況を紹介します。
型チェッカー名 |
2022年12月15日時点の最新バージョン |
PEP 681への対応状況 |
---|---|---|
1.1.284 |
対応済み |
|
0.991 |
未対応(Issue #12840で対応予定) |
|
0.9.17 |
0.9.11のリリースノートによると「Basic support for PEP 681 (dataclass transforms).」と書かれているが、動作確認したところ、まだ対応されていなかった。PEP 681対応関連のIssueは見当たらなかった |
|
2022.12.9 |
未対応(Python 3.11自体に未対応。PEP 681対応関連のIssueは見当たらなかった) |
「データクラスと似た構造を持つクラスを扱うライブラリ」のPEP 681への対応状況¶
冒頭で紹介した「データクラスと似た構造を持つクラスを扱うライブラリ」のPEP 681への対応状況についても以下で紹介します。 Django以外は対応済みなので、PEP 681の恩恵を受けることができます。
ライブラリ名 |
2022年12月15日時点の最新バージョン |
PEP 681への対応状況 |
---|---|---|
22.1.0 |
対応済み。 |
|
1.10.2 |
対応済み。 |
|
1.4.45 |
対応済み。attrsを使ったクラスをSQLAlchemy用のクラスにする機能を使うと |
|
Django内蔵のO/Rマッパー |
4.1.4 |
未対応。Django Issues、Django Enhancement Proposals (DEPs)[4]にはPEP 681対応関連のIssueは見当たらなかった |
まとめ¶
dataclass_transform
デコレーターの登場によって、より型ヒントを活用できる場面が増えてきそうです。まだ対応していない型チェッカーがあるので、今後に期待ですね!