魂の生命の領域

AWS とか Python とか本読んだ感想とか哲学とか書きます

datetime モジュールのソースコードをちょっとだけ読んだ話

Introduction

UNIX 系の環境で以下のコードを実行すると、整数に丸めたエポック秒が返ってきます。 一方、 Windowa 環境で実行すると ValueError が返ってきます。

from datetime import datetime

result = datetime.now().strftime('%s')

これ、ネタばらしをしてしまうとベースになっている C のライブラリが UNIX 系と Windows で違うから、というもので、 Pythonソースコードを全部読んでも出てきません。

ドキュメントではこの辺りです。

github.com

Python はプラットフォームの C ライブラリの strftime() 関数を呼び出していて、プラットフォームごとにその実装が異なるのはよくあることなので、サポートされる書式コード全体はプラットフォームごとに様々です。 手元のプラットフォームでサポートされているフォーマット記号全体を見るには、 strftime(3) のドキュメントを参照してください。

こういうことです。~完~

が、当時はそこに思い至らず、せっかくだし読んでみるか~と土曜日にずっと読んでました。 解釈を間違えている可能性は大いにありますが、せっかく読んだ内容を記録していたのでここで供養します。

読むぞ!

ソースコードはこちら

github.com

行数がちょこちょこずれている可能性がありますがご容赦下さい。

まず datetime クラスは 1562 行目 に定義されています。 date クラスを継承していますね。 そして datetime クラスでは strftime メソッドをオーバーライドしていません。 なので date クラスの方を見に行きましょう。(927行目)

def strftime(self, fmt):
    "Format using strftime()."
    return _wrap_strftime(self, fmt, self.timetuple())

timetuple メソッドの戻り値とともに _wrap_strftime に代入されたものを返していますね。 timetuple メソッドは date クラスに定義されていますが、 datetime クラスでオーバーライドされています。

timetuple メソッドの定義はこちらです。

def timetuple(self):
    "Return local time tuple compatible with time.localtime()."
    dst = self.dst()
    if dst is None:
        dst = -1
    elif dst:
        dst = 1
    else:
        dst = 0
    return _build_struct_time(self.year, self.month, self.day,
                                self.hour, self.minute, self.second,
                                dst)

_build_struct_time を返していますね。

_build_struct_time の定義をみる

これは datetime.py 直下の 156 行目に定義されています。

def _build_struct_time(y, m, d, hh, mm, ss, dstflag):
    wday = (_ymd2ord(y, m, d) + 6) % 7
    dnum = _days_before_month(y, m) + d
    return _time.struct_time((y, m, d, hh, mm, ss, wday, dnum, dstflag))

_ymd2ord_days_before_month を呼び出し、 struct_time を返してます。

_ymd2ord の定義をみる

これも datetime.py 直下の 63 行目にあり、

def _ymd2ord(year, month, day):
    "year, month, day -> ordinal, considering 01-Jan-0001 as day 1."
    assert 1 <= month <= 12, 'month must be in 1..12'
    dim = _days_in_month(year, month)
    assert 1 <= day <= dim, ('day must be in 1..%d' % dim)
    return (_days_before_year(year) +
            _days_before_month(year, month) +
            day)

_days_in_month を呼び出し、 _days_before_year_days_before_month を(と引数 day を足し合わせたもの)返しています。 すでに発狂しそうになってきますが、一つずつ見ていきます。

_days_in_month の定義をみる

同じく datetime.py 直下の 51 行目です。

def _days_in_month(year, month):
    "year, month -> number of days in that month in that year."
    assert 1 <= month <= 12, month
    if month == 2 and _is_leap(year):
        return 29
    return _DAYS_IN_MONTH[month]

まず引数 month が 1 から 12 の間にあるかどうかを判定しています。 論外なパターンはここで弾かれます。 大丈夫であればその月が 2 月かつ閏年であるかを判定し、その場合は閏年の 2 月なので 29 を返します。

_is_leap(year) は year が leap つまり閏年かどうかを boolean で返します。

def _is_leap(year):
    "year -> 1 if leap year, else 0."
    return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)

「4 で割り切れる」ことと「100 で割り切れないもしくは 400 で割り切れる」ことを同時に満たしていれば閏年です。 式変形すれば「400 で割り切れる」ことと「100 では割り切れないが 4 で割り切れる」のどちらかを満たしていれば閏年となり、こちらの方がわかりやすいかもしれないですね。 いずれにせよその通りの実装になっています。

閏年の 2 月以外の場合で return している _DAYS_IN_MONTH はその月の末尾(何日まであるか)を表すリストです。

_DAYS_IN_MONTH = [-1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]

まとめると、これは「その年のその月は何日まであるか」を返すものとなっていることがわかります。

_days_before_year の定義をみる

次に _days_before_year です。

def _days_before_year(year):
    "year -> number of days before January 1st of year."
    y = year - 1
    return y*365 + y//4 - y//100 + y//400

これは指定された年までに累計で西暦 1 年の元日から数えて何日あるかを返します。 例えば year = 1 であれば 0 が返ります。 西暦 1 年の場合、その前年には累積で 0 日ある、ということになります。 + y//4 - y//100 + y//400閏年の補正である。つまり閏年になる年分 1 日が加算されます。

_days_before_month の定義をみる

頑張って _days_before_month も見ていきましょう。

def _days_before_month(year, month):
    "year, month -> number of days in year preceding first day of month."
    assert 1 <= month <= 12, 'month must be in 1..12'
    return _DAYS_BEFORE_MONTH[month] + (month > 2 and _is_leap(year))

これもまず 1 月から 12 月以外が来たら弾くようになっています。 そのうえで _DAYS_BEFORE_MONTH を参照する。これはモジュール内に直接定義されているので、読み込まれた段階で処理が走りますね。

# -1 is a placeholder for indexing purposes.
_DAYS_IN_MONTH = [-1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]

_DAYS_BEFORE_MONTH = [-1]  # -1 is a placeholder for indexing purposes.
dbm = 0
for dim in _DAYS_IN_MONTH[1:]:
    _DAYS_BEFORE_MONTH.append(dbm)
    dbm += dim
del dbm, dim

脳内で追いかけると発狂しそうなので切り出して実際に動かしてみます。 すると以下の結果が返ってきます。 コメントにあるように 0 番目の要素はいずれもプレイスホルダーで実際の意味はないです。

_DAYS_IN_MONTH = [-1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]  # 変化なし
_DAYS_BEFORE_MONTH = [-1, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]

つまり、_DAYS_BEFORE_MONTH はその月の初日時点で、その年において何日経ったかを表すリストのようです。 1 番目の要素は1月1日なのでその時点で今年経過した日数は 0 。 2 番目の要素は2月1日なのでその時点で今年経過した日数は 31 。 なるほど。

話を戻しましょう。 改めて _days_before_month を見ると、どうやら指定された年、指定された月の初日において何日経過したかを返しているようです。

def _days_before_month(year, month):
    "year, month -> number of days in year preceding first day of month."
    assert 1 <= month <= 12, 'month must be in 1..12'
    return _DAYS_BEFORE_MONTH[month] + (month > 2 and _is_leap(year))

(month > 2 and _is_leap(year)) は True を int 型にしたとき 1 になる性質を利用して閏年で2月以降であればその分 1 を足すようになっています。

ということでもう一度 _ymd2ord の定義を見てみましょう。

def _ymd2ord(year, month, day):
    "year, month, day -> ordinal, considering 01-Jan-0001 as day 1."
    assert 1 <= month <= 12, 'month must be in 1..12'
    dim = _days_in_month(year, month)
    assert 1 <= day <= dim, ('day must be in 1..%d' % dim)
    return (_days_before_year(year) +
            _days_before_month(year, month) +
            day)

年、月、日を指定されるとまず月がまともな値(1 から 12 の間)かを判定します。 クリアすれば _days_in_month でその年、その月が何日まであるかを取得し、指定された日がその範囲に収まっているかを判定します。 それもクリアした場合はちゃんと存在し得る年月日であることが分かるので評価の対象となります。 そこまでチェックしたうえで以下の数字を全部足しあげたものを返します。

  • _days_before_year で取得した「その年の元日までに西暦 1 年の元日から数えて何日存在するか」
  • _days_before_month で取得した「その年においてその月の初日までに何日存在するか」
  • day つまり「その月においてその前日までに何日経ったか(何日目か)」

やっと意味が分かりました。 _ymd2ord は「指定された日付までに西暦 1 年の元日から数えた日数」取得していたのですな。

_build_struct_time 再訪

_build_struct_time の定義に戻ろう。

def _build_struct_time(y, m, d, hh, mm, ss, dstflag):
    wday = (_ymd2ord(y, m, d) + 6) % 7
    dnum = _days_before_month(y, m) + d
    return _time.struct_time((y, m, d, hh, mm, ss, wday, dnum, dstflag))

_ymd2ord は先ほど分かったので wday は「指定された日付までに西暦 1 年の元日から数えた日数」に 6 を足して 7 で割った余りです。 例えば、 _ymd2ord(1, 1, 1) の結果は 1 です。 それに 6 を足して 7 で割ると余りは 0 です。

おそらく察せると思いますが、これは曜日を算出しています。 実は 1086 行目で date クラスに weekday メソッドが定義されているのですが、ここで同じことをしていてコメントで解説があります。

def weekday(self):
    "Return day of the week, where Monday == 0 ... Sunday == 6."
    return (self.toordinal() + 6) % 7

月曜を 0 として日曜が 6 となるようにしているようですね。

次は _days_before_month です。 これは先ほど確認しましたね。 指定された年と月に対して「その年においてその月の初日までに何日経ったか」を返す関数でしたね。 いまはそこに d (明らかに day のことでしょう)が足されているので dnum は「その年においてその日まで元日から数えた何日」となります。

最終的に _build_struct_time

  • y : 年
  • m : 月
  • d : 日
  • hh : 時
  • mm : 分
  • ss : 秒
  • dstflag : ?

が渡され、 戻り値を作る struct_time には

  • y : 年
  • m : 月
  • d : 日
  • hh : 時
  • mm : 分
  • ss : 秒
  • wday : 曜日を表すインデックス
  • dnum : その日が西暦 1 年 1 月 1 日から数えて何日目か
  • dstflag : ?

で構成されたタプルが渡されていることになります。

timetuple の定義にちょっと戻る

そもそも何が知りたかったかと言うと、これですね。 strftime メソッドを呼び出したときに戻り値として返ってくる _wrap_strftime へ渡されている timetuple の中身です。

def timetuple(self):
    "Return local time tuple compatible with time.localtime()."
    dst = self.dst()
    if dst is None:
        dst = -1
    elif dst:
        dst = 1
    else:
        dst = 0
    return _build_struct_time(self.year, self.month, self.day,
                                self.hour, self.minute, self.second,
                                dst)

先ほど _build_struct_time の実装を読み解いたので実際にここでどう呼び出されているかを見てみると、こうなっています。

  • y : 年
  • m : 月
  • hh : 時
  • mm : 分
  • ss : 秒
  • dstflag : -1

というわけで _build_struct_time はこのようなラインナップのタプルを struct_time に渡しているわけですね。

  • y : 年
  • m : 月
  • d : 日
  • hh : 時
  • mm : 分
  • ss : 秒
  • wday : 曜日を表すインデックス
  • dnum : その日が西暦 1 年 1 月 1 日から数えて何日目か
  • dstflag : -1

さあ、やっとこさ struct_time の定義を見ようとなりますが、ここで大きな問題が生じます。

def _build_struct_time(y, m, d, hh, mm, ss, dstflag):
    wday = (_ymd2ord(y, m, d) + 6) % 7
    dnum = _days_before_month(y, m) + d
    return _time.struct_time((y, m, d, hh, mm, ss, wday, dnum, dstflag))

_time モジュールは 11 行目で import しているので実際には time.py モジュールのことであるようですが、

import time as _time

この time は VSCode で参照先をジャンプすると time.pyi というモジュールに飛びます。 見慣れない拡張子ですね。

変数の型を指定しているっぽいです。

_time.strftime は time.pyi に定義されていますが…

def strftime(format: str, t: Union[_TimeTuple, struct_time] = ...) -> str: ...

なにこれ

力尽きる

ここで力尽きました。

次はこの辺を読んでみますかね…

docs.python.org

ソースコードではなくドキュメントになりますが…

序盤で date クラスの strftime メソッドを見たとき

def strftime(self, fmt):
    "Format using strftime()."
    return _wrap_strftime(self, fmt, self.timetuple())

これの戻り値である _wrap_strftime の定義を見るとこんなん↓なので最終的にこれを読み下せたらいいなぁと思ってましたがたどり着けませんでした。

# Correctly substitute for %z and %Z escapes in strftime formats.
def _wrap_strftime(object, format, timetuple):
    # Don't call utcoffset() or tzname() unless actually needed.
    freplace = None  # the string to use for %f
    zreplace = None  # the string to use for %z
    Zreplace = None  # the string to use for %Z

    # Scan format for %z and %Z escapes, replacing as needed.
    newformat = []
    push = newformat.append
    i, n = 0, len(format)
    while i < n:
        ch = format[i]
        i += 1
        if ch == '%':
            if i < n:
                ch = format[i]
                i += 1
                if ch == 'f':
                    if freplace is None:
                        freplace = '%06d' % getattr(object,
                                                    'microsecond', 0)
                    newformat.append(freplace)
                elif ch == 'z':
                    if zreplace is None:
                        zreplace = ""
                        if hasattr(object, "utcoffset"):
                            offset = object.utcoffset()
                            if offset is not None:
                                sign = '+'
                                if offset.days < 0:
                                    offset = -offset
                                    sign = '-'
                                h, rest = divmod(offset, timedelta(hours=1))
                                m, rest = divmod(rest, timedelta(minutes=1))
                                s = rest.seconds
                                u = offset.microseconds
                                if u:
                                    zreplace = '%c%02d%02d%02d.%06d' % (sign, h, m, s, u)
                                elif s:
                                    zreplace = '%c%02d%02d%02d' % (sign, h, m, s)
                                else:
                                    zreplace = '%c%02d%02d' % (sign, h, m)
                    assert '%' not in zreplace
                    newformat.append(zreplace)
                elif ch == 'Z':
                    if Zreplace is None:
                        Zreplace = ""
                        if hasattr(object, "tzname"):
                            s = object.tzname()
                            if s is not None:
                                # strftime is going to have at this: escape %
                                Zreplace = s.replace('%', '%%')
                    newformat.append(Zreplace)
                else:
                    push('%')
                    push(ch)
            else:
                push('%')
        else:
            push(ch)
    newformat = "".join(newformat)
    return _time.strftime(newformat, timetuple)

まとめ

ぴえ~