首页
/ CPython zipfile 可复现构建改进:ZipFile 写入时间戳改用 UTC 以避免下溢

CPython zipfile 可复现构建改进:ZipFile 写入时间戳改用 UTC 以避免下溢

2026-09-09 20:08:52作者:龚格成

导读

本文讲解 CPython 标准库 zipfile 模块在可复现构建(reproducible builds)方面的一项修复:当环境变量 SOURCE_DATE_EPOCH 被设置时,ZipFile 写入归档条目的文件时间戳将改用 UTC 时间而非本地时间,从而避免因本地时区为负偏移(如 UTC-5 及更西的时区)导致 DOS 时间字段计算下溢(underflow)、进而引发 struct.error 等异常的问题。读完本文,你将理解 SOURCE_DATE_EPOCHzipfile 中的完整处理链路、时间戳下溢的产生机理,以及如何在自己的构建流程中验证与规避该问题。

变更概览:一条 NEWS 条目的来龙去脉

本变更记录在 Misc/NEWS.d/next/Library/2025-05-19-21-08-25.gh-issue-134261.ravGYm.rst,对应 GitHub issue 134261,属于 Library 类别(影响标准库 zipfile)。其核心内容原文如下:

zip: On reproducible builds, ZipFile uses UTC instead of the local time when writing file datetimes to avoid underflows.

即:在可复现构建场景下,ZipFile 写入文件日期时间时使用 UTC 代替本地时间,以避免下溢。

这条 NEWS 条目属于 Misc/NEWS.d 碎片化变更记录体系——CPython 的每个变更都以独立 .rst 文件按 YYYY-MM-DD-HH-MM-SS.gh-issue-XXXXXX.随机后缀.rst 的命名规范提交,最终由 blurb 工具合入 Misc/NEWS。因此该文件本身极简,但它指向的实现改动落在 Lib/zipfile/init.py 中,并配有完整的回归测试。

背景:可复现构建与 SOURCE_DATE_EPOCH

可复现构建要求同一份源码在任何时间、任何环境(包括不同时区)下构建都能产生逐字节一致的产物。打包工具(wheel、sdist、容器镜像等)一旦把「当前时间」写入产物元数据,产物就会随构建时刻漂移而不可复现。

业界对此的标准解决方案是 SOURCE_DATE_EPOCH(可复现构建项目提出的规范,本文仅作概念性背景说明,不输出外部链接):构建系统设置该环境变量为一个固定的 Unix 时间戳(自 1970-01-01 00:00:00 UTC 起的秒数),打包工具检测到后,用它替代「当前时间」作为所有时间戳的来源。

CPython 的 zipfile 从 gh-91279 起便支持该变量(见下方实现),而本次 gh-issue-134261 修复的是该支持在特定时区下的缺陷。

实现剖析:时间戳的两条写入路径

Lib/zipfile/init.py 中,时间戳的处理分两条路径:

路径一:ZipInfo._for_archive() —— 字符串形式写入的默认时间戳

def _for_archive(self, archive):
    """Resolve suitable defaults from the archive.

    Resolve the date_time, compression attributes, and external attributes
    to suitable defaults as used by :method:`ZipFile.writestr`.
    """
    # gh-91279: Set the SOURCE_DATE_EPOCH to a specific timestamp
    source_date_epoch = os.environ.get('SOURCE_DATE_EPOCH')

    if source_date_epoch:
        self.date_time = time.gmtime(int(source_date_epoch))[:6]
    else:
        self.date_time = time.localtime(time.time())[:6]
    ...

这段代码位于 Lib/zipfile/init.py 第 670~693 行,是 gh-issue-134261 修复的核心位置:

  • 设置 SOURCE_DATE_EPOCH:使用 time.gmtime(int(source_date_epoch))[:6],即把该时间戳按 UTC 展开为 (year, month, day, hour, minute, second) 六元组;
  • 未设置时:退回 time.localtime(time.time())[:6],即按本地时区取当前时间。

调用关系:当调用 ZipFile.writestr("文件名", data)(传入字符串而非 ZipInfo 实例)时,会执行 zinfo = ZipInfo(zinfo_or_arcname)._for_archive(self)(见 Lib/zipfile/init.py 第 2587 行),从而触发上述时间戳解析。这是绝大多数用户写入 ZIP 条目的默认路径。

路径二:ZipInfo.from_file() —— 从文件系统读取的 write() 路径

@classmethod
def from_file(cls, filename, arcname=None, *, strict_timestamps=True):
    ...
    st = os.stat(filename)
    isdir = stat.S_ISDIR(st.st_mode)
    mtime = time.localtime(st.st_mtime)
    date_time = mtime[0:6]
    if not strict_timestamps and date_time[0] < 1980:
        date_time = (1980, 1, 1, 0, 0, 0)
    elif not strict_timestamps and date_time[0] > 2107:
        date_time = (2107, 12, 31, 23, 59, 59)
    ...

Lib/zipfile/init.py 第 631~668 行。ZipFile.write(filename, ...) 会调用此方法(第 2554 行)。值得注意的是:这条路径并未读取 SOURCE_DATE_EPOCH,时间仍来自文件系统 mtime 的本地时区展开,仅在 strict_timestamps=False 时对超出 ZIP 格式 DOS 时间可表示范围(1980~2107)的值做钳制。

因此本次修复的实际效果范围是 writestr()(字符串/ZipInfo 参数)这一条路径;用 write() 写入磁盘文件时,时间戳仍以文件 mtime 为准,与本次 NEWS 描述一致("when writing file datetimes" 特指可复现构建下的默认时间解析)。

问题本质:为什么 UTC 能避免「下溢」

ZIP 格式使用 DOS 时间戳保存文件日期,其取值范围为 1980-01-01 00:00:00 至 2107-12-31 23:59:58(秒字段只存偶数秒)。在 zipfile 内部,DOS 日期/时间的位打包逻辑位于 Lib/zipfile/init.py 第 536 行与第 2678 行附近:

dostime = dt[3] << 11 | dt[4] << 5 | (dt[5] // 2)

即:小时占 5 bit、分钟占 6 bit、秒除以 2 后占 5 bit,而年份字段(1980 为基准偏移)存储在 dosdate 中。若年份 < 1980,year - 1980 会得到负数,进入位运算后即产生错误的位模式(下溢)。

在设置 SOURCE_DATE_EPOCH 的构建场景下,旧代码使用 time.localtime()

self.date_time = time.localtime(int(source_date_epoch))[:6]

localtime 会把时间戳转换为构建机的本地时区。例如在 America/New_York(UTC-5,即 UTC-05:00)下,时间戳 1735715999(对应 2025-01-01 07:19:58 UTC)会被转换为 2024-12-31 02:19:58 之类的本地时间——年份直接退回到 1980 年之前?不,这里退回的是 2024 年,并不触发 1980 下溢;真正触发下溢的场景是时间戳本身接近 DOS 时间下限且时区为负偏移。例如一个时间戳在 UTC 下恰好处于 1980-01-01 00:00:00 附近,若在 UTC-5 时区转换,本地时间会落入 1979-12-31 23:00:00,年份 < 1980,date_time[0] < 1980,随后 _get_dostime() 等位打包逻辑(Lib/zipfile/init.py 第 469 行附近的 if date_time[0] < 1980: 校验,以及 Lib/zipfile/init.py 第 536 行的位打包)便会遇到非法年份,产生 struct.error 或损坏的时间字段。

改用 time.gmtime() 后,时间戳始终按 UTC 展开,与构建机时区彻底解耦,既保证了时间戳语义的确定性(可复现构建要求产物不依赖时区),也从根因上消除了「UTC 时间合法、本地时间年份回退到 1980 之前」的下溢路径。这正是 NEWS 条目中 "to avoid underflows" 的完整含义。

测试验证:SOURCE_DATE_EPOCH 行为的回归保障

本次修复配有两组互为镜像的测试,位于 Lib/test/test_zipfile/test_core.py 第 4024~4047 行:

@with_source_date_epoch(epoch=1735715999)
def test_write_with_source_date_epoch(self):
    with zipfile.ZipFile(TESTFN, "w") as zf:
        zf.writestr("test_source_date_epoch.txt", "Testing SOURCE_DATE_EPOCH")

    with zipfile.ZipFile(TESTFN, "r") as zf:
        zip_info = zf.getinfo("test_source_date_epoch.txt")
        expected_utc = (2025, 1, 1, 7, 19, 58)
        self.assertEqual(zip_info.date_time, expected_utc)

@without_source_date_epoch
def test_write_without_source_date_epoch(self):
    with zipfile.ZipFile(TESTFN, "w") as zf:
        zf.writestr("test_no_source_date_epoch.txt", "Testing without SOURCE_DATE_EPOCH")

    with zipfile.ZipFile(TESTFN, "r") as zf:
        zip_info = zf.getinfo("test_no_source_date_epoch.txt")
        self.assertTimestampAlmostEqual(time.localtime(), zip_info.date_time, tolerance=2)

要点解读:

  • with_source_date_epoch(epoch=1735715999) 装饰器把环境变量 SOURCE_DATE_EPOCH 设为 1735715999(对应 2025-01-01 07:19:58 UTC),测试断言归档中条目的 date_time 精确等于 UTC 六元组 (2025, 1, 1, 7, 19, 58)——与运行测试的机器时区无关,即便在 UTC-5 等负偏移时区也不会回退到 2024-12-31;
  • without_source_date_epoch 装饰器确保变量被清除,此时断言时间戳与本地时间 time.localtime() 基本一致(容差 2 秒),验证了未设置变量时的旧行为不被破坏。

这两个装饰器定义在 Lib/test/support/os_helper.py 第 813~838 行:without_source_date_epoch 在测试期间 env.unset('SOURCE_DATE_EPOCH')with_source_date_epochenv['SOURCE_DATE_EPOCH'] = str(epoch)(默认 epoch=123456789)。同一文件中还有第 838 行注释 "Run tests with SOURCE_DATE_EPOCH set or unset explicitly",表明测试基础设施会显式控制该变量,避免环境干扰。此外,Lib/test/test_zipfile/test_core.py 第 1965、2010、2054、2259 行多处使用 @without_source_date_epoch,说明其余时间相关测试也刻意排除了 SOURCE_DATE_EPOCH 的干扰。

运行回归测试的方式:

# 在 CPython 源码根目录下(需已构建出 python 可执行文件)
./python -m test test_zipfile

或仅运行核心测试文件:

./python -m test test_zipfile.test_core

实操验证:在命令行复现该行为

你无需修改任何仓库文件,即可用仓库内已构建的 CPython 解释器验证该行为(以下命令在 CPython 源码根目录执行):

# 场景一:设置 SOURCE_DATE_EPOCH,时间戳将按 UTC 写入
SOURCE_DATE_EPOCH=1735715999 ./python - <<'PY'
import io, zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
    zf.writestr("demo.txt", "hello reproducible build")
with zipfile.ZipFile(buf, "r") as zf:
    print(zf.getinfo("demo.txt").date_time)  # 输出 (2025, 1, 1, 7, 19, 58),与本地时区无关
PY

# 场景二:不设置该变量,时间戳为本地当前时间
./python - <<'PY'
import io, time, zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
    zf.writestr("demo.txt", "hello")
with zipfile.ZipFile(buf, "r") as zf:
    print(zf.getinfo("demo.txt").date_time)
    print(time.localtime()[:6])  # 二者应基本一致
PY

可以进一步验证「可复现」效果:连续两次设置相同的 SOURCE_DATE_EPOCH 生成 ZIP,用 cmpsha256sum 比较,产物应逐字节一致(在相同的压缩参数、文件顺序与内容前提下);而把机器时区临时切换到不同时区(例如 TZ=UTCTZ=America/New_York)重新生成,产物依旧一致——这正是可复现构建对「时区无关」的要求,也是本次修复的意义所在。

适用范围与注意事项

  1. 适用版本:该行为变更已合入当前 CPython 主线(main 分支),对应 zipfile 模块 Lib/zipfile/init.py;后续将随下一个正式版本发布(可通过 Misc/NEWS.d 目录中该条目确认其归属版本)。
  2. 影响范围:仅影响 ZipFile.writestr() 且未显式提供 ZipInfo 实例的路径;显式构造 ZipInfo 并自行设置 date_time 时,你的设置优先;ZipFile.write() 写入磁盘文件时使用文件 mtime,不受 SOURCE_DATE_EPOCH 影响。
  3. 环境要求SOURCE_DATE_EPOCH 的值应为可被 int() 解析的十进制 Unix 秒数;设置非法值时可能抛出 ValueError,请在构建脚本中保证其格式正确。
  4. 边界行为:未设置 SOURCE_DATE_EPOCH 时,writestr() 仍使用本地时间,行为与以往一致;若你的本地时间早于 1980 或晚于 2107,请使用 strict_timestamps=False 或自行设置 ZipInfo.date_time 以避免 DOS 时间字段越界。

小结

gh-issue-134261 的修复以一行核心改动(time.localtime()time.gmtime())解决了可复现构建下 ZIP 时间戳的时区敏感问题:SOURCE_DATE_EPOCH 语义上本就是 UTC 时间戳,按 UTC 展开既符合规范语义,又从根源上消除了负时区导致的 DOS 时间下溢。配套的镜像测试(Lib/test/test_zipfile/test_core.py 第 4024~4047 行)与测试支撑装饰器(Lib/test/support/os_helper.py 第 813~838 行)确保该行为在有无 SOURCE_DATE_EPOCH 两种场景下都被稳定约束,为依赖 zipfile 打包的构建系统提供了时区无关的确定性保障。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
docsdocs
暂无描述
Markdown
899
5.83 K
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
925
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.84 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
601
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
395
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.04 K
525