サーバーサイドエンジニアの三村です。
弊社では2024年の初めから国外へサービス展開をする準備として、一年ほど前からシステムの国際化対応を行ってきました。 この準備には、サービスの多言語対応や日本標準時以外のタイムゾーンでサービスが利用できるようにする改修などが含まれますが、サーバーチームでは特に後者に苦労しました。
そこでこの改修で得たPythonでタイムゾーンを扱う際の知見の一部を、このブログ記事にまとめます。
タイムゾーンについて
Pythonでの実装の話に入る前に、まずはタイムゾーンの概念自体について説明します。
タイムゾーンの基準
世界中の各地域は異なる標準時間を用いていて、地域間に時差があることは言うまでもないと思います。 そしてこれらの異なる時間帯の基準となる時刻が存在することも有名かと思います。
こちらの基準時刻は、イギリスのグリニッジ天文台における時間を基に定められていると、昔学校で習った方も多いと思います。 経度0度の地点にある同天文台を基準時間とする考えは、GMT (Greenwich Mean Time)と呼ばれるものであり、現在こちらはUTC (Coordinated Universal Time)という概念に取って代わられています。
慣習的に同一のものと扱われることもある両者ですが、厳密には概念的、算出方法的な違いがあります。
GMTはグリニッジ天文台での地方平均時を表します。つまりGMTは特定地域での天体観測を基に算出される、ロンドンあたりを指す一つのタイムゾーンです。
比べてUTCは、天体観測だけではなく原子時計に基づく計算などから算出される基準時で、これ自体はどこかの地域のタイムゾーンではないです。地球上の地域の標準時を、この基準時との差分で表現するために存在しています。
UTCオフセットだけでタイムゾーンを扱う問題点
UTCのような基準となる時間の概念が存在すれば、それとのオフセット(基準との差異)のみを用いてシステムで世界中の時間を取り扱えそうだと一見思えます。しかし、実際はそんな甘くはないです。以下に、特定地域の人間の用いる時間の概念が単一のUTCオフセットでは表せないことの例を挙げます。
サマータイム
一部のタイムゾーンでは、一年を通して同じUTCオフセットの時間を用いているわけではなく、日照時間が長い時期にはサマータイムとして時間を早めることが行われています。
加えて、サマータイムの開始・終了時期も毎年一定とは限らず、頻繁に変更される地域もあります。 (例:1998年のブラジルでは、サマータイムの開始日をローマ教皇の来訪時期とずらすため一日遅らせました。)
特定タイムゾーンでのUTCオフセット変更
歴史を遡れば、地球上の多くの地点で採用している標準時間の変更が行われてきました。 (例:サモアでは、2011年にタイムゾーンをUTC-11からUTC+13に変更しました。)
後述する通り、このような標準時間の変更を経験した地域には、日本も含まれます。
IANAのTime Zone Database
上述の通り、人々が生活で用いている時間の概念は、季節的や歴史的な理由から単純なUTCオフセット一つでは表現しきれない場合があります。 そこで、このような一般的に使われるタイムゾーンを、季節的な変化や歴史的な経緯も包括してまとめているのが、Time Zone Databaseです。
Asia/Tokyo
などのよく見かけるタイムゾーンの表記は、IANAによって編纂されているこちらのデータベースによって規定されています。
こちらのデータベースの情報は、プログラムからはいろいろな環境で利用できます。Unix likeなシステムであれば/usr/share/zoneinfo/
にバイナリ形式で入っているほか、Pythonの実行環境から参照したい場合はtzdataというモジュールを用いることでOSによらず利用できます。
こちらの中身を確認する一番簡単な方法は、上記のIANAのリンクを開き、データベースのファイルをダウンロードし解凍し開くことです。試しにニューヨークのタイムゾーン情報を開いてみると、以下のようになっています。
# Monthly Notices of the Royal Astronomical Society 44, 4 (1884-02-08), 208 # says that New York City Hall time was 3 minutes 58.4 seconds fast of # Eastern time (i.e., -4:56:01.6) just before the 1883 switch. # Rule NAME FROM TO - IN ON AT SAVE LETTER Rule NYC 1920 only - Mar lastSun 2:00 1:00 D Rule NYC 1920 only - Oct lastSun 2:00 0 S Rule NYC 1921 1966 - Apr lastSun 2:00 1:00 D Rule NYC 1921 1954 - Sep lastSun 2:00 0 S Rule NYC 1955 1966 - Oct lastSun 2:00 0 S # Zone NAME STDOFF RULES FORMAT [UNTIL] #STDOFF -4:56:01.6 Zone America/New_York -4:56:02 - LMT 1883 Nov 18 17:00u -5:00 US E%sT 1920 -5:00 NYC E%sT 1942 -5:00 US E%sT 1946 -5:00 NYC E%sT 1967 -5:00 US E%sT
最初にコメントで、1883年の標準時間の変更までは東海岸時間より3分58.4秒早かったとする資料の紹介がされています。 その後過去のサマータイムの実施の歴史や、コメントで書かれていた1883年以前の時間の現在との時差についてが記述されています。
(余談) Time Zone Databaseから読み取る世界の歴史
上記のニューヨークの例の通り、Time Zone Databaseは単なる標準時の変化等を考慮した時刻変換のルールが記載されているのみでなく、豊富なコメントもあり読み物としても面白いです。日本の例を見てみます。
# From Paul Eggert (1995-03-06): # Today's _Asahi Evening News_ (page 4) reports that Japan had # daylight saving between 1948 and 1951, but "the system was discontinued # because the public believed it would lead to longer working hours." # From Mayumi Negishi in the 2005-08-10 Japan Times: # http://www.japantimes.co.jp/cgi-bin/getarticle.pl5?nn20050810f2.htm # Occupation authorities imposed daylight-saving time on Japan on # [1948-05-01].... But lack of prior debate and the execution of # daylight-saving time just three days after the bill was passed generated # deep hatred of the concept.... The Diet unceremoniously passed a bill to # dump the unpopular system in October 1951, less than a month after the San # Francisco Peace Treaty was signed. (A government poll in 1951 showed 53% # of the Japanese wanted to scrap daylight-saving time, as opposed to 30% who # wanted to keep it.) # -- 中略 -- # Rule NAME FROM TO - IN ON AT SAVE LETTER/S Rule Japan 1948 only - May Sat>=1 24:00 1:00 D Rule Japan 1948 1951 - Sep Sat>=8 25:00 0 S Rule Japan 1949 only - Apr Sat>=1 24:00 1:00 D Rule Japan 1950 1951 - May Sat>=1 24:00 1:00 D # Zone NAME STDOFF RULES FORMAT [UNTIL] Zone Asia/Tokyo 9:18:59 - LMT 1887 Dec 31 15:00u 9:00 Japan J%sT
コメント及びRule
の項には、日本が過去に実施していたサマータイムの情報が載っています。終戦直後日本ではGHQの指導によりタイムゾーンを導入していたようですが、人々はサマータイムによって労働時間を伸ばされるのではないかと不安になり大変不人気であったため、サンフランシスコ平和条約締結後日本が主権を回復すると、すぐにサマータイムは廃止されたそうです。
ちなみにZone
の項目にある通り、日本は1888年の正月より前には今より18分59秒
早い標準時間を用いていました(東京の地方平均時)。
今までプログラムで日本時間を扱うコードを書いた際に、タイムゾーン取り扱いの不備で意図した時刻よりも18分59秒
ずれた時刻となるバグを起こすなどしてこの時間を見たこともあるかと思いますが、この時間はここからきています(詳しい例は後述します)。
Pythonでの実装の注意点
やっとPythonの話に入ります。 Pythonでtimezone awareな形でdatetimeオブジェクトを取り扱う際に陥りそうな誤りを、いくつか挙げてみます。
pytzをdatetimeオブジェクトのコンストラクタに渡す
Pythonにおけるタイムゾーン関連のよくある間違いでは一番有名な話かもしれませんが、pytz型のタイムゾーンをdatetimeオブジェクトのコンストラクタに渡すと、大抵の場合意図しないdatetimeオブジェクトが作成されます。
>>> import datetime >>> import pytz >>> datetime.datetime(2023,8,25,12,0,0, tzinfo=pytz.timezone('Asia/Tokyo')).utcoffset() datetime.timedelta(seconds=33540) # ↑9時間19分
上記のようなコードを書いた場合、実装者は大抵の場合UTCオフセットが9時間ちょうどのタイムゾーンのオブジェクトの生成を期待していると思いますが、実際には9時間19分となっています。
pytzはdatetimeオブジェクトのコンストラクタに渡されるような利用方法は想定されておらず(詳しくは後述)、このような利用方法ができないことがドキュメントに記載されています。
This library differs from the documented Python API for tzinfo implementations; if you want to create local wallclock times you need to use the localize() method documented in this document.
上記引用にある通り、pytzでdatetimeオブジェクトにタイムゾーン情報を付与したい場合は、localize()
を用いるのが正解です。
>>> pytz.timezone('Asia/Tokyo').localize(datetime.datetime(2023,8,25,12,0,0)).utcoffset() datetime.timedelta(seconds=32400) # ↑9時間ちょうど
ちなみに、pytzをdatetimeオブジェクトのコンストラクタに渡す際のバグは上に例示しましたが、これはコンストラクタだけでなくreplace()
の引数として渡しても同様の事象は起こります。
>>> import datetime >>> import pytz # まずはtimezone unawareなdatetimeオブジェクトを作成 >>> d = datetime.datetime(2023,8,25,12,0,0) >>> d datetime.datetime(2023, 8, 25, 12, 0) # これにreplace()でtimezone情報をつけると、やはり19分ずれる >>> d.replace(tzinfo=pytz.timezone('Asia/Tokyo')).utcoffset() datetime.timedelta(seconds=33540) # ↑9時間19分
こちらの理由は単純で、datetimeクラスのreplace()
は内部で新規にオブジェクトの生成を行っていて、その際にコンストラクタの引数にreplace()
の引数をそのまま渡しているためです。
def replace(self, year=None, month=None, day=None, hour=None, minute=None, second=None, microsecond=None, tzinfo=True, *, fold=None): """Return a new datetime with new values for the specified fields.""" if year is None: year = self.year if month is None: month = self.month if day is None: day = self.day if hour is None: hour = self.hour if minute is None: minute = self.minute if second is None: second = self.second if microsecond is None: microsecond = self.microsecond if tzinfo is True: tzinfo = self.tzinfo if fold is None: fold = self.fold return type(self)(year, month, day, hour, minute, second, microsecond, tzinfo, fold=fold)
最終行で、新たにオブジェクトを作成しそのコンストラクタの中でtzinfoも渡しています。
pytzオブジェクトをdatetimeのコンストラクタに渡してもうまく動かない理由
pytzのこの挙動はPython界隈では結構有名ですが、なぜこのような挙動になっているのか説明しているブログ記事等は、(自分のなけなしのリサーチ能力だと)見当たりませんでした。そこで、少し自分で実装を調べてみました。
まず、Pythonのdatetimeのドキュメントには以下のような記述がありました。
For applications requiring aware objects, datetime and time objects have an optional time zone information attribute, tzinfo, that can be set to an instance of a subclass of the abstract tzinfo class. These tzinfo objects capture information about the offset from UTC time, the time zone name, and whether daylight saving time is in effect. Only one concrete tzinfo class, the timezone class, is supplied by the datetime module. The timezone class can represent simple timezones with fixed offsets from UTC, such as UTC itself or North American EST and EDT timezones. Supporting timezones at deeper levels of detail is up to the application. The rules for time adjustment across the world are more political than rational, change frequently, and there is no standard suitable for every application aside from UTC.
Pythonの標準ライブラリであるdatetimeでは、タイムゾーン情報としてはtzinfo
という抽象クラス*1と、その実装としてtimezone
というクラスを用意していますが、こちらの実装はUTCオフセットが固定である前提となっています。UTCオフセットが歴史的経緯などで変わってくるような、実際の時間の概念に近いものが使いたければ、同ライブラリを利用するアプリケーション側でtzinfo
をよしなに実装してどうにかしてね、という意図のようです。
こちらのtzinfoのクラスですが、実装の際にUTCオフセットをいい感じに対応するにはutcoffset()
というメソッドを上書きしてほしいそうです。
class tzinfo: """Abstract base class for time zone info classes. Subclasses must override the tzname(), utcoffset() and dst() methods. """ __slots__ = () def tzname(self, dt): "datetime -> string name of time zone." raise NotImplementedError("tzinfo subclass must override tzname()") def utcoffset(self, dt): "datetime -> timedelta, positive for east of UTC, negative for west of UTC" raise NotImplementedError("tzinfo subclass must override utcoffset()") def dst(self, dt): """datetime -> DST offset as timedelta, positive for east of UTC. Return 0 if DST not in effect. utcoffset() must include the DST offset. """ raise NotImplementedError("tzinfo subclass must override dst()") # 以下略
次に、datetimeオブジェクトのコンストラクタに渡しても正しく動く、dateutil.tzの実装ではこのutcoffset()
をどう実装しているのかみてみます。
def utcoffset(self, dt): if dt is None: return None if not self._ttinfo_std: return ZERO return self._find_ttinfo(dt).delta # 中略 ↓こちらは上の関数から呼ばれている def _find_ttinfo(self, dt): idx = self._resolve_ambiguous_time(dt) return self._get_ttinfo(idx) # 中略 ↓こちらは上の関数から呼ばれている def _resolve_ambiguous_time(self, dt): idx = self._find_last_transition(dt) # If we have no transitions, return the index _fold = self._fold(dt) if idx is None or idx == 0: return idx # If it's ambiguous and we're in a fold, shift to a different index. idx_offset = int(not _fold and self.is_ambiguous(dt, idx)) return idx - idx_offset
上のコードでは、utcoffset()
が呼ばれた際には毎回、内部で標準時間の変遷の歴史を参照する関数を呼び出し、その結果を参考に採用すべきUTCとのオフセットを導き出しています。このことから、dateutil.tzではutcoffset()
が呼び出されると、その都度標準時間の遍歴を考慮してUTCオフセットの判定をしていることがわかります。
比べて、コンストラクタに渡すとうまく動かないpytzの方の実装を見てみます。
def utcoffset(self, dt, is_dst=None): # 関数のコメントは長いので略 if dt is None: return None elif dt.tzinfo is not self: dt = self.localize(dt, is_dst) return dt.tzinfo._utcoffset else: return self._utcoffset
こちらは条件分岐によっては、dateutil.tzでの場合のようなUTCオフセットの決定のための複雑な計算はせずに、事前にクラスにセットされた_utcoffset
の値を単純に返すのみになります。基本的にdatetimeオブジェクトが生成されてからはutcoffset()
が呼ばれる際には、上記コードの条件分岐一番最後のelse
節に入るようであったので、pytzはインスタンス作成時に設定されたUTCオフセットを返し続ける挙動になっていそうです。
上記の実装2例からは、dateutil.tzの方はUTCオフセットを参照された場合は毎回引数のdatetimeオブジェクトの日付を確認してUTCオフセットを決定していて、pytzの方は一度UTCオフセットの値が設定されたらそれを返し続ける、という違いが見て取れます。
datetimeオブジェクトのコンストラクタにタイムゾーンを渡すユースケースの場合、先に引数として渡すために作成されるタイムゾーンオブジェクトは作成予定のdatetimeオブジェクトを読み取れないので、どのようなtzinfo
の実装であれ一旦は正確にはUTCオフセットを決められない状態になります。dateutil.tzの方はその後改めてdatetimeオブジェクトの日付を確認してUTCオフセットを再計算するタイミングがあるのに対し、pytzの場合はそれがなさそうです。
このことから、pytzオブジェクトをdatetimeオブジェクトのコンストラクタに渡すと、UTCオフセットが不明な状態で記録された該当タイムゾーンでの最も古いUTCオフセットの情報を保持し続け*2、上述のような挙動が起こると思われます。
zoneinfoファイルの「1901年問題」
上で、Time Zone Databseの情報はUnix likeな環境では/usr/share/zoneinfo
以下に同情報が入っていると書きました。
このファイルを用いてタイムゾーンを扱う際の意図しない挙動を紹介します。
一部の環境*3で以下のようなコードを実行すると、意図した時間よりも18分59秒ずれることになります。
>>> import datetime >>> import dateutil.tz # 年月日はなしに時間のみを指定してdatetimeオブジェクトを作成し、それにdateutil.tzでAsia/Tokyoのタイムゾーン情報を付与し、UTCオフセットを取得 >> datetime.datetime.strptime('12:00', '%H:%M').replace(tzinfo=dateutil.tz.gettz('Asia/Tokyo')).utcoffset() datetime.timedelta(seconds=33539) # UTCオフセットは9時間(32400秒)を期待しているのに、9時間18分59秒(33539秒)となった
(上のコードはそもそも、日付のない時間のデータを扱うのにdatetime.timeではなく余計に情報量の多いdatetime.datetimeを用いているところが本質的な間違いですが、そこには目を瞑ってください。)
こちらは、datetimeオブジェクトで時間のみを指定すると年月日は1900年1月1日となること、一部の環境でTime Zone Databaseの情報が載っているバイナリのファイルで日付データを32ビットまでしか扱えていないこと、の二つの要因が重なってこのような挙動になっています。
前者については、1900年1月1日時点では既に日本では今と同じ標準時間が採用されている状態であったため、こちら単体ではUTCオフセットは9時間18分59秒とはならないはずです。
後者については、32ビットで扱える時間の範囲を超えるとコンピュータが誤動作すると言われる「2038年問題」と同源です。32bitの秒数でエポック時間から負の方向に向かって表現できる限界は1901年の12月13日であり、datetimeオブジェクトに自動でつけられた1900年1月1日はこれよりも古いため、一部環境でUTCオフセットの計算がうまくいかなっているようです。
上記の事象が再現する環境で参照されているタイムゾーンの情報のファイルを、RFC8536での仕様をもとにバイナリを読んでみます。
$ hexdump -C /usr/share/zoneinfo/Asia/Tokyo 00000000 54 5a 69 66 32 00 00 00 00 00 00 00 00 00 00 00 |TZif2...........| 00000010 00 00 00 00 00 00 00 04 00 00 00 04 00 00 00 00 |................| 00000020 00 00 00 09 00 00 00 04 00 00 00 0c 80 00 00 00 |................| 00000030 d7 3e 02 70 d7 ed 59 f0 d8 f8 fa 70 d9 cd 3b f0 |.>.p..Y....p..;.| 00000040 db 07 00 f0 db ad 1d f0 dc e6 e2 f0 dd 8c ff f0 |................| 00000050 03 01 02 01 02 01 02 01 02 00 00 83 03 00 00 00 |................| 00000060 00 8c a0 01 04 00 00 7e 90 00 08 00 00 7e 90 00 |.......~.....~..| 00000070 08 4c 4d 54 00 4a 44 54 00 4a 53 54 00 00 00 00 |.LMT.JDT.JST....| 00000080 01 00 00 00 01 54 5a 69 66 32 00 00 00 00 00 00 |.....TZif2......| 00000090 00 00 00 00 00 00 00 00 00 00 00 00 04 00 00 00 |................| 000000a0 04 00 00 00 00 00 00 00 0a 00 00 00 04 00 00 00 |................| 000000b0 0c f8 00 00 00 00 00 00 00 ff ff ff ff 65 c2 a4 |.............e..| 000000c0 70 ff ff ff ff d7 3e 02 70 ff ff ff ff d7 ed 59 |p.....>.p......Y| 000000d0 f0 ff ff ff ff d8 f8 fa 70 ff ff ff ff d9 cd 3b |........p......;| 000000e0 f0 ff ff ff ff db 07 00 f0 ff ff ff ff db ad 1d |................| 000000f0 f0 ff ff ff ff dc e6 e2 f0 ff ff ff ff dd 8c ff |................| 00000100 f0 00 03 01 02 01 02 01 02 01 02 00 00 83 03 00 |................| 00000110 00 00 00 8c a0 01 04 00 00 7e 90 00 08 00 00 7e |.........~.....~| 00000120 90 00 08 4c 4d 54 00 4a 44 54 00 4a 53 54 00 00 |...LMT.JDT.JST..| 00000130 00 00 01 00 00 00 01 0a 4a 53 54 2d 39 0a |........JST-9.| 0000013e
下記画像に説明を書いた通り、本ファイルにはUTCオフセットが今よりも18分59秒ずれていた時期のルールが記載されていますが、そちらが採用されていた最後の時間にあたる部分に、符号付き32bitのintergerの最小値である80 00 00 00
(-2147483648)が入っています。こちらをUNIX timeとして読み取ると1901年12月13日中の時間になるため、それ以前の時間はこのファイルを参照する限りUTCオフセットが9時間18分59秒と扱われてしまいます。(実際にこのUTCオフセットの標準時間が利用されていたのは1887年末までなので、およそ14年分の日本時間は正しく変換できないことになります。)
逆に、この現象が起きない筆者のMac上の同じファイルを見てみると、この符号付き32bit整数ではUNIX timeとして扱えない時期のデータは、省略されていることがわかりました。
% hexdump -C /usr/share/zoneinfo/Asia/Tokyo 00000000 54 5a 69 66 32 00 00 00 00 00 00 00 00 00 00 00 |TZif2...........| 00000010 00 00 00 00 00 00 00 03 00 00 00 03 00 00 00 00 |................| 00000020 00 00 00 08 00 00 00 03 00 00 00 08 d7 3e 02 70 |.............>.p| 00000030 d7 ed 59 f0 d8 f8 fa 70 d9 cd 3b f0 db 07 00 f0 |..Y....p..;.....| 00000040 db ad 1d f0 dc e6 e2 f0 dd 8c ff f0 00 01 00 01 |................| 00000050 00 01 00 01 00 00 8c a0 01 00 00 00 7e 90 00 04 |............~...| 00000060 00 00 7e 90 00 04 4a 44 54 00 4a 53 54 00 00 00 |..~...JDT.JST...| 00000070 01 00 00 01 54 5a 69 66 32 00 00 00 00 00 00 00 |....TZif2.......| 00000080 00 00 00 00 00 00 00 00 00 00 00 04 00 00 00 04 |................| 00000090 00 00 00 00 00 00 00 09 00 00 00 04 00 00 00 0c |................| 000000a0 ff ff ff ff 65 c2 a4 70 ff ff ff ff d7 3e 02 70 |....e..p.....>.p| 000000b0 ff ff ff ff d7 ed 59 f0 ff ff ff ff d8 f8 fa 70 |......Y........p| 000000c0 ff ff ff ff d9 cd 3b f0 ff ff ff ff db 07 00 f0 |......;.........| 000000d0 ff ff ff ff db ad 1d f0 ff ff ff ff dc e6 e2 f0 |................| 000000e0 ff ff ff ff dd 8c ff f0 03 01 02 01 02 01 02 01 |................| 000000f0 02 00 00 83 03 00 00 00 00 8c a0 01 04 00 00 7e |...............~| 00000100 90 00 08 00 00 7e 90 00 08 4c 4d 54 00 4a 44 54 |.....~...LMT.JDT| 00000110 00 4a 53 54 00 00 00 00 01 00 00 00 01 0a 4a 53 |.JST..........JS| 00000120 54 2d 39 0a |T-9.| 00000124
データの個数が問題の起こる環境と比べ一つ減っていることと、9時間18分59秒のUTCオフセットを表す83 03
(09:18:59を秒数にした33540の16進数表記)という値が(時間の変換ルールの項目からは)見当たらないことがわかります。
これによって1888年1月1日から1901年12月13日までの日本の日付を正しく扱えるようになりますが、逆に1887年末以前のデータは実際の当時の標準時とはずれた時間で取り扱われます。
# 1887年末以前の標準時がzoneinfoファイルに記録されていない、筆者のMacで実行 >>> datetime.datetime(1887, 1, 1, 0, 0, 0, tzinfo=dateutil.tz.gettz("Asia/Tokyo")).utcoffset() datetime.timedelta(seconds=32400) # ↑実際は33540(9時間18分59秒)であるべきが、32400(9時間)となってしまっている
何らかの理由で1887年以前の日本の日付データを扱いたい場合は、こちらの挙動に気をつける必要がありそうです。
strftime(%s)でのエポック秒変換
datetimeオブジェクトをエポック秒に変換したい場合に、strftime(%s)
を用いることでこれが実現できると書いてある記事がちらほら存在します。
しかしこちらはPythonのdatetimeのドキュメントではサポートされているとは一切書いておらず、非推奨です。
# 日本時間で動く環境です >>> import time >>> time.tzname ('JST', 'JST') >>> import datetime >>> import dateutil.tz # 現在時刻を取得した後、それにニューヨークのタイムゾーンを付与します >>> d = datetime.datetime.now().astimezone(dateutil.tz.gettz('America/New_York')) >>> d datetime.datetime(2024, 2, 8, 1, 41, 57, 844204, tzinfo=tzfile('/usr/share/zoneinfo/America/New_York')) # strftime(%s)を用いてエポック秒に変換 >>> epoch_s = d.strftime("%s") >>> epoch_s '1707324117' # これをdatetimeに変換し再びニューヨークのタイムゾーンをつけると、もとより14時間ずれていることがわかります >>> datetime.datetime.fromtimestamp(int(epoch_s)).astimezone(dateutil.tz.gettz('America/New_York')) datetime.datetime(2024, 2, 7, 11, 41, 57, tzinfo=tzfile('/usr/share/zoneinfo/America/New_York')) # こちらはエポック秒変換の際に、ニューヨークでの時間としてではなく日本時間として変換されたためです
上記のコード例の通り、strftime(%s)
だと変換元のdatetimeオブジェクトがtimezone awareであったとしても、変換時にはそのタイムゾーン情報ではなく、実行環境のタイムゾーン情報を参照してしまいます。
(日本時間で動く環境で、日本時間の時刻データのみを扱っていると気づかずにこのような実装が紛れてしまうかもしれませんが、どちらか一方が日本時間以外となるとこちらは不具合を起こします。)
こちらdatetimeオブジェクトをエポック秒に変換したい場合は、timestamp()
を使うのが正解です。
まとめ
- コンピュータで扱う場合のタイムゾーンの概念は、単なる地域間の時差の寄せ集めではなく、歴史的変遷など時間軸の情報も含んだ複雑なものです
- (Pythonやタイムゾーンとかに限った話ではないですが)ちゃんとドキュメント読みましょう
*1:厳密にいうとこれは抽象クラスとは呼ばないかもしれないですが、実際の実装はなく利用側での関数のoverrideを期待しているクラスという意味で、雑にこの語を使ってます
*2:pytzのオブジェクトがコンストラクタ内でとりあえず最も古い時期のUTCオフセットを参照する部分の実装はこちら: https://github.com/stub42/pytz/blob/fb43f957c5149e750c3be3cfc72b22ad94db4886/src/pytz/tzinfo.py#L189
*3:ubuntu:focal-20230412のDockerイメージをもとにしたコンテナでapt-get install tzdataをした際に発生するのを確認しました