diff --git a/CHANGES b/CHANGES
index d8ed7954de0..dab9bb0afc2 100644
--- a/CHANGES
+++ b/CHANGES
@@ -16,6 +16,9 @@ Features added
Bugs fixed
----------
+* #11514: Fix ``SOURCE_DATE_EPOCH`` in multi-line copyright footer.
+ Patch by Bénédikt Tran.
+
Testing
-------
diff --git a/sphinx/config.py b/sphinx/config.py
index 8b8a136e185..e0c94d5449d 100644
--- a/sphinx/config.py
+++ b/sphinx/config.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-import re
+import time
import traceback
import types
from os import getenv, path
@@ -11,7 +11,6 @@
from sphinx.errors import ConfigError, ExtensionError
from sphinx.locale import _, __
from sphinx.util import logging
-from sphinx.util.i18n import format_date
from sphinx.util.osutil import fs_encoding
from sphinx.util.tags import Tags
from sphinx.util.typing import NoneType
@@ -22,6 +21,8 @@
from sphinx.util.osutil import _chdir as chdir
if TYPE_CHECKING:
+ from collections.abc import Sequence
+
from sphinx.application import Sphinx
from sphinx.environment import BuildEnvironment
@@ -29,7 +30,6 @@
CONFIG_FILENAME = 'conf.py'
UNSERIALIZABLE_TYPES = (type, types.ModuleType, types.FunctionType)
-copyright_year_re = re.compile(r'^((\d{4}-)?)(\d{4})(?=[ ,])')
class ConfigValue(NamedTuple):
@@ -417,17 +417,52 @@ def init_numfig_format(app: Sphinx, config: Config) -> None:
config.numfig_format = numfig_format # type: ignore
-def correct_copyright_year(app: Sphinx, config: Config) -> None:
+def correct_copyright_year(_app: Sphinx, config: Config) -> None:
"""Correct values of copyright year that are not coherent with
the SOURCE_DATE_EPOCH environment variable (if set)
See https://reproducible-builds.org/specs/source-date-epoch/
"""
- if getenv('SOURCE_DATE_EPOCH') is not None:
- for k in ('copyright', 'epub_copyright'):
- if k in config:
- replace = r'\g<1>%s' % format_date('%Y', language='en')
- config[k] = copyright_year_re.sub(replace, config[k])
+ if (source_date_epoch := getenv('SOURCE_DATE_EPOCH')) is None:
+ return
+
+ source_date_epoch_year = str(time.gmtime(int(source_date_epoch)).tm_year)
+
+ for k in ('copyright', 'epub_copyright'):
+ if k in config:
+ value: str | Sequence[str] = config[k]
+ if isinstance(value, str):
+ config[k] = _substitute_copyright_year(value, source_date_epoch_year)
+ else:
+ items = (_substitute_copyright_year(x, source_date_epoch_year) for x in value)
+ config[k] = type(value)(items) # type: ignore[call-arg]
+
+
+def _substitute_copyright_year(copyright_line: str, replace_year: str) -> str:
+ """Replace the year in a single copyright line.
+
+ Legal formats are:
+
+ * ``YYYY,``
+ * ``YYYY ``
+ * ``YYYY-YYYY,``
+ * ``YYYY-YYYY ``
+
+ The final year in the string is replaced with ``replace_year``.
+ """
+ if not copyright_line[:4].isdigit():
+ return copyright_line
+
+ if copyright_line[4] in ' ,':
+ return replace_year + copyright_line[4:]
+
+ if copyright_line[4] != '-':
+ return copyright_line
+
+ if copyright_line[5:9].isdigit() and copyright_line[9] in ' ,':
+ return copyright_line[:5] + replace_year + copyright_line[9:]
+
+ return copyright_line
def check_confval_types(app: Sphinx | None, config: Config) -> None:
diff --git a/tests/test_config.py b/tests/test_config.py
index f01ea0c32d2..19ad6969789 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -1,5 +1,6 @@
"""Test the sphinx.config.Config class."""
+import time
from unittest import mock
import pytest
@@ -444,23 +445,67 @@ def test_conf_py_nitpick_ignore_list(tempdir):
assert cfg.nitpick_ignore_regex == []
+@pytest.fixture(params=[
+ # test with SOURCE_DATE_EPOCH unset: no modification
+ None,
+ # test with SOURCE_DATE_EPOCH set: copyright year should be updated
+ 1293840000,
+ 1293839999,
+])
+def source_date_year(request, monkeypatch):
+ sde = request.param
+ with monkeypatch.context() as m:
+ if sde:
+ m.setenv('SOURCE_DATE_EPOCH', sde)
+ yield time.gmtime(sde).tm_year
+ else:
+ m.delenv('SOURCE_DATE_EPOCH', raising=False)
+ yield None
+
+
@pytest.mark.sphinx(testroot='copyright-multiline')
-def test_multi_line_copyright(app, status, warning):
+def test_multi_line_copyright(source_date_year, app, monkeypatch):
app.builder.build_all()
content = (app.outdir / 'index.html').read_text(encoding='utf-8')
- assert ' © Copyright 2006-2009, Alice.
' in content
- assert ' © Copyright 2010-2013, Bob.
' in content
- assert ' © Copyright 2014-2017, Charlie.
' in content
- assert ' © Copyright 2018-2021, David.
' in content
- assert ' © Copyright 2022-2025, Eve.' in content
-
- lines = (
- ' © Copyright 2006-2009, Alice.
\n \n'
- ' © Copyright 2010-2013, Bob.
\n \n'
- ' © Copyright 2014-2017, Charlie.
\n \n'
- ' © Copyright 2018-2021, David.
\n \n'
- ' © Copyright 2022-2025, Eve.\n \n'
- )
- assert lines in content
+ if source_date_year is None:
+ # check the copyright footer line by line (empty lines ignored)
+ assert ' © Copyright 2006-2009, Alice.
\n' in content
+ assert ' © Copyright 2010-2013, Bob.
\n' in content
+ assert ' © Copyright 2014-2017, Charlie.
\n' in content
+ assert ' © Copyright 2018-2021, David.
\n' in content
+ assert ' © Copyright 2022-2025, Eve.' in content
+
+ # check the raw copyright footer block (empty lines included)
+ assert (
+ ' © Copyright 2006-2009, Alice.
\n'
+ ' \n'
+ ' © Copyright 2010-2013, Bob.
\n'
+ ' \n'
+ ' © Copyright 2014-2017, Charlie.
\n'
+ ' \n'
+ ' © Copyright 2018-2021, David.
\n'
+ ' \n'
+ ' © Copyright 2022-2025, Eve.'
+ ) in content
+ else:
+ # check the copyright footer line by line (empty lines ignored)
+ assert f' © Copyright 2006-{source_date_year}, Alice.
\n' in content
+ assert f' © Copyright 2010-{source_date_year}, Bob.
\n' in content
+ assert f' © Copyright 2014-{source_date_year}, Charlie.
\n' in content
+ assert f' © Copyright 2018-{source_date_year}, David.
\n' in content
+ assert f' © Copyright 2022-{source_date_year}, Eve.' in content
+
+ # check the raw copyright footer block (empty lines included)
+ assert (
+ f' © Copyright 2006-{source_date_year}, Alice.
\n'
+ f' \n'
+ f' © Copyright 2010-{source_date_year}, Bob.
\n'
+ f' \n'
+ f' © Copyright 2014-{source_date_year}, Charlie.
\n'
+ f' \n'
+ f' © Copyright 2018-{source_date_year}, David.
\n'
+ f' \n'
+ f' © Copyright 2022-{source_date_year}, Eve.'
+ ) in content