Skip to content

Commit 8452300

Browse files
AA-Turnerpicnixz
andauthored
Fix multi-line copyright when SOURCE_DATE_EPOCH is set (#11524)
Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com>
1 parent fe08cec commit 8452300

File tree

3 files changed

+107
-24
lines changed

3 files changed

+107
-24
lines changed

CHANGES

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ Features added
1616
Bugs fixed
1717
----------
1818

19+
* #11514: Fix ``SOURCE_DATE_EPOCH`` in multi-line copyright footer.
20+
Patch by Bénédikt Tran.
21+
1922
Testing
2023
-------
2124

sphinx/config.py

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
import re
5+
import time
66
import traceback
77
import types
88
from os import getenv, path
@@ -11,7 +11,6 @@
1111
from sphinx.errors import ConfigError, ExtensionError
1212
from sphinx.locale import _, __
1313
from sphinx.util import logging
14-
from sphinx.util.i18n import format_date
1514
from sphinx.util.osutil import fs_encoding
1615
from sphinx.util.tags import Tags
1716
from sphinx.util.typing import NoneType
@@ -22,14 +21,15 @@
2221
from sphinx.util.osutil import _chdir as chdir
2322

2423
if TYPE_CHECKING:
24+
from collections.abc import Sequence
25+
2526
from sphinx.application import Sphinx
2627
from sphinx.environment import BuildEnvironment
2728

2829
logger = logging.getLogger(__name__)
2930

3031
CONFIG_FILENAME = 'conf.py'
3132
UNSERIALIZABLE_TYPES = (type, types.ModuleType, types.FunctionType)
32-
copyright_year_re = re.compile(r'^((\d{4}-)?)(\d{4})(?=[ ,])')
3333

3434

3535
class ConfigValue(NamedTuple):
@@ -417,17 +417,52 @@ def init_numfig_format(app: Sphinx, config: Config) -> None:
417417
config.numfig_format = numfig_format # type: ignore
418418

419419

420-
def correct_copyright_year(app: Sphinx, config: Config) -> None:
420+
def correct_copyright_year(_app: Sphinx, config: Config) -> None:
421421
"""Correct values of copyright year that are not coherent with
422422
the SOURCE_DATE_EPOCH environment variable (if set)
423423
424424
See https://reproducible-builds.org/specs/source-date-epoch/
425425
"""
426-
if getenv('SOURCE_DATE_EPOCH') is not None:
427-
for k in ('copyright', 'epub_copyright'):
428-
if k in config:
429-
replace = r'\g<1>%s' % format_date('%Y', language='en')
430-
config[k] = copyright_year_re.sub(replace, config[k])
426+
if (source_date_epoch := getenv('SOURCE_DATE_EPOCH')) is None:
427+
return
428+
429+
source_date_epoch_year = str(time.gmtime(int(source_date_epoch)).tm_year)
430+
431+
for k in ('copyright', 'epub_copyright'):
432+
if k in config:
433+
value: str | Sequence[str] = config[k]
434+
if isinstance(value, str):
435+
config[k] = _substitute_copyright_year(value, source_date_epoch_year)
436+
else:
437+
items = (_substitute_copyright_year(x, source_date_epoch_year) for x in value)
438+
config[k] = type(value)(items) # type: ignore[call-arg]
439+
440+
441+
def _substitute_copyright_year(copyright_line: str, replace_year: str) -> str:
442+
"""Replace the year in a single copyright line.
443+
444+
Legal formats are:
445+
446+
* ``YYYY,``
447+
* ``YYYY ``
448+
* ``YYYY-YYYY,``
449+
* ``YYYY-YYYY ``
450+
451+
The final year in the string is replaced with ``replace_year``.
452+
"""
453+
if not copyright_line[:4].isdigit():
454+
return copyright_line
455+
456+
if copyright_line[4] in ' ,':
457+
return replace_year + copyright_line[4:]
458+
459+
if copyright_line[4] != '-':
460+
return copyright_line
461+
462+
if copyright_line[5:9].isdigit() and copyright_line[9] in ' ,':
463+
return copyright_line[:5] + replace_year + copyright_line[9:]
464+
465+
return copyright_line
431466

432467

433468
def check_confval_types(app: Sphinx | None, config: Config) -> None:

tests/test_config.py

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Test the sphinx.config.Config class."""
22

3+
import time
34
from unittest import mock
45

56
import pytest
@@ -444,23 +445,67 @@ def test_conf_py_nitpick_ignore_list(tempdir):
444445
assert cfg.nitpick_ignore_regex == []
445446

446447

448+
@pytest.fixture(params=[
449+
# test with SOURCE_DATE_EPOCH unset: no modification
450+
None,
451+
# test with SOURCE_DATE_EPOCH set: copyright year should be updated
452+
1293840000,
453+
1293839999,
454+
])
455+
def source_date_year(request, monkeypatch):
456+
sde = request.param
457+
with monkeypatch.context() as m:
458+
if sde:
459+
m.setenv('SOURCE_DATE_EPOCH', sde)
460+
yield time.gmtime(sde).tm_year
461+
else:
462+
m.delenv('SOURCE_DATE_EPOCH', raising=False)
463+
yield None
464+
465+
447466
@pytest.mark.sphinx(testroot='copyright-multiline')
448-
def test_multi_line_copyright(app, status, warning):
467+
def test_multi_line_copyright(source_date_year, app, monkeypatch):
449468
app.builder.build_all()
450469

451470
content = (app.outdir / 'index.html').read_text(encoding='utf-8')
452471

453-
assert ' &#169; Copyright 2006-2009, Alice.<br/>' in content
454-
assert ' &#169; Copyright 2010-2013, Bob.<br/>' in content
455-
assert ' &#169; Copyright 2014-2017, Charlie.<br/>' in content
456-
assert ' &#169; Copyright 2018-2021, David.<br/>' in content
457-
assert ' &#169; Copyright 2022-2025, Eve.' in content
458-
459-
lines = (
460-
' &#169; Copyright 2006-2009, Alice.<br/>\n \n'
461-
' &#169; Copyright 2010-2013, Bob.<br/>\n \n'
462-
' &#169; Copyright 2014-2017, Charlie.<br/>\n \n'
463-
' &#169; Copyright 2018-2021, David.<br/>\n \n'
464-
' &#169; Copyright 2022-2025, Eve.\n \n'
465-
)
466-
assert lines in content
472+
if source_date_year is None:
473+
# check the copyright footer line by line (empty lines ignored)
474+
assert ' &#169; Copyright 2006-2009, Alice.<br/>\n' in content
475+
assert ' &#169; Copyright 2010-2013, Bob.<br/>\n' in content
476+
assert ' &#169; Copyright 2014-2017, Charlie.<br/>\n' in content
477+
assert ' &#169; Copyright 2018-2021, David.<br/>\n' in content
478+
assert ' &#169; Copyright 2022-2025, Eve.' in content
479+
480+
# check the raw copyright footer block (empty lines included)
481+
assert (
482+
' &#169; Copyright 2006-2009, Alice.<br/>\n'
483+
' \n'
484+
' &#169; Copyright 2010-2013, Bob.<br/>\n'
485+
' \n'
486+
' &#169; Copyright 2014-2017, Charlie.<br/>\n'
487+
' \n'
488+
' &#169; Copyright 2018-2021, David.<br/>\n'
489+
' \n'
490+
' &#169; Copyright 2022-2025, Eve.'
491+
) in content
492+
else:
493+
# check the copyright footer line by line (empty lines ignored)
494+
assert f' &#169; Copyright 2006-{source_date_year}, Alice.<br/>\n' in content
495+
assert f' &#169; Copyright 2010-{source_date_year}, Bob.<br/>\n' in content
496+
assert f' &#169; Copyright 2014-{source_date_year}, Charlie.<br/>\n' in content
497+
assert f' &#169; Copyright 2018-{source_date_year}, David.<br/>\n' in content
498+
assert f' &#169; Copyright 2022-{source_date_year}, Eve.' in content
499+
500+
# check the raw copyright footer block (empty lines included)
501+
assert (
502+
f' &#169; Copyright 2006-{source_date_year}, Alice.<br/>\n'
503+
f' \n'
504+
f' &#169; Copyright 2010-{source_date_year}, Bob.<br/>\n'
505+
f' \n'
506+
f' &#169; Copyright 2014-{source_date_year}, Charlie.<br/>\n'
507+
f' \n'
508+
f' &#169; Copyright 2018-{source_date_year}, David.<br/>\n'
509+
f' \n'
510+
f' &#169; Copyright 2022-{source_date_year}, Eve.'
511+
) in content

0 commit comments

Comments
 (0)