diff --git a/Changelog b/Changelog index ed797be3..780b58e6 100644 --- a/Changelog +++ b/Changelog @@ -23,6 +23,11 @@ Releases * Fixed `UnicodeDecodeError` when an archive has non-ASCII characters. + * Honor the ``SOURCE_DATE_EPOCH`` environment variable to support + `reproducible builds + `_. + [#187](https://github.com/matthew-brett/delocate/pull/187) + * [0.10.4] - 2022-12-17 * Dependency paths with ``@rpath``, ``@loader_path`` and ``@executable_path`` diff --git a/delocate/tests/env_tools.py b/delocate/tests/env_tools.py index 29d2cc9d..f73c4408 100644 --- a/delocate/tests/env_tools.py +++ b/delocate/tests/env_tools.py @@ -1,5 +1,6 @@ import os from contextlib import contextmanager +from typing import Iterator from ..tmpdirs import InTemporaryDirectory @@ -24,3 +25,20 @@ def TempDirWithoutEnvVars(*env_vars): else: if var in os.environ: del os.environ[var] + + +@contextmanager +def _scope_env(**env: str) -> Iterator[None]: + """Add `env` to the environment and remove them after testing + is complete. + """ + env_save = {key: os.environ.get(key) for key in env} + try: + os.environ.update(env) + yield + finally: + for key, value in env_save.items(): + if value is None: + del os.environ[key] + else: + os.environ[key] = value diff --git a/delocate/tests/test_wheelies.py b/delocate/tests/test_wheelies.py index 21da2fea..fabe189d 100644 --- a/delocate/tests/test_wheelies.py +++ b/delocate/tests/test_wheelies.py @@ -5,6 +5,7 @@ import stat import subprocess import sys +import zipfile from glob import glob from os.path import abspath, basename, exists, isdir from os.path import join as pjoin @@ -30,6 +31,7 @@ zip2dir, ) from ..wheeltools import InWheel +from .env_tools import _scope_env from .pytest_tools import assert_equal, assert_false, assert_raises, assert_true from .test_install_names import DATA_PATH, EXT_LIBS from .test_tools import ARCH_BOTH, ARCH_M1 @@ -439,3 +441,20 @@ def test_fix_namespace() -> None: } assert delocate_wheel(NAMESPACE_WHEEL, "out.whl") == stray_libs + + +def test_source_date_epoch() -> None: + with InTemporaryDirectory(): + zip2dir(PURE_WHEEL, "package") + for date_time, sde in ( + ((1980, 1, 1, 0, 0, 0), 42), + ((1980, 1, 1, 0, 0, 0), 315532800), + ((1980, 1, 1, 0, 0, 2), 315532802), + ((2020, 2, 2, 0, 0, 0), 1580601600), + ): + with _scope_env(SOURCE_DATE_EPOCH=str(sde)): + dir2zip("package", "package.zip") + with zipfile.ZipFile("package.zip", "r") as zip: + for name in zip.namelist(): + member = zip.getinfo(name) + assert_equal(member.date_time, date_time) diff --git a/delocate/tools.py b/delocate/tools.py index 18b727bc..bb1d1762 100644 --- a/delocate/tools.py +++ b/delocate/tools.py @@ -5,6 +5,7 @@ import re import stat import subprocess +import time import warnings import zipfile from datetime import datetime @@ -844,12 +845,42 @@ def zip2dir( os.utime(extracted_path, (modified_time, modified_time)) +_ZIP_TIMESTAMP_MIN = 315532800 # 1980-01-01 00:00:00 UTC +_DateTuple = Tuple[int, int, int, int, int, int] + + +def _get_zip_datetime( + date_time: Optional[_DateTuple] = None, +) -> Optional[_DateTuple]: + """Utility function to support reproducible builds + + https://reproducible-builds.org/docs/source-date-epoch/ + + Parameters + ---------- + date_time : tuple of int, optional + Datetime tuple ``(Y, m, d, H, M, S)``. + + Returns + ------- + zip_date_time : tuple of int, optional + Datetime tuple corresponding to the ``SOURCE_DATE_EPOCH`` + environment variable if set, otherwise the input `date_time`. + """ + source_date_epoch = os.environ.get("SOURCE_DATE_EPOCH") + if source_date_epoch is not None: + timestamp = max(int(source_date_epoch), _ZIP_TIMESTAMP_MIN) + date_time = time.gmtime(timestamp)[0:6] + return date_time + + def dir2zip( in_dir: str | PathLike[str], zip_fname: str | PathLike[str], *, compression: int = zipfile.ZIP_DEFLATED, compress_level: int = -1, + date_time: Optional[_DateTuple] = None, ) -> None: """Make a zip file `zip_fname` with contents of directory `in_dir` @@ -867,7 +898,10 @@ def dir2zip( The zipfile compression type used. compress_level : int, optional, keyword-only The compression level used for this archive. + date_time : tuple of int, optional, keyword-only + Datetime tuple ``(Y, m, d, H, M, S)`` for all recorded entries. """ + date_time = _get_zip_datetime(date_time) with zipfile.ZipFile( zip_fname, "w", compression=compression, compresslevel=compress_level ) as zip: @@ -876,11 +910,15 @@ def dir2zip( dir_path = Path(root, dir) out_dir_name = str(dir_path.relative_to(in_dir)) + "/" zip_info = zipfile.ZipInfo.from_file(dir_path, out_dir_name) + if date_time is not None: + zip_info.date_time = date_time zip.writestr(zip_info, b"") for file in files: file_path = Path(root, file) out_file_path = file_path.relative_to(in_dir) zip_info = zipfile.ZipInfo.from_file(file_path, out_file_path) + if date_time is not None: + zip_info.date_time = date_time zip.writestr( zip_info, file_path.read_bytes(),