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