Skip to content

Commit ac4510a

Browse files
committed
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
1 parent e44a40e commit ac4510a

7 files changed

Lines changed: 185 additions & 43 deletions

File tree

CHANGES.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ Release 9.1.1 (in development)
44
Bugs fixed
55
----------
66

7+
* #14664: Do not crash with a ``KeyError`` when a translated message
8+
catalogue contains a damaged ``str.format`` placeholder; affected messages
9+
now fall back to the original English text.
10+
Patch by Shash Bhaskar
11+
712
* #14465: LaTeX: PDF build crash since LaTeX June 2026 release if tables are
813
styled with ``'colorrows'`` (which is the default).
914
Patch by Jean-François B.

sphinx/_cli/__init__.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
terminal_supports_colour,
3333
underline,
3434
)
35-
from sphinx.locale import __, init_console
35+
from sphinx.locale import __, init_console, safe_format
3636

3737
if TYPE_CHECKING:
3838
from collections.abc import Callable, Iterable, Iterator, Sequence
@@ -70,7 +70,7 @@ def format_help(self) -> str:
7070
help_fragments: list[str] = [
7171
bold(underline(__('Usage:'))),
7272
' ',
73-
__('{0} [OPTIONS] <COMMAND> [<ARGS>]').format(bold(self.prog)),
73+
safe_format('{0} [OPTIONS] <COMMAND> [<ARGS>]', bold(self.prog)),
7474
'\n',
7575
'\n',
7676
__(' The Sphinx documentation generator.'),
@@ -166,8 +166,13 @@ def _format_metavar(
166166
raise ValueError(msg)
167167

168168
def error(self, message: str) -> NoReturn:
169-
msg = __("{0}: error: {1}\nRun '{0} --help' for information")
170-
sys.stderr.write(msg.format(self.prog, message))
169+
sys.stderr.write(
170+
safe_format(
171+
"{0}: error: {1}\nRun '{0} --help' for information",
172+
self.prog,
173+
message,
174+
)
175+
)
171176
raise SystemExit(2)
172177

173178

sphinx/config.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from typing import TYPE_CHECKING, Any, Literal, NamedTuple
1212

1313
from sphinx.errors import ConfigError, ExtensionError
14-
from sphinx.locale import _, __
14+
from sphinx.locale import _, __, safe_format
1515
from sphinx.util import logging
1616

1717
if TYPE_CHECKING:
@@ -793,13 +793,13 @@ def check_confval_types(app: Sphinx | None, config: Config) -> None:
793793

794794
if isinstance(valid_types, ENUM):
795795
if not valid_types.match(value):
796-
msg = __(
797-
'The config value `{name}` has to be a one of {candidates}, '
798-
'but `{current}` is given.'
799-
)
800796
logger.warning(
801-
msg.format(
802-
name=name, current=value, candidates=valid_types._candidates
797+
safe_format(
798+
'The config value `{name}` has to be a one of {candidates}, '
799+
'but `{current}` is given.',
800+
name=name,
801+
current=value,
802+
candidates=valid_types._candidates,
803803
),
804804
once=True,
805805
)
@@ -824,10 +824,6 @@ def check_confval_types(app: Sphinx | None, config: Config) -> None:
824824
continue # at least we share a non-trivial base class
825825

826826
if valid_types:
827-
msg = __(
828-
"The config value `{name}' has type `{current.__name__}'; "
829-
'expected {permitted}.'
830-
)
831827
wrapped_valid_types = sorted(f"`{c.__name__}'" for c in valid_types)
832828
if len(wrapped_valid_types) > 2:
833829
permitted = (
@@ -837,16 +833,24 @@ def check_confval_types(app: Sphinx | None, config: Config) -> None:
837833
else:
838834
permitted = ' or '.join(wrapped_valid_types)
839835
logger.warning(
840-
msg.format(name=name, current=type_value, permitted=permitted),
836+
safe_format(
837+
"The config value `{name}' has type `{current.__name__}'; "
838+
'expected {permitted}.',
839+
name=name,
840+
current=type_value,
841+
permitted=permitted,
842+
),
841843
once=True,
842844
)
843845
else:
844-
msg = __(
845-
"The config value `{name}' has type `{current.__name__}', "
846-
"defaults to `{default.__name__}'."
847-
)
848846
logger.warning(
849-
msg.format(name=name, current=type_value, default=type_default),
847+
safe_format(
848+
"The config value `{name}' has type `{current.__name__}', "
849+
"defaults to `{default.__name__}'.",
850+
name=name,
851+
current=type_value,
852+
default=type_default,
853+
),
850854
once=True,
851855
)
852856

sphinx/locale/__init__.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import sys
77
from gettext import NullTranslations, translation
88
from pathlib import Path
9+
from string import Formatter
910
from typing import TYPE_CHECKING
1011

1112
from sphinx import package_dir
@@ -215,6 +216,41 @@ def gettext(message: str) -> str:
215216
return gettext
216217

217218

219+
def _format_field_names(message: str) -> set[str]:
220+
return {
221+
field_name
222+
for _, field_name, _, _ in Formatter().parse(message)
223+
if field_name is not None
224+
}
225+
226+
227+
def safe_format(
228+
message: str,
229+
/,
230+
*args: Any,
231+
catalog: str = 'sphinx',
232+
namespace: str = 'console',
233+
**kwargs: Any,
234+
) -> str:
235+
"""Format the translation of *message* using ``str.format``, falling back
236+
to the untranslated *message* when the translation is damaged: a
237+
placeholder that was renamed, dropped, or added, or a format call that
238+
raises.
239+
240+
This is the safe companion to :func:`_` and ``__`` for messages that are
241+
formatted eagerly: pass the *untranslated* message and the format
242+
arguments, and a damaged catalogue degrades to an English message
243+
instead of raising or losing information during the build.
244+
"""
245+
translated = get_translator(catalog, namespace).gettext(message)
246+
if _format_field_names(translated) == _format_field_names(message):
247+
try:
248+
return translated.format(*args, **kwargs)
249+
except (KeyError, IndexError, ValueError):
250+
pass
251+
return message.format(*args, **kwargs)
252+
253+
218254
# A shortcut for sphinx-core
219255
#: Translation function for messages on documentation (menu, labels, themes and so on).
220256
#: This function follows :confval:`language` setting.

sphinx/transforms/i18n.py

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from sphinx import addnodes
1414
from sphinx.domains.std import make_glossary_term, split_term_classifiers
1515
from sphinx.errors import ConfigError
16-
from sphinx.locale import __
16+
from sphinx.locale import safe_format
1717
from sphinx.locale import init as init_locale
1818
from sphinx.transforms import SphinxTransform
1919
from sphinx.util import get_filetype, logging
@@ -153,7 +153,7 @@ def compare_references(
153153
old_ref_rawsources = [ref.rawsource for ref in old_refs]
154154
new_ref_rawsources = [ref.rawsource for ref in new_refs]
155155
logger.warning(
156-
warning_msg.format(old_ref_rawsources, new_ref_rawsources),
156+
safe_format(warning_msg, old_ref_rawsources, new_ref_rawsources),
157157
location=self.node,
158158
type='i18n',
159159
subtype='inconsistent_references',
@@ -240,10 +240,8 @@ def list_replace_or_append[N: nodes.Node](lst: list[N], old: N, new: N) -> None:
240240
self.compare_references(
241241
old_foot_refs,
242242
new_foot_refs,
243-
__(
244-
'inconsistent footnote references in translated message.'
245-
' original: {0}, translated: {1}'
246-
),
243+
'inconsistent footnote references in translated message.'
244+
' original: {0}, translated: {1}',
247245
)
248246
old_foot_namerefs: dict[str, list[nodes.footnote_reference]] = {}
249247
for r in old_foot_refs:
@@ -285,10 +283,8 @@ def update_refnamed_references(self) -> None:
285283
self.compare_references(
286284
old_refs,
287285
new_refs,
288-
__(
289-
'inconsistent references in translated message.'
290-
' original: {0}, translated: {1}'
291-
),
286+
'inconsistent references in translated message.'
287+
' original: {0}, translated: {1}',
292288
)
293289
old_ref_names = [r['refname'] for r in old_refs]
294290
new_ref_names = [r['refname'] for r in new_refs]
@@ -315,10 +311,8 @@ def update_refnamed_footnote_references(self) -> None:
315311
self.compare_references(
316312
old_foot_refs,
317313
new_foot_refs,
318-
__(
319-
'inconsistent footnote references in translated message.'
320-
' original: {0}, translated: {1}'
321-
),
314+
'inconsistent footnote references in translated message.'
315+
' original: {0}, translated: {1}',
322316
)
323317
for oldf in old_foot_refs:
324318
refname_ids_map.setdefault(oldf['refname'], []).append(oldf['ids'])
@@ -335,10 +329,8 @@ def update_citation_references(self) -> None:
335329
self.compare_references(
336330
old_cite_refs,
337331
new_cite_refs,
338-
__(
339-
'inconsistent citation references in translated message.'
340-
' original: {0}, translated: {1}'
341-
),
332+
'inconsistent citation references in translated message.'
333+
' original: {0}, translated: {1}',
342334
)
343335
refname_ids_map: dict[str, list[str]] = {}
344336
for oldc in old_cite_refs:
@@ -357,10 +349,8 @@ def update_pending_xrefs(self) -> None:
357349
self.compare_references(
358350
old_xrefs,
359351
new_xrefs,
360-
__(
361-
'inconsistent term references in translated message.'
362-
' original: {0}, translated: {1}'
363-
),
352+
'inconsistent term references in translated message.'
353+
' original: {0}, translated: {1}',
364354
# Compare by reftarget only, allowing translated display text.
365355
key_func=lambda ref: ref.get('reftarget'),
366356
)

tests/test_config/test_config.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import gettext
56
import pickle
67
from collections import Counter
78
from typing import TYPE_CHECKING, Any
@@ -10,6 +11,7 @@
1011
import pytest
1112

1213
import sphinx
14+
from sphinx import locale
1315
from sphinx.config import (
1416
ENUM,
1517
Config,
@@ -531,6 +533,24 @@ def test_conf_warning_message(logger, name, default, annotation, actual, message
531533
assert logger.warning.call_args[0][0] == message
532534

533535

536+
@mock.patch('sphinx.config.logger')
537+
def test_conf_warning_message_corrupted_translation(logger, monkeypatch):
538+
class _CorruptedTranslations(gettext.NullTranslations):
539+
def gettext(self, message):
540+
return message.replace('{current.__name__}', '{current__name__}')
541+
542+
monkeypatch.setitem(
543+
locale.translators, ('console', 'sphinx'), _CorruptedTranslations()
544+
)
545+
config = Config({'value1': ['foo', 'bar']})
546+
config.add('value1', 'string', False, [str])
547+
check_confval_types(None, config)
548+
assert logger.warning.called
549+
assert logger.warning.call_args[0][0] == (
550+
"The config value `value1' has type `list'; expected `str'."
551+
)
552+
553+
534554
@mock.patch('sphinx.config.logger')
535555
def test_check_enum(logger):
536556
config = Config()

tests/test_locale.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Test the ``sphinx.locale.safe_format`` helper."""
2+
3+
from __future__ import annotations
4+
5+
from gettext import NullTranslations
6+
7+
import pytest
8+
9+
from sphinx import locale
10+
from sphinx.locale import safe_format
11+
12+
13+
class _CorruptedTranslations(NullTranslations):
14+
"""A catalogue whose msgstrs contain damaged ``str.format`` placeholders."""
15+
16+
def gettext(self, message: str) -> str:
17+
return {
18+
'healthy message: {name}': 'saine message: {name}',
19+
'keyword message: {name}': 'keyword message: {nomen}',
20+
'positional message: {0} and {1}': 'positional message: {0}',
21+
'mixed message: {0} {name}': 'mixed message: {0} {nom}',
22+
'reordered message: {0} then {1}': 'reordered message: {1} then {0}',
23+
}.get(message, message)
24+
25+
26+
@pytest.fixture
27+
def corrupted_console_catalogue(monkeypatch: pytest.MonkeyPatch) -> None:
28+
monkeypatch.setitem(
29+
locale.translators, ('console', 'sphinx'), _CorruptedTranslations()
30+
)
31+
32+
33+
def test_safe_format_without_translator_is_plain_format() -> None:
34+
assert safe_format('a message: {name}', name='x') == 'a message: x'
35+
assert safe_format('{0} and {1}', 1, 2) == '1 and 2'
36+
37+
38+
def test_safe_format_returns_translated_message(
39+
corrupted_console_catalogue: None,
40+
) -> None:
41+
assert safe_format('healthy message: {name}', name='x') == 'saine message: x'
42+
43+
44+
def test_safe_format_falls_back_on_renamed_keyword(
45+
corrupted_console_catalogue: None,
46+
) -> None:
47+
assert safe_format('keyword message: {name}', name='x') == 'keyword message: x'
48+
49+
50+
def test_safe_format_falls_back_on_missing_positional(
51+
corrupted_console_catalogue: None,
52+
) -> None:
53+
assert safe_format('positional message: {0} and {1}', 1, 2) == (
54+
'positional message: 1 and 2'
55+
)
56+
57+
58+
def test_safe_format_falls_back_on_mixed_corruption(
59+
corrupted_console_catalogue: None,
60+
) -> None:
61+
assert safe_format('mixed message: {0} {name}', 1, name='x') == (
62+
'mixed message: 1 x'
63+
)
64+
65+
66+
def test_safe_format_allows_reordered_placeholders(
67+
corrupted_console_catalogue: None,
68+
) -> None:
69+
assert safe_format('reordered message: {0} then {1}', 1, 2) == (
70+
'reordered message: 2 then 1'
71+
)
72+
73+
74+
def test_safe_format_supports_other_namespaces(
75+
monkeypatch: pytest.MonkeyPatch,
76+
) -> None:
77+
monkeypatch.setitem(
78+
locale.translators, ('general', 'sphinx'), _CorruptedTranslations()
79+
)
80+
assert safe_format('keyword message: {name}', namespace='general', name='x') == (
81+
'keyword message: x'
82+
)

0 commit comments

Comments
 (0)