Python の datetime よくわからん人向けのなにか

2022/02/28に公開

この記事では Python の datetime について取り扱います。

import datetime

忙しい人向けのまとめ

  • なるべく aware (タイムゾーン付き)日時を扱う。
    • datetime.datetime.now() にタイムゾーンを入れ忘れないようにする。
    • ビジネスロジック上で naive と aware 日時を混ぜないようにする。
      • リポジトリなど生データに近い部分で変換しておく。
    • 日付情報だけを扱いたい場合は date を使う。
      • 記録媒体によっては日付をサポートしないこともある。
  • シリアライズ・デシリアライズする場合:
    • naive な日時でしか保存できない場合は UTC とする。
    • 代替案として Unix Timestamp や ISO8601 形式が利用できないか検討する。
      • Unix Timestamp だと比較がしやすい。
        • システムがタイムスタンプを 64bit で扱うか確認が必要。
  • もちろん例外はあるので適宜いい感じにする(重要)。

naive 日時と aware 日時

  • Pythonの日時は二種類あります。
    • naive 日時。 時差情報 tzinfo を持ちません。
    • aware 日時。 時差情報 tzinfo を持ちます。
  • datetime のインスタンス dt が以下のふたつを満たす場合に aware 日時となります。
    • dt.tzinfoNone でない (タイムゾーン情報が存在する)
    • dt.tzinfo.utcoffset(dt)None を返さない (タイムゾーンのオフセットが未定義でない)
def is_aware(dt: datetime.datetime):
    return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None

インスタンスの生成

日時を指定してインスタンスを生成する

  • 少なくとも year, month, day が指定されていればインスタンスを生成できます。省略された引数は 0 を指定したものとして扱われます。
  • JavaScript では1月32日のような存在しない日時を指定しても2月1日に正規化されますが、Pythonでは ValueError となります。
# 指定した日時情報でローカル日時を生成する (naive)
datetime.datetime(2022, 1, 1, 0, 0, 0)
# 指定した日時情報でUTC日時を生成する (aware)
datetime.datetime(2022, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc)

現在時刻を持つインスタンスを生成する

  • now() は naive な現在日時を返します。
  • utcnow() も同じく naive なUTC現在日時を返します。
    • システムのタイムゾーンと異なることがあるので、naive 日時でUTCを扱うのはあまりよくない考えです。
    • システムのタイムゾーンが特定のタイムゾーンであることを想定しないでください。
  • now()utcnow() を短い間に呼び出すと同じ値を得ることがあります。
    • 特に Windows のような日時分解能が低い環境で発生します。
    • 毎回異なる日時が得られるといった想定をしないでください。
# ローカル日時を取得する (naive)
datetime.datetime.now()
# UTC日時を取得する (naive)
datetime.datetime.utcnow()
# 指定したタイムゾーンでの日時を取得する (aware)
datetime.datetime.now(datetime.timezone.utc)

扱える年の最小値と最大値

  • 西暦1年から9999年まで扱えます。
datetime.MINYEAR    # 1
datetime.MAXYEAR    # 9999
  • この範囲を超える西暦を扱いたい場合には天文ライブラリの astropy.time などがあります。
  • システム上の無効な値として 0001-01-01T00:00:009999-12-31T23:59:59 を使用しないでください。
    • tzinfo によってこの範囲を超えることがあります。
    • 記録媒体によってはこの日時をサポートしないことがあります。
    • 1970-01-01T00:00:00Z 未満の日時は Unix Timestamp が負数になります。

日時の一部を変更する

  • replace() を使用すると、指定した要素だけ書き換えた新しいインスタンスを生成できます。
a = datetime.datetime(2022, 1, 1, 0, 0, 0)
a.replace(day=5)
# datetime.datetime(2022, 1, 5, 0, 0)

日時情報のシリアライズ

Unix Timestamp との相互変換

  • timestamp() はシステムのタイムゾーンまたは tzinfo に基づいて Unix Timestamp を返します。
  • naive 日時の場合、システムのタイムゾーンと一致している限り正しく変換されます。
    • utcnow() で取得した naive 日時を Unix Timestamp に変換するとずれます。
a = datetime.datetime(2022, 1, 1, 0, 0, 0)
b = datetime.datetime(2022, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc)
a.timestamp()    # 1640962800.0
b.timestamp()    # 1640995200.0
  • fromtimestamp() はシステムのタイムゾーンまたは tz に基づいて日時が復元されます。
  • naive, aware 日時ともに正しく変換されます。
a = datetime.datetime.fromtimestamp(1640995200)
b = datetime.datetime.fromtimestamp(1640995200, tz=datetime.timezone.utc)
a    # datetime.datetime(2022, 1, 1, 9, 0)
b    # datetime.datetime(2022, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
  • 現在の Unix Timestamp だけが欲しいのであれば、time.time() も使用できます。

ISO8601 形式との相互変換

  • isoformat() で ISO8601 形式に変換できます。
    • naive 日時でも動作しますが、時差情報が付加されません。
a = datetime.datetime(2022, 1, 1, 0, 0, 0)
b = datetime.datetime(2022, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc)
a.isoformat()    # '2022-01-01T00:00:00'
b.isoformat()    # '2022-01-01T00:00:00+00:00'
  • fromisoformat()isoformat() の逆関数です。
    • 逆関数としての役割しか持たないので、例えば時差情報が +00:00 のかわりに Z になっていると動作しません。
    • 高度な変換を行いたい場合にはサードパーティ製の dateutil を利用可能です。
  • 時差情報があれば aware, なければ naive 日時が返却されます。
datetime.datetime.fromisoformat('2022-01-01T00:00:00')
# datetime.datetime(2022, 1, 1, 0, 0)

datetime.datetime.fromisoformat('2022-01-01T00:00:00+00:00')
# datetime.datetime(2022, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)

datetime.datetime.fromisoformat('2022-01-01T00:00:00+09:00')
# datetime.datetime(2022, 1, 1, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=32400)))

自由な形式での相互変換

  • strftime() で日時から文字列に、 strptime() で文字列から日時に変換できます。
    • ISO8601 の相互変換をするなら前述の dateutil を使用したほうがいいかもしれません。
    • %z 指定子が与えられなかった場合、 strptime() は naive 日時を返します。
# 日時から年月日表記に変換する
a = datetime.datetime(2022, 1, 1)
a.strftime('%Y年%m月%d日')    # '2022年01月01日'

# 年月日表記から日時に変換する
datetime.datetime.strptime('2022年01月01日', '%Y年%m月%d日')
# datetime.datetime(2022, 1, 1, 0, 0)

日時の計算

日時に時間を足す・引く

  • timedelta オブジェクトを使います。
  • days, seconds, microseconds の範囲で正規化されます。
datetime.timedelta(days=5)         # datetime.timedelta(days=5)
datetime.timedelta(seconds=86401)  # datetime.timedelta(days=1, seconds=1)
  • 負の時間を与えることもできますが、正の時間を作って引く方が見栄えがいいかもしれません。
a = datetime.datetime(2022, 1, 1)
a + datetime.timedelta(days=1)    # datetime.datetime(2022, 1, 2, 0, 0)
a - datetime.timedelta(days=1)    # datetime.datetime(2021, 12, 31, 0, 0)
a - datetime.timedelta(days=-1)   # datetime.datetime(2022, 1, 2, 0, 0)

日時の差

  • datetime オブジェクト同士を引くと timedelta オブジェクトになります。
  • timedelta が表す時間の総秒数を得るには total_seconds() を使います。
a = datetime.datetime(2022, 1, 5)
b = datetime.datetime(2022, 1, 1)
a - b                      # datetime.timedelta(days=4)
(a - b).total_seconds()    # 345600.0

大小比較

  • ==, !=, <, <=, >, >= が利用可能です。
  • naive と aware 日時を比較することはできません。
  • aware 日時は時差情報に基づいて絶対的な時間で判定するので、次のふたつの日時は同じになります。
a = datetime.datetime.fromisoformat('2022-01-01T00:00:00+00:00')
b = datetime.datetime.fromisoformat('2022-01-01T09:00:00+09:00')
a == b    # True

タイムゾーン

標準ライブラリに用意されているタイムゾーン

  • UTCタイムゾーンの datetime.timezone.utc だけが用意されています。
    • timezonetzinfo クラスのサブクラスです。
  • それ以外のタイムゾーンは自分で定義するか、dateutil.tz を使用してください。
a = datetime.datetime(2022, 1, 1)
datetime.timezone.utc.utcoffset(a)    # datetime.timedelta(0)
datetime.timezone.utc.tzname(a)       # UTC

タイムゾーンの作成

  • Python 3.9 からは zoneinfo が使えます。
import zoneinfo
zoneinfo.ZoneInfo('Asia/Tokyo')
  • Python 3.8 以下では…
    • tzinfo クラスのサブクラスを定義します。
    • utcoffset(), tzname(), dst() の定義が必須です。
# Asia/Tokyo
class JST(datetime.tzinfo):
    def __repr__(self):
        return self.tzname(self)

    def utcoffset(self, dt):
        # ローカル時刻とUTCの差分に等しいtimedeltaを返す
        return datetime.timedelta(hours=9)

    def tzname(self, dt):
        # タイムゾーン名を返す
        return 'Asia/Tokyo'

    def dst(self, dt):
        # 夏時間を返す。有効でない場合はtimedelta(0)を返す
        return datetime.timedelta(0)

とにかく日本時間で aware 日時を得たい場合

# Python 3.8 まで
datetime.datetime.now(JST())

# Python 3.9 から
datetime.datetime.now(zoneinfo.ZoneInfo('Asia/Tokyo'))

タイムゾーンの変更

日時を追従させない場合

  • replace() を使ったタイムゾーンの変更では tzinfo だけが変更され、日時は変更されません。
# naive日時にタイムゾーンを付加する
a = datetime.datetime(2022, 1, 1, 0, 0, 0)
a.replace(tzinfo=JST())
# datetime.datetime(2022, 1, 1, 0, 0, tzinfo=Asia/Tokyo)

# aware日時のタイムゾーンを変更する
b = datetime.datetime(2022, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc)
b.replace(tzinfo=JST())
# datetime.datetime(2022, 1, 1, 0, 0, tzinfo=Asia/Tokyo)

# aware日時のタイムゾーンを削除する
c = datetime.datetime(2022, 1, 1, 0, 0, 0, tzinfo=JST())
c.replace(tzinfo=None)
# datetime.datetime(2022, 1, 1, 0, 0)

日時を追従させる場合

  • astimezone() によるタイムゾーンの変更は与えられた tzinfo に追従するように日時が正規化されます。
    • naive 日時はシステムのタイムゾーンでの日時として解釈されます。
# naive日時のタイムゾーンを変更する(日本時間なので変更なし)
a = datetime.datetime(2022, 1, 1, 0, 0, 0)
a.astimezone(tz=JST())
# datetime.datetime(2022, 1, 1, 0, 0, tzinfo=Asia/Tokyo)

# aware日時のタイムゾーンを変更する
b = datetime.datetime(2022, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc)
b.astimezone(tz=JST())
# datetime.datetime(2022, 1, 1, 9, 0, tzinfo=Asia/Tokyo)

# システムのタイムゾーンに変更する
c = datetime.datetime(2022, 1, 1, 0, 0, 0, tzinfo=JST())
c.astimezone(tz=None)
# datetime.datetime(2022, 1, 1, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=32400), 'JST'))

naive から aware への変換 (現地時間 → 絶対時間)

  • replace() で naive 日時が想定しているタイムゾーンを設定する。

aware から naive への変換 (絶対時間 → 現地時間)

  • astimezone() で得たい日時にしてから replace() でタイムゾーンを削除する。
# awareなUTC日時からnaiveな日本時間を得る
a = datetime.datetime(2022, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc)
b = a.astimezone(tz=JST()).replace(tzinfo=None)
# datetime.datetime(2022, 1, 1, 9, 0)

aware から aware への変換 (時差の変更)

  • astimezone() でタイムゾーンを変更する。

SQLite3 を使用する際の注意点

  • SQLite3 には TIMESTAMP 型が存在しません。
    • 必然的に INTEGERNUMERIC, TEXT 型で保存することになります。
    • ユーザー定義型とその変換関数を定義することもできます。

datetime をシリアライズ・デシリアライズする

  • 標準で datedatetime 用に変換関数が用意されています。
    • connect()detect_typesPARSE_DECLTYPES パラメータを与えると、CREATE 文で DATE(date) または TIMESTAMP(datetime) 型として定義したカラムの読み書きで相互変換が行われるようになります。
    • PARSE_COLNAMES パラメータを与える方法もありますが、通常利用では必要ないでしょう。
with sqlite3.connect('a.db', detect_types=sqlite3.PARSE_DECLTYPES) as con:
    cur = con.cursor()
    cur.execute('''
        CREATE TABLE IF NOT EXISTS test (
            id INTEGER PRIMARY KEY,
            created_at TIMESTAMP
        )
    ''')
  • ただしこの変換関数は aware な日時には対応していません。
    • は?🙄
  • この問題を解決するためにユーザー定義の変換関数を用意します。
    • データベース上の日時をUnix Timestamp で扱う場合は日時の大小比較が簡単です。また、ISO8601 形式で扱う場合は人間にとって読みやすいです。今回は Unix Timestamp で扱う例を示します。
    • ユーザー定義の変換関数でもともとの変換関数を上書きできます。
# Adapter: Python→SQLite3
sqlite3.register_adapter(
    datetime.datetime,
    lambda x: x.timestamp()
)

# Converter: SQLite3→Python
sqlite3.register_converter(
    'TIMESTAMP',
    lambda x: datetime.datetime.fromtimestamp(
        float(x),    # SQLite3からは必ずbytesオブジェクトが渡されます
	tz=datetime.timezone.utc
    )
)

with sqlite3.connect('a.db', detect_types=sqlite3.PARSE_DECLTYPES) as con:
    pass

おわり🌱

Discussion