8
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

DjangoAdvent Calendar 2022

Day 5

Djangoプログラマーの強力な味方、Python 3.11の新機能Data Class Transforms(PEP 681)の紹介

Posted at

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__メソッドには型情報がない

以下のサンプルコードを見てください。

books/models.py
from django.db import models

class Book(models.Model):
    title = models.CharField(max_length=50)
    price = models.IntegerField()

次に、上記のModelを使った以下のサンプルコードを見てください。

example1.py
from books.models import Book

Book(title="Python実践レシピ", price="本体2700円+税")

priceIntegerFieldとして定義しているのに関わらず、初期化時に整数ではなく文字列の値を渡しています。これは型が合わないので、データクラスであれば型チェックでエラーになります。

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クラスを模したコードを以下のように書きました。

example2.py
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を付けられるようにしてしまえばいいのでは? と思うかもしれません。以下のコードを見てください。

example3.py
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のこと)だけ書いてみてはどうでしょう?

example4.py
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.pytyping.dataclass_transformを使ってみます。typing.dataclass_transformを使う方法はいくつかありますが、今回はメタクラスを使う方法を採用します。2

以下のコードを見てください。

example5.py
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側でクラスに型アノテーションを書いたらCharFieldIntegerFieldのようなDjango独自のクラスを使ったフィールドを生やす仕組みを導入すれば、よりデータクラスに近いModelを定義できるようになるかもしれませんね!

  1. ネーミングセンスがなさすぎてアレですが、許してください…

  2. 実際のDjangoのコードでもModelクラスにメタクラスModelBaseを定義しているので、これに近い形を選びました。

8
6
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
8
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?