Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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 @@ -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.
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'
)
Loading