[Python3] タイムゾーン変換で詰まった話

外部API から時刻を含む情報を受け取ってSlackに通知するちょっとしたツールを書いていたのですが、タイムゾーンを一貫して安定して変換することができず、つまりました。調査した際に勉強した内容と今考えられる解消方法をメモっておきます。

※この記事では、python標準の datetime モジュールと pytz を利用しています


# サマリ

- pytz を併用してタイムゾーン変換を行う場合は `pytz.timezone.localize(datetime)` を使うこと
- datetime に備わっている `datetime.replace` は使わない方が良さそう
- datetime には Timezeon aware / Offest native の2種類がある
- Python で特に意識せず datetime を生成した場合は offset native が生成される
- 外部パッケージなどが timezone aware な datetime を返し、それをJSTに変換し直す場合は一度 timestamp を経由させて offset native を生成し、その後 pytz の localize を使って変換する
- [pytzの仕様が変わっている | Qiita @higitune](https://qiita.com/higitune/items/0ca244373d380cf1c060#comments) の記事がコメント欄も含めて超参考になったので、同じくタイムゾーンの扱いで躓いている方はこちらも見るべき
- 特に特別な理由がない限り、
  - ロジック層ではできるだけ一貫して timestamp を使っておけ
  - UI層に出すときに必要に応じて変換するようにしておく



# Python × pytz におけるタイムゾーン処理のややこしさ

私の場合、いくつかの種類のつまづきがありました。

1. Timezeon aware / Offest native
2. pytz の厳密さ
3. pytz の仕様

これらについて調べたこと、そしてこれらを乗り越えるためにどういうコードを書いたのか、最後に提示します。

## Timezeon aware / Offest native

datetime には上記の2種類があります。

Timezone aware というのは明示的にタイムゾーンが指定された datetime で、Offset native というのはタイムゾーン指定のない時刻のことです。この両者は比較できません。

これらは区別する必要があります。

```python
import os
from datetime import datetime
import pytz

# Offest native
d1 = datetime.utcnow()                                                                                                        
print(d1)
>>> 2020-04-19 10:07:47.885883

# Timezone aware
d2 = pytz.utc.localize(d1)
print(d2)
>>> 2020-04-19 10:07:47.885883+00:00

# 両者は比較できない
d1 < d2
# >>> TypeError: can't compare offset-naive and offset-aware datetimes
```

## pytz の厳密さ

タイムゾーンは、場所と **時間** によって決定されるらしいです。歴史的経緯で時差の定義が変更されたりする事例とかがあるようで、我らが日本もその例に該当しています。

1888年までの東京とそれ以降では時差が違うらしく、1888以前は UTC から **+09:19** ずれるそうです。で、 pytz はそのへんが厳密に考慮されており、タイムゾーン変換系の一部のメソッド (datetime.replace とか)ではこっちが考慮される模様。

```python
import pytz

# ここでは DISPLAY_TIMEZONE='Asia/Tokyo' であるものとする
DISPLAY_TIMEZONE = os.environ.get('DISPLAY_TIMEZONE') 
tz = pytz.timezone(DISPLAY_TIMEZONE)

tz                                                                                                                                              
>>> <DstTzInfo 'Asia/Tokyo' LMT+9:19:00 STD>
```

19分ずれちゃってますね。ただ、ちゃんと規格に準拠した結果としてこれは正しいようです。

プログラムの中で **Offset native を扱っている場合** は、 pytz の timezone オブジェクトが提供する `timezone.localize()` を使えばOKです。ちゃんと +09:00 で変換されます。

**pytz の `localize` メソッドを使う限りは、意図した通り +09:00 でタイムゾーン変換が行われるので、このずれを気にする必要はありません。**

```python
tz.localize(d1)                                                                                                                                 
>>> datetime.datetime(2020, 4, 19, 10, 20, 3, 201190, tzinfo=<DstTzInfo 'Asia/Tokyo' JST+9:00:00 STD>)
```

## pytz の仕様

pytz の `localize()` は Timezone aware なタイムゾーンを受け付けません。この点は注意する必要があります。

```python
# localize offset ative
tz.localize(d1)
>>> datetime.datetime(2020, 4, 19, 10, 7, 47, 885883, tzinfo=<DstTzInfo 'Asia/Tokyo' JST+9:00:00 STD>)

# localize timezone aware (error)
tz.localize(d2)
# >>> ValueError: Not naive datetime (tzinfo is already set)
```

よって、**timezone aware な時刻を扱う場合はそのまま `localize` は使えず、一度 offset  native に変換する必要があります。**これは、外部APIのラッパーなどを使って返される時刻が timezone aware である場合で遭遇することが多いでしょう。

ここで書いた内容は [pytzの仕様が変わっている | Qiita @higitune](https://qiita.com/higitune/items/0ca244373d380cf1c060#comments) に言及がありますので、そちらをご覧になると良いでしょう。コメント欄も含めて非常に参考になります。

## timezone aware な datetime 型を別のタイムゾーンに変換する

自分以外の誰かが開発したパッケージを使う場合、そのパッケージモジュールが返す時刻が timezone aware であるか offset native であるかは実装者依存です。それらを組み合わせて自分の開発要件を実装するには、これらの混在をどうにかする必要が出てきます。

※海外サービスのAPIをラップしたパッケージなどでよく遭遇するのではないかと思います

よって、（セオリー通りではありますが）一度タイムスタンプに落とすのが良いでしょう。下記のコードでは offset native / timezone aware の両方を JST 変換することができます。（他の国で同様の結果が得られるかどうかは未確認）


```python
import pytz
from datetime import datetime

DISPLAY_TIMEZONE = 'Asia/Tokyo'
tz = pytz.timezone(DISPLAY_TIMEZONE)

def localized_datetime(date: datetime):
    return datetime.fromtimestamp(date.timestamp(), tz=tz)


if __name__ == '__main__':
    # d1: native datetime
    d1 = datetime.now()
    print(f"d1: {d1}")

    # d2: utc localize
    d2 = tz.localize(d1)
    print(f"d2: {d2}")

    print(localized_datetime(d1))
    print(localized_datetime(d2))

>>> d1: 2020-04-19 20:15:30.974272
>>> d2: 2020-04-19 20:15:30.974272+09:00
>>> 2020-04-19 20:15:30.974272+09:00
>>> 2020-04-19 20:15:30.974272+09:00
```


# まとめ

タイムゾーンはややこしい...。

参考実装として上記のコードを掲載していますが、実際にローカライズした時刻が必要となるのは主にプレゼンテーション層（つまり見た目）であろうと思われますので、ロジック層や永続化層ではできるだけ datetime ではなく timestamp を使っておいたほうがいいだろうな、という気はします。

とはいえ、普段コード書くうえでの取り回しは datetime の方が圧倒的に便利ですし、日付演算をロジック層で頻繁に行う必要があるケースも頻出すると思います。

そのような場合は少なくとも自身で書いているロジック層寄りのコードでは timezone awre / offset native のどちらを使うのかは統一しておくのが良いように思います。外部パッケージの扱う時刻が不統一である場合は、自分が使いたいインタフェースを薄くラップした独自の層を作っておき、そこで datetime の型やタイムゾーンの不統一を吸収するようにしておくなどすれば、メインロジックが綺麗になるのではないかと思います。

どちらに寄せるべきか、という話は現時点の私のナレッジで判断がつかないので、コメント欄でご意見をいただければと思います。

また、上記のコードにも微妙に落とし穴があります。例えば、

「関数／メソッドが返す datetime 型が、 offset native であるにも関わらず時刻が UTC 基準になっている」

といった場合です。外部サービスのAPIが一貫してUTCを返す仕様になっており、かつそれをラップした外部パッケージ実装がイケてなく、タイムゾーンを考慮した datetime の生成を行っていない場合...あたりで遭遇することになると思います。。。その場合は個別対処しましょう。