Skip to content
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

Fix multi-line copyright when SOURCE_DATE_EPOCH is set #11524

Merged
merged 2 commits into from Jul 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES
Expand Up @@ -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
-------

Expand Down
53 changes: 44 additions & 9 deletions sphinx/config.py
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

import re
import time
import traceback
import types
from os import getenv, path
Expand All @@ -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
Expand All @@ -22,14 +21,15 @@
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

logger = logging.getLogger(__name__)

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


class ConfigValue(NamedTuple):
Expand Down Expand Up @@ -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:
Expand Down
75 changes: 60 additions & 15 deletions tests/test_config.py
@@ -1,5 +1,6 @@
"""Test the sphinx.config.Config class."""

import time
from unittest import mock

import pytest
Expand Down Expand Up @@ -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 ' &#169; Copyright 2006-2009, Alice.<br/>' in content
assert ' &#169; Copyright 2010-2013, Bob.<br/>' in content
assert ' &#169; Copyright 2014-2017, Charlie.<br/>' in content
assert ' &#169; Copyright 2018-2021, David.<br/>' in content
assert ' &#169; Copyright 2022-2025, Eve.' in content

lines = (
' &#169; Copyright 2006-2009, Alice.<br/>\n \n'
' &#169; Copyright 2010-2013, Bob.<br/>\n \n'
' &#169; Copyright 2014-2017, Charlie.<br/>\n \n'
' &#169; Copyright 2018-2021, David.<br/>\n \n'
' &#169; 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 ' &#169; Copyright 2006-2009, Alice.<br/>\n' in content
assert ' &#169; Copyright 2010-2013, Bob.<br/>\n' in content
assert ' &#169; Copyright 2014-2017, Charlie.<br/>\n' in content
assert ' &#169; Copyright 2018-2021, David.<br/>\n' in content
assert ' &#169; Copyright 2022-2025, Eve.' in content

# check the raw copyright footer block (empty lines included)
assert (
' &#169; Copyright 2006-2009, Alice.<br/>\n'
' \n'
' &#169; Copyright 2010-2013, Bob.<br/>\n'
' \n'
' &#169; Copyright 2014-2017, Charlie.<br/>\n'
' \n'
' &#169; Copyright 2018-2021, David.<br/>\n'
' \n'
' &#169; Copyright 2022-2025, Eve.'
) in content
else:
# check the copyright footer line by line (empty lines ignored)
assert f' &#169; Copyright 2006-{source_date_year}, Alice.<br/>\n' in content
assert f' &#169; Copyright 2010-{source_date_year}, Bob.<br/>\n' in content
assert f' &#169; Copyright 2014-{source_date_year}, Charlie.<br/>\n' in content
assert f' &#169; Copyright 2018-{source_date_year}, David.<br/>\n' in content
assert f' &#169; Copyright 2022-{source_date_year}, Eve.' in content

# check the raw copyright footer block (empty lines included)
assert (
f' &#169; Copyright 2006-{source_date_year}, Alice.<br/>\n'
f' \n'
f' &#169; Copyright 2010-{source_date_year}, Bob.<br/>\n'
f' \n'
f' &#169; Copyright 2014-{source_date_year}, Charlie.<br/>\n'
f' \n'
f' &#169; Copyright 2018-{source_date_year}, David.<br/>\n'
f' \n'
f' &#169; Copyright 2022-{source_date_year}, Eve.'
) in content