From 8452300d54dce2da751941d9547dd54dc03e69bf Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+AA-Turner@users.noreply.github.com> Date: Thu, 27 Jul 2023 21:27:14 +0100 Subject: [PATCH] Fix multi-line copyright when ``SOURCE_DATE_EPOCH`` is set (#11524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- CHANGES | 3 ++ sphinx/config.py | 53 +++++++++++++++++++++++++------ tests/test_config.py | 75 +++++++++++++++++++++++++++++++++++--------- 3 files changed, 107 insertions(+), 24 deletions(-) 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