Django Advent Calendar 2022 5日目は、Python 3.11の新機能Data Class Transforms(PEP 681)がDjangoを使うプログラマーにとっていかに嬉しいか、という話をします。
Python 3.11の新機能Data Class Transforms(PEP 681)とは何か
この解説のサンプルコードで使用している環境は以下のとおりです。
- Python 3.11.0
- Django 4.1.3
- pyright 1.1.282
pyrightはNode.js製なのでnpmかyarnでインストールします。
「Django Model初期化時の型チェック問題」とは?
Django ModelはPythonのデータクラス風の構造をしていますが、データクラスそのものではないため、初期化時に型ヒントの恩恵を受けられない問題があります。
本記事では、この問題を「Django Model初期化時の型チェック問題」と呼びます(なお、私が勝手に命名したもので一般的な呼称ではありません)。1
「Django Model初期化時の型チェック問題」が発生する具体例について、以下で解説します。
Django Modelの__init__
メソッドには型情報がない
以下のサンプルコードを見てください。
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=50)
price = models.IntegerField()
次に、上記のModelを使った以下のサンプルコードを見てください。
from books.models import Book
Book(title="Python実践レシピ", price="本体2700円+税")
price
はIntegerField
として定義しているのに関わらず、初期化時に整数ではなく文字列の値を渡しています。これは型が合わないので、データクラスであれば型チェックでエラーになります。
pyrightに上記コードを読ませてみましょう。何のエラーも発生しません。Book.__init__
メソッドの引数には型情報がないためです。以下はhelp(Book.__init__)
の実行結果です。
Help on function __init__ in module django.db.models.base:
__init__(self, *args, **kwargs)
Initialize self. See help(type(self)) for accurate signature.
$ pyright example1.py
(省略)
0 errors, 0 warnings, 0 informations
Completed in 0.462sec
✨ Done in 0.58s.
一方、同じコードをデータクラスで書いてみると…
データクラスではどうなるのかも見ておきましょう。前述のBook
クラスを模したコードを以下のように書きました。
from dataclasses import dataclass
@dataclass
class Book:
title: str
price: int
Book(title="Python実践レシピ", price="本体2700円+税")
こちらは__init__
メソッドに型情報があるので、pyrightでのチェックでエラーになります。
$ pyright example2.py
(省略)
/***/example2.py
/***/example2.py:8:33 - error: Argument of type "Literal['本体2700円+税']" cannot be assigned to parameter "price" of type "int" in function "__init__"
"Literal['本体2700円+税']" is incompatible with "int" (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations
Completed in 0.454sec
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
以下はhelp(Book.__init__)
の実行結果です。
Help on function __init__ in module builtins:
__init__(self, title: str, price: int) -> None
「Django Model初期化時の型チェック問題」解決のため試行錯誤してみる
以下で、Django Modelでもデータクラスのような型チェックができるようにならないか、試行錯誤してみます(いきなりネタバレしてしまいますが、いずれも失敗に終わっています)。
【試行錯誤その1】Django Modelをデータクラスそのものにしてしまえばいいのでは?
データクラスなら型チェックできるというなら、Django側で対応してModelクラスにdataclasses.dataclass
を付けられるようにしてしまえばいいのでは? と思うかもしれません。以下のコードを見てください。
from dataclasses import dataclass
class Model:
def __init__(self, *args, **kwargs):
print("Model")
@dataclass
class Book(Model):
title: str
price: int
Book(title="Python実践レシピ", price="本体2700円+税")
上記Book
クラスはデータクラスなので型チェックは効きますが、基底クラスのModel
に定義された__init__
メソッドが呼ばれません。dataclasses.dataclass
によって勝手にBook.__init__
メソッドが作られてしまうからです。
$ python3.11 example3.py # Model.__init__に書いたprint関数が呼ばれない
【試行錯誤その2】dataclasses.dataclass
を使わずに型アノテーションだけ書いてみる
それでは、以下のようにdataclasses.dataclass
は使わず型アノテーション(title: str
とかprice: int
のこと)だけ書いてみてはどうでしょう?
class Model:
pass
class Book(Model):
title: str
price: int
Book(title="Python実践レシピ", price="本体2700円+税")
これはデータクラスではないので基底クラスの__init__
メソッドは呼ばれます。
しかし、pyrightを実行してみると、「コンストラクタに引数がない」旨のエラーが出ます。今度はデータクラスなら作られるはずの引数付きBook.__init__
メソッドがないため型チェックができません。
$ pyright example4.py
(省略)
/***/example4.py
/***/example4.py:8:1 - error: Expected no arguments to "Book" constructor (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations
Completed in 0.442sec
error Command failed with exit code 1.
「Django Model初期化時の型チェック問題」解決の切り札「Data Class Transforms(PEP 681)」
色々試してみましたが、Django Modelとしての機能を維持しつつ、初期化する際に型チェックを受けるのはどうやってもできません。
この状況を打破してくれるのが、Python 3.11の新機能Data Class Transforms(PEP 681)です。
PEP 681で追加されたのはtyping.dataclass_transformというデコレーターです。前述のexample4.py
にtyping.dataclass_transform
を使ってみます。typing.dataclass_transform
を使う方法はいくつかありますが、今回はメタクラスを使う方法を採用します。2
以下のコードを見てください。
from typing import dataclass_transform
# これを追加
@dataclass_transform()
class ModelBase(type):
pass
class Model(metaclass=ModelBase): # metaclassにModelBaseを追加
pass
class Book(Model):
title: str
price: int
Book(title="Python実践レシピ", price="本体2700円+税")
typing.dataclass_transform
を使うことで、型チェックツールに対して「このクラスはデータクラスではないが、データクラスのように扱ってほしい」という情報を伝えることができます。
上記のコードはBook.__init__
メソッドに型情報はありませんが、型チェックツールはクラスに定義されている型アノテーションを元に、型情報付きのBook.__init__
メソッドがあるかのように扱ってくれます。
上記コードをpyrightに読ませると、price
に指定した値の型が間違っている旨のエラーが発生します。
$ pyright example5.py
(省略)
/***/example5.py
/***/example5.py:15:33 - error: Argument of type "Literal['本体2700円+税']" cannot be assigned to parameter "price" of type "int" in function "__init__"
"Literal['本体2700円+税']" is incompatible with "int" (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations
Completed in 0.439sec
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
これで、Modelの初期化時でも型チェックができるようになりました。あとは、Django側でクラスに型アノテーションを書いたらCharField
やIntegerField
のようなDjango独自のクラスを使ったフィールドを生やす仕組みを導入すれば、よりデータクラスに近いModelを定義できるようになるかもしれませんね!