Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ Release 9.1.1 (released Oct 19, 2026)
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.
Expand Down
13 changes: 9 additions & 4 deletions sphinx/_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -70,7 +70,7 @@ def format_help(self) -> str:
help_fragments: list[str] = [
bold(underline(__('Usage:'))),
' ',
__('{0} [OPTIONS] <COMMAND> [<ARGS>]').format(bold(self.prog)),
safe_format('{0} [OPTIONS] <COMMAND> [<ARGS>]', bold(self.prog)),
'\n',
'\n',
__(' The Sphinx documentation generator.'),
Expand Down Expand Up @@ -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)


Expand Down
38 changes: 21 additions & 17 deletions sphinx/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
)
Expand All @@ -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 = (
Expand All @@ -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,
)

Expand Down
36 changes: 36 additions & 0 deletions sphinx/locale/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
34 changes: 12 additions & 22 deletions sphinx/transforms/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 __
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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Expand All @@ -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'])
Expand All @@ -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:
Expand All @@ -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'),
)
Expand Down
20 changes: 20 additions & 0 deletions tests/test_config/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import gettext
import pickle
from collections import Counter
from typing import TYPE_CHECKING, Any
Expand All @@ -10,6 +11,7 @@
import pytest

import sphinx
from sphinx import locale
from sphinx.config import (
ENUM,
Config,
Expand Down Expand Up @@ -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()
Expand Down
82 changes: 82 additions & 0 deletions tests/test_locale.py
Original file line number Diff line number Diff line change
@@ -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'
)
11 changes: 10 additions & 1 deletion utils/babel_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading