Pythonでのタイムゾーンの扱い(datetime, pytz)

Pythonでのタイムゾーンを扱う方法がわかりづらく感じたので、整理したメモです。

バージョン情報

- Python 3.8
- pytz 2019.3

# datetimeクラス

同じクラスで以下の2種類のインスタンスがある。

- Aware
    - 単に年月日時分秒の数値とタイムゾーンを持つ
    - 絶対的な時間がわかり、Unixtimeへの変換もできる
    - タイムゾーンが違えば同じ絶対的な時間でも別の値のインスタンスになる
- Naive
    - 単に年月日時分秒の数値を持ち、タイムゾーンの情報がない
    - Unixtimeなど絶対的な時間に変換するにはローカルのタイムゾーン情報への依存が必要

[Aware and Naive Objects - datetime — Basic date and time types — Python 3.8.1rc1 documentation](https://docs.python.org/3/library/datetime.html#aware-and-naive-objects)

※以下の実行例はすべて、環境変数が`TZ=JST-9`となっている場合

## 現在時刻を取得する方法

```python
from datetime import datetime
from datetime import timezone

now = datetime.now()
print(now) # => 日本時間のいま
print(now.tzinfo) # => None
# Naive

now = datetime.utcnow()
print(now) # => 日本時間から9時間引いた時刻
print(now.tzinfo) # => None
# Naive

now = datetime.now(timezone.utc)
print(now) # 9時間引いた時刻に "+00:00" という表記付き
print(now.tzinfo) # => UTC
# Aware
```

## Unixtimeからdatetimeのインスタンスを取得する方法

```python
from datetime import datetime
from datetime import timezone

unixtime = 1572764400

now = datetime.fromtimestamp(unixtime)
print(now) # => unixtimeが表す瞬間の日本時間での表記
print(now.tzinfo) # => None
# Naive

now = datetime.utcfromtimestamp(unixtime)
print(now) # 9時間引いた時刻 = unixtimeが表す瞬間のUTCでの表記
print(now.tzinfo) # => None
# Naive

now = datetime.fromtimestamp(unixtime, timezone.utc)
print(now) # 9時間引いた時刻に "+00:00" という表記付き
print(now.tzinfo)
# => UTC
# Aware
```

## 難しいことを言わずに手っ取り早く9時間ずらす方法

```python
from datetime import datetime
from datetime import timedelta

now = datetime.now() # Naive
print(now) # => 日本時間のいま(タイムゾーン情報なし)
print(now - timedelta(hours = +9)) # => 9時間引いた時刻(タイムゾーン情報なし)

now = datetime.utcnow() # Naive
print(now) # => 9時間引いたいま(タイムゾーン情報なし)
print(now + timedelta(hours = +9)) # => 9時間足した日本時間(タイムゾーン情報なし)
```

# タイムゾーンを表すクラス

## tzinfoクラス

`tzinfo`は以下の情報を持つ抽象クラス。

- タイムゾーンの名前
- UTCとの時間のオフセット値
- 夏時間におけるUTCとの時間のオフセット値

[tzinfo - datetime — Basic date and time types — Python 3.8.1rc1 documentation](https://docs.python.org/3/library/datetime.html#tzinfo-objects)

具象クラスとしては`timezone`を使うか、`pytz`を使う。`pytz`は標準ライブラリにはなくてインストールが必要。

## timezoneクラス

`timezone`は固定のオフセット値を持った`tzinfo`の具象クラス。固定で+09:00のようなタイムゾーンとしては使えるが、夏時間など日時によってオフセット値が変わることを考慮できない。

## pytzパッケージ

`pytz`パッケージでは`"Asia/Tokyo"`のような文字列から`tzinfo`の具象クラスを生成できる。UTCとのオフセット値が日時によって変わることを考慮できる。

オフセット値が日時によって変わる例

- 北米、ヨーロッパ、オーストラリアなどでの夏時間
- 1995年にキリバスの一部で24時間時計を進めた
- 2014年にロシアで1時間時計を戻した

`pytz`は標準のライブラリではないためインストールが必要。

```
$ pip install pytz
```

## 例

timezoneクラス

```python
from datetime import datetime
from datetime import timedelta
from datetime import timezone
import pytz

dt = datetime.utcfromtimestamp(1572764400)
print(dt)
# => 2019-11-03 07:00:00
# 1572764400は日本時間で3日16時

# timezone.utcはオフセット値が0、夏時間はない
utc = timezone.utc
print(utc.utcoffset(dt))
# => 0:00:00
print(utc.dst(dt))
# => None

# timezoneクラスのインスタンス
tz8 = timezone(timedelta(hours=-8)) # UTCより8時間遅れを表すtimezone
print(tz8.utcoffset(dt))
# => -1 day, 16:00:00
print(tz8.dst(dt))
# => None

# 日本時間はオフセット値が+9時間で夏時間はない
tokyo = pytz.timezone('Asia/Tokyo') # 日本時間
print(tokyo.utcoffset(dt))
# => 9:00:00
print(tokyo.dst(dt))
# => 0:00:00

# UTCの11月3日7時はアメリカ太平洋時間ではオフセット値が-8時間で夏時間ではない
lasvegas = pytz.timezone('America/Los_Angeles') # アメリカ太平洋時間
print(lasvegas.utcoffset(dt))
# => -1 day, 16:00:00
print(lasvegas.dst(dt))
# => 0:00:00

# その前日はアメリカ太平洋時間ではオフセット値が-7時間で夏時間である
print(lasvegas.utcoffset(dt - timedelta(days = 1)))
# => -1 day, 17:00:00
print(lasvegas.dst(dt - timedelta(days = 1)))
# => 1:00:00
```

# datetimeのタイムゾーン変換

タイムゾーン情報のないNaiveなdatetimeインスタンスに対してastimezoneを使うとローカル環境のタイムゾーンから指定のタイムゾーンへ変換される。変換後はAwareなインスタンスになる。

```python
from datetime import datetime
from datetime import timedelta
from datetime import timezone
import pytz

dt = datetime.utcfromtimestamp(1572764400)
print(dt)
# => 2019-11-03 07:00:00
# 1572764400は日本時間で3日16時

# dtはUTCで3日7時のつもりで生成したインスタンスだが、
# タイムゾーン情報のないNaiveなインスタンスなので、
# 日本時間の3日7時とみなして指定のタイムゾーンに変換される。

tokyo = pytz.timezone('Asia/Tokyo') # 日本時間
print(dt.astimezone(tokyo))
# => 2019-11-03 07:00:00+09:00
# ローカルPC環境が日本時間なので、時間は変わらない

lasvegas = pytz.timezone('America/Los_Angeles') # アメリカ太平洋時間
print(dt.astimezone(lasvegas))
# => 2019-11-02 15:00:00-07:00
# 日本時間の11月3日7時はアメリカ太平洋時間では夏時間で日本よりも16時間遅れ

tz8 = timezone(timedelta(hours=-8)) # UTCより8時間遅れを表すtimezone
print(dt.astimezone(tz8))
# => 2019-11-02 14:00:00-08:00
```

タイムゾーン情報付きのAwareなdatetimeインスタンスに対してastimezoneを使うと元のタイムゾーンから指定のタイムゾーンへ変換される。変換後も引き続きAwareなインスタンスになる。

```python
# dt.replace(tzinfo=utc) で年月日時分秒の数値を変えずにタイムゾーンの情報だけ付加している

print(dt.replace(tzinfo=utc).astimezone(tokyo))
# => 2019-11-03 16:00:00+09:00
print(dt.replace(tzinfo=utc).astimezone(lasvegas))
# => 2019-11-03 00:00:00-07:00
print(dt.replace(tzinfo=utc).astimezone(tz8))
# => 2019-11-02 23:00:00-08:00
```

夏時間が終わるタイミングでのUTCからアメリカ太平洋時間への変換を見てみる。

```python
from datetime import datetime
import pytz

lasvegas = pytz.timezone('America/Los_Angeles') # アメリカ太平洋時間

for i in range(7):
    dt = datetime.utcfromtimestamp(1572764400 + i * 1800)
    # 1572764400はUTCで3日7時
    print(dt.replace(tzinfo=utc).astimezone(lasvegas))
# =>
# 2019-11-03 00:00:00-07:00
# 2019-11-03 00:30:00-07:00
# 2019-11-03 01:00:00-07:00
# 2019-11-03 01:30:00-07:00
# 2019-11-03 01:00:00-08:00 <- 夏時間終了で時間が戻る
# 2019-11-03 01:30:00-08:00
# 2019-11-03 02:00:00-08:00
```

# 19分ずれる問題

`pytz`から生成したタイムゾーンのオブジェクトで`dt.replace(tzinfo=tokyo)`としたり、`datetime(2019, 11, 3, 1, 0, 0, tzinfo=tokyo)`のようにすると、19分ずれる問題あり。日本時間だと19分ずれるが、アメリカ太平洋時間で試すと7分ずれる。

```python
from datetime import datetime
from datetime import timedelta
from datetime import timezone
import pytz

dt = datetime.utcfromtimestamp(1572742800)
print(dt)
# => 2019-11-03 01:00:00
# 1572764400は日本時間で3日1時

utc = timezone.utc
print(dt.replace(tzinfo=utc))
# => 2019-11-03 01:00:00+00:00
```

Naiveなdatetimeのインスタンスに`replace(tzinfo=utc)`とすると、`+00:00`の情報を付与したAwareなインスタンスに変わる。`utc`の代わりに日本時間を指定すると、

```python
tokyo = pytz.timezone('Asia/Tokyo') # 日本時間
print(dt.replace(tzinfo=tokyo))
# => 2019-11-03 01:00:00+09:19
```

なぜか時差が19分ずれてしまう。

`localize`というメソッドを使うと問題ない。なにが違うのかよくわからない。

```python
print(tokyo.localize(dt))
# => 2019-11-03 01:00:00+09:00
```

アメリカ太平洋時間で試しても、ずれ幅は違うけどずれることに変わりない。

```python
lasvegas = pytz.timezone('America/Los_Angeles') # アメリカ太平洋時間

print(dt.replace(tzinfo=lasvegas))
# => 2019-11-03 01:00:00-07:53

print(lasvegas.localize(dt))
# => 2019-11-03 01:00:00-08:00
```

# 夏時間が終わるタイミングでの挙動

アメリカ太平洋時間の3日は夏時間が終わる日で、上のほうの例で出てきたように、30分ごとの時間を羅列すると以下のように時間が流れる。

2019-11-03 00:00:00-07:00
2019-11-03 00:30:00-07:00
2019-11-03 01:00:00-07:00
2019-11-03 01:30:00-07:00
2019-11-03 01:00:00-08:00 <- 夏時間終了で時間が戻る
2019-11-03 01:30:00-08:00
2019-11-03 02:00:00-08:00

1時から1時59分までは2回めぐる。つまり、1時という情報だけでは1回目の1時なのか2回目の1時なのかはわからない曖昧性がある。

この事実を踏まえて`localize`を試してみる。

```python
from datetime import datetime
from datetime import timedelta
from datetime import timezone
import pytz

dt = datetime.utcfromtimestamp(1572742800)
print(dt)
# => 2019-11-03 01:00:00
# dtは3日1時のNaiveなインスタンス

lasvegas = pytz.timezone('America/Los_Angeles') # アメリカ太平洋時間

print(lasvegas.localize(dt))
# => 2019-11-03 01:00:00-08:00
# 2回目の1時が返された
```

ちなみに2回目の1時の1時間前を計算すると0時になる。0時は本当は夏時間のはずだが冬時間のまま。

```python
print(lasvegas.localize(dt) - timedelta(hours=1))
# => 2019-11-03 00:00:00-08:00
```

`localize`する前のNaiveなインスタンスに対して1時間前を計算してから`localize`すると、夏時間の0時になる。これは2回目の1時の2時間前。

```python
print(lasvegas.localize(dt - timedelta(hours=1)))
# => 2019-11-03 00:00:00-07:00
```

以上。