Open23

Python/Django のTips 備忘録

ピン留めされたアイテム
tenkeitenkei

概要

Python/Django 開発での個人的な Tips を書き足していく。
あくまで Python 初心者である筆者の備忘録です。内容の正確性が担保されたものではないことに注意。

tenkeitenkei

クラスメソッドのモック

テスト時、特定のクラスのメソッドをモックしたい場合

例: Foo クラスの do メソッドの中で Slack 通知を行うような実装

import unittest.mock

from foo import Foo
from utils.slack import SlackUtils

class TestFoo
    @unittest.mock.patch.object(SlackUtils, 'send')
    def test_get(self, mock_slack_send):
        Foo.do()
        assert "メッセージ内容" in mock_slack_send.call_args.kwargs['message']

tenkeitenkei

Model 同士にリレーションを張る際、 related_name を指定することができる。

from django.db import models

class User(models.Model):
  # 色々なプロパティ

class Profile(models.Model):
  user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='user_profile')

user = User.objects.first()
user.user_profile
tenkeitenkei

1対1でのリレーションの存在確認

ObjectDoesNotExist でエラーハンドリングする。

from django.core.exceptions import ObjectDoesNotExist

class Person(models.Model):
    def get_profile(self):
        try:
            return self.profile
        except ObjectDoesNotExist:
            return None

class Profile(models.Model):
    person = models.OneToOneField(Person, on_delete=models.CASCADE)

あるいは has_attr を利用してもよい。

class Person(models.Model):
    def get_profile(self):
        return self.profile if hasattr(self, 'profile') else None

class Profile(models.Model):
    person = models.OneToOneField(Person, on_delete=models.CASCADE)

https://docs.djangoproject.com/en/5.1/topics/db/examples/one_to_one/

tenkeitenkei

related_name で指定した名称で解決しようとすると、Any になってしまう。(自分の書き方が悪いのかもしれない)

from typing import Optional

class User(models.Model):
  # 色々なプロパティ

class Profile(models.Model):
  user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='user_profile')

user = User.objects.first()
profile = user.user_profile # Any

# 型指定しても Any は取れない
profile: Optional[UserProfile] = user.user_profile # UserProfile | None | Any

cast を使うといったん解決。

from typing import cast, Optional

profile = cast(Optional[UserProfile], user.user_profile) # UserProfile | None
tenkeitenkei

Django Admin の save_model メソッド

Django Admin 上の保存・変更をフックにして、何かの処理をしたいときがある。関連するデータも更新する、など。
そのような場合に save_model メソッドが利用できる。

from models import Person

@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
    # 色々なプロパティ

    # 色々なメソッド

    def save_model(self, request, obj, form, change):
        before = User.objects.get(id=obj.id)

        # before と after(obj) を利用して、ここで処理を実行できる。

        # 保存を続行するには、親メソッドを実行
        super().save_model(request, obj, form, change)

request には HTTP リクエスト情報が含まれる。あまり使うことはないかも?
change には bool が入り、新規追加なら False が入り、既存のレコードへの編集なら True が入る。

tenkeitenkei

admin.py のファイル分割

Django Admin の記述を1つの admin.py ファイルに加えていくと、ファイルの行数がどんどん増えていく。これを分割する方法。

# 分割前
- my_project
    - admin.py  # 例えば、Author, Book などの Model を含むとする

# 分割後
- my_project
    - admin # ディレクトリ(モジュール)にしてしまう
        - __init__.py
        - author_admin.py
        - book_admin.py

分割したファイル内で admin.register を行う。

from django.contrib import admin
from api.models.author import Author

@admin.register(Author)
class AuthorAdmin(admin.ModelAdmin):
    # list_display や had_delete_permission など

そして __init__.py に呼び出せばOK

import my_project.admin.author
import my_project.admin.book
tenkeitenkei

マイグレーション時に何もしない

マイグレーションファイルで「何もしない」を表現したいとき、 noop が利用できる。

from django.db import migrations


def upgrade(apps, schema_editor):
    # アップグレード時の処理


class Migration(migrations.Migration):

    dependencies = [
        ('app_name', '1234_previous_migration'),
    ]

    operations = [
        migrations.AddField(
            model_name='person',
            name='address',
            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='app_name.address'),
        ),
        migrations.RunPython(
            code=upgrade,
            reverse_code=migrations.RunPython.noop,  # ロールバック時は何もしない
        )
    ]

ちなみに、 RunPython の reverse_code だけを省略することはできない。

migrations.RunPython(
    code=upgrade
)
tenkeitenkei

TYPE_CHECKING で型定義の循環参照を回避する

Django でモデルが複雑になってくると、循環参照に陥ることがある。

# Person
from django.db import models
from models.book import Book

class Person(models.Model):
    def get_books() -> list[Book]
        return ...


# Book
from django.db import models
from models.person import Person

class Book(models.Model):
    person = models.ForeignKey(Person, ...)

ForeignKey の指定に文字列を使えば、循環参照の問題は回避できる。が、型情報が失われてしまい不便。

from django.db import models

class Book(models.Model):
    person = models.ForeignKey('Person', ...)  # 文字列に変更した

typing が提供する TYPE_CHECKING を利用すれば、この問題は解決できる。

# Person
from typing import TYPE_CHECKING
from django.db import models
if TYPE_CHECKING:
    from models.book import Book

class Person(models.Model):
    def get_books() -> list['Book']
        return ...


# Book
from typing import TYPE_CHECKING
from django.db import models
if TYPE_CHECKING:
    from models.person import Person

class Book(models.Model):
    person = models.ForeignKey['Person']('Person', ...)

クォーテーションで囲まないと行けないのがちょっと気持ち悪いけど…。

参考:

https://zenn.dev/ganariya/articles/python-lazy-annotation

tenkeitenkei

複合インデックス、複合ユニーク制約

Django で複合インデックス、複合ユニーク制約を指定する方法。 Meta クラス内で指定する。

from django.db import models

class Person(models.Model):
    foo = models.CharField()
    bar = models.CharField()

    class Meta:
        indexes = [
            models.Index(fields=['foo', 'bar'], name='%(class)s_foo_bar_idx')
        ]
        constraints = [
            models.UniqueConstraint(fields=['foo', 'bar'], name='%(class)s_foo_bar_unique')
        ]

name はDB全体でユニークであるべきなので、 %(class)s_ プレフィックスをつけている。

tenkeitenkei

除外クエリ(__ne)

除外クエリは __ne で実現可能。

result = Book.objects.filter(category__ne=BOOK_CATEGORY.NOVELS)

ただし、以下のような複合クエリでは使えない。

result = Book.objects.filter(
    Q(category__ne=BOOK_CATEGORY.NOVELS) |
    Q(published_at__gte=...
)
# django.core.exceptions.FieldError: Unsupported lookup 'ne' for CharField or join on the field not permitted.

exclude で除外すればOK。__ne とは真偽が反転することに注意。

result = Book.objects \
    .filter(published_at__gte=...) \
    .exclude(category=BOOK_CATEGORY.NOVELS)
tenkeitenkei

クエリ確認

発行されるSQLや、実行プランを確認する方法

all_books = Book.objects.all()
print(all_books.query)
# SELECT `books`.`id`, `books`.`name` FROM `books`

filtered_books = Book.objects.filter(name='John Doe')
print(filtered_books.explain())
# -> Filter: (books.`name` = 'John Doe')  (cost=0.35 rows=1)
#    -> Table scan on books  (cost=0.35 rows=1)
tenkeitenkei

TimeZone

Python3.9 で ZoneInfo が追加された。標準ライブラリでタイムゾーンが扱える。

from zoneinfo import ZoneInfo
from datetime import datetime, timedelta

date = datetime(2020, 10, 31, 12, tzinfo=ZoneInfo("America/Los_Angeles"))

参考: https://docs.python.org/ja/3/library/zoneinfo.html

以前は pytz というライブラリがポピュラーだった模様。おそらくこれを使い続ける理由はない?と思うので、これからは ZoneInfo を使えば良さそう。

tenkeitenkei

pytest.mark

pytest の各テストケースにマーキングをすることができる。
以下のテストコードでは、 slow というマークを付けている。

import pytest

@pytest.mark.slow
def test_foo():
    # ...

このようなカスタムマークを利用可能にするには、pytest.ini に設定を加える。

[pytest]
markers =
    slow: marks tests as slow
    # 他にもあればここから追加

CLI で -m slow を指定することで、slow マークをつけたテストのみを実行できる。

参考: How to mark test functions with attributes - docs.pytest.org

tenkeitenkei

dict setdefault

dict型にデータを追加する時、すでにキーが有るかどうかを気にすることがある。以下のような感じ。

# 変数定義
d: dict[int, list[int]] = {}

# 別の箇所
if 'foo' not in d:
    d['foo'] = []  # まだなければ設定
d['foo'].append(0)

setdefault メソッドを使うと、記述を簡略化できる。

d.setdefault('foo', []).append(0)

値を指定しないと None になる。

d.setdefault('foo')

print(d['foo'])  # None

参照: setdefault - docs.python.org

tenkeitenkei

dict の keys, values, items

dict(辞書型)を for ループの中で使いたいとき、keys, values, items メソッドが利用できる。

d = { ... }

for k in d.keys():
    print(k)

for v in d.values():
    print(v)

for k, v in d.items():
    print(k, v)

ただし keys は for k in d: でも同様のことができる。

items は JavaScript で言うところの Object.entries。キーと値を Tuple で返す。

tenkeitenkei

UUIDクラス

Python標準のUUIDクラスについて。

https://docs.python.org/3/library/uuid.html#uuid.UUID

以下のような Django のモデルがあったとして、

import uuid
from django.db import models

class Group(models.Model)
    group_hash = models.UUIDField(max_length=50, unique=True, default=uuid.uuid4, editable=False)

以下のように型チェックに使えるのが便利。

from uuid import UUID

def create_url(group_hash: UUID)
    return f'https://example.com/groups/{group_hash}/'

group = Group.objects.first()

# 誤って ID を渡している!(int 型なので、型チェックで気付ける)
url = create_url(group.id)

# UUID なので OK
url = create_url(group.group_hash)
tenkeitenkei

refresh_from_db

Django Model のインスタンスに生えている refresh_from_db メソッド。フィールドの値を DB から取得したい時に使える。

参照: https://docs.djangoproject.com/en/5.1/ref/models/instances/#django.db.models.Model.refresh_from_db

user = User.objects.create(name='foo')

# データ更新処理
update_user_name(user, 'bar')

print(user.name)  # foo ?! 

user.refresh_from_db()

print(user.name)  # bar

インスタンスの値が古いまま?という問題が発生したら、使うと良いかも知れない。
自分はテストコードでしか使ったことがないけど。

tenkeitenkei

CharField vs TextField

Django のモデルに文字列のフィールドを追加するには CharField と TextField が使える。

自分が調べた限りだと、CharField を max_length 指定して使うのが無難そう。パフォーマンスの観点から。もしかしたら古い MySQL など、特定のケース以外では問題にならないかもしれないけど。

from django.db import models

class Blog(models.Model):
    body = models.TextField()

class Blog(models.Model):
    body = models.CharField(max_length=1000)

文字数制限のない長文を保持するなら、TextField でも良いかも知れない。

tenkeitenkei

OneToOneField ならデフォルトでユニーク制約

OneToOneField でリレーションを作成する場合、自動的にユニーク制約が付与される。

from django.db import models

class Company(modes.Model):
    # 色々なプロパティ

# ForeignKeyだと以下のような感じ。
class CompanySomeStatus(modes.Model)
    company = models.ForeignKey(Company, on_delete=models.CASCADE)

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=['company'], name='%(class)s_company_unique'),
        ]

# OneToOneだとスッキリ
class CompanySomeStatus(modes.Model)
    company = models.OneToOneField(Company, on_delete=models.CASCADE)
tenkeitenkei

pytest環境でDjangoの初期データ投入

pytest 実行時、最初にいくつかのデータを投入しておく方法。

import pytest
from django_db_blocker import django_db_blocker
from api.models.some_token import SomeToken

@pytest.fixture(scope='session', autouse=True)
def create_tokens(django_db_setup, django_db_blocker):
    with django_db_blocker.unblock():
        SomeToken.objects.create(
            token_type="FOO_SERVICE_TOKEN",
            value="xxxxx"
        )
        SomeToken.objects.create(
            token_type="BAR_SERVICE_TOKEN",
            value="xxxxx"
        )

これを conftest.py に書く。あるいは、別ファイルに書いて、conftest.py に読み込む。

json ファイルにしてそれをロードする、という方法もありそうなので、初期データが増えてきたらそうしても良いかも。

tenkeitenkei

ハッシュ値生成は uuid4

何かしらのIDなどのためにハッシュ値を作る時、uuid4 が良いかもしれない。

Claude が以下の理由でオススメしてきた。

- 衝突確率が極めて低い(実質的にゼロ)
- 標準ライブラリで提供
- 実装が簡単
- 予測不可能(セキュリティ面で安全)

というわけで、以下のようにハッシュ値生成する。

from uuid import uuid4

def generate_hash() -> str:
    return str(uuid4())
tenkeitenkei

Django Admin の初期値変更

DjangoAdminのフォームで表示する初期値を変更する方法。

get_initial_for_field メソッドを上書きする。(ちょっとハックかも)

from django import forms
from django.contrib import admin
from import_export.admin import ExportMixin
from models.foo import Foo

class FooForm(forms.ModelForm):
    class Meta:
        model = Foo
        fields = '__all__'

    def get_initial_for_field(self, field, field_name):
        if field_name == 'secret_name':
            return 'xxx'

        return super().get_initial_for_field(field, field_name)

@admin.register(Foo)
class FooAdmin(ExportMixin, admin.ModelAdmin):
    form = FooForm

__init__ メソッドでもできると公式ドキュメントに書いてあったけど、自分の場合は上手くいかなかった。