日付のみの公開日をカレンダー日付として解析し、その日付を正として保持します。エクスポートで UTC タイムスタンプが必要な場合は、UTC の午前0時を使う規約を明示的に追加します。この Mac のターミナルを使った手順では、その違いを示し、往復変換をテストします。

公開日フィールドが実際に何を意味するかを判断する

公開日一覧に、合成値 2028-06-15 が含まれているとします。この値を検証し、誤って6月14日と表示されないようにエクスポート用に準備するのが目的です。パーサーを選ぶ前に、このフィールドが公開日を記録するのか、実際の公開時点を記録するのかを判断してください。

次の4つの概念は、それぞれ役割が異なります。

概念 合成例 意味
暦日 2028-06-15 時刻やオフセットを伴わないカレンダー上の日
時点 2028-06-15T00:00:00+00:00 UTC で表された特定の瞬間
UTC オフセット +09:00 現地の時計の時刻と UTC との差
名前付きタイムゾーン America/Los_Angeles 適用されるタイムゾーン規則を取得するための識別子

RFC 3339 では、full-date と date-time は別々に定義されています。タイムスタンプの文法では、日付に時刻とオフセットを加えます。したがって、日付だけの公開日には、公開時点を特定するために必要な情報がありません。午前0時を選ぶと、入力になかった情報が追加されます。RFC 3339、セクション5.6

名前付きタイムゾーンには、単一の数値オフセットよりも多くの情報が含まれています。Python の zoneinfo のドキュメントでは、夏時間への移行をまたいで、タイムゾーンのオフセットが変わる例が示されています。日付のみのラベルでは、こうした規則を適用する必要はありません。Python:IANA タイムゾーンサポート

エクスポートする前に、次の判断基準を使ってください。

  • フィールドの意味が「この日に公開」: 保存時も表示時も、日付のみの値を保持します。
  • 受け取り側の形式でタイムスタンプが必要: 日付を保持したまま、文書化された規約に基づいて UTC 午前0時の代替値を導出します。
  • フィールドの意味が「この時点で公開」: 実際の時刻とオフセット、またはその時点を特定するために必要な時刻と名前付きタイムゾーンを取得します。

代替値を使う規約は互換性のために役立ちますが、分からない公開時刻を復元するものではありません。utc_carrier のようなフィールド名なら、この制限が明確です。published_at と呼ぶと、ソースが一度も提供していない情報を示唆してしまいます。

前日へのずれがどこで発生するかを理解する

2028-06-15 に対する2つの処理経路を考えてみましょう。1つ目では、プログラムが固定オフセット +09:00 で午前0時を仮定し、その時点を UTC に変換します。結果は 2028-06-14T15:00:00+00:00 です。9時間差し引くと日付境界を越えるためです。

2つ目では、プログラムが意図的に UTC の午前0時を作ります。2028-06-15T00:00:00+00:00 です。同じ時点を固定オフセット -07:00 で表示すると、2028-06-14T17:00:00-07:00 になります。これらはオフセット計算の例であり、その日付における名前付きタイムゾーンのオフセットを示すものではありません。

どちらの計算も、それぞれの時点は保持します。しかし、どちらも元のカレンダー上の日付ラベルをあらゆる表示タイムゾーンで保持するわけではありません。Python のドキュメントによると、astimezone() は表される UTC 時刻を保ったまま、時計の各フィールドを調整します。Python:datetime.astimezone()

実務上は、解析と表示で共通の契約が必要です。日付を UTC 午前0時の代替値にした場合は、UTC でカレンダーの各フィールドを取り出してください。エクスポートを開く場所にかかわらず公開日ラベルを変えずに表示したいなら、保持した暦日を表示してください。

すべての言語で、欠けている情報が同じように補われるとは限りません。ECMAScript では、オフセットのない日付のみの形式は UTC として解釈されますが、オフセットのない日時形式は現地時刻として解釈されます。そのため、JavaScript で文字列に T00:00:00 を付け加えると、解釈が変わることがあります。ECMAScript:Date.parse

Mac のターミナルで公開日を一通り検証する

この手順では、合成日付を3つ検証し、タブ区切りのプレビューを表示して、不正な入力が拒否されることをテストします。公開日一覧を変更したり、エクスポートファイルを作成したりはしません。

Finder で /Applications/Utilities を開き、Terminal をダブルクリックします。Apple は、これをコマンドラインアプリを開く方法として案内しています。Apple:Mac でターミナルを開く/終了する

次のコマンドを実行して、インタープリターを確認します。

python3 --version

この手順では Python 3.11 以降を使います。python3 が使えない場合、または古いバージョンが表示された場合は、続ける前に公式の macOS インストール手順に従ってください。その後、Terminal を開き直して、もう一度確認します。Python のドキュメントには、macOS 用インストーラーと python3 を使ったスクリプトの実行方法の両方が記載されています。Python:macOS での Python の使用

下のブロック全体を、最後の PY も含めて貼り付け、Return を押します。- 引数は、Python に標準入力からプログラムを読み込むよう指示します。サンプルデータとインポートはすべて含まれています。Python:コマンドラインと環境

python3 - <<'PY'
import re
from datetime import date, datetime, time, timezone


def parse_publication_date(text):
    if not isinstance(text, str) or re.fullmatch(
        r"[0-9]{4}-[0-9]{2}-[0-9]{2}", text
    ) is None:
        raise ValueError("Expected YYYY-MM-DD")
    return date.fromisoformat(text)


def utc_carrier(day):
    return datetime.combine(day, time(0), tzinfo=timezone.utc)


samples = ["2028-02-29", "2028-06-15", "2028-12-31"]
parsed = [(text, parse_publication_date(text)) for text in samples]

print("publication_date\tutc_carrier")
for original, day in parsed:
    carrier = utc_carrier(day)
    assert day.isoformat() == original
    assert carrier.astimezone(timezone.utc).date() == day
    print(day.isoformat(), carrier.isoformat(timespec="seconds"), sep="\t")

for invalid in [
    "2027-02-29",
    "2028-6-15",
    "2028-06-15T00:00:00",
    " 2028-06-15",
]:
    try:
        parse_publication_date(invalid)
    except ValueError:
        pass
    else:
        raise AssertionError(f"Unexpected acceptance: {invalid!r}")

print("Round-trip and rejection checks passed.")
PY

期待される出力は次のとおりです。

publication_date	utc_carrier
2028-02-29	2028-02-29T00:00:00+00:00
2028-06-15	2028-06-15T00:00:00+00:00
2028-12-31	2028-12-31T00:00:00+00:00
Round-trip and rejection checks passed.

この例と2つの固定オフセット計算は、利用可能な Python インタープリターで正常に実行されました。Mac 固有の起動方法とインタープリターの説明は、リンク先の公式ドキュメントに基づいています。

この例で公開日が保持される理由

このパーサーは、文字列の形式が正確かどうかと、日付として有効かどうかを別々に確認します。正規表現では、年を4桁、月と日をそれぞれ2桁にする必要があります。その後、日付パーサーが実在しない日付を拒否します。これにより、公開日列でタイムスタンプ、前後の空白、省略形式が受け入れられるのを防ぎます。

この形式の明示的なチェックが重要なのは、date.fromisoformat() が、コンパクト形式の日付や週日付など、ほかの ISO 表現も受け入れるためです。続いて datetime.combine() が、午前0時と UTC を意図的に指定します。Python:基本的な日付と時刻の型

プログラムはヘッダーを表示する前に、サンプル一覧全体を解析します。サンプルを無効な値に置き換えると、プレビュー行が表示される前に実行が停止します。最後にある意図的な無効入力チェックは別個のテストです。予想される例外を捕捉するため、すべての拒否ケースを確認できます。

2つの往復変換アサーションで契約を表しています。1つ目は、解析して日付形式で出力すると元のラベルに戻ることを確認します。2つ目は、導出した代替値を UTC で解釈すると同じ日付になることを確認します。この2つにより、単に何らかのタイムスタンプを生成できるかではなく、日付を正確に保持するという目的を検証できます。

出力では +00:00 を明示的に使っています。RFC 3339 では、UTC オフセットの表記として Z も認められていますが、日付だけの値にどちらの接尾辞を付けても、欠けている時刻の情報は補われません。日付のみの形式を保つか、完全なタイムスタンプを意図的に作成してください。RFC 3339、セクション5.6

エクスポート後も日付が変わる場合の診断

後続のプレビューに前日が表示されたら、各段階の値を調べてください。すべての段階を既知の1つのラベルと比較できるよう、合成データの中央の例 2028-06-15 を使います。

確認箇所 代替値を使う規約での期待値 違いが示唆すること
元の公開日フィールド 2028-06-15 解析前に入力が変わった
テキストとして表示した解析済み日付 2028-06-15 解析または正規化でラベルが変わった
導出した UTC 代替値 2028-06-15T00:00:00+00:00 別の時刻またはオフセットが加えられた
UTC で取り出した日付 2028-06-15 変換または抽出で異なる契約が使われた
読者に表示される公開日ラベル 2028-06-15 ビューアーが別のタイムゾーンで代替値を表示している可能性がある

パーサーとは別に、表示処理を確認してください。正しい UTC 代替値でも、現地タイムゾーンの設定を使って表示すると、別の日付になることがあります。公開日ラベルには、保持した日付のみの値を表示処理に渡してください。タイムスタンプしか受け付けない場合は、文書化された UTC 表示オプションを使い、結果を確認してください。

受け取り側のツールが時点しか扱えず、常に現地時刻で表示する場合、UTC 午前0時の規約では日付ラベルが変わらないことを保証できません。日付のみのテキスト列を別に保持するか、対応する日付のみのフィールドを使うか、UTC 形式を明示できる表示機能を選んでください。この制限は上記のオフセット計算から生じるため、パーサーだけを変えても解消できません。

また、既存のタイムスタンプを日付のみの入力として扱わないでください。実際の時点では、タイムゾーンを変えるときに変換が必要です。Python ではその操作と、時計の時刻を調整せずにタイムゾーン情報を付与する tzinfo を区別しています。Python:タイムゾーン変換の動作

公開日一覧では、publication_date を正とするカレンダー値として保持してください。UTC 代替値は必要なインターフェースでのみ導出し、そのフィールドに規約を付記してから、プレビューを信頼する前に UTC での日付抽出を検証します。これにより、公開時点を捏造せずに、提供された日付を保持する手順を繰り返し実行できます。

参考資料