From ac4510a1ecc09472fa525fb7e4a7c3258c7b7912 Mon Sep 17 00:00:00 2001 From: shashb27 Date: Wed, 2 Sep 2026 01:00:57 -0700 Subject: [PATCH 1/3] Fall back to English for messages with damaged format placeholders Some translated catalogues contain msgstrs with damaged str.format placeholders. Six call sites formatted translated text eagerly, so a single damaged placeholder in any locale crashed the build with a KeyError. Add sphinx.locale.safe_format, which formats the translation and falls back to the untranslated message when the placeholder sets do not match or formatting raises, and route the eager call sites through it. Refs #14664 --- CHANGES.rst | 5 ++ sphinx/_cli/__init__.py | 13 +++-- sphinx/config.py | 38 ++++++++------- sphinx/locale/__init__.py | 36 ++++++++++++++ sphinx/transforms/i18n.py | 34 +++++-------- tests/test_config/test_config.py | 20 ++++++++ tests/test_locale.py | 82 ++++++++++++++++++++++++++++++++ 7 files changed, 185 insertions(+), 43 deletions(-) create mode 100644 tests/test_locale.py diff --git a/CHANGES.rst b/CHANGES.rst index ad6d698341a..d45a655ebaf 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,11 @@ Release 9.1.1 (in development) Bugs fixed ---------- +* #14664: Do not crash with a ``KeyError`` when a translated message + catalogue contains a damaged ``str.format`` placeholder; affected messages + now fall back to the original English text. + Patch by Shash Bhaskar + * #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/sphinx/_cli/__init__.py b/sphinx/_cli/__init__.py index cce4c56e7d9..88493a5cf03 100644 --- a/sphinx/_cli/__init__.py +++ b/sphinx/_cli/__init__.py @@ -32,7 +32,7 @@ terminal_supports_colour, underline, ) -from sphinx.locale import __, init_console +from sphinx.locale import __, init_console, safe_format if TYPE_CHECKING: from collections.abc import Callable, Iterable, Iterator, Sequence @@ -70,7 +70,7 @@ def format_help(self) -> str: help_fragments: list[str] = [ bold(underline(__('Usage:'))), ' ', - __('{0} [OPTIONS] []').format(bold(self.prog)), + safe_format('{0} [OPTIONS] []', bold(self.prog)), '\n', '\n', __(' The Sphinx documentation generator.'), @@ -166,8 +166,13 @@ def _format_metavar( raise ValueError(msg) def error(self, message: str) -> NoReturn: - msg = __("{0}: error: {1}\nRun '{0} --help' for information") - sys.stderr.write(msg.format(self.prog, message)) + sys.stderr.write( + safe_format( + "{0}: error: {1}\nRun '{0} --help' for information", + self.prog, + message, + ) + ) raise SystemExit(2) diff --git a/sphinx/config.py b/sphinx/config.py index 9b791702ec0..06bae3f3df8 100644 --- a/sphinx/config.py +++ b/sphinx/config.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, Literal, NamedTuple from sphinx.errors import ConfigError, ExtensionError -from sphinx.locale import _, __ +from sphinx.locale import _, __, safe_format from sphinx.util import logging if TYPE_CHECKING: @@ -793,13 +793,13 @@ def check_confval_types(app: Sphinx | None, config: Config) -> None: if isinstance(valid_types, ENUM): if not valid_types.match(value): - msg = __( - 'The config value `{name}` has to be a one of {candidates}, ' - 'but `{current}` is given.' - ) logger.warning( - msg.format( - name=name, current=value, candidates=valid_types._candidates + safe_format( + 'The config value `{name}` has to be a one of {candidates}, ' + 'but `{current}` is given.', + name=name, + current=value, + candidates=valid_types._candidates, ), once=True, ) @@ -824,10 +824,6 @@ def check_confval_types(app: Sphinx | None, config: Config) -> None: continue # at least we share a non-trivial base class if valid_types: - msg = __( - "The config value `{name}' has type `{current.__name__}'; " - 'expected {permitted}.' - ) wrapped_valid_types = sorted(f"`{c.__name__}'" for c in valid_types) if len(wrapped_valid_types) > 2: permitted = ( @@ -837,16 +833,24 @@ def check_confval_types(app: Sphinx | None, config: Config) -> None: else: permitted = ' or '.join(wrapped_valid_types) logger.warning( - msg.format(name=name, current=type_value, permitted=permitted), + safe_format( + "The config value `{name}' has type `{current.__name__}'; " + 'expected {permitted}.', + name=name, + current=type_value, + permitted=permitted, + ), once=True, ) else: - msg = __( - "The config value `{name}' has type `{current.__name__}', " - "defaults to `{default.__name__}'." - ) logger.warning( - msg.format(name=name, current=type_value, default=type_default), + safe_format( + "The config value `{name}' has type `{current.__name__}', " + "defaults to `{default.__name__}'.", + name=name, + current=type_value, + default=type_default, + ), once=True, ) diff --git a/sphinx/locale/__init__.py b/sphinx/locale/__init__.py index 7d0586dace8..a82884a8e6c 100644 --- a/sphinx/locale/__init__.py +++ b/sphinx/locale/__init__.py @@ -6,6 +6,7 @@ import sys from gettext import NullTranslations, translation from pathlib import Path +from string import Formatter from typing import TYPE_CHECKING from sphinx import package_dir @@ -215,6 +216,41 @@ def gettext(message: str) -> str: return gettext +def _format_field_names(message: str) -> set[str]: + return { + field_name + for _, field_name, _, _ in Formatter().parse(message) + if field_name is not None + } + + +def safe_format( + message: str, + /, + *args: Any, + catalog: str = 'sphinx', + namespace: str = 'console', + **kwargs: Any, +) -> str: + """Format the translation of *message* using ``str.format``, falling back + to the untranslated *message* when the translation is damaged: a + placeholder that was renamed, dropped, or added, or a format call that + raises. + + This is the safe companion to :func:`_` and ``__`` for messages that are + formatted eagerly: pass the *untranslated* message and the format + arguments, and a damaged catalogue degrades to an English message + instead of raising or losing information during the build. + """ + translated = get_translator(catalog, namespace).gettext(message) + if _format_field_names(translated) == _format_field_names(message): + try: + return translated.format(*args, **kwargs) + except (KeyError, IndexError, ValueError): + pass + return message.format(*args, **kwargs) + + # A shortcut for sphinx-core #: Translation function for messages on documentation (menu, labels, themes and so on). #: This function follows :confval:`language` setting. diff --git a/sphinx/transforms/i18n.py b/sphinx/transforms/i18n.py index 3be280859c2..47292fbaa7c 100644 --- a/sphinx/transforms/i18n.py +++ b/sphinx/transforms/i18n.py @@ -13,7 +13,7 @@ from sphinx import addnodes from sphinx.domains.std import make_glossary_term, split_term_classifiers from sphinx.errors import ConfigError -from sphinx.locale import __ +from sphinx.locale import safe_format from sphinx.locale import init as init_locale from sphinx.transforms import SphinxTransform from sphinx.util import get_filetype, logging @@ -153,7 +153,7 @@ def compare_references( old_ref_rawsources = [ref.rawsource for ref in old_refs] new_ref_rawsources = [ref.rawsource for ref in new_refs] logger.warning( - warning_msg.format(old_ref_rawsources, new_ref_rawsources), + safe_format(warning_msg, old_ref_rawsources, new_ref_rawsources), location=self.node, type='i18n', subtype='inconsistent_references', @@ -240,10 +240,8 @@ def list_replace_or_append[N: nodes.Node](lst: list[N], old: N, new: N) -> None: self.compare_references( old_foot_refs, new_foot_refs, - __( - 'inconsistent footnote references in translated message.' - ' original: {0}, translated: {1}' - ), + 'inconsistent footnote references in translated message.' + ' original: {0}, translated: {1}', ) old_foot_namerefs: dict[str, list[nodes.footnote_reference]] = {} for r in old_foot_refs: @@ -285,10 +283,8 @@ def update_refnamed_references(self) -> None: self.compare_references( old_refs, new_refs, - __( - 'inconsistent references in translated message.' - ' original: {0}, translated: {1}' - ), + 'inconsistent references in translated message.' + ' original: {0}, translated: {1}', ) old_ref_names = [r['refname'] for r in old_refs] new_ref_names = [r['refname'] for r in new_refs] @@ -315,10 +311,8 @@ def update_refnamed_footnote_references(self) -> None: self.compare_references( old_foot_refs, new_foot_refs, - __( - 'inconsistent footnote references in translated message.' - ' original: {0}, translated: {1}' - ), + 'inconsistent footnote references in translated message.' + ' original: {0}, translated: {1}', ) for oldf in old_foot_refs: refname_ids_map.setdefault(oldf['refname'], []).append(oldf['ids']) @@ -335,10 +329,8 @@ def update_citation_references(self) -> None: self.compare_references( old_cite_refs, new_cite_refs, - __( - 'inconsistent citation references in translated message.' - ' original: {0}, translated: {1}' - ), + 'inconsistent citation references in translated message.' + ' original: {0}, translated: {1}', ) refname_ids_map: dict[str, list[str]] = {} for oldc in old_cite_refs: @@ -357,10 +349,8 @@ def update_pending_xrefs(self) -> None: self.compare_references( old_xrefs, new_xrefs, - __( - 'inconsistent term references in translated message.' - ' original: {0}, translated: {1}' - ), + 'inconsistent term references in translated message.' + ' original: {0}, translated: {1}', # Compare by reftarget only, allowing translated display text. key_func=lambda ref: ref.get('reftarget'), ) diff --git a/tests/test_config/test_config.py b/tests/test_config/test_config.py index f7245b88795..2ac8ef56460 100644 --- a/tests/test_config/test_config.py +++ b/tests/test_config/test_config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import gettext import pickle from collections import Counter from typing import TYPE_CHECKING, Any @@ -10,6 +11,7 @@ import pytest import sphinx +from sphinx import locale from sphinx.config import ( ENUM, Config, @@ -531,6 +533,24 @@ def test_conf_warning_message(logger, name, default, annotation, actual, message assert logger.warning.call_args[0][0] == message +@mock.patch('sphinx.config.logger') +def test_conf_warning_message_corrupted_translation(logger, monkeypatch): + class _CorruptedTranslations(gettext.NullTranslations): + def gettext(self, message): + return message.replace('{current.__name__}', '{current__name__}') + + monkeypatch.setitem( + locale.translators, ('console', 'sphinx'), _CorruptedTranslations() + ) + config = Config({'value1': ['foo', 'bar']}) + config.add('value1', 'string', False, [str]) + check_confval_types(None, config) + assert logger.warning.called + assert logger.warning.call_args[0][0] == ( + "The config value `value1' has type `list'; expected `str'." + ) + + @mock.patch('sphinx.config.logger') def test_check_enum(logger): config = Config() diff --git a/tests/test_locale.py b/tests/test_locale.py new file mode 100644 index 00000000000..a9f0b2efc35 --- /dev/null +++ b/tests/test_locale.py @@ -0,0 +1,82 @@ +"""Test the ``sphinx.locale.safe_format`` helper.""" + +from __future__ import annotations + +from gettext import NullTranslations + +import pytest + +from sphinx import locale +from sphinx.locale import safe_format + + +class _CorruptedTranslations(NullTranslations): + """A catalogue whose msgstrs contain damaged ``str.format`` placeholders.""" + + def gettext(self, message: str) -> str: + return { + 'healthy message: {name}': 'saine message: {name}', + 'keyword message: {name}': 'keyword message: {nomen}', + 'positional message: {0} and {1}': 'positional message: {0}', + 'mixed message: {0} {name}': 'mixed message: {0} {nom}', + 'reordered message: {0} then {1}': 'reordered message: {1} then {0}', + }.get(message, message) + + +@pytest.fixture +def corrupted_console_catalogue(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem( + locale.translators, ('console', 'sphinx'), _CorruptedTranslations() + ) + + +def test_safe_format_without_translator_is_plain_format() -> None: + assert safe_format('a message: {name}', name='x') == 'a message: x' + assert safe_format('{0} and {1}', 1, 2) == '1 and 2' + + +def test_safe_format_returns_translated_message( + corrupted_console_catalogue: None, +) -> None: + assert safe_format('healthy message: {name}', name='x') == 'saine message: x' + + +def test_safe_format_falls_back_on_renamed_keyword( + corrupted_console_catalogue: None, +) -> None: + assert safe_format('keyword message: {name}', name='x') == 'keyword message: x' + + +def test_safe_format_falls_back_on_missing_positional( + corrupted_console_catalogue: None, +) -> None: + assert safe_format('positional message: {0} and {1}', 1, 2) == ( + 'positional message: 1 and 2' + ) + + +def test_safe_format_falls_back_on_mixed_corruption( + corrupted_console_catalogue: None, +) -> None: + assert safe_format('mixed message: {0} {name}', 1, name='x') == ( + 'mixed message: 1 x' + ) + + +def test_safe_format_allows_reordered_placeholders( + corrupted_console_catalogue: None, +) -> None: + assert safe_format('reordered message: {0} then {1}', 1, 2) == ( + 'reordered message: 2 then 1' + ) + + +def test_safe_format_supports_other_namespaces( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem( + locale.translators, ('general', 'sphinx'), _CorruptedTranslations() + ) + assert safe_format('keyword message: {name}', namespace='general', name='x') == ( + 'keyword message: x' + ) From 6f2d36ef365c89eca1b26cab05da254c6c33f800 Mon Sep 17 00:00:00 2001 From: shashb27 Date: Wed, 2 Sep 2026 01:03:00 -0700 Subject: [PATCH 2/3] Sort the import block in transforms/i18n.py --- sphinx/transforms/i18n.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/transforms/i18n.py b/sphinx/transforms/i18n.py index 47292fbaa7c..74987202180 100644 --- a/sphinx/transforms/i18n.py +++ b/sphinx/transforms/i18n.py @@ -13,8 +13,8 @@ from sphinx import addnodes from sphinx.domains.std import make_glossary_term, split_term_classifiers from sphinx.errors import ConfigError -from sphinx.locale import safe_format from sphinx.locale import init as init_locale +from sphinx.locale import safe_format from sphinx.transforms import SphinxTransform from sphinx.util import get_filetype, logging from sphinx.util.docutils import LoggingReporter From 440f11137b0e92bb7bf3148ecd11547684385f84 Mon Sep 17 00:00:00 2001 From: Shash Bhaskar <210754767+shashb27@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:14:51 -0700 Subject: [PATCH 3/3] Extract messages translated by `safe_format` in `babel_runner.py` Teach the extraction keywords about `safe_format` (its first argument is the msgid) and `_NodeUpdater.compare_references` (its third argument is the untranslated warning message), so that the strings moved behind `safe_format` in #14664 keep appearing in `sphinx/locale/sphinx.pot`. --- utils/babel_runner.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/utils/babel_runner.py b/utils/babel_runner.py index 63fe8ab13e8..48e17907f38 100644 --- a/utils/babel_runner.py +++ b/utils/babel_runner.py @@ -76,7 +76,16 @@ 'include_attrs': 'alt title summary', }, } -KEYWORDS = {**DEFAULT_KEYWORDS, '_': None, '__': None} +KEYWORDS = { + **DEFAULT_KEYWORDS, + '_': None, + '__': None, + # messages that are translated by ``sphinx.locale.safe_format`` + 'safe_format': None, + # ``_NodeUpdater.compare_references`` receives its untranslated warning + # message as the third argument + 'compare_references': (3,), +} def run_extract() -> None: