nykergoto’s blog

機械学習とpythonをメインに

python の DateTime・Timezone と Django での取扱い

django でアプリケーションを作っていて timezone 周りで困ったことがあったので、初めて django および python での時間の取り扱いについて真面目に調べてみた記録です。

はじめに・ われわれが使っている時計の時間とは何か

まず時計が示している時間とは何かということを考えてみましょう。 世界中に時計がひとつしかなければ(すべての人間がひとつの時計を正として生活していれば)特に問題はなくその時計の時刻自体を YYYY-m-dd HH:mm などで保存したものが常に正しいので何も考えることはありません。 皆同じものを参照しているので不整合はありません。

しかし実際には世界には時差があります。時差があるというのはどういうことかというと時計を文字通り読み取った値が同じでも、物理的には違う時間なことがあるということです。

例えば東京の 4/23 18:00 とニューヨークの 4/23 5:00 は物理的には同じ時間を表していますが、時計は違う時刻を指しています。それは東京・ニューヨークが別の Timezone に属していているためです。

Timezone ごとに、標準時 (イギリスのあたり) の時計に対して何時間ずらすのかが決まっています。例えば東京では標準時から +9H ・ ニューヨークなら -4H ときまっているので、先の 18:00 と 5:00 が同じ物理時間を指していることが確認できます (差分が 9 + 4 = 13 時間であるからです)。

python で時刻を扱う

これらの問題は python で時刻を取り扱う場合でも同様なので、 単に数値としての時刻と Timezone の情報を持った時刻を区別して取り扱うように実装されています。 前者を naive time, 後者を aware time と呼びます。

naive time は単に datetime として作成したときの状態です。標準ライブラリの datetime で現在時刻をとったような状態では naive time です。

from datetime import datetime

naive = datetime.now()  
# datetime.datetime(2020, 4, 23, 10, 15, 27, 886221)

aware な時刻かどうかは datetime object が持っている tz によって判定されています。naive な datetime は tz を持っていません。

naive.tzname() is None  # True

tzを設定するひとつの方法が naive.astimezone です。この関数は tzinfo instance を引数にとってその timezone の aware な時刻のオブジェクトを返します。 tzinfo の設定は pytz を使うのが楽ちんです。例えば UTC 時刻として登録する場合は以下のようになります。

import pytz
naive.astimezone(pytz.UTC) 
# datetime.datetime(2020, 4, 23, 10, 15, 27, 886221, tzinfo=<UTC>)

一度 aware になってしまうと naive な時刻とは引き算をすることができません。

くどいですが区別するのは Timezone 込の情報と Timezone 無しのものは比較することができないからです。 これら2つを同様に扱ってしまってナイーブに引き算などすると例えば東京とニューヨークを同様に扱って引き算できてしまうので、先の例だと時間差がゼロになってしまいます。

実際 naive time と aware time を引き算すると TypeError が発生します。親切ですね。

naive - naive.astimezone(pytz.UTC)

---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-24-98bec8f1194b> in <module>
----> 1 naive - naive.astimezone(pytz.UTC)

TypeError: can't subtract offset-naive and offset-aware datetimes

astimezone で変換さた時刻は、元の naive な時刻を標準時で持つような tzinfo の時刻となります。 そのためここで tokyo や newyork を設定しても引き算すると時刻の差はゼロです。

tokyo_tz = pytz.timezone('Asia/Tokyo')
nyc_tz = pytz.timezone('America/New_York')

naive.astimezone(tokyo_tz) - naive.astimezone(nyc_tz)
# datetime.timedelta(0)

もし「東京の時計で naive な時刻」を持つような時間を作りたい場合には timezone.locale を使います。 例えば東京とニューヨークそれぞれで同じ時計が表示されている状態の差分を見る場合は以下のようになります。期待通り時差 +9H から -4H を引くので 13時間のズレになっていることがわかります。

# 東京とニューヨークで文字盤上同じ時刻を引き算すると時差と一致する
diff = tokyo_tz.localize(naive) - nyc_tz.localize(naive)
diff.total_seconds() / 60 ** 2  # -13.0

Django での時間の取り扱い

Django では USE_TZ=True のとき時差を考慮した処理を行なうようになります。すなわち djangopython object として扱う時刻はすべて aware な datetime になるということです。反対に USE_TZ=False にしていると tz を設定しない naive な datetime を使います。この場合現在時刻を知りたければ datetime を使うことができます。

from datetime import datetime

now = datetime.now()

一方で USE_TZ=True の場合は aware な方法で now を作成しなくてはいけません。このような場合のために django では django.utils.timezone というモジュールがありその内部に now が実装されています。

from django.utils import timezone

aware_now = tiemzone.now()

この内部では datetime.now を呼び出したあとに UTC を tz に設定しています。 このように django では時差考慮するとなった時には基本的に UTC として取り扱います。これによってデータベースに保存する際にすべての時刻が UTC であることが担保されますので、引き算等の演算での不整合が発生することを防ぐことができます。

# django/utils/timezone.py

# UTC time zone as a tzinfo instance.
utc = pytz.utc

# 中略

def now():
    """
    Return an aware or naive datetime.datetime, depending on settings.USE_TZ.
    """
    if settings.USE_TZ:
        # timeit shows that datetime.now(tz=utc) is 24% slower
        return datetime.utcnow().replace(tzinfo=utc)
    else:
        return datetime.now()

webapplication ではリクエスト元のユーザーが違う timezone に属していることがあります。このような場合にはリクエストごとに Timezone を変更したくなりますが、この用途のために django には activate という関数があり、これを呼び出すとデフォルトの TimeZone を上書きすることができます。

from django.utils.timezone import activate
activate('Your-Favorite-Timezone')

テンプレートエンジンや DRF のシリアライザなどユーザーに情報を返す際にはここで設定された TimeZone での locale datetime への変換がされるので、ユーザーの TimeZone の時計で見た時の時刻をユーザーごとに設定して返すことができます。

Example: time から datetime への変換

「TimeField を今日の時間とみなして、その時間と DateTimeField の値を比較したい」といいう要望があったとしましょう。 Time を date に設定する関数 combine を使うと以下のように書くことができるように思えますが USE_TZ の場合エラーになります。それは to_today_datetime が返している datetime が naive だからです。

from django.utils import timezone

def to_today_datetime(time, now):
    today = now.today()
    dt = timezone.datetime.combine(today, time)
    return dt

time = # TimeField の値. Time 型

now = timezone.now()

# Log という model があったとする
created_at = Log.objects.first().created_at # created_at は DateTimeField
created_at - to_today_datetime(time, now) # TypeError: can't subtract offset-naive and offset-aware datetimes

では utc にすれば OK かというとそうでもありません。 なぜなら今日と認識しているのはリクエストを送ったユーザーですから、ユーザーにとっての時間として表現する必要があるからです。 今設定されている timezone は get_current_timezone で取得することができます。 得られた tzinfo を使って localize することで時間をユーザーのtimezoneでtimeを時計で見たときの時刻に変更することができます。

def to_today_datetime(time, now):
    today = now.today()
    dt = timezone.datetime.combine(today, time)

    # これでは UTC の人にとって `dt` が時計に表示されているときの時刻になる。
    # dt = dt.astimezone(timezone.utc)
    
    # 今のリクエスト context での timezone を取得して、その timezone で `dt` が表示された時の時間に直す
    tz = timezone.get_current_timezone()
    dt = tz.localize(dt)
    return dt

参考