Agent 安排预约前,先问清这个当地时间是哪一次
凌晨一点半可能对应两个时刻,另一个日期的两点半甚至不存在。先列出候选,再请用户确认。
- 明确日期、当地钟面与地区规则,再列出候选
- 用 UTC 往返过滤,并在 UTC 上去重排序
- 唯一候选仍待确认,结果明确绑定实验数据版本
1. “纽约凌晨一点半”还不能直接写进日历
假设一位用户要在 2026 年 11 月 1 日纽约当地凌晨 1 点 30 分预约。日期写得完整,分钟也很明确,程序甚至能顺利构造一个带时区的 datetime。然而,在本文实际使用的时区数据中,这个钟面会走过两次。第一次对应 05:30 UTC,第二次对应 06:30 UTC。少问一句,预约就可能偏差一小时。
另一位用户给出 2026 年 3 月 8 日纽约凌晨 2 点 30 分。这个时间落在向前拨钟跳过的区间。给 datetime 加上 America/New_York 不会自动拒绝它。若后续只看到一个带偏移的字符串,就可能误以为校验已经完成。
这篇文章只完成预约流程里的一个小步骤:接收已经由人整理好的具体日期、当地钟面时间和 IANA 时区键,列出可解释的 UTC 候选,再返回待确认记录。它不读取真实聊天,不调用模型,不解析“明天”“下周五”,也不连接日历、创建邀请或处理账户。所有人名、输入语句与预约场景均为自造示例。
2. 先把入口收窄到可以说明白的程度
函数接收一个 JSON 对象。original_input 保存原话;proposed_local 必须是 YYYY-MM-DDTHH:MM,不含秒、UTC 偏移或尾随空格;zone 是调用方明确提供的 IANA 数据库查找键,例如 Asia/Shanghai;input_source 必须写 human_structured,表示这条记录来自本例约定的人工整理步骤。这个标签只是调用合同,不能证明发送者身份,也不能验证原话与字段是否真的一致。
如果输入是“明天下午三点”,人工整理者首先需要知道“明天”以哪里、何时为基准,再向用户补齐明确日期。示例程序不会读取系统当前日期来代替这一步。即使模型填出了一个语法正确的日期,只要来源仍标为 model_guess,也会要求澄清。字段缺失、类型不对或来源未填写时同样停止解释时间。
为避开历史与极端年份的另外一组问题,示例仅接受 2000 至 2100 年。这个范围是教学入口约束,不意味着期间所有国家和地区都已经测试。时区查找使用运行环境提供的数据;本次实测只覆盖上海、纽约和 Lord Howe 的列举日期。代码不翻译城市名、不把 CST 猜成某个地区、不做别名归一化。
3. 一份能让人接着回答的记录
original_input 和 proposed_local 分开保存,方便在确认时核对人工整理有没有改变意思。zone 保留用户采用的地区规则。candidates 里的每一项包含带偏移的当地时间、UTC 时刻和往返得到的 fold。这里的状态名是本文自己的约定,不是日历服务或 Agent 框架的通用接口。
只有一个候选时,状态为 awaiting_time_confirmation。界面可以问:“整理后的时间是 2026 年 10 月 7 日 09:00,Asia/Shanghai,UTC+08:00,对应 01:00 UTC;这样理解对吗?”程序得到唯一时刻,只说明这组字段在当前规则下不含重复钟面;它没有证明这正是用户想说的时间。
有两个候选时,ambiguous_local 要求用户在完整日期、地区与偏移都可见的情况下选择。零个候选时,nonexistent_local 请用户重新给出一个存在的当地时间。缺字段或无法解释时,needs_clarification 保留问题原因。所有结果的 selected_utc 都是 null,user_confirmed 与 action_authorized 都是 false。本文没有实现确认接口;这些布尔字段也不是可以抵挡恶意调用方的授权机制。
4. fold 帮我们找候选,不替用户做选择
PEP 495引入 fold,用来区分钟面重复区间中较早和较晚的那一次。它允许构造不符合真实当地时间的对象,所以显式写出 fold=0 不能当作“已经验证存在”。本例分别尝试 0 和 1,随后各自换到 UTC,再从 UTC 换回同一时区。
在纽约 2026 年 11 月 1 日 01:30 这个输入上,两个尝试都回到了原来的日期与钟面,因此两个候选都留下。输出按 UTC 排序,先列 05:30,再列 06:30。对话里应把它们说成带偏移的两个实际时刻,让用户选择,不要只抛出技术字段 fold。
5. 不存在的钟面,为什么构造时没有报错
纽约 2026 年 3 月 8 日的例子恰好展示了另一种结果。fold=0 的尝试会在 UTC 往返后变成 03:30;fold=1 会变成 01:30。两个返回值都不等于输入的 02:30,候选列表因而为空。这里比较的是完整年月日和时分,跨日变化也会被看到。
不要把“构造器没有报错”当成当地时间存在性的证据,也不要把一次 astimezone 转换当成语义确认。本文使用的过滤条件是:UTC 往返后,去掉时区的本地年月日时分必须与输入完全一致。在这些受控样本上,它让重复区间保留两个候选,让跳过区间保留零个候选,让普通时刻的两个尝试归并为一个。
6. 两个容易在后续处理中重新丢掉的信息
第一个是 UTC 的日期。上海 2026 年 1 月 1 日 00:30,在本次数据下对应 2025 年 12 月 31 日 16:30 UTC。输出必须保存完整日期时间。只存“16:30”,或者在界面中沿用当地日期再拼 UTC 时间,都会把预约移到错误的一天甚至错误的一年。
第二个是候选之间的实际间隔。Australia/Lord_Howe 在本次数据下,2026 年 4 月 5 日 01:45 的两个候选分别为前一天 14:45 UTC 和 15:15 UTC,相差半小时。2026 年 10 月 4 日 02:15 又落在不存在的区间。纽约的一小时例子不能变成世界各地通用的常量。
Python datetime 文档说明,同一 tzinfo 对象下的某些比较与减法会忽略 fold。本机实验里,纽约两个 01:30 直接比较为相等,直接相减为 0 秒;分别转为 UTC 后相减为 3600 秒。所以代码用 UTC datetime 做候选字典的键,并在 UTC 上排序,避免本地相等语义把重复区间压成一个候选。
7. 先记下这次到底用了哪份规则
本次运行的是 Python 3.12.14。新进程的 TZPATH 首先找到 /usr/share/zoneinfo 下的文件,配套 tzdata.zi 标头是 2026b。环境中还安装了 tzdata 2026.3,但这不等于默认加载了该 Python 包。zoneinfo 文档描述的默认顺序是先查 TZPATH,再尝试 tzdata 包;模块自身不附带完整的时区规则。
probe_environment.py 会记录三个实际文件路径、SHA-256、配套版本标头以及新进程里的 fold 与 gap 观察。文件哈希用于识别这次计算的输入,不代表未来规则永远不变。探针在没有系统文件时会明确写明包回退未做指纹识别;它不会把已安装包的版本冒充实际数据来源。
2026 年 10 月 7 日查阅 IANA 时区页面时,页面列出的最新版是 2026e。本次没有升级系统数据,也没有在 2026e 上复测,文中的结果全部限定于上述 2026b 实验。实际预约前仍应确认运行服务采用的规则版本,尤其当某地近期宣布改变时区政策时。
IANA 的 theory 文档解释了地区键、历史资料与模型范围。一个地区键提供的是一组规则,不是“永远 UTC 加几个小时”的固定常量。本文不把有限样本推广成全球历史时间引擎,也不承诺未来政治决定不会改变同一个当地预约的 UTC 映射。
8. 完整代码与输入:可以从空目录开始
准备一个有可用 IANA 时区数据的 Python 3.12 环境,将下面五个文件按文件名保存到同一空目录。脚本只使用 Python 标准库;标准库不自带完整时区数据库,因此“代码无第三方依赖”不等于“环境无需时区数据”。本次未安装软件或修改系统时区。若目标环境找不到地区数据,先由环境维护者准备数据,再重跑测试;不要把失败改成猜测固定偏移。
输入是 JSONL,每行一条记录。case 只便于读者辨认样本,函数不使用它作判断。格式正确的 JSON 是命令行接口的前置条件;损坏的 JSON 会报错,不会自动修复。函数自身会把非对象、缺字段、类型错误等结构问题返回为 needs_clarification。
9. check_time.py
"""Local appointment intake: explicit human-structured inputs, no booking."""
from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import json
import re
import sys
def inspect_record(record):
"""Return questions/candidates only. Supported wall-clock years: 2000..2100."""
result = dict(status="needs_clarification", candidates=[], selected_utc=None,
user_confirmed=False, action_authorized=False)
if not isinstance(record, dict):
return {**result, "reason": "record_object_required"}
for key in ("original_input", "proposed_local", "zone", "input_source"):
result[key] = record.get(key)
if record.get("input_source") != "human_structured":
return {**result, "reason": "human_structured_source_required"}
original = record.get("original_input")
local = record.get("proposed_local")
zone = record.get("zone")
if not all(isinstance(v, str) and v.strip() for v in (original, local, zone)):
return {**result, "reason": "explicit_original_local_and_zone_required"}
# No natural-language interpretation, trimming, offset removal or zone guessing.
if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}", local):
return {**result, "reason": "canonical_local_required"}
try:
wall = datetime.strptime(local, "%Y-%m-%dT%H:%M")
except ValueError:
return {**result, "reason": "invalid_local_date_or_clock"}
if not 2000 <= wall.year <= 2100:
return {**result, "reason": "year_outside_demo_scope"}
try:
tz = ZoneInfo(zone)
except (ValueError, ZoneInfoNotFoundError):
return {**result, "reason": "unknown_zone_or_unavailable_data"}
candidates = {}
for fold in (0, 1):
tentative = wall.replace(tzinfo=tz, fold=fold)
utc = tentative.astimezone(timezone.utc)
back = utc.astimezone(tz)
if back.replace(tzinfo=None) == wall:
candidates[utc] = dict(local=back.isoformat(), utc=utc.isoformat(), fold=back.fold)
result["candidates"] = [candidates[utc] for utc in sorted(candidates)]
count = len(candidates)
result["status"] = ("nonexistent_local", "awaiting_time_confirmation", "ambiguous_local")[count]
result["reason"] = ("choose_a_different_local_time", "confirm_this_explicit_time", "choose_between_distinct_instants")[count]
return result
if __name__ == "__main__":
# JSONL file or standard input; malformed JSON is a caller error, not a time guess.
stream = open(sys.argv[1], encoding="utf-8") if len(sys.argv) > 1 else sys.stdin
try:
for line in stream:
if line.strip():
print(json.dumps(inspect_record(json.loads(line)), ensure_ascii=False))
finally:
if stream is not sys.stdin:
stream.close()
10. inputs.jsonl
{"case": "normal", "original_input": "2026年10月7日上海上午9点预约", "proposed_local": "2026-10-07T09:00", "zone": "Asia/Shanghai", "input_source": "human_structured"}
{"case": "fold", "original_input": "2026年11月1日纽约凌晨1点半预约", "proposed_local": "2026-11-01T01:30", "zone": "America/New_York", "input_source": "human_structured"}
{"case": "gap", "original_input": "2026年3月8日纽约凌晨2点半预约", "proposed_local": "2026-03-08T02:30", "zone": "America/New_York", "input_source": "human_structured"}
{"case": "cross_year", "original_input": "2026年1月1日上海凌晨0点半预约", "proposed_local": "2026-01-01T00:30", "zone": "Asia/Shanghai", "input_source": "human_structured"}
{"case": "half_hour_fold", "original_input": "2026年4月5日Lord Howe凌晨1点45预约", "proposed_local": "2026-04-05T01:45", "zone": "Australia/Lord_Howe", "input_source": "human_structured"}
{"case": "half_hour_gap", "original_input": "2026年10月4日Lord Howe凌晨2点15预约", "proposed_local": "2026-10-04T02:15", "zone": "Australia/Lord_Howe", "input_source": "human_structured"}
{"case": "missing_zone", "original_input": "明天下午3点预约", "proposed_local": null, "zone": null, "input_source": "human_structured"}
{"case": "bad_zone", "original_input": "2026年10月7日下午3点预约", "proposed_local": "2026-10-07T15:00", "zone": "Unknown/Zone", "input_source": "human_structured"}
{"case": "bad_date", "original_input": "2026年2月30日上午9点预约", "proposed_local": "2026-02-30T09:00", "zone": "Asia/Shanghai", "input_source": "human_structured"}
{"case": "offset_in_wall", "original_input": "带偏移的输入需要单独合同", "proposed_local": "2026-10-07T09:00+08:00", "zone": "Asia/Shanghai", "input_source": "human_structured"}
{"case": "unknown_source", "original_input": "明天下午三点", "proposed_local": "2026-10-08T15:00", "zone": "Asia/Shanghai", "input_source": "model_guess"}
{"case": "missing_source", "original_input": "2026年10月7日9点", "proposed_local": "2026-10-07T09:00", "zone": "Asia/Shanghai"}
{"case": "year_scope", "original_input": "历史输入超出示例范围", "proposed_local": "1900-01-01T12:00", "zone": "Asia/Shanghai", "input_source": "human_structured"}
11. expected.jsonl
{"status": "awaiting_time_confirmation", "candidates": [{"local": "2026-10-07T09:00:00+08:00", "utc": "2026-10-07T01:00:00+00:00", "fold": 0}], "selected_utc": null, "user_confirmed": false, "action_authorized": false, "original_input": "2026年10月7日上海上午9点预约", "proposed_local": "2026-10-07T09:00", "zone": "Asia/Shanghai", "input_source": "human_structured", "reason": "confirm_this_explicit_time"}
{"status": "ambiguous_local", "candidates": [{"local": "2026-11-01T01:30:00-04:00", "utc": "2026-11-01T05:30:00+00:00", "fold": 0}, {"local": "2026-11-01T01:30:00-05:00", "utc": "2026-11-01T06:30:00+00:00", "fold": 1}], "selected_utc": null, "user_confirmed": false, "action_authorized": false, "original_input": "2026年11月1日纽约凌晨1点半预约", "proposed_local": "2026-11-01T01:30", "zone": "America/New_York", "input_source": "human_structured", "reason": "choose_between_distinct_instants"}
{"status": "nonexistent_local", "candidates": [], "selected_utc": null, "user_confirmed": false, "action_authorized": false, "original_input": "2026年3月8日纽约凌晨2点半预约", "proposed_local": "2026-03-08T02:30", "zone": "America/New_York", "input_source": "human_structured", "reason": "choose_a_different_local_time"}
{"status": "awaiting_time_confirmation", "candidates": [{"local": "2026-01-01T00:30:00+08:00", "utc": "2025-12-31T16:30:00+00:00", "fold": 0}], "selected_utc": null, "user_confirmed": false, "action_authorized": false, "original_input": "2026年1月1日上海凌晨0点半预约", "proposed_local": "2026-01-01T00:30", "zone": "Asia/Shanghai", "input_source": "human_structured", "reason": "confirm_this_explicit_time"}
{"status": "ambiguous_local", "candidates": [{"local": "2026-04-05T01:45:00+11:00", "utc": "2026-04-04T14:45:00+00:00", "fold": 0}, {"local": "2026-04-05T01:45:00+10:30", "utc": "2026-04-04T15:15:00+00:00", "fold": 1}], "selected_utc": null, "user_confirmed": false, "action_authorized": false, "original_input": "2026年4月5日Lord Howe凌晨1点45预约", "proposed_local": "2026-04-05T01:45", "zone": "Australia/Lord_Howe", "input_source": "human_structured", "reason": "choose_between_distinct_instants"}
{"status": "nonexistent_local", "candidates": [], "selected_utc": null, "user_confirmed": false, "action_authorized": false, "original_input": "2026年10月4日Lord Howe凌晨2点15预约", "proposed_local": "2026-10-04T02:15", "zone": "Australia/Lord_Howe", "input_source": "human_structured", "reason": "choose_a_different_local_time"}
{"status": "needs_clarification", "candidates": [], "selected_utc": null, "user_confirmed": false, "action_authorized": false, "original_input": "明天下午3点预约", "proposed_local": null, "zone": null, "input_source": "human_structured", "reason": "explicit_original_local_and_zone_required"}
{"status": "needs_clarification", "candidates": [], "selected_utc": null, "user_confirmed": false, "action_authorized": false, "original_input": "2026年10月7日下午3点预约", "proposed_local": "2026-10-07T15:00", "zone": "Unknown/Zone", "input_source": "human_structured", "reason": "unknown_zone_or_unavailable_data"}
{"status": "needs_clarification", "candidates": [], "selected_utc": null, "user_confirmed": false, "action_authorized": false, "original_input": "2026年2月30日上午9点预约", "proposed_local": "2026-02-30T09:00", "zone": "Asia/Shanghai", "input_source": "human_structured", "reason": "invalid_local_date_or_clock"}
{"status": "needs_clarification", "candidates": [], "selected_utc": null, "user_confirmed": false, "action_authorized": false, "original_input": "带偏移的输入需要单独合同", "proposed_local": "2026-10-07T09:00+08:00", "zone": "Asia/Shanghai", "input_source": "human_structured", "reason": "canonical_local_required"}
{"status": "needs_clarification", "candidates": [], "selected_utc": null, "user_confirmed": false, "action_authorized": false, "original_input": "明天下午三点", "proposed_local": "2026-10-08T15:00", "zone": "Asia/Shanghai", "input_source": "model_guess", "reason": "human_structured_source_required"}
{"status": "needs_clarification", "candidates": [], "selected_utc": null, "user_confirmed": false, "action_authorized": false, "original_input": "2026年10月7日9点", "proposed_local": "2026-10-07T09:00", "zone": "Asia/Shanghai", "input_source": null, "reason": "human_structured_source_required"}
{"status": "needs_clarification", "candidates": [], "selected_utc": null, "user_confirmed": false, "action_authorized": false, "original_input": "历史输入超出示例范围", "proposed_local": "1900-01-01T12:00", "zone": "Asia/Shanghai", "input_source": "human_structured", "reason": "year_outside_demo_scope"}
12. test_check_time.py
import json
from datetime import datetime, timezone
from pathlib import Path
import unittest
from zoneinfo import ZoneInfo
from check_time import inspect_record
HERE = Path(__file__).parent
CASES = {r["case"]: r for r in map(json.loads, (HERE / "inputs.jsonl").read_text().splitlines())}
class TimeTests(unittest.TestCase):
def result(self, name):
return inspect_record(CASES[name])
def test_all_expected_records(self):
expected = [json.loads(line) for line in (HERE / "expected.jsonl").read_text().splitlines()]
self.assertEqual([inspect_record(r) for r in CASES.values()], expected)
def test_normal(self):
r = self.result("normal")
self.assertEqual(r["status"], "awaiting_time_confirmation")
self.assertEqual([c["utc"] for c in r["candidates"]], ["2026-10-07T01:00:00+00:00"])
def test_new_york_fold(self):
r = self.result("fold")
self.assertEqual(r["status"], "ambiguous_local")
self.assertEqual([c["utc"] for c in r["candidates"]], ["2026-11-01T05:30:00+00:00", "2026-11-01T06:30:00+00:00"])
self.assertEqual([c["fold"] for c in r["candidates"]], [0, 1])
def test_gap(self):
self.assertEqual(self.result("gap")["status"], "nonexistent_local")
self.assertEqual(self.result("gap")["candidates"], [])
def test_gap_construction_does_not_validate(self):
z = ZoneInfo("America/New_York")
d = datetime(2026, 3, 8, 2, 30, tzinfo=z)
self.assertNotEqual(d.astimezone(timezone.utc).astimezone(z).replace(tzinfo=None), d.replace(tzinfo=None))
def test_fold_equality_trap(self):
z = ZoneInfo("America/New_York")
a = datetime(2026, 11, 1, 1, 30, tzinfo=z, fold=0)
b = a.replace(fold=1)
self.assertEqual(a, b)
self.assertEqual((b-a).total_seconds(), 0)
self.assertEqual((b.astimezone(timezone.utc)-a.astimezone(timezone.utc)).total_seconds(), 3600)
def test_cross_year(self):
self.assertEqual(self.result("cross_year")["candidates"][0]["utc"], "2025-12-31T16:30:00+00:00")
def test_half_hour_fold(self):
cs = self.result("half_hour_fold")["candidates"]
self.assertEqual([c["utc"] for c in cs], ["2026-04-04T14:45:00+00:00", "2026-04-04T15:15:00+00:00"])
self.assertEqual((datetime.fromisoformat(cs[1]["utc"])-datetime.fromisoformat(cs[0]["utc"])).total_seconds(), 1800)
def test_half_hour_gap(self):
self.assertEqual(self.result("half_hour_gap")["status"], "nonexistent_local")
def test_missing_fields(self):
for key in ("original_input", "proposed_local", "zone", "input_source"):
r = dict(CASES["normal"])
del r[key]
with self.subTest(key=key):
self.assertEqual(inspect_record(r)["status"], "needs_clarification")
def test_unknown_source(self):
self.assertEqual(self.result("unknown_source")["reason"], "human_structured_source_required")
def test_wrong_types(self):
for key in ("original_input", "proposed_local", "zone"):
for value in (None, 5, [], {}):
r = {**CASES["normal"], key: value}
with self.subTest(key=key, value=value):
self.assertEqual(inspect_record(r)["status"], "needs_clarification")
def test_non_object(self):
for r in (None, [], "text"):
self.assertEqual(inspect_record(r)["reason"], "record_object_required")
def test_noncanonical_clock(self):
for local in ("2026-1-01T09:00", "2026-10-07 09:00", "2026-10-07T09:00:00", "2026-10-07T09:00+08:00", " 2026-10-07T09:00", "明天下午三点"):
self.assertEqual(inspect_record({**CASES["normal"], "proposed_local": local})["reason"], "canonical_local_required")
def test_bad_date(self):
self.assertEqual(self.result("bad_date")["reason"], "invalid_local_date_or_clock")
def test_bad_zone(self):
for zone in ("Unknown/Zone", "/etc/zoneinfo", "../Asia/Shanghai", "上海"):
self.assertEqual(inspect_record({**CASES["normal"], "zone": zone})["reason"], "unknown_zone_or_unavailable_data")
def test_year_scope(self):
for year in ("0001", "1900", "2101", "9999"):
self.assertEqual(inspect_record({**CASES["normal"], "proposed_local": year+"-01-01T12:00"})["reason"], "year_outside_demo_scope")
def test_never_select_or_authorize(self):
for record in CASES.values():
r = inspect_record(record)
self.assertIsNone(r["selected_utc"])
self.assertFalse(r["user_confirmed"])
self.assertFalse(r["action_authorized"])
def test_original_preserved(self):
for record in CASES.values():
self.assertEqual(inspect_record(record)["original_input"], record["original_input"])
def test_input_not_mutated(self):
for record in CASES.values():
before = json.dumps(record)
inspect_record(record)
self.assertEqual(json.dumps(record), before)
def test_ordinary_time_deduplicates(self):
self.assertEqual(len(self.result("normal")["candidates"]), 1)
if __name__ == "__main__":
unittest.main()
13. probe_environment.py
"""Record this fresh process's default data search and independent time probes."""
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo, TZPATH
from importlib.metadata import version, PackageNotFoundError
import hashlib
import json
import platform
zones = ("America/New_York", "Asia/Shanghai", "Australia/Lord_Howe")
try:
package_version = version("tzdata")
except PackageNotFoundError:
package_version = None
sources = []
for zone in zones:
source = next((Path(root) / zone for root in TZPATH if (Path(root) / zone).is_file()), None)
# The root, not the zone's depth, identifies the version file.
root = next((Path(root) for root in TZPATH if (Path(root) / zone).is_file()), None)
label = root / "tzdata.zi" if root else None
sources.append(dict(zone=zone, source=str(source) if source else None,
sha256=hashlib.sha256(source.read_bytes()).hexdigest() if source else None,
tzdb_label=label.read_text().splitlines()[0] if label and label.is_file() else None,
source_note="first existing TZPATH file in this fresh process" if source else "system file not found; package fallback not fingerprinted"))
ny = ZoneInfo("America/New_York")
a = datetime(2026, 11, 1, 1, 30, tzinfo=ny, fold=0)
b = a.replace(fold=1)
gap = datetime(2026, 3, 8, 2, 30, tzinfo=ny)
print(json.dumps(dict(python=platform.python_version(), tzpath=TZPATH,
installed_tzdata=package_version, sources=sources,
fold_probe=dict(same_tzinfo=a.tzinfo is b.tzinfo, direct_equal=a == b,
direct_seconds=(b-a).total_seconds(),
utc_seconds=(b.astimezone(timezone.utc)-a.astimezone(timezone.utc)).total_seconds()),
gap_probe=dict(construction_succeeded=True, constructed=gap.isoformat(),
round_trip=gap.astimezone(timezone.utc).astimezone(ny).isoformat())), ensure_ascii=False, indent=2))
14. 运行与对照
在刚才的目录运行下面三条命令。第一条输出 13 条待确认或澄清记录;第二条执行 21 项 unittest;第三条打印实际运行环境及独立观察。expected.jsonl 是随稿固定的参考结果,测试按 JSON 对象比较,避免字段顺序影响验收。该文件不是每次运行时根据当前程序输出重新生成的。
python check_time.py inputs.jsonl
python -m unittest -v
python probe_environment.py本次记录如下。测试经过真实运行后才写入,不是事先假设的成功输出。它证明列出的样本和边界在这一环境通过,不证明所有时区、所有年份和所有日历服务都正确。
test_all_expected_records (test_check_time.TimeTests.test_all_expected_records) ... ok
test_bad_date (test_check_time.TimeTests.test_bad_date) ... ok
test_bad_zone (test_check_time.TimeTests.test_bad_zone) ... ok
test_cross_year (test_check_time.TimeTests.test_cross_year) ... ok
test_fold_equality_trap (test_check_time.TimeTests.test_fold_equality_trap) ... ok
test_gap (test_check_time.TimeTests.test_gap) ... ok
test_gap_construction_does_not_validate (test_check_time.TimeTests.test_gap_construction_does_not_validate) ... ok
test_half_hour_fold (test_check_time.TimeTests.test_half_hour_fold) ... ok
test_half_hour_gap (test_check_time.TimeTests.test_half_hour_gap) ... ok
test_input_not_mutated (test_check_time.TimeTests.test_input_not_mutated) ... ok
test_missing_fields (test_check_time.TimeTests.test_missing_fields) ... ok
test_never_select_or_authorize (test_check_time.TimeTests.test_never_select_or_authorize) ... ok
test_new_york_fold (test_check_time.TimeTests.test_new_york_fold) ... ok
test_non_object (test_check_time.TimeTests.test_non_object) ... ok
test_noncanonical_clock (test_check_time.TimeTests.test_noncanonical_clock) ... ok
test_normal (test_check_time.TimeTests.test_normal) ... ok
test_ordinary_time_deduplicates (test_check_time.TimeTests.test_ordinary_time_deduplicates) ... ok
test_original_preserved (test_check_time.TimeTests.test_original_preserved) ... ok
test_unknown_source (test_check_time.TimeTests.test_unknown_source) ... ok
test_wrong_types (test_check_time.TimeTests.test_wrong_types) ... ok
test_year_scope (test_check_time.TimeTests.test_year_scope) ... ok
----------------------------------------------------------------------
Ran 21 tests in 0.014s
OK
环境与探针的实际输出:
{
"python": "3.12.14",
"tzpath": [
"/usr/share/zoneinfo",
"/usr/lib/zoneinfo",
"/usr/share/lib/zoneinfo",
"/etc/zoneinfo"
],
"installed_tzdata": "2026.3",
"sources": [
{
"zone": "America/New_York",
"source": "/usr/share/zoneinfo/America/New_York",
"sha256": "e9ed07d7bee0c76a9d442d091ef1f01668fee7c4f26014c0a868b19fe6c18a95",
"tzdb_label": "# version 2026b",
"source_note": "first existing TZPATH file in this fresh process"
},
{
"zone": "Asia/Shanghai",
"source": "/usr/share/zoneinfo/Asia/Shanghai",
"sha256": "64ffc2e43a94435a043c040d1d3af7e92d031adc78e7737af1861baa4eeef3e6",
"tzdb_label": "# version 2026b",
"source_note": "first existing TZPATH file in this fresh process"
},
{
"zone": "Australia/Lord_Howe",
"source": "/usr/share/zoneinfo/Australia/Lord_Howe",
"sha256": "a323c5433991a963eb497b7da4d1d09848bf3ef5f5d64d9c9649f388e4bab9df",
"tzdb_label": "# version 2026b",
"source_note": "first existing TZPATH file in this fresh process"
}
],
"fold_probe": {
"same_tzinfo": true,
"direct_equal": true,
"direct_seconds": 0.0,
"utc_seconds": 3600.0
},
"gap_probe": {
"construction_succeeded": true,
"constructed": "2026-03-08T02:30:00-05:00",
"round_trip": "2026-03-08T03:30:00-04:00"
}
}
15. 接回预约对话时,停在哪一步
使用这个函数的上层对话可以先把原话和人工整理字段放在一起展示,再根据 reason 提问。重复钟面要选择具体候选,不存在的钟面要换一个当地时间,唯一候选也要核对解释。用户确认时间以后,若还要发出邀请,需要另行明确日历、参与人、时长和执行意图。这份示例停在候选记录,没有任何创建事件的代码。
字段被人工改过,候选也应重新计算。例如用户从纽约改成上海,不能保留之前已选 UTC 再只换显示标签。若应用长期保存未来预约,后续时区数据更新的处理策略也要由产品另外定义:是维持用户的当地钟面,还是维持既定 UTC 时刻,以及什么时候重新确认。本文没有实现迁移策略。
相对日期、口语解析、历史极端年份、时区别名、多参与人各自的日历显示、重复预约、持续时间跨越切换点,以及 Google、Microsoft 或其他平台的实际行为都未验证。我们得到的是一小段可运行、可检查的预约时间入口:每个结论带着明确的字段、候选和实验版本,下一句该问什么也留在记录里。
16. 补充:起点选好了,60 分钟之后是哪一个当地时间?
前文停在候选记录,并把“持续时间跨越切换点”留作未验证范围。这个 2026 年 10 月 13 日的补充只向后走一步:假设上层已经另行完成起点选择与确认,交给计算函数一个明确的 UTC 时刻,以及“经过多少分钟”的时长。我们验证结束时刻的计算,不改变前文候选结果始终待确认的合同。
这里的 60 分钟指 UTC 时间线上经过 3,600 秒,再把结束时刻转回地区时间显示,采用 datetime 不表示闰秒的模型。“明天仍在当地九点”或“每周同一钟面”的规则需要另外定义;它们不能因为也出现了时间加法,就直接套用这个函数。本文没有实现重复预约或业务钟面策略。
输入全部是固定合成日期。selected_utc 这个参数名不证明已经得到真实用户同意;示例也没有确认状态、日历连接、联系人、邀请或消息发送。它只是计算函数的前置条件说明。所有预约执行仍需由上层在自己的授权流程中处理。
17. 秋季会多走一小时,也可能向后倒退
先选纽约 2026 年 11 月 1 日的第一次 01:30−04:00,也就是 05:30 UTC。对当地 datetime 直接加 60 分钟,结果是 02:30−05:00,换算后为 07:30 UTC,实际经过 120 分钟。在 UTC 上先加 60 分钟,则得到 06:30 UTC,转回当地为第二次 01:30−05:00,fold 为 1。结束钟面与开始相同,但偏移和实际时刻已经不同。
第二个样本从当日第二次 01:30−05:00,也就是 06:30 UTC 开始,只加 15 分钟。当地直接相加会把 fold 变回 0,得到 01:45−04:00,即 05:45 UTC,反而比起点早 45 分钟。UTC 先加得到 06:45 UTC,转回后为 01:45−05:00,fold 仍为 1。
PEP 495 的时间运算约定明确说明,timedelta 加减会忽略输入的 fold,结果的 fold 为 0。这是既定行为,不是本次实验发现的新回归。datetime 文档的加减法说明也区分了同一 tzinfo 下的直接运算与 UTC 间隔。因而不能用“当地结束减当地开始等于 60 分钟”作为唯一验收。
春季样本说明,单查 UTC 间隔也还不够。纽约 2026 年 3 月 8 日 01:30−05:00 直接加 60 分钟,产生不存在的 02:30−05:00;它转换出的 UTC 间隔恰好仍为 60 分钟。把这个结果先转 UTC 再转回当地,会变成 03:30−04:00,暴露显示不一致。UTC 先加后转换直接给出这个有效的 03:30−04:00。
18. 两个验收条件,加上五个负对照
elapsed_end 的入口刻意收窄:selected_utc 必须是精确 datetime 类型,且 tzinfo 就是 timezone.utc 单例;字符串、没有时区的 datetime、其他时区对象,即使是 ZoneInfo("UTC"),也不符合这个接口。minutes 必须是 1 至 1440 的精确 int,因此 True 和 60.0 都拒绝。这个上限是教学约束,不是预约产品的通用标准。zone 必须是字符串;查不到地区键时,由 ZoneInfoNotFoundError 明确停止,不猜一个固定偏移。
验收分开检查两件事。其一,把结束值转为 UTC,再减去起点 UTC,所得分钟数必须等于请求值。其二,结束值经 UTC 往返后,完整 ISO 字符串和 fold 都必须一致。这里比较字符串与 fold 的组合,不让同一 tzinfo 下的 datetime 相等语义掩盖差别。zoneinfo 的转换示例说明,从 UTC 转到地区时间时会设置相应的 fold。
十个固定样本中,五个覆盖切换:上述纽约三例,以及 Lord Howe 的秋季和春季。后两例当地直接加 60 分钟,实际分别经过 90 和 30 分钟,提醒我们不能把偏差统一修正为一小时。另五个是负对照:纽约秋季切换前一天、后一天、上海、UTC、上海跨年。这五行里两种算法完全一致。选择 UTC 先加,是为了实现这次约定的经过时长,并不表示所有当地加法都会出错。
下面的结果摘要是人工固定的期望,与测试文件里的 EXPECTED 相互对照,不会根据本轮程序输出重建。测试矩阵包含十行,但只算一个测试方法;整套补充共 11 个方法。原文的 21 项测试属于原实验,此处不合并计数,也没有重新宣称原实验已经复跑。
expected-output.txt
十个固定合成样本:人工写定的期望摘要
列:case | 当地直接相加的结束显示 | UTC实际分钟 | 往返有效 | UTC先加的结束显示 | fold
ny_fall_early | 2026-11-01T02:30:00-05:00 | 120 | true | 2026-11-01T01:30:00-05:00 | 1
ny_fall_late | 2026-11-01T01:45:00-04:00 | -45 | true | 2026-11-01T01:45:00-05:00 | 1
ny_spring_gap | 2026-03-08T02:30:00-05:00 | 60 | false | 2026-03-08T03:30:00-04:00 | 0
lord_howe_fall | 2026-04-05T02:45:00+10:30 | 90 | true | 2026-04-05T02:15:00+10:30 | 0
lord_howe_spring | 2026-10-04T02:45:00+11:00 | 30 | true | 2026-10-04T03:15:00+11:00 | 0
ny_before_fall_control | 2026-10-31T02:30:00-04:00 | 60 | true | 2026-10-31T02:30:00-04:00 | 0
ny_after_fall_control | 2026-11-02T02:30:00-05:00 | 60 | true | 2026-11-02T02:30:00-05:00 | 0
shanghai_control | 2026-10-13T02:30:00+08:00 | 60 | true | 2026-10-13T02:30:00+08:00 | 0
utc_control | 2026-11-01T02:30:00+00:00 | 60 | true | 2026-11-01T02:30:00+00:00 | 0
cross_year_control | 2027-01-01T00:30:00+08:00 | 60 | true | 2027-01-01T00:30:00+08:00 | 0
UTC 先加的十行都应:实际分钟等于 CASES 中请求值,UTC 往返有效。
当地直接相加的十行 fold 都为 0。负对照五行的两种算法一致。
11 个 unittest 方法应通过;十行矩阵在其中一个方法内,不另算十个方法。
19. 从空目录复跑,并故意把代码改坏一次
准备已有 Python 3.12 与可用 IANA 时区数据的环境,将下面三个 Python 文件保存到同一空目录,或解压源码 ZIP。代码仅使用标准库,但 zoneinfo 仍需要环境提供规则文件;没有规则数据时应先由环境维护者准备,再重跑。程序不会安装软件、修改系统时区或读取当前日期。
python -B duration_lab.py
python -B -m unittest -v
python -B mutation_checks.py
第一条打印十个样本的完整 JSON,以及解释器、TZPATH、已安装 tzdata 版本、实际查到的系统文件路径和 SHA-256。第二条运行 11 个方法。第三条只在临时副本里做两种故障注入:把 UTC 先加改成当地直接加,五个切换样本必须失败;把精确 int 检查改成 isinstance,布尔时长测试必须失败。原始代码不受修改。脚本随后在 TZ=Pacific/Honolulu、PYTHONHASHSEED=321 的新进程中重跑未修改代码,检查输出相同且仍有 11 个方法通过;这不增加样本覆盖数量。
duration_lab.py
"""Offline duration experiment; no calendar, model, clock, or network calls."""
from datetime import datetime, timedelta, timezone
import hashlib
from importlib.metadata import PackageNotFoundError, version
import json
from pathlib import Path
import platform
import zoneinfo
UTC = timezone.utc
# Synthetic starts stand in for previously selected UTC candidates.
# This program does not implement confirmation or action authorization.
CASES = (
("ny_fall_early", "America/New_York", "2026-11-01T05:30:00+00:00", 60),
("ny_fall_late", "America/New_York", "2026-11-01T06:30:00+00:00", 15),
("ny_spring_gap", "America/New_York", "2026-03-08T06:30:00+00:00", 60),
("lord_howe_fall", "Australia/Lord_Howe", "2026-04-04T14:45:00+00:00", 60),
("lord_howe_spring", "Australia/Lord_Howe", "2026-10-03T15:15:00+00:00", 60),
("ny_before_fall_control", "America/New_York", "2026-10-31T05:30:00+00:00", 60),
("ny_after_fall_control", "America/New_York", "2026-11-02T06:30:00+00:00", 60),
("shanghai_control", "Asia/Shanghai", "2026-10-12T17:30:00+00:00", 60),
("utc_control", "UTC", "2026-11-01T01:30:00+00:00", 60),
("cross_year_control", "Asia/Shanghai", "2026-12-31T15:30:00+00:00", 60),
)
def elapsed_end(selected_utc, minutes, zone):
"""Add elapsed minutes to a selected UTC instant, then display in zone."""
if type(selected_utc) is not datetime or selected_utc.tzinfo is not UTC:
raise ValueError("selected_utc_must_be_datetime_with_timezone_utc")
if type(minutes) is not int or not 1 <= minutes <= 1440:
raise ValueError("minutes_must_be_integer_from_1_to_1440")
if type(zone) is not str:
raise ValueError("zone_must_be_iana_key")
target = zoneinfo.ZoneInfo(zone)
end_utc = selected_utc + timedelta(minutes=minutes)
return end_utc.astimezone(target)
def display_signature(value):
return (value.isoformat(), value.fold)
def roundtrip_valid(value):
returned = value.astimezone(UTC).astimezone(value.tzinfo)
return display_signature(value) == display_signature(returned)
def observe(case):
name, key, start_text, minutes = case
start_utc = datetime.fromisoformat(start_text)
start = start_utc.astimezone(zoneinfo.ZoneInfo(key))
raw = start + timedelta(minutes=minutes)
correct = elapsed_end(start_utc, minutes, key)
return {
"case": name,
"zone": key,
"minutes": minutes,
"start": start.isoformat(),
"start_fold": start.fold,
"local_add_end": raw.isoformat(),
"local_add_fold": raw.fold,
"local_add_elapsed_minutes": int((raw.astimezone(UTC) - start_utc).total_seconds() / 60),
"local_add_roundtrip_valid": roundtrip_valid(raw),
"utc_add_end": correct.isoformat(),
"utc_add_fold": correct.fold,
"utc_add_elapsed_minutes": int((correct.astimezone(UTC) - start_utc).total_seconds() / 60),
"utc_add_roundtrip_valid": roundtrip_valid(correct),
}
def environment():
try:
installed = version("tzdata")
except PackageNotFoundError:
installed = None
sources = []
for key in dict.fromkeys(case[1] for case in CASES):
root = next((Path(p) for p in zoneinfo.TZPATH if (Path(p) / key).is_file()), None)
source = root / key if root else None
label = root / "tzdata.zi" if root else None
sources.append({
"zone": key,
"path": str(source) if source else None,
"sha256": hashlib.sha256(source.read_bytes()).hexdigest() if source else None,
"tzdb_label": label.read_text().splitlines()[0] if label and label.is_file() else None,
"source_note": "first existing TZPATH file in this fresh process" if source else "fallback package not fingerprinted",
})
return {"python": platform.python_version(), "tzpath": list(zoneinfo.TZPATH),
"installed_tzdata": installed, "sources": sources}
if __name__ == "__main__":
print(json.dumps({"environment": environment(), "cases": [observe(c) for c in CASES]}, indent=2))
test_duration_lab.py
from datetime import datetime, timedelta, timezone
import unittest
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from duration_lab import CASES, elapsed_end, observe
UTC = timezone.utc
# Hand-authored expectations; not regenerated from the implementation.
# name: (local_add_end, raw minutes, raw valid, utc_add_end, correct fold)
EXPECTED = {
"ny_fall_early": ("2026-11-01T02:30:00-05:00", 120, True, "2026-11-01T01:30:00-05:00", 1),
"ny_fall_late": ("2026-11-01T01:45:00-04:00", -45, True, "2026-11-01T01:45:00-05:00", 1),
"ny_spring_gap": ("2026-03-08T02:30:00-05:00", 60, False, "2026-03-08T03:30:00-04:00", 0),
"lord_howe_fall": ("2026-04-05T02:45:00+10:30", 90, True, "2026-04-05T02:15:00+10:30", 0),
"lord_howe_spring": ("2026-10-04T02:45:00+11:00", 30, True, "2026-10-04T03:15:00+11:00", 0),
"ny_before_fall_control": ("2026-10-31T02:30:00-04:00", 60, True, "2026-10-31T02:30:00-04:00", 0),
"ny_after_fall_control": ("2026-11-02T02:30:00-05:00", 60, True, "2026-11-02T02:30:00-05:00", 0),
"shanghai_control": ("2026-10-13T02:30:00+08:00", 60, True, "2026-10-13T02:30:00+08:00", 0),
"utc_control": ("2026-11-01T02:30:00+00:00", 60, True, "2026-11-01T02:30:00+00:00", 0),
"cross_year_control": ("2027-01-01T00:30:00+08:00", 60, True, "2027-01-01T00:30:00+08:00", 0),
}
class DurationTests(unittest.TestCase):
def test_hand_authored_matrix(self):
self.assertEqual(set(EXPECTED), {case[0] for case in CASES})
for case in CASES:
with self.subTest(case=case[0]):
row = observe(case)
self.assertEqual((row["local_add_end"], row["local_add_elapsed_minutes"],
row["local_add_roundtrip_valid"], row["utc_add_end"],
row["utc_add_fold"]), EXPECTED[case[0]])
self.assertEqual(row["local_add_fold"], 0)
self.assertEqual(row["utc_add_elapsed_minutes"], case[3])
self.assertTrue(row["utc_add_roundtrip_valid"])
def test_negative_controls(self):
for case in CASES:
if case[0].endswith("control"):
with self.subTest(case=case[0]):
row = observe(case)
self.assertEqual(row["local_add_end"], row["utc_add_end"])
self.assertEqual(row["local_add_elapsed_minutes"], row["minutes"])
self.assertTrue(row["local_add_roundtrip_valid"])
def test_naive_rejected(self):
with self.assertRaisesRegex(ValueError, "selected_utc"):
elapsed_end(datetime(2026, 11, 1, 1, 30), 60, "UTC")
def test_non_utc_rejected(self):
for tz in (timezone(timedelta(hours=8)), ZoneInfo("UTC")):
with self.subTest(tz=str(tz)), self.assertRaisesRegex(ValueError, "selected_utc"):
elapsed_end(datetime(2026, 11, 1, tzinfo=tz), 60, "UTC")
def test_text_is_not_a_selected_datetime(self):
with self.assertRaisesRegex(ValueError, "selected_utc"):
elapsed_end("2026-11-01T05:30:00+00:00", 60, "UTC")
def test_boolean_duration_rejected(self):
with self.assertRaisesRegex(ValueError, "minutes"):
elapsed_end(datetime(2026, 11, 1, tzinfo=UTC), True, "UTC")
def test_float_duration_rejected(self):
with self.assertRaisesRegex(ValueError, "minutes"):
elapsed_end(datetime(2026, 11, 1, tzinfo=UTC), 60.0, "UTC")
def test_duration_limits(self):
start = datetime(2026, 11, 1, tzinfo=UTC)
for minutes in (-1, 0, 1441):
with self.subTest(minutes=minutes), self.assertRaisesRegex(ValueError, "minutes"):
elapsed_end(start, minutes, "UTC")
for minutes in (1, 1440):
self.assertEqual(elapsed_end(start, minutes, "UTC") - start, timedelta(minutes=minutes))
def test_unknown_zone_stops(self):
with self.assertRaises(ZoneInfoNotFoundError):
elapsed_end(datetime(2026, 11, 1, tzinfo=UTC), 60, "NoSuch/DurationLab")
def test_zone_type_rejected(self):
with self.assertRaisesRegex(ValueError, "zone"):
elapsed_end(datetime(2026, 11, 1, tzinfo=UTC), 60, None)
def test_confirmed_start_unchanged(self):
start = datetime.fromisoformat("2026-11-01T06:30:00+00:00")
before = start.isoformat()
elapsed_end(start, 15, "America/New_York")
self.assertEqual(start.isoformat(), before)
self.assertIs(start.tzinfo, UTC)
if __name__ == "__main__":
unittest.main()
mutation_checks.py
"""Run deliberately broken copies in temporary directories; never edit source."""
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parent
SOURCE = (ROOT / "duration_lab.py").read_text(encoding="utf-8")
TEST = (ROOT / "test_duration_lab.py").read_text(encoding="utf-8")
MUTATIONS = (
("local_add", "end_utc = selected_utc + timedelta(minutes=minutes)\n return end_utc.astimezone(target)",
"return selected_utc.astimezone(target) + timedelta(minutes=minutes)",
("ny_fall_early", "ny_fall_late", "ny_spring_gap", "lord_howe_fall", "lord_howe_spring"), 5),
("allow_bool", "type(minutes) is not int", "not isinstance(minutes, int)",
("test_boolean_duration_rejected",), 1),
)
def run_tests(folder, env=None):
return subprocess.run([sys.executable, "-B", "-m", "unittest", "-v"],
cwd=folder, capture_output=True, text=True,
env=env, timeout=30)
def main():
results = []
with tempfile.TemporaryDirectory(prefix="duration-mutations-") as tmp:
folder = Path(tmp)
(folder / "test_duration_lab.py").write_text(TEST, encoding="utf-8")
for name, old, new, expected_names, failures in MUTATIONS:
if SOURCE.count(old) != 1:
raise RuntimeError("mutation anchor must occur exactly once: " + name)
(folder / "duration_lab.py").write_text(SOURCE.replace(old, new), encoding="utf-8")
result = run_tests(folder)
if result.returncode != 1 or "Ran 11 tests" not in result.stderr or \
f"FAILED (failures={failures})" not in result.stderr or \
any(value not in result.stderr for value in expected_names):
raise RuntimeError("unexpected mutation result: " + name + "\n" + result.stderr)
results.append({"mutation": name, "rejected": True, "failures": failures,
"test_log": result.stderr})
(folder / "duration_lab.py").write_text(SOURCE, encoding="utf-8")
env = {**os.environ, "TZ": "Pacific/Honolulu", "PYTHONHASHSEED": "321"}
rerun = run_tests(folder, env)
if rerun.returncode != 0 or "Ran 11 tests" not in rerun.stderr:
raise RuntimeError("unmodified alternate-environment suite failed")
command = [sys.executable, "-B", "duration_lab.py"]
normal = subprocess.run(command, cwd=folder, capture_output=True, text=True, timeout=30)
alternate = subprocess.run(command, cwd=folder, capture_output=True, text=True, timeout=30, env=env)
if normal.returncode != 0 or alternate.returncode != 0 or normal.stdout != alternate.stdout:
raise RuntimeError("alternate environment changed observation")
print(json.dumps({"mutations": results,
"alternate_environment": {"TZ": "Pacific/Honolulu", "PYTHONHASHSEED": "321",
"same_report": True, "tests_passed": 11,
"test_log": rerun.stderr}}, indent=2))
if __name__ == "__main__":
main()
本次测试记录
test_boolean_duration_rejected (test_duration_lab.DurationTests.test_boolean_duration_rejected) ... ok
test_confirmed_start_unchanged (test_duration_lab.DurationTests.test_confirmed_start_unchanged) ... ok
test_duration_limits (test_duration_lab.DurationTests.test_duration_limits) ... ok
test_float_duration_rejected (test_duration_lab.DurationTests.test_float_duration_rejected) ... ok
test_hand_authored_matrix (test_duration_lab.DurationTests.test_hand_authored_matrix) ... ok
test_naive_rejected (test_duration_lab.DurationTests.test_naive_rejected) ... ok
test_negative_controls (test_duration_lab.DurationTests.test_negative_controls) ... ok
test_non_utc_rejected (test_duration_lab.DurationTests.test_non_utc_rejected) ... ok
test_text_is_not_a_selected_datetime (test_duration_lab.DurationTests.test_text_is_not_a_selected_datetime) ... ok
test_unknown_zone_stops (test_duration_lab.DurationTests.test_unknown_zone_stops) ... ok
test_zone_type_rejected (test_duration_lab.DurationTests.test_zone_type_rejected) ... ok
----------------------------------------------------------------------
Ran 11 tests in 0.002s
OK
本次补充实测解释器是 Python 3.12.14,实际查到的四份系统时区文件均位于 /usr/share/zoneinfo,其配套 tzdata.zi 标头为 2026b。环境里安装了 tzdata 2026.4,但本次没有从这个包读取规则。zoneinfo 的数据来源说明指出,查找先使用 TZPATH,再回退到 tzdata 包。安装版本与实际数据来源应分别记录。
2026 年 10 月 13 日查阅的 Python 3.12 文档显示版本 3.12.15,页面标注更新于 2026 年 10 月 1 日 15:25 UTC;那是文档版本,不是实测解释器。前文 10 月 7 日记录的已安装 tzdata 2026.3 仍保留为当时观察。本次未升级时区数据、未测试新版规则,也未验证其他平台、全部年份、闰秒或真实日历服务。极端年份溢出等异常仍由 datetime 抛出,示例没有包装成完整产品接口。
源码、正文提取副本和源码 ZIP 解压副本已离线复跑,十行观察一致,11 个方法均通过。下面保留本次完整环境和观察,方便核对文件来源;在别的环境运行时,应重新看本机输出,不能把这份记录当成它的数据来源。
{
"environment": {
"python": "3.12.14",
"tzpath": [
"/usr/share/zoneinfo",
"/usr/lib/zoneinfo",
"/usr/share/lib/zoneinfo",
"/etc/zoneinfo"
],
"installed_tzdata": "2026.4",
"sources": [
{
"zone": "America/New_York",
"path": "/usr/share/zoneinfo/America/New_York",
"sha256": "e9ed07d7bee0c76a9d442d091ef1f01668fee7c4f26014c0a868b19fe6c18a95",
"tzdb_label": "# version 2026b",
"source_note": "first existing TZPATH file in this fresh process"
},
{
"zone": "Australia/Lord_Howe",
"path": "/usr/share/zoneinfo/Australia/Lord_Howe",
"sha256": "a323c5433991a963eb497b7da4d1d09848bf3ef5f5d64d9c9649f388e4bab9df",
"tzdb_label": "# version 2026b",
"source_note": "first existing TZPATH file in this fresh process"
},
{
"zone": "Asia/Shanghai",
"path": "/usr/share/zoneinfo/Asia/Shanghai",
"sha256": "64ffc2e43a94435a043c040d1d3af7e92d031adc78e7737af1861baa4eeef3e6",
"tzdb_label": "# version 2026b",
"source_note": "first existing TZPATH file in this fresh process"
},
{
"zone": "UTC",
"path": "/usr/share/zoneinfo/UTC",
"sha256": "8b85846791ab2c8a5463c83a5be3c043e2570d7448434d41398969ed47e3e6f2",
"tzdb_label": "# version 2026b",
"source_note": "first existing TZPATH file in this fresh process"
}
]
},
"cases": [
{
"case": "ny_fall_early",
"zone": "America/New_York",
"minutes": 60,
"start": "2026-11-01T01:30:00-04:00",
"start_fold": 0,
"local_add_end": "2026-11-01T02:30:00-05:00",
"local_add_fold": 0,
"local_add_elapsed_minutes": 120,
"local_add_roundtrip_valid": true,
"utc_add_end": "2026-11-01T01:30:00-05:00",
"utc_add_fold": 1,
"utc_add_elapsed_minutes": 60,
"utc_add_roundtrip_valid": true
},
{
"case": "ny_fall_late",
"zone": "America/New_York",
"minutes": 15,
"start": "2026-11-01T01:30:00-05:00",
"start_fold": 1,
"local_add_end": "2026-11-01T01:45:00-04:00",
"local_add_fold": 0,
"local_add_elapsed_minutes": -45,
"local_add_roundtrip_valid": true,
"utc_add_end": "2026-11-01T01:45:00-05:00",
"utc_add_fold": 1,
"utc_add_elapsed_minutes": 15,
"utc_add_roundtrip_valid": true
},
{
"case": "ny_spring_gap",
"zone": "America/New_York",
"minutes": 60,
"start": "2026-03-08T01:30:00-05:00",
"start_fold": 0,
"local_add_end": "2026-03-08T02:30:00-05:00",
"local_add_fold": 0,
"local_add_elapsed_minutes": 60,
"local_add_roundtrip_valid": false,
"utc_add_end": "2026-03-08T03:30:00-04:00",
"utc_add_fold": 0,
"utc_add_elapsed_minutes": 60,
"utc_add_roundtrip_valid": true
},
{
"case": "lord_howe_fall",
"zone": "Australia/Lord_Howe",
"minutes": 60,
"start": "2026-04-05T01:45:00+11:00",
"start_fold": 0,
"local_add_end": "2026-04-05T02:45:00+10:30",
"local_add_fold": 0,
"local_add_elapsed_minutes": 90,
"local_add_roundtrip_valid": true,
"utc_add_end": "2026-04-05T02:15:00+10:30",
"utc_add_fold": 0,
"utc_add_elapsed_minutes": 60,
"utc_add_roundtrip_valid": true
},
{
"case": "lord_howe_spring",
"zone": "Australia/Lord_Howe",
"minutes": 60,
"start": "2026-10-04T01:45:00+10:30",
"start_fold": 0,
"local_add_end": "2026-10-04T02:45:00+11:00",
"local_add_fold": 0,
"local_add_elapsed_minutes": 30,
"local_add_roundtrip_valid": true,
"utc_add_end": "2026-10-04T03:15:00+11:00",
"utc_add_fold": 0,
"utc_add_elapsed_minutes": 60,
"utc_add_roundtrip_valid": true
},
{
"case": "ny_before_fall_control",
"zone": "America/New_York",
"minutes": 60,
"start": "2026-10-31T01:30:00-04:00",
"start_fold": 0,
"local_add_end": "2026-10-31T02:30:00-04:00",
"local_add_fold": 0,
"local_add_elapsed_minutes": 60,
"local_add_roundtrip_valid": true,
"utc_add_end": "2026-10-31T02:30:00-04:00",
"utc_add_fold": 0,
"utc_add_elapsed_minutes": 60,
"utc_add_roundtrip_valid": true
},
{
"case": "ny_after_fall_control",
"zone": "America/New_York",
"minutes": 60,
"start": "2026-11-02T01:30:00-05:00",
"start_fold": 0,
"local_add_end": "2026-11-02T02:30:00-05:00",
"local_add_fold": 0,
"local_add_elapsed_minutes": 60,
"local_add_roundtrip_valid": true,
"utc_add_end": "2026-11-02T02:30:00-05:00",
"utc_add_fold": 0,
"utc_add_elapsed_minutes": 60,
"utc_add_roundtrip_valid": true
},
{
"case": "shanghai_control",
"zone": "Asia/Shanghai",
"minutes": 60,
"start": "2026-10-13T01:30:00+08:00",
"start_fold": 0,
"local_add_end": "2026-10-13T02:30:00+08:00",
"local_add_fold": 0,
"local_add_elapsed_minutes": 60,
"local_add_roundtrip_valid": true,
"utc_add_end": "2026-10-13T02:30:00+08:00",
"utc_add_fold": 0,
"utc_add_elapsed_minutes": 60,
"utc_add_roundtrip_valid": true
},
{
"case": "utc_control",
"zone": "UTC",
"minutes": 60,
"start": "2026-11-01T01:30:00+00:00",
"start_fold": 0,
"local_add_end": "2026-11-01T02:30:00+00:00",
"local_add_fold": 0,
"local_add_elapsed_minutes": 60,
"local_add_roundtrip_valid": true,
"utc_add_end": "2026-11-01T02:30:00+00:00",
"utc_add_fold": 0,
"utc_add_elapsed_minutes": 60,
"utc_add_roundtrip_valid": true
},
{
"case": "cross_year_control",
"zone": "Asia/Shanghai",
"minutes": 60,
"start": "2026-12-31T23:30:00+08:00",
"start_fold": 0,
"local_add_end": "2027-01-01T00:30:00+08:00",
"local_add_fold": 0,
"local_add_elapsed_minutes": 60,
"local_add_roundtrip_valid": true,
"utc_add_end": "2027-01-01T00:30:00+08:00",
"utc_add_fold": 0,
"utc_add_elapsed_minutes": 60,
"utc_add_roundtrip_valid": true
}
]
}
这个补充把原文未验证的持续时间范围缩小到上述十个固定样本及入口检查;没有改变“列出候选后仍需确认”的原则。未来规则变化、重复预约、按当地钟面定义的业务时长,以及日历平台对事件的实际解释,仍需要各自的合同与验证。
只有一个 UTC 候选就能发日历邀请吗?
不能。它只表示给定字段在当前规则下唯一;还要确认用户意图及具体预约动作。本文不实现确认或邀请。
给 datetime 加上 ZoneInfo 就能拒绝不存在的当地时间吗?
不能。本文的纽约缺口样本可成功构造 datetime;通过 UTC 往返发现其钟面无法返回原输入。
这些输出使用最新版时区规则吗?
没有。本次使用系统 tzdb 2026b。查阅 IANA 时已列出 2026e,但未升级或复测。