Skip to content
Open
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Release 9.1.1 (in development)
Bugs fixed
----------

* #4371, #8974: Invalidate cached doctrees when switching between builders
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).
Patch by Jean-François B.
Expand Down
17 changes: 4 additions & 13 deletions doc/usage/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1281,16 +1281,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, Sphinx automatically re-parses
source files when switching between a builder for which Smart Quotes is
enabled and one for which it is disabled. Builders with the same Smart Quotes
state can continue to reuse cached doctrees.

.. hint::

Expand All @@ -1302,9 +1296,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


Expand Down
17 changes: 17 additions & 0 deletions sphinx/builders/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -475,6 +477,21 @@ 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 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
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)

Expand Down
11 changes: 8 additions & 3 deletions sphinx/environment/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,19 +76,21 @@

# 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
CONFIG_OK = 1
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 state changed'),
}


Expand Down Expand Up @@ -119,6 +121,9 @@ 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 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
self.project: Project = app.project
self.version: Mapping[str, int] = _get_env_version(app.extensions)
Expand Down Expand Up @@ -378,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
Expand Down
26 changes: 18 additions & 8 deletions sphinx/transforms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,25 +380,35 @@ 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_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_enabled_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]
Expand Down
2 changes: 1 addition & 1 deletion tests/js/fixtures/cpp/searchindex.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion tests/js/fixtures/multiterm/searchindex.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion tests/js/fixtures/partial/searchindex.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion tests/js/fixtures/titles/searchindex.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions tests/roots/test-smartquotes-cache/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import sys
from pathlib import Path

sys.path.insert(0, str(Path.cwd().resolve()))

project = 'Smart quotes cache'
extensions = ['smartquotes_builder']
4 changes: 4 additions & 0 deletions tests/roots/test-smartquotes-cache/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
A "quoted" title
================

This is a line with a quote, isn't it?
12 changes: 12 additions & 0 deletions tests/roots/test-smartquotes-cache/smartquotes_builder.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading