Pythonアプリケーションでタイムゾーンを扱う方法
始めに
小ネタです。
Pythonでdatetime型を扱うときに、timezone情報を与えることができます。
from datetime import datetime
from zoneinfo import ZoneInfo
datetime.now(tz=ZoneInfo("UTC"))
簡単にtimezoneに合わせた時刻へ変更できることはメリットではあるのですが、timezoneなし(native datetime)とあり(aware datetime)の時刻を比較すると次のエラーが発生します。
<ExceptionInfo TypeError("can't compare offset-naive and offset-aware datetimes") tblen=1>
一番楽な方法がtimezoneなしでもありでも同様に比較できるよう共通メソッドを用意して、astimezone
で毎回timezoneを追加することです。
@staticmethod
def compare(dt_1: datetime, dt_2: datetime) -> bool:
dt_1_aware_datetime = to_aware_datetime(dt_1)
dt_2_aware_datetime = to_aware_datetime(dt_2)
return dt_1_aware_datetime > dt_2_aware_datetime
@staticmethod
def to_aware_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
value = value.replace(tzinfo=ZoneInfo("UTC"))
return value.astimezone(tz=ZoneInfo("UTC"))
ただ、単純比較で十分な時に共通メソッドをわざわざ呼びたくないので、可能な限りPythonアプリケーション上の処理ロジック上ではこのtimezoneの有無を意識しないですむようにする方法をブログにまとめます。
環境
- Python
- 3.13
- pydantic
- 2.11.1
- SQLModel
- 0.0.24
- SQLAlchemy
- 2.0.40
実装
共通
基本的にはdatetime
の生成時にtimezone
も一緒に付与する方法をまとめています。また、共通ロジックとしてtimezoneを付与してUTCに変換するメソッドを使用していきます。
class DatetimeResolver:
@staticmethod
def enforce_utc(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=ZoneInfo("UTC"))
return value.astimezone(tz=ZoneInfo("UTC"))
アプリケーション上での生成
基本的にはtzを付与した状態で生成します。テストしやすいように共通処理側で生成しましょう。
from datetime import datetime
from zoneinfo import ZoneInfo
datetime.now(tz=ZoneInfo("UTC"))
APIのRequest変換
pydantic
でのマッピングを記載します。
一括適用
継承元でmodel_serializer
のmode=wrap
で既存のシリアライズ方法を上書きできます。ここでdatetime
だった場合に処理を割り込ませます。
from pydantic import BaseModel, model_serializer
from src.helper.datetime_resolver import DatetimeResolver
class CustomModel(BaseModel):
@model_serializer(mode="wrap")
def serialize_datetime(self, handler, info) -> dict:
# 標準のシリアライズ処理を実行
result = handler(self)
# datetimeフィールドを一括変換
for field_name, value in self.__dict__.items():
field_type = self.model_fields[field_name].annotation
if field_type is datetime or isinstance(value, datetime):
result[field_name] = DatetimeResolver.enforce_utc(value)
return result
部分的適用
timezoneを付与する型を定義します。pydanticのAfterValidator
を使用することで、マッピングが終わった後にtimezoneを付与できます。
from typing import Annotated
from pydantic import AfterValidator
CustomDatetime = Annotated[
datetime,
AfterValidator(DatetimeResolver.enforce_utc),
]
class _TestModel(BaseModel):
dt: CustomDatetime
DBのマッピング
一括適用
DBに関しては、すべて型定義が必要なようで簡単に横展開できる機能は見つかりませんでした。
部分的適用
データマッピングする型定義をdatetime
ではなく、拡張した型定義を使用します。
from sqlalchemy import DATETIME, TypeDecorator
from sqlmodel import SQLModel
class UTCDateTime(TypeDecorator):
impl = DATETIME
@override
def process_result_value(self, value, dialect):
return DatetimeResolver.enforce_utc(value)
class User(SQLModel, table=True):
__tablename__ = "users"
# tzを付与する
created_at: datetime = Field(sa_column=Column(UTCDateTime))
# tzを付与しない(デフォルト)
updated_at: datetime = Field(sa_column=Column(DATETIME))
できなかったこと
SQLModelを利用しているのでpydantic
側の型変換定義さえ行えば動くと思っていましたが、実際はそんなことなかったです。
CustomDatetime = Annotated[
datetime,
AfterValidator(DatetimeResolver.enforce_utc),
]
class User(SQLModel, table=True):
__tablename__ = "users"
created_at: CustomDatetime = Field(sa_column=Column(DATETIME))
ソースコード
こちらでテストをしています。
終わりに
もともと調べ始めたきっかけはフロントからTZなしとTZありの日付で連携されることがきっかけでした。挙動としては、次の流れです。
- フロントでは
datetime
がTZありの状態で連携される - DBでTZを保存していないので、TZなしの状態をDBに保存
- DBから取得した値をTZなしのまま、フロントに
datetime
を返却する - フロントでは受け取ったTZなしの
datetime
をそのまま使用し、別項目で設定した新規のTZありのdatetime
が連携される - バックエンドとしては複数の
datetime
の整合性を確認するため、比較したらエラーが発生した
エラー内容がわかりやすいので解決はしやすいものの、アプリケーションの外側(API, DB)側も含めた根本解決を考えると結構面倒ですので、自分としてはまとめられて満足です。
Discussion