Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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 @@ -25,6 +25,9 @@ Bugs fixed

* #13369: Correctly parse and cross-reference unpacked type annotations.
Patch by Alicia Garcia-Raboso.
* #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 @@ -8,7 +8,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 @@ -21,6 +21,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 @@ -200,11 +201,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 @@ -700,7 +701,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:
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