Repository: sphinx-doc/sphinx

Issue:
[config] Support more precise build-year copyright substitution
### Describe the bug

Projects can configure multiline copyright notices (ref #4925), a useful feature for projects that have transitions between copyright holders over time.

However, the current config year-substitution logic enabled when [`SOURCE_DATE_EPOCH`](https://reproducible-builds.org/docs/source-date-epoch/) is configured will attempt to pattern-match and substitute the end-year from the build-year in _all_ of the copyright lines.

I'm not sure that's intended; I think that we should only apply the substitution for the most recent (which for an iterable I suppose means the final) line in the notice.

### How to Reproduce

**conf.py**
```py
copyright = [
    '2000-2001 - first copyright holder',
    '2002-2003 - second copyright holder',
]
```

**index.rst**
```rst
Example Document
```

**Expected**
```
['2000-2001 - first copyright holder', '2002-2024 - second copyright holder']
```

**Actual**
```
['2000-2024 - first copyright holder', '2002-2024 - second copyright holder']
```

### Environment Information

```text
Platform:              linux; (Linux-6.8.12-arm64-aarch64-with-glibc2.38)
Python version:        3.11.9 (main, Apr 10 2024, 13:16:36) [GCC 13.2.0])
Python implementation: CPython
Sphinx version:        7.4.0+/6471027d1
Docutils version:      0.21.2
Jinja2 version:        3.1.4
Pygments version:      2.18.0
```


### Sphinx extensions

```python
N/A
```


### Additional context

Discovered while investigating whether multiline copyright config could be a way to reduce risk of rewriting unintended copyright lines re: any interaction between matplotlib/matplotlib#28418 and #12450.


Discussion:
Although I feel like I want to make adjustments here and may have ideas about how to do that, I'd appreciate input from other maintainers/developers because churn/incorrect fixes here could _potentially_ be more disruptive than simply leaving the code-is.

AFAIK we don't currently have a way for downstream projects to entirely disable the dynamic copyright substitution logic when `SOURCE_DATE_EPOCH` is enabled.

The `matplotlib` project is not affected; they do have a multi-statement copyright notice, but all the statements are configured in a single `str` (not a `list`) `copyright` config value, and, crucially, the first line of that text uses a Unicode en-dash instead of an ASCII dash (minus symbol), which Sphinx doesn't match on during substitution.
Also: maintainers: please feel free to convert this into a GitHub discussion if that's a more appropriate venue for this thread.
I'm not convinced that my assertion that only the most-recent/last copyright line should be updated is always true.

Although updating every line does seem _incorrect_ in some situations, it seems unclear/intractable how to determine which lines to update.  This could be a reason why some projects have chosen to avoid the substitution logic by using en-dash.

In terms of ideas for routes towards improvements, though -- and bearing in mind that, as far as possible, we should avoid breaking existing projects:

Perhaps we could provide a `{{current_year}}` helper variable so that documentation projects that _do_ want dynamic year values could opt-into a documented, somewhat-supported way to dynamically retrieve that build-time value (with support for `SOURCE_DATE_EPOCH`).

Projects that do want dynamic copyright notices could then use that variable in either their single-line or multi-line copyright notices, as appropriate for their use case(s).

As [mentioned in a previous thread](https://github.com/sphinx-doc/sphinx/pull/11516#issuecomment-1652533878) I'm not too keen on dynamic copyright notices personally, but we do have practical examples of Sphinx projects that use them, and a way to retrieve the current year could help us support those cases.  If I were to implement that, I'd document the substitution variable with a note to indicate that it is available for use, but not necessarily recommended.
I'd propose a combination of two changes:

1. Add a config setting to allow downstream sites to disable copyright year substitution entirely if they choose.
2. Add the ability to substitute a specific `{{ current_year }}` pattern within the copyright notice, so that sites can opt-in to current-year substitution with more control, compatible with both single-line and multi-line copyright notices.

I'll attempt to draft a pull request for this soon (within the next week or two).
Exploratory thoughts: in some ways I think that using the last-modified-time (aka `mtime`) of the most recent source document would be a better value to use during current-year copyright notice substitution.  However, from practical experience, and for understandable convenience, many Sphinx projects build directly from `git clone` (checkout of source control) of their documentation sources -- and that cloning process _does not_ preserve the last-modification time of files (in other words: a file last edited in source control two years ago, when retrieved in a clone of the containing repository today, will have today's date in its timestamps (created and modified)).

(context: if we are to add `current_year` replacement -- something I'm weighing up the pros and cons and moral qualms about - it would be good to make that value reasonably-accurate in standard/typical use cases, and relatively difficult to unwittingly build with a nonsensical value.  a nice side-effect (although not the design goal) of using latest-modified-file time as the value would be that the emitted current-year would reflect the last time the source work was indicated as modified)

Although technically feasible, I don't think that it would make sense to attempt to add code to determine whether the source files are contained in a version control system (in order to detect modification times) -- doing that would mean that we end up maintaining code to handle an arbitrary and incomplete list of version control systems.
An incomplete, approximated code search for [`path:conf.py /^copyright.*today/`](https://github.com/search?q=path%3Aconf.py%20%2F%5Ecopyright.*today%2F&type=code) using GitHub code search today (20240705) returns over four thousand results.  No doubt some of those are duplicates, but even so I think this indicates that there is a relatively frequent desire/use-case for current-year in Sphinx copyright notice config.

One reason that I checked that is to determine whether a harsher/breaking-change approach of disabling dynamic copyright notices would be acceptable.  Initially I feel that it is not -- and also it would simply have the effect of pushing the problem elsewhere rather than solving it.

Given that context, the current use of `SOURCE_DATE_EPOCH`-based replacement to achieve replacement selectively in the case of reproducible builds seems like a pragmatic approach.

Possible improvements remaining in my mind:

  * Could/should we detect dynamic vs static `copyright` config, and only perform substitution in the dynamic case?
  * In the case of multiline statements could/should we detect end-years _not_ to modify?  (for example: perhaps we should only update the largest year values?)

There is also a risk of trying to be too clever and creating unnecessary complexity.
>     * Could/should we detect dynamic vs static `copyright` config, and only perform substitution in the dynamic case?

Based on my understanding of Python: detecting statically-declared strings may be possible --we could locate and parse the `conf.py` and then apply some heuristic/AST checks -- but figuring out whether an f-string, for example, actually varies or not in practice is not feasible.  So: it would be somewhat complex, and would have some false-positives (string considered dynamic although it may not vary).  On the plus side, this would allow many simple, statically-configured projects to build reproducibly and correctly without modifications.
 
>     * In the case of multiline statements could/should we detect end-years _not_ to modify?  (for example: perhaps we should only update the largest year values?)

In the general case this seems intractable, because there's very little that we can infer from copyright year ranges in multiline statements.  Did contributions stop because of a change of ownership?  Or simply because a contributor no longer provides edits to a project?  We can't know those during isolated computerized builds from source.

However, it's possible that we could improve the substitution heuristics; for example: we could determine the system-clock year and only perform replacement of years in the notice that match that.  The only edge case I can think of there is dynamic `copyright` values that insert a _different_ year -- but I can't think of a valid reason for a project to do that.



Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index c1d0f410acb..55fb2cf8f6c 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -110,6 +110,10 @@ Bugs fixed
 * #12916: Restore support for custom templates named with the legacy ``_t``
   suffix during ``apidoc`` RST rendering (regression in 7.4.0).
   Patch by James Addison.
+* #12451: Only substitute copyright notice years with values from
+  ``SOURCE_DATE_EPOCH`` for entries that match the current system clock year,
+  and disallow substitution of future years.
+  Patch by James Addison and Adam Turner.
 
 Testing
 -------
diff --git a/sphinx/config.py b/sphinx/config.py
index 3096cef0359..5654eb2558f 100644
--- a/sphinx/config.py
+++ b/sphinx/config.py
@@ -625,22 +625,34 @@ def correct_copyright_year(_app: Sphinx, config: Config) -> None:
 
     See https://reproducible-builds.org/specs/source-date-epoch/
     """
-    if (source_date_epoch := getenv('SOURCE_DATE_EPOCH')) is None:
+    if source_date_epoch := int(getenv('SOURCE_DATE_EPOCH', '0')):
+        source_date_epoch_year = time.gmtime(source_date_epoch).tm_year
+    else:
         return
 
-    source_date_epoch_year = str(time.gmtime(int(source_date_epoch)).tm_year)
+    # If the current year is the replacement year, there's no work to do.
+    # We also skip replacement years that are in the future.
+    current_year = time.localtime().tm_year
+    if current_year <= source_date_epoch_year:
+        return
 
+    current_yr = str(current_year)
+    replace_yr = str(source_date_epoch_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)
+                config[k] = _substitute_copyright_year(value, current_yr, replace_yr)
             else:
-                items = (_substitute_copyright_year(x, source_date_epoch_year) for x in value)
+                items = (
+                    _substitute_copyright_year(x, current_yr, replace_yr) for x in value
+                )
                 config[k] = type(value)(items)  # type: ignore[call-arg]
 
 
-def _substitute_copyright_year(copyright_line: str, replace_year: str) -> str:
+def _substitute_copyright_year(
+    copyright_line: str, current_year: str, replace_year: str
+) -> str:
     """Replace the year in a single copyright line.
 
     Legal formats are:
@@ -657,13 +669,17 @@ def _substitute_copyright_year(copyright_line: str, replace_year: str) -> str:
     if len(copyright_line) < 4 or not copyright_line[:4].isdigit():
         return copyright_line
 
-    if copyright_line[4:5] in {'', ' ', ','}:
+    if copyright_line[:4] == current_year and copyright_line[4:5] in {'', ' ', ','}:
         return replace_year + copyright_line[4:]
 
-    if copyright_line[4] != '-':
+    if copyright_line[4:5] != '-':
         return copyright_line
 
-    if copyright_line[5:9].isdigit() and copyright_line[9:10] in {'', ' ', ','}:
+    if (
+        copyright_line[5:9].isdigit()
+        and copyright_line[5:9] == current_year
+        and copyright_line[9:10] in {'', ' ', ','}
+    ):
         return copyright_line[:5] + replace_year + copyright_line[9:]
 
     return copyright_line


Test patch:
diff --git a/tests/test_builders/test_build_html_copyright.py b/tests/test_builders/test_build_html_copyright.py
index 2ddc50532aa..8e017ede4d7 100644
--- a/tests/test_builders/test_build_html_copyright.py
+++ b/tests/test_builders/test_build_html_copyright.py
@@ -2,16 +2,21 @@
 
 import pytest
 
+LT = time.localtime()
+LT_NEW = (2009, *LT[1:], LT.tm_zone, LT.tm_gmtoff)
+LOCALTIME_2009 = type(LT)(LT_NEW)
+
 
 @pytest.fixture(
     params=[
-        1293840000,  # 2011-01-01 00:00:00
-        1293839999,  # 2010-12-31 23:59:59
+        1199145600,  # 2008-01-01 00:00:00
+        1199145599,  # 2007-12-31 23:59:59
     ]
 )
 def source_date_year(request, monkeypatch):
     source_date_epoch = request.param
     with monkeypatch.context() as m:
+        m.setattr(time, 'localtime', lambda *a: LOCALTIME_2009)
         m.setenv('SOURCE_DATE_EPOCH', str(source_date_epoch))
         yield time.gmtime(source_date_epoch).tm_year
 
@@ -53,24 +58,24 @@ def test_html_multi_line_copyright_sde(source_date_year, app):
     content = (app.outdir / 'index.html').read_text(encoding='utf-8')
 
     # check the copyright footer line by line (empty lines ignored)
-    assert f'  &#169; Copyright {source_date_year}.<br/>\n' in content
+    assert '  &#169; Copyright 2006.<br/>\n' in content
     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
+    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 (
-        f'      &#169; Copyright {source_date_year}.<br/>\n'
-        f'    \n'
+        '      &#169; Copyright 2006.<br/>\n'
+        '    \n'
         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.'
+        '    \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
diff --git a/tests/test_config/test_correct_year.py b/tests/test_config/test_correct_year.py
index 4d0c70f32e4..7aafe5a66f5 100644
--- a/tests/test_config/test_correct_year.py
+++ b/tests/test_config/test_correct_year.py
@@ -14,12 +14,13 @@
 @pytest.fixture(
     params=[
         # test with SOURCE_DATE_EPOCH unset: no modification
-        (None, ''),
-        # test with SOURCE_DATE_EPOCH set: copyright year should be updated
-        ('1293840000', '2011'),
-        ('1293839999', '2010'),
-        ('1199145600', '2008'),
-        ('1199145599', '2007'),
+        (None, None),
+        # test with post-2009 SOURCE_DATE_EPOCH set: copyright year should not be updated
+        ('1293840000', 2011),
+        ('1293839999', 2010),
+        # test with pre-2009 SOURCE_DATE_EPOCH set: copyright year should be updated
+        ('1199145600', 2008),
+        ('1199145599', 2007),
     ],
 )
 def expect_date(request, monkeypatch):
@@ -39,7 +40,7 @@ def test_correct_year(expect_date):
     cfg = Config({'copyright': copyright_date}, {})
     assert cfg.copyright == copyright_date
     correct_copyright_year(None, cfg)  # type: ignore[arg-type]
-    if expect_date:
+    if expect_date and expect_date <= LOCALTIME_2009.tm_year:
         assert cfg.copyright == f'2006-{expect_date}, Alice'
     else:
         assert cfg.copyright == copyright_date
@@ -51,7 +52,7 @@ def test_correct_year_space(expect_date):
     cfg = Config({'copyright': copyright_date}, {})
     assert cfg.copyright == copyright_date
     correct_copyright_year(None, cfg)  # type: ignore[arg-type]
-    if expect_date:
+    if expect_date and expect_date <= LOCALTIME_2009.tm_year:
         assert cfg.copyright == f'2006-{expect_date} Alice'
     else:
         assert cfg.copyright == copyright_date
@@ -63,7 +64,7 @@ def test_correct_year_no_author(expect_date):
     cfg = Config({'copyright': copyright_date}, {})
     assert cfg.copyright == copyright_date
     correct_copyright_year(None, cfg)  # type: ignore[arg-type]
-    if expect_date:
+    if expect_date and expect_date <= LOCALTIME_2009.tm_year:
         assert cfg.copyright == f'2006-{expect_date}'
     else:
         assert cfg.copyright == copyright_date
@@ -75,7 +76,7 @@ def test_correct_year_single(expect_date):
     cfg = Config({'copyright': copyright_date}, {})
     assert cfg.copyright == copyright_date
     correct_copyright_year(None, cfg)  # type: ignore[arg-type]
-    if expect_date:
+    if expect_date and expect_date <= LOCALTIME_2009.tm_year:
         assert cfg.copyright == f'{expect_date}, Alice'
     else:
         assert cfg.copyright == copyright_date
@@ -87,7 +88,7 @@ def test_correct_year_single_space(expect_date):
     cfg = Config({'copyright': copyright_date}, {})
     assert cfg.copyright == copyright_date
     correct_copyright_year(None, cfg)  # type: ignore[arg-type]
-    if expect_date:
+    if expect_date and expect_date <= LOCALTIME_2009.tm_year:
         assert cfg.copyright == f'{expect_date} Alice'
     else:
         assert cfg.copyright == copyright_date
@@ -99,7 +100,7 @@ def test_correct_year_single_no_author(expect_date):
     cfg = Config({'copyright': copyright_date}, {})
     assert cfg.copyright == copyright_date
     correct_copyright_year(None, cfg)  # type: ignore[arg-type]
-    if expect_date:
+    if expect_date and expect_date <= LOCALTIME_2009.tm_year:
         assert cfg.copyright == f'{expect_date}'
     else:
         assert cfg.copyright == copyright_date
@@ -108,7 +109,7 @@ def test_correct_year_single_no_author(expect_date):
 def test_correct_year_multi_line(expect_date):
     # test that copyright is substituted
     copyright_dates = (
-        '2006',
+        '2009',
         '2006-2009, Alice',
         '2010-2013, Bob',
         '2014-2017, Charlie',
@@ -118,14 +119,15 @@ def test_correct_year_multi_line(expect_date):
     cfg = Config({'copyright': copyright_dates}, {})
     assert cfg.copyright == copyright_dates
     correct_copyright_year(None, cfg)  # type: ignore[arg-type]
-    if expect_date:
+    if expect_date and expect_date <= LOCALTIME_2009.tm_year:
         assert cfg.copyright == (
             f'{expect_date}',
             f'2006-{expect_date}, Alice',
-            f'2010-{expect_date}, Bob',
-            f'2014-{expect_date}, Charlie',
-            f'2018-{expect_date}, David',
-            f'2022-{expect_date}, Eve',
+            # post 2009-dates aren't substituted
+            '2010-2013, Bob',
+            '2014-2017, Charlie',
+            '2018-2021, David',
+            '2022-2025, Eve',
         )
     else:
         assert cfg.copyright == copyright_dates
@@ -144,7 +146,7 @@ def test_correct_year_multi_line_all_formats(expect_date):
     cfg = Config({'copyright': copyright_dates}, {})
     assert cfg.copyright == copyright_dates
     correct_copyright_year(None, cfg)  # type: ignore[arg-type]
-    if expect_date:
+    if expect_date and expect_date <= LOCALTIME_2009.tm_year:
         assert cfg.copyright == (
             f'{expect_date}',
             f'{expect_date} Alice',
@@ -166,7 +168,7 @@ def test_correct_year_app(expect_date, tmp_path, make_app):
         srcdir=tmp_path,
         confoverrides={'copyright': copyright_date},
     )
-    if expect_date:
+    if expect_date and expect_date <= LOCALTIME_2009.tm_year:
         assert app.config.copyright == f'2006-{expect_date}, Alice'
     else:
         assert app.config.copyright == copyright_date