diff --git a/CHANGES.rst b/CHANGES.rst index 06990919e1b..d58ad85ad71 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -89,6 +89,9 @@ Bugs fixed Patch by Jean-François B. * #13685: gettext: Correctly ignore trailing backslashes. Patch by Bénédikt Tran. +* #13526: Improve ``SOURCE_DATE_EPOCH`` support during ``%Y`` pattern + substition in :confval:`copyright` (and :confval:`project_copyright`). + Patch by James Addison. Testing ------- diff --git a/sphinx/builders/gettext.py b/sphinx/builders/gettext.py index fc659d744d5..c452cd6fbf8 100644 --- a/sphinx/builders/gettext.py +++ b/sphinx/builders/gettext.py @@ -7,7 +7,7 @@ import os.path import time from collections import defaultdict -from os import getenv, walk +from os import walk from pathlib import Path from typing import TYPE_CHECKING from uuid import uuid4 @@ -20,6 +20,7 @@ from sphinx.errors import ThemeError from sphinx.locale import __ from sphinx.util import logging +from sphinx.util._timestamps import _get_publication_time from sphinx.util.display import status_iterator from sphinx.util.i18n import docname_to_domain from sphinx.util.index_entries import split_index_msg @@ -199,11 +200,7 @@ def write_doc(self, docname: str, doctree: nodes.document) -> None: # If set, use the timestamp from SOURCE_DATE_EPOCH # https://reproducible-builds.org/specs/source-date-epoch/ -if (source_date_epoch := getenv('SOURCE_DATE_EPOCH')) is not None: - timestamp = time.gmtime(float(source_date_epoch)) -else: - # determine timestamp once to remain unaffected by DST changes during build - timestamp = time.localtime() +timestamp = _get_publication_time() ctime = time.strftime('%Y-%m-%d %H:%M%z', timestamp) diff --git a/sphinx/config.py b/sphinx/config.py index ba6ec2bd619..1cc0a7e6635 100644 --- a/sphinx/config.py +++ b/sphinx/config.py @@ -15,6 +15,7 @@ from sphinx.errors import ConfigError, ExtensionError from sphinx.locale import _, __ from sphinx.util import logging +from sphinx.util._timestamps import _get_publication_time if TYPE_CHECKING: import os @@ -707,7 +708,8 @@ def init_numfig_format(app: Sphinx, config: Config) -> None: def evaluate_copyright_placeholders(_app: Sphinx, config: Config) -> None: """Replace copyright year placeholders (%Y) with the current year.""" - replace_yr = str(time.localtime().tm_year) + publication_time = _get_publication_time() + replace_yr = str(publication_time.tm_year) for k in ('copyright', 'epub_copyright'): if k in config: value: str | Sequence[str] = config[k] diff --git a/sphinx/util/_timestamps.py b/sphinx/util/_timestamps.py index a4836491155..111123bfe77 100644 --- a/sphinx/util/_timestamps.py +++ b/sphinx/util/_timestamps.py @@ -1,6 +1,7 @@ from __future__ import annotations import time +from os import getenv def _format_rfc3339_microseconds(timestamp: int, /) -> str: @@ -11,3 +12,19 @@ def _format_rfc3339_microseconds(timestamp: int, /) -> str: seconds, fraction = divmod(timestamp, 10**6) time_tuple = time.gmtime(seconds) return time.strftime('%Y-%m-%d %H:%M:%S', time_tuple) + f'.{fraction // 1_000}' + + +def _get_publication_time() -> time.struct_time: + """Return the publication time to use for the current build. + + If set, use the timestamp from SOURCE_DATE_EPOCH + https://reproducible-builds.org/specs/source-date-epoch/ + + Publication time cannot be projected into the future (beyond the local system + clock time). + """ + system_time = time.localtime() + if (source_date_epoch := getenv('SOURCE_DATE_EPOCH')) is not None: + if (rebuild_time := time.localtime(float(source_date_epoch))) < system_time: + return rebuild_time + return system_time diff --git a/tests/test_config/test_copyright.py b/tests/test_config/test_copyright.py index 407743045f0..00b7dcb7662 100644 --- a/tests/test_config/test_copyright.py +++ b/tests/test_config/test_copyright.py @@ -43,7 +43,8 @@ def expect_date( ) -> Iterator[int | None]: sde, expect = request.param with monkeypatch.context() as m: - m.setattr(time, 'localtime', lambda *a: LOCALTIME_2009) + lt_orig = time.localtime + m.setattr(time, 'localtime', lambda *a: lt_orig(*a) if a else LOCALTIME_2009) if sde: m.setenv('SOURCE_DATE_EPOCH', sde) else: @@ -129,7 +130,6 @@ def test_correct_year_placeholder(expect_date: int | None) -> None: cfg = Config({'copyright': copyright_date}, {}) assert cfg.copyright == copyright_date evaluate_copyright_placeholders(None, cfg) # type: ignore[arg-type] - correct_copyright_year(None, cfg) # type: ignore[arg-type] if expect_date and expect_date <= LOCALTIME_2009.tm_year: assert cfg.copyright == f'2006-{expect_date}, Alice' else: @@ -203,11 +203,12 @@ def test_correct_year_multi_line_all_formats_placeholder( # other format codes are left as-is '2006-%y, Eve', '%Y-%m-%d %H:%M:S %z, Francis', + # non-ascii range patterns are supported + '2000–%Y Guinevere', ) cfg = Config({'copyright': copyright_dates}, {}) assert cfg.copyright == copyright_dates evaluate_copyright_placeholders(None, cfg) # type: ignore[arg-type] - correct_copyright_year(None, cfg) # type: ignore[arg-type] if expect_date and expect_date <= LOCALTIME_2009.tm_year: assert cfg.copyright == ( f'{expect_date}', @@ -217,7 +218,8 @@ def test_correct_year_multi_line_all_formats_placeholder( f'2006-{expect_date} Charlie', f'2006-{expect_date}, David', '2006-%y, Eve', - '2009-%m-%d %H:%M:S %z, Francis', + f'{expect_date}-%m-%d %H:%M:S %z, Francis', + f'2000–{expect_date} Guinevere', ) else: assert cfg.copyright == ( @@ -229,6 +231,7 @@ def test_correct_year_multi_line_all_formats_placeholder( '2006-2009, David', '2006-%y, Eve', '2009-%m-%d %H:%M:S %z, Francis', + '2000–2009 Guinevere', )