Skip to content

Commit 886bb94

Browse files
authored
Log differences in configuration from the pickled environment (#12949)
1 parent 1bfa4e6 commit 886bb94

File tree

6 files changed

+87
-7
lines changed

6 files changed

+87
-7
lines changed

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ Features added
7373
for builders to enable use of :mod:`sphinx.ext.linkcode`-generated
7474
references.
7575
Patch by James Knight.
76+
* #12949: Print configuration options that differ from the pickled environment.
77+
This can be helpful in diagnosing the cause of a full rebuild.
78+
Patch by Adam Turner.
7679

7780
Bugs fixed
7881
----------

sphinx/environment/__init__.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from sphinx.transforms import SphinxTransformer
2424
from sphinx.util import logging
2525
from sphinx.util._files import DownloadFiles, FilenameUniqDict
26+
from sphinx.util._serialise import stable_str
2627
from sphinx.util._timestamps import _format_rfc3339_microseconds
2728
from sphinx.util.docutils import LoggingReporter
2829
from sphinx.util.i18n import CatalogRepository, docname_to_domain
@@ -270,7 +271,7 @@ def setup(self, app: Sphinx) -> None:
270271
# The old config is self.config, restored from the pickled environment.
271272
# The new config is app.config, always recreated from ``conf.py``
272273
self.config_status, self.config_status_extra = self._config_status(
273-
old_config=self.config, new_config=app.config
274+
old_config=self.config, new_config=app.config, verbosity=app.verbosity
274275
)
275276
self.config = app.config
276277

@@ -279,7 +280,7 @@ def setup(self, app: Sphinx) -> None:
279280

280281
@staticmethod
281282
def _config_status(
282-
*, old_config: Config | None, new_config: Config
283+
*, old_config: Config | None, new_config: Config, verbosity: int
283284
) -> tuple[int, str]:
284285
"""Report the differences between two Config objects.
285286
@@ -302,6 +303,27 @@ def _config_status(
302303
extension = f'{len(extensions)}'
303304
return CONFIG_EXTENSIONS_CHANGED, f' ({extension!r})'
304305

306+
# Log any changes in configuration keys
307+
if changed_keys := _differing_config_keys(old_config, new_config):
308+
changed_num = len(changed_keys)
309+
if changed_num == 1:
310+
logger.info(
311+
__('The configuration has changed (1 option: %r)'),
312+
next(iter(changed_keys)),
313+
)
314+
elif changed_num <= 5 or verbosity >= 1:
315+
logger.info(
316+
__('The configuration has changed (%d options: %s)'),
317+
changed_num,
318+
', '.join(map(repr, sorted(changed_keys))),
319+
)
320+
else:
321+
logger.info(
322+
__('The configuration has changed (%d options: %s, ...)'),
323+
changed_num,
324+
', '.join(map(repr, sorted(changed_keys)[:5])),
325+
)
326+
305327
# check if a config value was changed that affects how doctrees are read
306328
for item in new_config.filter(frozenset({'env'})):
307329
if old_config[item.name] != item.value:
@@ -756,6 +778,19 @@ def check_consistency(self) -> None:
756778
self.events.emit('env-check-consistency', self)
757779

758780

781+
def _differing_config_keys(old: Config, new: Config) -> frozenset[str]:
782+
"""Return a set of keys that differ between two config objects."""
783+
old_vals = {c.name: c.value for c in old}
784+
new_vals = {c.name: c.value for c in new}
785+
not_in_both = old_vals.keys() ^ new_vals.keys()
786+
different_values = {
787+
key
788+
for key in old_vals.keys() & new_vals.keys()
789+
if stable_str(old_vals[key]) != stable_str(new_vals[key])
790+
}
791+
return frozenset(not_in_both | different_values)
792+
793+
759794
def _traverse_toctree(
760795
traversed: set[str],
761796
parent: str | None,

sphinx/ext/autosummary/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ def autosummary_table_visit_html(self: HTML5Translator, node: autosummary_table)
151151
# -- autodoc integration -------------------------------------------------------
152152

153153
class FakeApplication:
154+
verbosity = 0
155+
154156
def __init__(self) -> None:
155157
self.doctreedir = None
156158
self.events = None

sphinx/util/_serialise.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def _stable_str_prep(obj: Any) -> dict[str, Any] | list[Any] | str:
4444
return dict(obj)
4545
if isinstance(obj, list | tuple | set | frozenset):
4646
# Convert to a sorted list
47-
return sorted(map(_stable_str_prep, obj))
47+
return sorted(map(_stable_str_prep, obj), key=str)
4848
if isinstance(obj, type | types.FunctionType):
4949
# The default repr() of functions includes the ID, which is not ideal.
5050
# We use the fully qualified name instead.

tests/roots/test-basic/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
html_theme = 'basic'
12
latex_documents = [
23
('index', 'test.tex', 'The basic Sphinx documentation for testing', 'Sphinx', 'report')
34
]

tests/test_environment/test_environment.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88

99
from sphinx.builders.html import StandaloneHTMLBuilder
1010
from sphinx.builders.latex import LaTeXBuilder
11+
from sphinx.config import Config
1112
from sphinx.environment import (
1213
CONFIG_CHANGED,
1314
CONFIG_EXTENSIONS_CHANGED,
1415
CONFIG_NEW,
1516
CONFIG_OK,
17+
_differing_config_keys,
1618
)
19+
from sphinx.util.console import strip_colors
1720

1821

1922
@pytest.mark.sphinx('dummy', testroot='basic')
@@ -24,13 +27,17 @@ def test_config_status(make_app, app_params):
2427
app1 = make_app(*args, freshenv=True, **kwargs)
2528
assert app1.env.config_status == CONFIG_NEW
2629
app1.build()
27-
assert '[new config] 1 added' in app1._status.getvalue()
30+
output = strip_colors(app1.status.getvalue())
31+
# assert 'The configuration has changed' not in output
32+
assert '[new config] 1 added' in output
2833

2934
# incremental build (no config changed)
3035
app2 = make_app(*args, **kwargs)
3136
assert app2.env.config_status == CONFIG_OK
3237
app2.build()
33-
assert '0 added, 0 changed, 0 removed' in app2._status.getvalue()
38+
output = strip_colors(app2.status.getvalue())
39+
assert 'The configuration has changed' not in output
40+
assert '0 added, 0 changed, 0 removed' in output
3441

3542
# incremental build (config entry changed)
3643
app3 = make_app(*args, confoverrides={'root_doc': 'indexx'}, **kwargs)
@@ -40,7 +47,9 @@ def test_config_status(make_app, app_params):
4047
assert app3.env.config_status == CONFIG_CHANGED
4148
app3.build()
4249
shutil.move(fname[:-4] + 'x.rst', fname)
43-
assert "[config changed ('master_doc')] 1 added" in app3._status.getvalue()
50+
output = strip_colors(app3.status.getvalue())
51+
assert 'The configuration has changed' in output
52+
assert "[config changed ('master_doc')] 1 added," in output
4453

4554
# incremental build (extension changed)
4655
app4 = make_app(
@@ -49,7 +58,9 @@ def test_config_status(make_app, app_params):
4958
assert app4.env.config_status == CONFIG_EXTENSIONS_CHANGED
5059
app4.build()
5160
want_str = "[extensions changed ('sphinx.ext.autodoc')] 1 added"
52-
assert want_str in app4._status.getvalue()
61+
output = strip_colors(app4.status.getvalue())
62+
assert 'The configuration has changed' not in output
63+
assert want_str in output
5364

5465

5566
@pytest.mark.sphinx('dummy', testroot='root')
@@ -181,3 +192,31 @@ def test_env_relfn2path(app):
181192
app.env.temp_data.clear()
182193
with pytest.raises(KeyError):
183194
app.env.relfn2path('images/logo.jpg')
195+
196+
197+
def test_differing_config_keys():
198+
diff = _differing_config_keys
199+
200+
old = Config({'project': 'old'})
201+
new = Config({'project': 'new'})
202+
assert diff(old, new) == frozenset({'project'})
203+
204+
old = Config({'project': 'project', 'release': 'release'})
205+
new = Config({'project': 'project', 'version': 'version'})
206+
assert diff(old, new) == frozenset({'release', 'version'})
207+
208+
old = Config({'project': 'project', 'release': 'release'})
209+
new = Config({'project': 'project'})
210+
assert diff(old, new) == frozenset({'release'})
211+
212+
old = Config({'project': 'project'})
213+
new = Config({'project': 'project', 'version': 'version'})
214+
assert diff(old, new) == frozenset({'version'})
215+
216+
old = Config({'project': 'project', 'release': 'release', 'version': 'version'})
217+
new = Config({'project': 'project', 'release': 'release', 'version': 'version'})
218+
assert diff(old, new) == frozenset()
219+
220+
old = Config({'project': 'old', 'release': 'release'})
221+
new = Config({'project': 'new', 'version': 'version'})
222+
assert diff(old, new) == frozenset({'project', 'release', 'version'})

0 commit comments

Comments
 (0)