Pythonのstrptime %zに関するバグ報告をした

timefmt-goというGoライブラリを公開してメンテしているのですが、最近タイムゾーン周りの対応が弱いことに気がついて実装していました。タイムゾーンオフセットの表記は +0900 のようにコロンを入れないほうが一般的だと思いますが、RFC3339では +09:00 のようにコロンありの形もvalidです。strftime では %:z によりこの形を出力し、strptime%z はコロンありなし両方の形をparseする必要があります。GNU拡張のstrftimeでは %::z を使うと秒まで表記し、%:::z ではオフセットの精度によっていい感じに表記するようです (分精度なら分までなど)。

 $ date +%z
+0900
 $ date +%:z
+09:00
 $ date +%::z
+09:00:00
 $ date +%:::z
+09

strptimeの他の言語の挙動を確認しているときに、Python (3.9.2) で思わぬエラーが発生することに気が付きました。

>>> from datetime import datetime
>>> datetime.strptime('Z', '%z')
datetime.datetime(1900, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
>>> datetime.strptime('z', '%z')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/lib/python3.9/_strptime.py", line 568, in _strptime_datetime
    tt, fraction, gmtoff_fraction = _strptime(data_string, format)
  File "/usr/local/lib/python3.9/_strptime.py", line 453, in _strptime
    if z[3] == ':':
IndexError: string index out of range

%z が大文字の Z を解釈するのは正しいのですが、それ以外のおかしな入力のときは ValueError となるべきです。

>>> datetime.strptime('x', '%z')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/lib/python3.9/_strptime.py", line 568, in _strptime_datetime
    tt, fraction, gmtoff_fraction = _strptime(data_string, format)
  File "/usr/local/lib/python3.9/_strptime.py", line 349, in _strptime
    raise ValueError("time data %r does not match format %r" %
ValueError: time data 'x' does not match format '%z'

例外の型が違うということは except ValueError のようなキャッチをすり抜けてしまうということですね。ざんねんでした。

早速ソースコードを読んでいると、フォーマットを正規表現に変換するときに大文字小文字を無視するオプションを使っていて (%aMonでもMONでもマッチするように)、UTCを表す Z が意図せず z にもマッチしているようでした。

            'z': r"(?P<z>[+-]\d\d:?[0-5]\d(:?[0-5]\d(\.\d{1,6})?)?|Z)",
# 略
    def compile(self, format):
        """Return a compiled re object for the format string."""
        return re_compile(self.pattern(format), IGNORECASE)
# 略
        elif group_key == 'z':
            z = found_dict['z']
            if z == 'Z':
                gmtoff = 0
            else:
                if z[3] == ':':
                    z = z[:3] + z[4:]

考慮漏れだとわかったので、既存のissueがないことを確かめて新たにチケットを切りました。 bugs.python.org GitHubではないので面倒かと思ったのですが、OpenIDですぐにログインできて、本名も要求されずとても簡単でした (Linux Foundationとかと比べると遥かに)。 次の日には興味を持った人がパッチを投げてくれました。 月や曜日のマッチングにはignore caseフラグが必要なのでどう修正するのかわからなかったのですが、 (?-i:PATTERN) で一時的に無視することができるのですね。知らなかった。 github.com 3.9.3では直るんじゃないでしょうか。 世界から一つバグが消えてよかったですね。

Pythonstrptime正規表現で実装されていて結構遅いと思いますが、あまり気にしない文化なのでしょうか。 日時なんて大した長さの文字列ではないからこれで良いのかもしれません (しかも strptime('999', '%H%M%S') みたいなケースも対応している)。 timefmtは極力アロケーションを減らし、strconv.Atoiすら避けてカリカリにチューニングしているので、Pythonのライブラリはだいぶ富豪的だなと思いました。 終わり。

itchyny.hatenablog.com