From b23be291e79205f62f74818acef3b80ca71169ef Mon Sep 17 00:00:00 2001 From: Walentalien Date: Sat, 3 Oct 2026 09:52:25 +0200 Subject: [PATCH 01/10] Fix cached SmartQuotes across builders --- CHANGES.rst | 3 + doc/usage/configuration.rst | 13 ++--- sphinx/builders/__init__.py | 14 +++++ sphinx/environment/__init__.py | 6 +- sphinx/transforms/__init__.py | 25 +++++--- tests/js/fixtures/cpp/searchindex.js | 2 +- tests/js/fixtures/multiterm/searchindex.js | 2 +- tests/js/fixtures/partial/searchindex.js | 2 +- tests/js/fixtures/titles/searchindex.js | 2 +- tests/roots/test-smartquotes-cache/conf.py | 1 + tests/roots/test-smartquotes-cache/index.rst | 4 ++ tests/test_markup/test_smartquotes.py | 61 ++++++++++++++++++++ 12 files changed, 113 insertions(+), 22 deletions(-) create mode 100644 tests/roots/test-smartquotes-cache/conf.py create mode 100644 tests/roots/test-smartquotes-cache/index.rst diff --git a/CHANGES.rst b/CHANGES.rst index ad6d698341a..8582a67025e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,9 @@ Release 9.1.1 (in development) Bugs fixed ---------- +* #4371, #8974: Re-read cached doctrees when switching between builders + with different Smart Quotes settings. + * #14465: LaTeX: PDF build crash since LaTeX June 2026 release if tables are styled with ``'colorrows'`` (which is the default). Patch by Jean-François B. diff --git a/doc/usage/configuration.rst b/doc/usage/configuration.rst index 6bb45d41840..79ba3bbb114 100644 --- a/doc/usage/configuration.rst +++ b/doc/usage/configuration.rst @@ -1282,15 +1282,10 @@ Options for smart quotes .. note:: - Currently, in case of invocation of :program:`make` with multiple - targets, the first target name is the only one which is tested against - the :code-py:`'builders'` entry and it decides for all. - Also, a ``make text`` following ``make html`` needs to be issued - in the form ``make text SPHINXOPTS="-E"`` to force re-parsing - of source files, as the cached ones are already transformed. - On the other hand the issue does not arise with - direct usage of :program:`sphinx-build` as it caches - (in its default usage) the parsed source files in per builder locations. + When builders share a doctree directory, switching between builders + with different Smart Quotes settings causes source files to be + re-parsed automatically. Builders with the same effective setting + can reuse the cached doctrees. .. hint:: diff --git a/sphinx/builders/__init__.py b/sphinx/builders/__init__.py index ad7523927f3..76d427d3b9a 100644 --- a/sphinx/builders/__init__.py +++ b/sphinx/builders/__init__.py @@ -17,11 +17,13 @@ from sphinx.environment import ( CONFIG_CHANGED_REASON, CONFIG_OK, + CONFIG_SMARTQUOTES_CHANGED, _CurrentDocument, ) from sphinx.environment.adapters.asset import ImageAdapter from sphinx.errors import SphinxError from sphinx.locale import __ +from sphinx.transforms import SphinxSmartQuotes from sphinx.util import get_filetype, logging from sphinx.util._importer import import_object from sphinx.util._pathlib import _StrPathProperty @@ -475,6 +477,18 @@ def read(self) -> list[str]: logger.info(bold(__('updating environment: ')), nonl=True) self.env.find_files(self.config, self) + # SmartQuotes changes doctrees during reading, so builder state affects the cache. + smartquotes_enabled = SphinxSmartQuotes._is_available_for_builder( + self.config, self.env._builder_cls.name, self.env.settings['language_code'] + ) + if ( + self.env.config_status == CONFIG_OK + and self.env._smartquotes_enabled is not None + and self.env._smartquotes_enabled != smartquotes_enabled + ): + self.env.config_status = CONFIG_SMARTQUOTES_CHANGED + self.env._smartquotes_enabled = smartquotes_enabled + updated = self.env.config_status != CONFIG_OK added, changed, removed = self.env.get_outdated_files(updated) diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py index c5cc77139f7..efc5b27b1f3 100644 --- a/sphinx/environment/__init__.py +++ b/sphinx/environment/__init__.py @@ -76,7 +76,7 @@ # This is increased every time an environment attribute is added # or changed to properly invalidate pickle files. -ENV_VERSION = 66 +ENV_VERSION = 67 # config status CONFIG_UNSET = -1 @@ -84,11 +84,13 @@ CONFIG_NEW = 2 CONFIG_CHANGED = 3 CONFIG_EXTENSIONS_CHANGED = 4 +CONFIG_SMARTQUOTES_CHANGED = 5 CONFIG_CHANGED_REASON = { CONFIG_NEW: __('new config'), CONFIG_CHANGED: __('config changed'), CONFIG_EXTENSIONS_CHANGED: __('extensions changed'), + CONFIG_SMARTQUOTES_CHANGED: __('smart quotes builder setting changed'), } @@ -119,6 +121,8 @@ def __init__(self, app: Sphinx) -> None: self.config: Config = None # type: ignore[assignment] self.config_status: int = CONFIG_UNSET self.config_status_extra: str = '' + # SmartQuotes is applied before doctrees are cached, but depends on the builder. + self._smartquotes_enabled: bool | None = None self.events: EventManager = app.events self.project: Project = app.project self.version: Mapping[str, int] = _get_env_version(app.extensions) diff --git a/sphinx/transforms/__init__.py b/sphinx/transforms/__init__.py index eb04025954b..b1dee9c5c02 100644 --- a/sphinx/transforms/__init__.py +++ b/sphinx/transforms/__init__.py @@ -380,25 +380,34 @@ def apply(self, **kwargs: Any) -> None: super().apply() def is_available(self) -> bool: - builders = self.config.smartquotes_excludes.get('builders', []) - languages = self.config.smartquotes_excludes.get('languages', []) - if self.document.settings.smart_quotes is False: # disabled by 3rd party extension (workaround) return False - if self.config.smartquotes is False: + return self._is_available_for_builder( + self.config, self.env._builder_cls.name, self.env.settings['language_code'] + ) + + @staticmethod + def _is_available_for_builder( + config: Config, builder_name: str, language_code: str + ) -> bool: + builders = config.smartquotes_excludes.get('builders', []) + languages = config.smartquotes_excludes.get('languages', []) + + if config.smartquotes is False: # disabled by confval smartquotes return False - if self.env._builder_cls.name in builders: + if builder_name in builders: # disabled by confval smartquotes_excludes['builders'] return False - if self.config.language in languages: + if config.language in languages: # disabled by confval smartquotes_excludes['languages'] return False # confirm selected language supports smart_quotes or not - language = self.env.settings['language_code'] - return any(tag in smartchars.quotes for tag in normalize_language_tag(language)) + return any( + tag in smartchars.quotes for tag in normalize_language_tag(language_code) + ) def get_tokens( # type: ignore[override] self, txtnodes: Iterable[Node] diff --git a/tests/js/fixtures/cpp/searchindex.js b/tests/js/fixtures/cpp/searchindex.js index 1dac186a7e7..da4c01586b9 100644 --- a/tests/js/fixtures/cpp/searchindex.js +++ b/tests/js/fixtures/cpp/searchindex.js @@ -1 +1 @@ -Search.setIndex({"alltitles":{},"docnames":["index"],"envversion":{"sphinx":66,"sphinx.domains.c":3,"sphinx.domains.changeset":1,"sphinx.domains.citation":1,"sphinx.domains.cpp":9,"sphinx.domains.index":1,"sphinx.domains.javascript":3,"sphinx.domains.math":2,"sphinx.domains.python":4,"sphinx.domains.rst":2,"sphinx.domains.std":2},"filenames":["index.rst"],"indexentries":{"sphinx (c++ class)":[[0,"_CPPv46Sphinx",false]]},"objects":{"":[[0,0,1,"_CPPv46Sphinx","Sphinx"]]},"objnames":{"0":["cpp","class","C++ class"]},"objtypes":{"0":"cpp:class"},"terms":{"The":0,"This":0,"becaus":0,"c":0,"can":0,"cardin":0,"challeng":0,"charact":0,"class":0,"descript":0,"drop":0,"engin":0,"fixtur":0,"frequent":0,"generat":0,"index":0,"inflat":0,"mathemat":0,"occur":0,"often":0,"project":0,"punctuat":0,"queri":0,"relat":0,"sampl":0,"search":0,"size":0,"sphinx":0,"term":0,"token":0,"use":0,"web":0},"titles":[""],"titleterms":{}}) \ No newline at end of file +Search.setIndex({"alltitles":{},"docnames":["index"],"envversion":{"sphinx":67,"sphinx.domains.c":3,"sphinx.domains.changeset":1,"sphinx.domains.citation":1,"sphinx.domains.cpp":9,"sphinx.domains.index":1,"sphinx.domains.javascript":3,"sphinx.domains.math":2,"sphinx.domains.python":4,"sphinx.domains.rst":2,"sphinx.domains.std":2},"filenames":["index.rst"],"indexentries":{"sphinx (c++ class)":[[0,"_CPPv46Sphinx",false]]},"objects":{"":[[0,0,1,"_CPPv46Sphinx","Sphinx"]]},"objnames":{"0":["cpp","class","C++ class"]},"objtypes":{"0":"cpp:class"},"terms":{"The":0,"This":0,"becaus":0,"c":0,"can":0,"cardin":0,"challeng":0,"charact":0,"class":0,"descript":0,"drop":0,"engin":0,"fixtur":0,"frequent":0,"generat":0,"index":0,"inflat":0,"mathemat":0,"occur":0,"often":0,"project":0,"punctuat":0,"queri":0,"relat":0,"sampl":0,"search":0,"size":0,"sphinx":0,"term":0,"token":0,"use":0,"web":0},"titles":[""],"titleterms":{}}) \ No newline at end of file diff --git a/tests/js/fixtures/multiterm/searchindex.js b/tests/js/fixtures/multiterm/searchindex.js index bd732522b3d..5991620b3a9 100644 --- a/tests/js/fixtures/multiterm/searchindex.js +++ b/tests/js/fixtures/multiterm/searchindex.js @@ -1 +1 @@ -Search.setIndex({"alltitles":{"Main Page":[[0,null]]},"docnames":["index"],"envversion":{"sphinx":66,"sphinx.domains.c":3,"sphinx.domains.changeset":1,"sphinx.domains.citation":1,"sphinx.domains.cpp":9,"sphinx.domains.index":1,"sphinx.domains.javascript":3,"sphinx.domains.math":2,"sphinx.domains.python":4,"sphinx.domains.rst":2,"sphinx.domains.std":2},"filenames":["index.rst"],"indexentries":{},"objects":{},"objnames":{},"objtypes":{},"terms":{"At":0,"This":0,"adjac":0,"appear":0,"applic":0,"built":0,"can":0,"check":0,"contain":0,"document":0,"doesn":0,"fixtur":0,"format":0,"function":0,"futur":0,"html":0,"includ":0,"match":0,"messag":0,"multipl":0,"multiterm":0,"order":0,"output":0,"perform":0,"perhap":0,"phrase":0,"project":0,"queri":0,"requir":0,"search":0,"success":0,"support":0,"t":0,"term":0,"test":0,"time":0,"use":0,"will":0,"write":0},"titles":["Main Page"],"titleterms":{"main":0,"page":0}}) \ No newline at end of file +Search.setIndex({"alltitles":{"Main Page":[[0,null]]},"docnames":["index"],"envversion":{"sphinx":67,"sphinx.domains.c":3,"sphinx.domains.changeset":1,"sphinx.domains.citation":1,"sphinx.domains.cpp":9,"sphinx.domains.index":1,"sphinx.domains.javascript":3,"sphinx.domains.math":2,"sphinx.domains.python":4,"sphinx.domains.rst":2,"sphinx.domains.std":2},"filenames":["index.rst"],"indexentries":{},"objects":{},"objnames":{},"objtypes":{},"terms":{"At":0,"This":0,"adjac":0,"appear":0,"applic":0,"built":0,"can":0,"check":0,"contain":0,"document":0,"doesn":0,"fixtur":0,"format":0,"function":0,"futur":0,"html":0,"includ":0,"match":0,"messag":0,"multipl":0,"multiterm":0,"order":0,"output":0,"perform":0,"perhap":0,"phrase":0,"project":0,"queri":0,"requir":0,"search":0,"success":0,"support":0,"t":0,"term":0,"test":0,"time":0,"use":0,"will":0,"write":0},"titles":["Main Page"],"titleterms":{"main":0,"page":0}}) \ No newline at end of file diff --git a/tests/js/fixtures/partial/searchindex.js b/tests/js/fixtures/partial/searchindex.js index 8a65718e2ea..a9d3ec42130 100644 --- a/tests/js/fixtures/partial/searchindex.js +++ b/tests/js/fixtures/partial/searchindex.js @@ -1 +1 @@ -Search.setIndex({"alltitles":{"sphinx_utils module":[[0,null]]},"docnames":["index"],"envversion":{"sphinx":66,"sphinx.domains.c":3,"sphinx.domains.changeset":1,"sphinx.domains.citation":1,"sphinx.domains.cpp":9,"sphinx.domains.index":1,"sphinx.domains.javascript":3,"sphinx.domains.math":2,"sphinx.domains.python":4,"sphinx.domains.rst":2,"sphinx.domains.std":2},"filenames":["index.rst"],"indexentries":{},"objects":{},"objnames":{},"objtypes":{},"terms":{"This":0,"built":0,"confirm":0,"document":0,"function":0,"html":0,"includ":0,"input":0,"javascript":0,"match":0,"partial":0,"possibl":0,"project":0,"provid":0,"restructuredtext":0,"sampl":0,"search":0,"term":0,"titl":0,"use":0},"titles":["sphinx_utils module"],"titleterms":{"modul":0,"sphinx_util":0}}) \ No newline at end of file +Search.setIndex({"alltitles":{"sphinx_utils module":[[0,null]]},"docnames":["index"],"envversion":{"sphinx":67,"sphinx.domains.c":3,"sphinx.domains.changeset":1,"sphinx.domains.citation":1,"sphinx.domains.cpp":9,"sphinx.domains.index":1,"sphinx.domains.javascript":3,"sphinx.domains.math":2,"sphinx.domains.python":4,"sphinx.domains.rst":2,"sphinx.domains.std":2},"filenames":["index.rst"],"indexentries":{},"objects":{},"objnames":{},"objtypes":{},"terms":{"This":0,"built":0,"confirm":0,"document":0,"function":0,"html":0,"includ":0,"input":0,"javascript":0,"match":0,"partial":0,"possibl":0,"project":0,"provid":0,"restructuredtext":0,"sampl":0,"search":0,"term":0,"titl":0,"use":0},"titles":["sphinx_utils module"],"titleterms":{"modul":0,"sphinx_util":0}}) \ No newline at end of file diff --git a/tests/js/fixtures/titles/searchindex.js b/tests/js/fixtures/titles/searchindex.js index fe325c7742d..0d661568511 100644 --- a/tests/js/fixtures/titles/searchindex.js +++ b/tests/js/fixtures/titles/searchindex.js @@ -1 +1 @@ -Search.setIndex({"alltitles":{"Main Page":[[0,null]],"Relevance":[[0,"relevance"],[1,null]],"Result Scoring":[[0,"result-scoring"]]},"docnames":["index","relevance"],"envversion":{"sphinx":66,"sphinx.domains.c":3,"sphinx.domains.changeset":1,"sphinx.domains.citation":1,"sphinx.domains.cpp":9,"sphinx.domains.index":1,"sphinx.domains.javascript":3,"sphinx.domains.math":2,"sphinx.domains.python":4,"sphinx.domains.rst":2,"sphinx.domains.std":2},"filenames":["index.rst","relevance.rst"],"indexentries":{"example (class in relevance)":[[0,"relevance.Example",false]],"module":[[0,"module-relevance",false]],"relevance":[[0,"index-1",false],[0,"module-relevance",false]],"relevance (relevance.example attribute)":[[0,"relevance.Example.relevance",false]],"scoring":[[0,"index-0",true]]},"objects":{"":[[0,0,0,"-","relevance"]],"relevance":[[0,1,1,"","Example"]],"relevance.Example":[[0,2,1,"","relevance"]]},"objnames":{"0":["py","module","Python module"],"1":["py","class","Python class"],"2":["py","attribute","Python attribute"]},"objtypes":{"0":"py:module","1":"py:class","2":"py:attribute"},"terms":{"A":1,"By":0,"For":[0,1],"In":[0,1],"This":0,"align":0,"also":1,"answer":0,"appear":1,"area":0,"ask":0,"assign":0,"attempt":0,"attribut":0,"built":1,"can":[0,1],"class":0,"code":[0,1],"collect":0,"consid":1,"contain":0,"context":0,"corpus":1,"demonstr":0,"describ":1,"detail":1,"determin":[0,1],"docstr":0,"document":[0,1],"domain":1,"dure":0,"engin":0,"evalu":0,"exampl":[0,1],"extract":0,"feedback":0,"find":0,"found":0,"function":1,"handl":0,"happen":1,"head":0,"help":0,"high":[0,1],"improv":0,"inform":0,"intend":0,"issu":[0,1],"knowledg":0,"languag":1,"less":1,"like":[0,1],"mani":0,"match":0,"mention":1,"name":[0,1],"numer":0,"object":0,"often":0,"one":[0,1],"onli":[0,1],"order":0,"page":1,"part":1,"particular":0,"present":0,"printf":1,"program":1,"project":0,"queri":[0,1],"question":0,"re":0,"relat":0,"research":0,"result":1,"retriev":0,"s":[0,1],"say":0,"search":[0,1],"seem":0,"softwar":1,"sphinx":0,"straightforward":1,"subject":0,"subsect":0,"term":[0,1],"test":0,"text":0,"time":0,"titl":0,"two":0,"typic":0,"use":0,"user":[0,1],"whether":1,"will":0,"within":0,"word":0},"titles":["Main Page","Relevance"],"titleterms":{"main":0,"page":0,"relev":[0,1],"result":0,"score":0}}) \ No newline at end of file +Search.setIndex({"alltitles":{"Main Page":[[0,null]],"Relevance":[[0,"relevance"],[1,null]],"Result Scoring":[[0,"result-scoring"]]},"docnames":["index","relevance"],"envversion":{"sphinx":67,"sphinx.domains.c":3,"sphinx.domains.changeset":1,"sphinx.domains.citation":1,"sphinx.domains.cpp":9,"sphinx.domains.index":1,"sphinx.domains.javascript":3,"sphinx.domains.math":2,"sphinx.domains.python":4,"sphinx.domains.rst":2,"sphinx.domains.std":2},"filenames":["index.rst","relevance.rst"],"indexentries":{"example (class in relevance)":[[0,"relevance.Example",false]],"module":[[0,"module-relevance",false]],"relevance":[[0,"index-1",false],[0,"module-relevance",false]],"relevance (relevance.example attribute)":[[0,"relevance.Example.relevance",false]],"scoring":[[0,"index-0",true]]},"objects":{"":[[0,0,0,"-","relevance"]],"relevance":[[0,1,1,"","Example"]],"relevance.Example":[[0,2,1,"","relevance"]]},"objnames":{"0":["py","module","Python module"],"1":["py","class","Python class"],"2":["py","attribute","Python attribute"]},"objtypes":{"0":"py:module","1":"py:class","2":"py:attribute"},"terms":{"A":1,"By":0,"For":[0,1],"In":[0,1],"This":0,"align":0,"also":1,"answer":0,"appear":1,"area":0,"ask":0,"assign":0,"attempt":0,"attribut":0,"built":1,"can":[0,1],"class":0,"code":[0,1],"collect":0,"consid":1,"contain":0,"context":0,"corpus":1,"demonstr":0,"describ":1,"detail":1,"determin":[0,1],"docstr":0,"document":[0,1],"domain":1,"dure":0,"engin":0,"evalu":0,"exampl":[0,1],"extract":0,"feedback":0,"find":0,"found":0,"function":1,"handl":0,"happen":1,"head":0,"help":0,"high":[0,1],"improv":0,"inform":0,"intend":0,"issu":[0,1],"knowledg":0,"languag":1,"less":1,"like":[0,1],"mani":0,"match":0,"mention":1,"name":[0,1],"numer":0,"object":0,"often":0,"one":[0,1],"onli":[0,1],"order":0,"page":1,"part":1,"particular":0,"present":0,"printf":1,"program":1,"project":0,"queri":[0,1],"question":0,"re":0,"relat":0,"research":0,"result":1,"retriev":0,"s":[0,1],"say":0,"search":[0,1],"seem":0,"softwar":1,"sphinx":0,"straightforward":1,"subject":0,"subsect":0,"term":[0,1],"test":0,"text":0,"time":0,"titl":0,"two":0,"typic":0,"use":0,"user":[0,1],"whether":1,"will":0,"within":0,"word":0},"titles":["Main Page","Relevance"],"titleterms":{"main":0,"page":0,"relev":[0,1],"result":0,"score":0}}) \ No newline at end of file diff --git a/tests/roots/test-smartquotes-cache/conf.py b/tests/roots/test-smartquotes-cache/conf.py new file mode 100644 index 00000000000..01f9ff70929 --- /dev/null +++ b/tests/roots/test-smartquotes-cache/conf.py @@ -0,0 +1 @@ +project = 'Smart quotes cache' diff --git a/tests/roots/test-smartquotes-cache/index.rst b/tests/roots/test-smartquotes-cache/index.rst new file mode 100644 index 00000000000..a3aac899842 --- /dev/null +++ b/tests/roots/test-smartquotes-cache/index.rst @@ -0,0 +1,4 @@ +A "quoted" title +================ + +This is a line with a quote, isn't it? diff --git a/tests/test_markup/test_smartquotes.py b/tests/test_markup/test_smartquotes.py index a083693a68a..75694e67577 100644 --- a/tests/test_markup/test_smartquotes.py +++ b/tests/test_markup/test_smartquotes.py @@ -9,6 +9,10 @@ from sphinx.testing.util import etree_parse if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + from sphinx.testing.fixtures import _app_params from sphinx.testing.util import SphinxTestApp @@ -169,3 +173,60 @@ def test_smartquotes_excludes_builders(app: SphinxTestApp) -> None: content = (app.outdir / 'projectnamenotset.1').read_text(encoding='utf8') assert '– “Sphinx” is a tool that makes it easy …' in content + + +@pytest.mark.parametrize( + ('first_builder', 'second_builder', 'expected', 'rereads'), + [ + ( + 'text', + 'xml', + 'This is a line with a quote, isn’t it?', + True, + ), + ('xml', 'text', "This is a line with a quote, isn't it?", True), + ( + 'html', + 'xml', + 'This is a line with a quote, isn’t it?', + False, + ), + ('text', 'man', r'isn\(aqt it?', False), + ], +) +@pytest.mark.sphinx(testroot='smartquotes-cache', srcdir='smartquotes-cache') +def test_smartquotes_when_reusing_doctrees_across_builders( + make_app: Callable[..., SphinxTestApp], + app_params: _app_params, + first_builder: str, + second_builder: str, + expected: str, + rereads: bool, + tmp_path: Path, +) -> None: + _args, kwargs = app_params + kwargs = {**kwargs, 'builddir': tmp_path / '_build'} + first_app = make_app(first_builder, freshenv=True, **kwargs) + first_app.build() + + second_app = make_app(second_builder, **kwargs) + assert second_app.fresh_env_used is False + assert second_app.doctreedir == first_app.doctreedir + read_doctrees: list[object] = [] + second_app.connect( + 'doctree-read', lambda _app, doctree: read_doctrees.append(doctree) + ) + second_app.build() + + assert len(read_doctrees) == int(rereads) + if second_builder == 'man': + output_file = next(second_app.outdir.glob('*.1')) + else: + filename = 'index.xml' if second_builder == 'xml' else 'index.txt' + output_file = second_app.outdir / filename + content = output_file.read_text(encoding='utf8') + assert expected in content + title = second_app.env.titles['index'].astext() + assert ('“quoted”' in title) == (second_builder == 'xml') + toc = second_app.env.tocs['index'].astext() + assert ('“quoted”' in toc) == (second_builder == 'xml') From 52519c1aafcc60dd5d16541d1b85ea88b867a46d Mon Sep 17 00:00:00 2001 From: Walentalien Date: Sat, 3 Oct 2026 10:33:32 +0200 Subject: [PATCH 02/10] Clarify SmartQuotes cache state handling --- sphinx/builders/__init__.py | 5 +++-- sphinx/environment/__init__.py | 5 +++-- sphinx/transforms/__init__.py | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/sphinx/builders/__init__.py b/sphinx/builders/__init__.py index 76d427d3b9a..873f6071ca3 100644 --- a/sphinx/builders/__init__.py +++ b/sphinx/builders/__init__.py @@ -477,8 +477,9 @@ def read(self) -> list[str]: logger.info(bold(__('updating environment: ')), nonl=True) self.env.find_files(self.config, self) - # SmartQuotes changes doctrees during reading, so builder state affects the cache. - smartquotes_enabled = SphinxSmartQuotes._is_available_for_builder( + # SmartQuotes runs before doctrees are cached; a different builder-wide + # state requires the source documents to be reread. + smartquotes_enabled = SphinxSmartQuotes._is_enabled_for_builder( self.config, self.env._builder_cls.name, self.env.settings['language_code'] ) if ( diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py index efc5b27b1f3..c4d3118c312 100644 --- a/sphinx/environment/__init__.py +++ b/sphinx/environment/__init__.py @@ -90,7 +90,7 @@ CONFIG_NEW: __('new config'), CONFIG_CHANGED: __('config changed'), CONFIG_EXTENSIONS_CHANGED: __('extensions changed'), - CONFIG_SMARTQUOTES_CHANGED: __('smart quotes builder setting changed'), + CONFIG_SMARTQUOTES_CHANGED: __('smart quotes state changed'), } @@ -121,7 +121,8 @@ def __init__(self, app: Sphinx) -> None: self.config: Config = None # type: ignore[assignment] self.config_status: int = CONFIG_UNSET self.config_status_extra: str = '' - # SmartQuotes is applied before doctrees are cached, but depends on the builder. + # Builder-wide SmartQuotes state of the cached doctrees. + # A change requires rereading the source documents. self._smartquotes_enabled: bool | None = None self.events: EventManager = app.events self.project: Project = app.project diff --git a/sphinx/transforms/__init__.py b/sphinx/transforms/__init__.py index b1dee9c5c02..78494f808b7 100644 --- a/sphinx/transforms/__init__.py +++ b/sphinx/transforms/__init__.py @@ -383,12 +383,13 @@ def is_available(self) -> bool: if self.document.settings.smart_quotes is False: # disabled by 3rd party extension (workaround) return False - return self._is_available_for_builder( + return self._is_enabled_for_builder( self.config, self.env._builder_cls.name, self.env.settings['language_code'] ) + # Document-specific settings are checked by is_available(). @staticmethod - def _is_available_for_builder( + def _is_enabled_for_builder( config: Config, builder_name: str, language_code: str ) -> bool: builders = config.smartquotes_excludes.get('builders', []) From 45766bbde58c4094f7839479223326dde45e4411 Mon Sep 17 00:00:00 2001 From: Walentalien Date: Sat, 3 Oct 2026 10:34:35 +0200 Subject: [PATCH 03/10] Test SmartQuotes cache across builder transitions --- tests/test_markup/test_smartquotes.py | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_markup/test_smartquotes.py b/tests/test_markup/test_smartquotes.py index 75694e67577..8fb284f1463 100644 --- a/tests/test_markup/test_smartquotes.py +++ b/tests/test_markup/test_smartquotes.py @@ -230,3 +230,35 @@ def test_smartquotes_when_reusing_doctrees_across_builders( assert ('“quoted”' in title) == (second_builder == 'xml') toc = second_app.env.tocs['index'].astext() assert ('“quoted”' in toc) == (second_builder == 'xml') + + +@pytest.mark.sphinx(testroot='smartquotes-cache', srcdir='smartquotes-cache') +def test_smartquotes_cache_after_same_state_builder( + make_app: Callable[..., SphinxTestApp], + app_params: _app_params, + tmp_path: Path, +) -> None: + _args, kwargs = app_params + kwargs = {**kwargs, 'builddir': tmp_path / '_build'} + + html_app = make_app('html', freshenv=True, **kwargs) + html_app.build() + assert 'isn’t it?' in (html_app.outdir / 'index.html').read_text(encoding='utf8') + + xml_app = make_app('xml', **kwargs) + assert xml_app.fresh_env_used is False + assert xml_app.env._smartquotes_enabled is True + xml_reads: list[object] = [] + xml_app.connect('doctree-read', lambda _app, doctree: xml_reads.append(doctree)) + xml_app.build() + assert xml_reads == [] + assert 'isn’t it?' in (xml_app.outdir / 'index.xml').read_text(encoding='utf8') + + text_app = make_app('text', **kwargs) + assert text_app.fresh_env_used is False + assert text_app.env._smartquotes_enabled is True + text_reads: list[object] = [] + text_app.connect('doctree-read', lambda _app, doctree: text_reads.append(doctree)) + text_app.build() + assert len(text_reads) == 1 + assert "isn't it?" in (text_app.outdir / 'index.txt').read_text(encoding='utf8') From b9ae7421bb5107f2c0a9e89ccd086152e615fabb Mon Sep 17 00:00:00 2001 From: Walentalien Date: Sat, 3 Oct 2026 10:35:23 +0200 Subject: [PATCH 04/10] Document SmartQuotes cache invalidation --- CHANGES.rst | 4 ++-- doc/usage/configuration.rst | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 8582a67025e..23cce57f5e6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,8 +4,8 @@ Release 9.1.1 (in development) Bugs fixed ---------- -* #4371, #8974: Re-read cached doctrees when switching between builders - with different Smart Quotes settings. +* #4371, #8974: Invalidate cached doctrees when switching between builders + with different Smart Quotes states. * #14465: LaTeX: PDF build crash since LaTeX June 2026 release if tables are styled with ``'colorrows'`` (which is the default). diff --git a/doc/usage/configuration.rst b/doc/usage/configuration.rst index 79ba3bbb114..d6063203eaf 100644 --- a/doc/usage/configuration.rst +++ b/doc/usage/configuration.rst @@ -1283,8 +1283,8 @@ Options for smart quotes .. note:: When builders share a doctree directory, switching between builders - with different Smart Quotes settings causes source files to be - re-parsed automatically. Builders with the same effective setting + with different effective Smart Quotes states causes source files to be + re-parsed automatically. Builders with the same effective state can reuse the cached doctrees. .. hint:: @@ -1297,9 +1297,6 @@ Options for smart quotes make latex SPHINXOPTS="-D smartquotes_action=" - This can follow some ``make html`` with no problem, in contrast to the - situation from the prior note. - .. versionadded:: 1.6.6 From c5e781ec3eadc0a317a20627f8d993c897832cc0 Mon Sep 17 00:00:00 2001 From: Walentalien Date: Sat, 3 Oct 2026 10:51:46 +0200 Subject: [PATCH 05/10] Clarify SmartQuotes cache terminology --- CHANGES.rst | 2 +- doc/usage/configuration.rst | 6 +++--- sphinx/builders/__init__.py | 2 +- sphinx/environment/__init__.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 23cce57f5e6..86e0d9b44e1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,7 +5,7 @@ Bugs fixed ---------- * #4371, #8974: Invalidate cached doctrees when switching between builders - with different Smart Quotes states. + with different builder-dependent Smart Quotes states. * #14465: LaTeX: PDF build crash since LaTeX June 2026 release if tables are styled with ``'colorrows'`` (which is the default). diff --git a/doc/usage/configuration.rst b/doc/usage/configuration.rst index d6063203eaf..51d09897de6 100644 --- a/doc/usage/configuration.rst +++ b/doc/usage/configuration.rst @@ -1283,9 +1283,9 @@ Options for smart quotes .. note:: When builders share a doctree directory, switching between builders - with different effective Smart Quotes states causes source files to be - re-parsed automatically. Builders with the same effective state - can reuse the cached doctrees. + where Smart Quotes is enabled for one builder and disabled for the other + causes source files to be re-parsed automatically. Builders with the same + builder-dependent Smart Quotes state can reuse the cached doctrees. .. hint:: diff --git a/sphinx/builders/__init__.py b/sphinx/builders/__init__.py index 873f6071ca3..4321279be6e 100644 --- a/sphinx/builders/__init__.py +++ b/sphinx/builders/__init__.py @@ -477,7 +477,7 @@ def read(self) -> list[str]: logger.info(bold(__('updating environment: ')), nonl=True) self.env.find_files(self.config, self) - # SmartQuotes runs before doctrees are cached; a different builder-wide + # SmartQuotes runs before doctrees are cached; a different builder-dependent # state requires the source documents to be reread. smartquotes_enabled = SphinxSmartQuotes._is_enabled_for_builder( self.config, self.env._builder_cls.name, self.env.settings['language_code'] diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py index c4d3118c312..4fc42360be1 100644 --- a/sphinx/environment/__init__.py +++ b/sphinx/environment/__init__.py @@ -121,7 +121,7 @@ def __init__(self, app: Sphinx) -> None: self.config: Config = None # type: ignore[assignment] self.config_status: int = CONFIG_UNSET self.config_status_extra: str = '' - # Builder-wide SmartQuotes state of the cached doctrees. + # Builder-dependent SmartQuotes state of the cached doctrees. # A change requires rereading the source documents. self._smartquotes_enabled: bool | None = None self.events: EventManager = app.events From 2a9f611aeb7dfa7b9a8c9bcf39458c2e136d32a0 Mon Sep 17 00:00:00 2001 From: Walentalien Date: Sat, 3 Oct 2026 11:30:00 +0200 Subject: [PATCH 06/10] Test SmartQuotes cache with builder override --- tests/roots/test-smartquotes-cache/conf.py | 6 ++++ .../smartquotes_builder.py | 12 +++++++ tests/test_markup/test_smartquotes.py | 35 +++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 tests/roots/test-smartquotes-cache/smartquotes_builder.py diff --git a/tests/roots/test-smartquotes-cache/conf.py b/tests/roots/test-smartquotes-cache/conf.py index 01f9ff70929..55508dfba7f 100644 --- a/tests/roots/test-smartquotes-cache/conf.py +++ b/tests/roots/test-smartquotes-cache/conf.py @@ -1 +1,7 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path.cwd().resolve())) + project = 'Smart quotes cache' +extensions = ['smartquotes_builder'] diff --git a/tests/roots/test-smartquotes-cache/smartquotes_builder.py b/tests/roots/test-smartquotes-cache/smartquotes_builder.py new file mode 100644 index 00000000000..c9d0c1a2eed --- /dev/null +++ b/tests/roots/test-smartquotes-cache/smartquotes_builder.py @@ -0,0 +1,12 @@ +from sphinx.builders.xml import XMLBuilder + + +class NoSmartQuotesBuilder(XMLBuilder): + name = 'xml-no-smartquotes' + + def init(self): + self.env.settings['smart_quotes'] = False + + +def setup(app): + app.add_builder(NoSmartQuotesBuilder) diff --git a/tests/test_markup/test_smartquotes.py b/tests/test_markup/test_smartquotes.py index 8fb284f1463..9aa8277f61f 100644 --- a/tests/test_markup/test_smartquotes.py +++ b/tests/test_markup/test_smartquotes.py @@ -262,3 +262,38 @@ def test_smartquotes_cache_after_same_state_builder( text_app.build() assert len(text_reads) == 1 assert "isn't it?" in (text_app.outdir / 'index.txt').read_text(encoding='utf8') + + +@pytest.mark.parametrize( + ('first_builder', 'second_builder', 'expected'), + [ + ('html', 'xml-no-smartquotes', "isn't it?"), + ('xml-no-smartquotes', 'html', 'isn’t it?'), + ], +) +@pytest.mark.sphinx(testroot='smartquotes-cache', srcdir='smartquotes-cache') +def test_smartquotes_cache_with_builder_override( + make_app: Callable[..., SphinxTestApp], + app_params: _app_params, + first_builder: str, + second_builder: str, + expected: str, + tmp_path: Path, +) -> None: + _args, kwargs = app_params + kwargs = {**kwargs, 'builddir': tmp_path / '_build'} + + first_app = make_app(first_builder, freshenv=True, **kwargs) + first_app.build() + + second_app = make_app(second_builder, **kwargs) + assert second_app.fresh_env_used is False + assert second_app.doctreedir == first_app.doctreedir + read_doctrees: list[object] = [] + second_app.connect('doctree-read', lambda _app, doctree: read_doctrees.append(doctree)) + second_app.build() + + suffix = 'html' if second_builder == 'html' else 'xml' + content = (second_app.outdir / f'index.{suffix}').read_text(encoding='utf8') + assert expected in content + assert len(read_doctrees) == 1 From 6e42b85688c741bd861e4cc6a10fcb2e4502cca9 Mon Sep 17 00:00:00 2001 From: Walentalien Date: Sat, 3 Oct 2026 11:37:11 +0200 Subject: [PATCH 07/10] Handle SmartQuotes builder overrides in cached doctrees --- sphinx/builders/__init__.py | 6 ++++-- sphinx/environment/__init__.py | 6 +++--- tests/test_markup/test_smartquotes.py | 4 +++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/sphinx/builders/__init__.py b/sphinx/builders/__init__.py index 4321279be6e..aee537a9224 100644 --- a/sphinx/builders/__init__.py +++ b/sphinx/builders/__init__.py @@ -477,11 +477,13 @@ def read(self) -> list[str]: logger.info(bold(__('updating environment: ')), nonl=True) self.env.find_files(self.config, self) - # SmartQuotes runs before doctrees are cached; a different builder-dependent - # state requires the source documents to be reread. + # SmartQuotes runs before doctrees are cached; a different state requires + # the source documents to be reread. smartquotes_enabled = SphinxSmartQuotes._is_enabled_for_builder( self.config, self.env._builder_cls.name, self.env.settings['language_code'] ) + if self.env.settings['smart_quotes'] is False: + smartquotes_enabled = False if ( self.env.config_status == CONFIG_OK and self.env._smartquotes_enabled is not None diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py index 4fc42360be1..f90f4abd55d 100644 --- a/sphinx/environment/__init__.py +++ b/sphinx/environment/__init__.py @@ -121,7 +121,7 @@ def __init__(self, app: Sphinx) -> None: self.config: Config = None # type: ignore[assignment] self.config_status: int = CONFIG_UNSET self.config_status_extra: str = '' - # Builder-dependent SmartQuotes state of the cached doctrees. + # SmartQuotes state selected for reading, including builder overrides. # A change requires rereading the source documents. self._smartquotes_enabled: bool | None = None self.events: EventManager = app.events @@ -383,8 +383,8 @@ def _update_settings(self, config: Config) -> None: ) self.settings['language_code'] = config.language - # Allow to disable by 3rd party extension (workaround) - self.settings.setdefault('smart_quotes', True) + # Discard a previous builder's override before the active builder initializes. + self.settings['smart_quotes'] = True def set_versioning_method( self, method: str | Callable[[Node], bool], compare: bool diff --git a/tests/test_markup/test_smartquotes.py b/tests/test_markup/test_smartquotes.py index 9aa8277f61f..22fe82a15ad 100644 --- a/tests/test_markup/test_smartquotes.py +++ b/tests/test_markup/test_smartquotes.py @@ -290,7 +290,9 @@ def test_smartquotes_cache_with_builder_override( assert second_app.fresh_env_used is False assert second_app.doctreedir == first_app.doctreedir read_doctrees: list[object] = [] - second_app.connect('doctree-read', lambda _app, doctree: read_doctrees.append(doctree)) + second_app.connect( + 'doctree-read', lambda _app, doctree: read_doctrees.append(doctree) + ) second_app.build() suffix = 'html' if second_builder == 'html' else 'xml' From b04ea8d39d72accaf81aba83831aaeeb6eed8765 Mon Sep 17 00:00:00 2001 From: Walentalien Date: Sat, 3 Oct 2026 12:24:01 +0200 Subject: [PATCH 08/10] Add Walentalien to contributors --- AUTHORS.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS.rst b/AUTHORS.rst index b702c64c0f9..4c808c2c57a 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -122,6 +122,7 @@ Contributors * Valentin Heinisch -- warning types improvement * Victor Wheeler -- documentation improvements * Vince Salvino -- JavaScript search improvements +* Walentalien -- Smart Quotes cache handling across builders * Will Maier -- directory HTML builder * Zac Hatfield-Dodds -- doctest reporting improvements, intersphinx performance From f4776d0ede87074d1f2b57b46fe6912fdca0f895 Mon Sep 17 00:00:00 2001 From: Walentalien Date: Sat, 3 Oct 2026 12:52:00 +0200 Subject: [PATCH 09/10] Improve doc paragraph --- doc/usage/configuration.rst | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/doc/usage/configuration.rst b/doc/usage/configuration.rst index 51d09897de6..dfcfee8874c 100644 --- a/doc/usage/configuration.rst +++ b/doc/usage/configuration.rst @@ -1281,13 +1281,12 @@ Options for smart quotes } .. note:: + When builders share a doctree directory, Sphinx authomatically re-parses + source files when switching between a builder for which Smart Quotes is + enabled and one for which is disabled. Builders with the same Smart Quotes + state can continue to reuse cached doctrees. - When builders share a doctree directory, switching between builders - where Smart Quotes is enabled for one builder and disabled for the other - causes source files to be re-parsed automatically. Builders with the same - builder-dependent Smart Quotes state can reuse the cached doctrees. - - .. hint:: + .. hint:: An alternative way to effectively deactivate (or customise) the smart quotes for a given builder, for example ``latex``, From 36058d983ceba8b7b58d415595ed556d21d309b8 Mon Sep 17 00:00:00 2001 From: Walentalien Date: Sat, 3 Oct 2026 17:13:35 +0200 Subject: [PATCH 10/10] fix wording --- doc/usage/configuration.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/usage/configuration.rst b/doc/usage/configuration.rst index dfcfee8874c..9f198376915 100644 --- a/doc/usage/configuration.rst +++ b/doc/usage/configuration.rst @@ -1281,12 +1281,12 @@ Options for smart quotes } .. note:: - When builders share a doctree directory, Sphinx authomatically re-parses + When builders share a doctree directory, Sphinx automatically re-parses source files when switching between a builder for which Smart Quotes is - enabled and one for which is disabled. Builders with the same Smart Quotes + enabled and one for which it is disabled. Builders with the same Smart Quotes state can continue to reuse cached doctrees. - .. hint:: + .. hint:: An alternative way to effectively deactivate (or customise) the smart quotes for a given builder, for example ``latex``,