Skip to content

Config: native 'SOURCE_DATE_EPOCH' pattern-replacement support #13538

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------
9 changes: 3 additions & 6 deletions sphinx/builders/gettext.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)


Expand Down
4 changes: 3 additions & 1 deletion sphinx/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
17 changes: 17 additions & 0 deletions sphinx/util/_timestamps.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import time
from os import getenv


def _format_rfc3339_microseconds(timestamp: int, /) -> str:
Expand All @@ -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:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: in gettext.py, previously SOURCE_DATE_EPOCH was used in combination with time.gmtime (UTC-relative), instead of time.localtime (system-local-timezone relative).

I will admit that one aspect of using localtime here is that it required significantly less adjustment to the test suite. That's not a great reason to adjust it, though.

I do think that strictly speaking, time.localtime provides the most flexibility for users -- although it does open up the quirk/oddity that some dates close to the start/end of the year could flip between years if people are not careful to set a constant timezone alongside their SOURCE_DATE_EPOCH.

However: if someone does want to create reproducible documentation, and legitimately wants to fix their timezone to their location (or the location of their organization, or the place where the printer is going to spool their printout, or whatever), then I think allowing a combination of TZ (e.g. time.localtime) alongside SOURCE_DATE_EPOCH is the way to go.

return rebuild_time
return system_time
11 changes: 7 additions & 4 deletions tests/test_config/test_copyright.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}',
Expand All @@ -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 == (
Expand All @@ -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',
)


Expand Down
Loading