Python/Django のTips 備忘録

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

クラスメソッドのモック
テスト時、特定のクラスのメソッドをモックしたい場合
例: 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']

related_name
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

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)

related_name 経由でリレーション解決すると Any になる
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

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 が入る。

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

マイグレーション時に何もしない
マイグレーションファイルで「何もしない」を表現したいとき、 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
)

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', ...)
クォーテーションで囲まないと行けないのがちょっと気持ち悪いけど…。
参考:

複合インデックス、複合ユニーク制約
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_
プレフィックスをつけている。

除外クエリ(__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)

クエリ確認
発行される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)

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 を使えば良さそう。

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

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

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 で返す。

UUIDクラス
Python標準の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)

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

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 でも良いかも知れない。

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)

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

ハッシュ値生成は uuid4
何かしらのIDなどのためにハッシュ値を作る時、uuid4 が良いかもしれない。
Claude が以下の理由でオススメしてきた。
- 衝突確率が極めて低い(実質的にゼロ)
- 標準ライブラリで提供
- 実装が簡単
- 予測不可能(セキュリティ面で安全)
というわけで、以下のようにハッシュ値生成する。
from uuid import uuid4
def generate_hash() -> str:
return str(uuid4())

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__
メソッドでもできると公式ドキュメントに書いてあったけど、自分の場合は上手くいかなかった。