Watch at Ease看見世界,讀懂生活。

科技 · 實用指南

Python ZoneInfo:檢查不存在的時間,別誤拒合法的夏令時間回撥時刻

直接與 UTC 轉換結果比較,可能把重複時段的兩個合法時刻都判為無效。先檢查本地時間往返轉換,再處理歧義並選擇瞬時鍵。

原創檢查矩陣,對照倫敦普通時間、回撥兩次時間與缺失時間的往返結果。
原創技術示意圖:兩個回撥時刻都存在,春季空檔無法通過往返驗證。 Watch at Ease

datetime 帶有時區,並不能證明這個本地鐘面時間曾經存在。Python 可以建立落在春季跳時空檔中的 ZoneInfo datetime。反過來也會出錯:直接把 datetime 與它的 UTC 轉換結果比較,可能把重複時段中真實存在的時間判為不存在。

把三個判斷分開:這個本地時間是否存在、使用者指的是哪一次,以及選定的瞬時應如何儲存和比較。以下檢查保留回撥時段的兩次合法時間,並拒絕缺失的本地時間,而不悄悄將它移到別處。

1. 辨識會誤拒的檢查

pytz-deprecation-shim 的遷移指南提供一個標準函式庫存在性輔助函式,使用 dt.astimezone(timezone.utc) == dt。附近的 dateutil 方案是另一種做法。當改變 fold 會改變 UTC 位移時,這個相等比較捷徑會對合法的回撥時間失效:PEP 495 刻意讓這種跨時區相等比較傳回 False。因此,False 不能證明時間位於空檔中。

在 CPython 3.12.14 的有限測試中,Europe/London 的 2024-10-27 01:30 在 fold=0 時轉換為 UTC 00:30,在 fold=1 時轉換為 UTC 01:30。兩者都是真實瞬時。分別與各自的 UTC 轉換結果直接比較,都傳回 False。應把這作為輔助函式的迴歸案例,不能據此認為 ZoneInfo 無法表示重複時段。

2. 比較往返轉換後的本地鐘面欄位

對 tzinfo 為 ZoneInfo 實例的 datetime dt,先要求它帶有有效時區:dt.tzinfo 必須存在,且 dt.utcoffset() 不能為 None。計算 back = dt.astimezone(timezone.utc).astimezone(dt.tzinfo),再計算 exists = back.replace(tzinfo=None) == dt.replace(tzinfo=None)。這裡刻意比較轉換出去又返回之後的本地日期與鐘面欄位,而不是把原值直接與另一個時區的值比較。

若 exists 為 False,就拒絕輸入,或執行另外明確規定的產品政策。不要自動用 back 取代 dt 並稱之為驗證;那樣會修改原本不存在的輸入。如果應用程式已使用 dateutil,可以採用其文件中的 datetime_exists(dt) 和 datetime_ambiguous(dt),避免自行維護實作。無論部署哪種實作,都要執行同一組測試。

3. 確認存在後,再處理歧義

在 exists 為 True 後,若 dt.replace(fold=1-dt.fold).utcoffset() 與 dt.utcoffset() 不同,這個 ZoneInfo 值就有歧義。詢問、保留或明確選擇較早的一次(fold=0)或較晚的一次(fold=1)。設定 fold 本身不是驗證步驟。這個存在性測試回答的是本地鐘面時間是否發生過,並非對重複時段之外所有 fold 標籤的嚴格驗證。

若上游紀錄已透過 UTC 或位移量指明瞬時,應保留這些資訊,不要從不帶位移量的本地字串重新猜測。ZoneInfo 在轉換既有瞬時時會正確設定 fold。對使用者輸入的本地預約時間,除了最終瞬時,也應保存時區鍵與歧義處理政策。不要寫死一小時修正量:轉換幅度與規則取決於所選時區資料。

4. 保留四種情況的迴歸矩陣

使用 ZoneInfo('Europe/London'),測試檢查了普通時間 2024-10-26 01:30、2024-10-27 01:30 的兩個 fold 值,以及不存在的 2024-03-31 01:30。往返存在性結果依序為 True、True、True、False。僅在存在性通過後判斷歧義,結果依序為 False、True、True、False。空檔值經 UTC 返回後變成本地 02:30,顯示原始鐘面欄位未能保留。

這些是歷史測試案例,不是對每次未來轉換的預測。記錄應用程式所用的 Python 版本、時區鍵與時區資料來源,並為實際服務地區增加案例。ZoneInfo 讀取系統時區資料,必要時使用第一方 tzdata 套件。缺少資料套件或時區鍵無效,是另一類初始化問題;它不是夏令時間空檔,不應被轉換成對使用者輸入的拒絕。

5. 驗證之後,再使用瞬時鍵

輸入存在且已確定是哪一次之後,如果業務識別代表瞬時,就使用 dt.astimezone(timezone.utc)。測試中,將共用同一個 ZoneInfo 物件的兩個倫敦回撥值放入集合,只得到一個本地鍵;先轉換為 UTC,則得到兩個。兩者以本地時間相減為零秒,以 UTC 相減為 3,600 秒。這是不同的比較語義,不能把儲存選擇視為等價。

最後的轉換適用於事件排序、依瞬時去除重複資料,以及計算實際經過時間。重複執行的本地時間排程可能刻意使用鐘面語義,因此應保留原始本地規則。現在更換鍵,無法重建先前因本地鍵衝突而被覆寫的事件;若仍保留來源紀錄,應從中復原。只有普通時間、兩次重複時間與空檔都走到預期路徑,才算修復驗收通過。

原創流程圖,先檢查本地時間存在性,再確定重複時段的哪一次,最後建立 UTC 瞬時鍵。
原創技術示意圖:存在性、歧義選擇與瞬時身分是三個獨立判斷。 Watch at Ease