"""Translation rendering primitives shared across the codebase.
The application and adapter layers import :func:`tr` from here so they
can render translatable keys without reaching into the CLI entrypoints.
``python-i18n`` is initialised lazily on first call.
"""
from __future__ import annotations
import importlib.resources # nosemgrep
import os
import re
import sys
from collections.abc import Callable, Generator, Mapping
from contextlib import contextmanager
from contextvars import ContextVar
from functools import lru_cache
from pathlib import Path
from string import Formatter
from typing import TYPE_CHECKING
import i18n
import yaml
from ..errors.hierarchy import CoreError
from ..external_constants import DEFAULT_OUTPUT_LANGUAGE, OUTPUT_LANGUAGE_ENV_VAR, SUPPORTED_OUTPUT_LANGUAGES
from ..logging import get_logger
from ..product_identity import PRODUCT_IDENTITY, normalise_product_identity_references
from ..type_guards import is_object_mapping
from ._lazy_catalogue import load_yaml_handle
if TYPE_CHECKING:
from ._lazy_catalogue import LazyLocaleCatalogue
_log = get_logger(__name__)
_initialised = False
_PLACEHOLDER_RE = re.compile(r"%\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)\}")
_FORMAT_FIELD_ROOT_RE = re.compile(r"(?P<name>[A-Za-z_][A-Za-z0-9_]*)(?=$|[.\[])")
_FORMATTER = Formatter()
_output_language_cache_version = 0
# Test-scope flag: when True, tr raises UnmatchedPlaceholderError for any
# declared placeholder not supplied by the caller. Production leaves this False.
I18N_STRICT_PLACEHOLDERS: ContextVar[bool] = ContextVar("cadrumo_i18n_strict_placeholders", default=False)
# Test-scope flag: when True, tr raises MissingTranslationError for a key the
# catalogue does not carry, instead of returning a humanised fallback. A caller
# supplying an explicit `default` has opted into a fallback and never raises.
# Production leaves this False so a missing string can never abort a filing.
I18N_STRICT_MISSING_KEYS: ContextVar[bool] = ContextVar("cadrumo_i18n_strict_missing_keys", default=False)
[docs]
class UnmatchedPlaceholderError(CoreError):
"""Raised in strict-placeholder mode when a locale value retains a {name} token.
Indicates that a ``tr()`` call site supplies a key whose locale value
contains a placeholder not covered by the supplied kwargs (ORPHAN), or
that the locale value was never interpolated at all.
Attributes:
key: The locale translation key that triggered the error.
name: The placeholder name that survived substitution.
rendered: The partially-rendered string at the time of detection.
"""
def __init__(self, *, key: str, name: str, rendered: str) -> None:
"""Initialise with the translation key, placeholder name, and partial render.
Args:
key: The locale translation key that triggered the error.
name: The placeholder name that survived substitution.
rendered: The partially-rendered string at the time of detection.
"""
super().__init__(f"unmatched placeholder {{{name!r}}} in locale key {key!r}: {rendered!r}")
self.key = key
self.name = name
self.rendered = rendered
[docs]
class MissingTranslationError(CoreError):
"""Raised in strict-missing-key mode when a catalogue does not carry a key.
Indicates that a ``tr()`` call site names a key absent from the locale, or
one whose value is the key itself (the scaffold placeholder for "not
translated yet"). Outside strict mode both cases fall back to a humanised
label, so a missing string never aborts a filing.
Attributes:
key: The locale translation key that could not be resolved.
locale: The locale whose catalogue was consulted.
"""
def __init__(self, *, key: str, locale: str) -> None:
"""Initialise with the unresolved translation key and its locale.
Args:
key: The locale translation key that could not be resolved.
locale: The locale whose catalogue was consulted.
"""
super().__init__(
f"locale key {key!r} is not translated in {locale!r}; "
f"declare it in the locale catalogues and set a real translation for {locale!r}"
)
self.key = key
self.locale = locale
# Application-layer hook: set by cadrumo.application at startup to allow the i18n
# layer to read the active-profile output language without importing application
# modules directly. Remains None until explicitly registered.
_profile_language_resolver: Callable[[], str | None] | None = None
[docs]
def register_profile_language_resolver(fn: Callable[[], str | None]) -> None:
"""Register a callback that resolves the active-profile output language.
The application layer calls this once at startup so ``core.i18n`` can
read profile-level language preferences without importing application
modules directly.
"""
global _profile_language_resolver
_profile_language_resolver = fn
def _ensure_initialised() -> None:
"""Lazy-initialise the ``python-i18n`` backend on first call."""
global _initialised
if _initialised:
return
i18n.load_path.append(str(importlib.resources.files(PRODUCT_IDENTITY.python_package).joinpath("locales")))
i18n.set("filename_format", "{locale}.{format}")
i18n.set("file_format", "yml")
i18n.set("skip_locale_root_data", True)
_initialised = True
[docs]
def normalise_supported_language(value: object) -> str | None:
"""Return the supported-language token ``value`` names, or ``None``."""
return _normalised_supported_language(str(value))
@lru_cache(maxsize=64)
def _normalised_supported_language(value: str) -> str | None:
"""Coerce one already-stringified language token, once per distinct spelling.
The answer is fixed by the supported-language enum, so it cannot change
within a process. Every catalogue lookup normalises its locale first, and
that made this the hot half of a lookup: 1.20us of the 2.39us a
``lookup_translation`` cost, over the 692k lookups one registry validation
issues. Bounded rather than unbounded because the argument is caller
supplied and an unrecognised spelling is a legitimate ``None``.
"""
from ..config_support import coerce_output_language_setting
language = coerce_output_language_setting(value)
return language.value if language is not None else None
[docs]
def output_language() -> str:
"""Resolve the operator-facing output language.
An explicit ``cadrumo_output_language`` value on the active Settings
(env var, ``override_settings`` block, or ``.env`` file) wins for
one-off sessions and automation. Otherwise the active profile's
``output.language`` key is used. The settings default remains the
final fallback and defaults to Spanish for a clean install.
Returns:
The resolved ISO 639-1 language code.
"""
return _cached_output_language(_output_language_cache_key())
[docs]
def clear_output_language_cache() -> None:
"""Invalidate cached language resolution after profile/config writes."""
global _output_language_cache_version
_output_language_cache_version += 1
_cached_output_language.cache_clear()
[docs]
def clear_output_language_cache_for_settings_override() -> None:
"""Invalidate at an ``override_settings`` boundary the resolver does not own.
``override_settings`` invalidates at both of its boundaries because the
cache keys an override block by ``id(override)`` and a GC'd block's address
can be reused. That is right for every block an operator or a command
opens, and wrong for exactly one: resolving the active profile's language
scopes its own read with ``override_settings(cadrumo_active_profile=...)``,
so the resolution invalidated the very entry it was about to store.
The version is part of the cache key, so each resolution left the next call
keyed to a dead version — a cache that could never serve a hit while a
bucket session was bound. Every ``tr()`` then paid a full encrypted profile
read and three ``Settings`` constructions, which is roughly 44 ms each on
Windows, where a construction resolves 28 configured paths. One manager
repaint resolves a few hundred strings, so re-wording the page after a
language change cost around thirteen seconds.
Suppressing the boundary only while a resolution is in flight is what
separates the two cases: the profile-write, registration, and CLI-init
invalidations all go through :func:`clear_output_language_cache` and are
never suppressed, so a write that genuinely moves the language is still
seen. The flag is a :class:`~contextvars.ContextVar`, so a resolution
running on one task cannot silence another's boundary, and a thread that
never resolved sees the default and invalidates normally.
"""
if _RESOLVING_OUTPUT_LANGUAGE.get():
return
clear_output_language_cache()
_OUTPUT_LANGUAGE_KEY_ENV_VARS: tuple[str, ...] = (
OUTPUT_LANGUAGE_ENV_VAR,
"CADRUMO_DATABASE_URL",
)
def _active_settings_override() -> object | None:
"""Return the scoped settings override without importing the settings model.
An override can only have been installed by a module that already imported
:mod:`cadrumo.core.config`; until then there is none to observe.
"""
if "cadrumo.core.config" not in sys.modules:
return None
from ..config import settings_override
return settings_override.get()
def _output_language_cache_key() -> tuple[object, ...]:
override = _active_settings_override()
if override is not None:
return ("override", id(override), _output_language_cache_version)
# The key is built from in-memory inputs ONLY — no filesystem call. It
# samples the raw env vars Pydantic merges into ``Settings`` so a cache
# miss still rebuilds ``Settings`` normally (with the correct
# .env+os.environ merge order); the key only needs to *change* when the
# effective value could change, never to carry the merged value itself.
#
# This deliberately does NOT stat ``env/.env``. An earlier revision put
# that file's mtime in the key as a freshness probe, which cost one
# ``stat`` syscall per ``tr()`` call — measured at 77% of all time spent
# in ``tr()``, on a function whose own catalogue lookup is 0.4us. A
# translated string is rendered dozens of times per TUI frame and ~100
# times per ``--help``, so the probe dominated every rendering path in
# the app.
#
# Dropping it is safe because a ``.env`` edit cannot change the effective
# language without one of: an ``override_settings`` block (keyed above by
# override identity, and invalidated at both boundaries), a mutation of
# the stored profile language, or a process restart. The first two call
# ``clear_output_language_cache`` explicitly — registration, the profile
# repository, workflow persistence, CLI init, and language activation all
# do — and nothing else in the app hot-reloads ``.env`` mid-process
# either, so honouring it here alone was an inconsistency, not a feature.
#
# os.environ.get allowlist: these reads compute a cache-key signature, not
# a settings value, and do not bypass the Pydantic merge order.
env_signature = tuple(os.environ.get(name) for name in _OUTPUT_LANGUAGE_KEY_ENV_VARS)
return (
"env",
*env_signature,
_output_language_cache_version,
)
_RESOLVING_OUTPUT_LANGUAGE: ContextVar[bool] = ContextVar("cadrumo_i18n_resolving_output_language", default=False)
"""Whether this context is inside :func:`_resolve_output_language`.
Read by :func:`clear_output_language_cache_for_settings_override` to tell a
resolution's own scoping block from every other override boundary.
"""
@lru_cache(maxsize=128)
def _cached_output_language(_cache_key: tuple[object, ...]) -> str:
token = _RESOLVING_OUTPUT_LANGUAGE.set(True)
try:
return _resolve_output_language()
finally:
_RESOLVING_OUTPUT_LANGUAGE.reset(token)
def _resolve_output_language() -> str:
"""Resolve the language from settings and the active profile, uncached."""
if _profile_language_resolver is None and _active_settings_override() is None:
return _resolve_output_language_before_profile_composition()
from ..config import load_settings
from ..config_state_root import FormerProductStateError
try:
settings = load_settings()
except (CoreError, FormerProductStateError, KeyError, ValueError, AttributeError) as exc:
_log.debug(
"i18n: unable to load settings for output language; falling back to default (%s)",
type(exc).__name__,
exc_info=True,
)
return DEFAULT_OUTPUT_LANGUAGE
if "cadrumo_output_language" in settings.model_fields_set:
explicit = normalise_supported_language(settings.cadrumo_output_language)
if explicit is not None:
return explicit
profile_language = _active_profile_output_language()
if profile_language is not None:
return profile_language
return normalise_supported_language(settings.cadrumo_output_language) or DEFAULT_OUTPUT_LANGUAGE
def _environment_setting(name: str) -> str | None:
"""Read one setting the way the settings environment source does.
Names match case-insensitively with the last spelling winning, and an
empty value counts as unset.
"""
wanted = name.lower()
value: str | None = None
for key, candidate in os.environ.items():
if key.lower() == wanted:
value = candidate
return value or None
def _resolve_output_language_before_profile_composition() -> str:
"""Resolve the language with no profile resolver and no settings override.
Only the environment and the default can decide the language here, so the
settings model is not built: command-graph construction renders every help
string through this path. The state-root refusals that make settings
unloadable are still observed and fall back to the default exactly as
:func:`_resolve_output_language` does.
"""
from .._config_runtime import active_profile_pointer_observation
from ..config_state_root import FormerProductStateError, default_storage_root, refuse_former_product_database
from ..paths import normalize_project_relative_path
try:
root, pointer = active_profile_pointer_observation(
normalizer=normalize_project_relative_path,
storage_root=default_storage_root,
)
if _environment_setting("CADRUMO_DATABASE_URL") is None:
bucket_id = (pointer.bucket_id or "").strip()
refuse_former_product_database(root, bucket_id=bucket_id or None)
except (CoreError, FormerProductStateError, KeyError, ValueError, AttributeError) as exc:
_log.debug(
"i18n: state root cannot back settings for output language; falling back to default (%s)",
type(exc).__name__,
exc_info=True,
)
return DEFAULT_OUTPUT_LANGUAGE
return normalise_supported_language(_environment_setting(OUTPUT_LANGUAGE_ENV_VAR)) or DEFAULT_OUTPUT_LANGUAGE
def _active_profile_output_language() -> str | None:
"""Return active profile language without mutating workflow state.
Delegates to the application-registered resolver if one has been
provided via :func:`register_profile_language_resolver`. Falls back
gracefully to ``None`` (settings-level language) when no resolver is
registered or the resolver raises.
"""
resolver = _profile_language_resolver
if resolver is None:
return None
try:
return normalise_supported_language(resolver() or "")
except Exception as exc:
_log.debug(
"i18n: unable to resolve active-profile output language; falling back to settings (%s)",
type(exc).__name__,
exc_info=True,
)
return None
[docs]
def tr(translation_key: str, /, **kwargs: object) -> str:
"""Render an abstract translation key in the configured output language.
Args:
translation_key: The abstract namespace key to render
(e.g., ``"cli.auth.purpose"``). Positional-only so callers
can pass interpolation kwargs named ``key`` without
collision.
**kwargs: Interpolation arguments for ``python-i18n``.
Returns:
The translated string.
Raises:
UnmatchedPlaceholderError: When strict-placeholder mode is
active and the rendered string still contains an
un-interpolated ``{name}`` token.
MissingTranslationError: When strict-missing-key mode is active,
the catalogue does not carry ``translation_key``, and no
explicit ``default`` was supplied.
"""
if "locale" not in kwargs or kwargs["locale"] is None:
kwargs["locale"] = output_language()
locale = normalise_supported_language(kwargs["locale"]) or "en"
default = kwargs.pop("default", None)
looked_up = _lookup_translation(locale, translation_key, default=default)
interpolation = {key: value for key, value in kwargs.items() if key not in {"locale", "default"}}
if interpolation:
interpolated, format_succeeded = _interpolate_with_status(translation_key, looked_up, interpolation)
else:
interpolated, format_succeeded = looked_up, True
rendered = normalise_product_identity_references(interpolated)
if I18N_STRICT_PLACEHOLDERS.get():
_enforce_strict_placeholders(
translation_key,
looked_up=looked_up,
interpolated=interpolated,
rendered=rendered,
interpolation=interpolation,
format_succeeded=format_succeeded,
)
return rendered
def _enforce_strict_placeholders(
translation_key: str,
*,
looked_up: str,
interpolated: str,
rendered: str,
interpolation: Mapping[str, object],
format_succeeded: bool,
) -> None:
"""Raise when a declared placeholder survived rendering in strict mode.
``looked_up`` is the pre-interpolation locale value (its residual
``%{name}``/``{name}`` tokens define the unmatched set); ``interpolated`` is
the post-interpolation value whose surviving format roots are inspected only
when the format pass failed; ``rendered`` is the fully normalised value
reported on the raised error.
"""
unmatched_placeholders = sorted(extract_placeholders(looked_up) - interpolation.keys())
failed_format_placeholders = sorted(_extract_format_placeholder_roots(interpolated)) if not format_succeeded else []
if unmatched_placeholders or failed_format_placeholders:
raise UnmatchedPlaceholderError(
key=translation_key,
name=(unmatched_placeholders or failed_format_placeholders)[0],
rendered=rendered,
)
def _extract_format_placeholder_roots(value: str) -> frozenset[str]:
"""Return named root kwargs consumed by the format pass.
Malformed strings are scanned for independently valid replacement fields
so strict mode can still reject supported tokens around the damaged region.
The recovery path never treats the malformed whole as a valid format.
"""
parsed = _parse_format_placeholder_roots(value)
if parsed is not None:
return frozenset(parsed)
return frozenset(_recover_format_placeholder_roots(value))
def _match_group_text(match: re.Match[str], name: str) -> str:
"""Return a regex capture after enforcing the text-node invariant."""
value = match.group(name)
if not isinstance(value, str):
raise ValueError(f"placeholder capture {name!r} matched no text in {match.string!r}")
return value
def _parse_format_placeholder_roots(value: str) -> set[str] | None:
"""Parse one valid format fragment, or return ``None`` when malformed."""
try:
fields = tuple(_FORMATTER.parse(value))
except ValueError:
return None
names: set[str] = set()
for _literal, field_name, format_spec, _conversion in fields:
if field_name is not None:
root = _FORMAT_FIELD_ROOT_RE.match(field_name)
if root is not None:
names.add(_match_group_text(root, "name"))
if format_spec:
nested = _parse_format_placeholder_roots(format_spec)
if nested is None:
names.update(_recover_format_placeholder_roots(format_spec))
else:
names.update(nested)
return names
def _recover_format_placeholder_roots(value: str) -> set[str]:
"""Recover valid fields surrounding malformed brace syntax."""
names: set[str] = set()
index = 0
while index < len(value):
if value.startswith("{{", index):
index += 2
continue
if value[index] != "{":
index += 1
continue
recovered_end: int | None = None
for closing in range(index + 1, len(value)):
if value[closing] != "}":
continue
fragment = value[index : closing + 1]
parsed = _parse_format_placeholder_roots(fragment)
if parsed is None:
continue
names.update(parsed)
recovered_end = closing + 1
break
index = recovered_end if recovered_end is not None else index + 1
return names
_I18N_LOCALES_ROOT: ContextVar[Path | None] = ContextVar("cadrumo_i18n_locales_root", default=None)
[docs]
@contextmanager
def override_locales_root(root: Path) -> Generator[None]:
"""Resolve catalogues from ``root`` instead of the packaged resources.
The renderer's miss semantics — an absent key and a key-echo value are
both misses (:func:`_lookup_translation`) — can only be exercised
against a catalogue that carries the defect, and the shipped
catalogues are gated echo-free. This override is the sanctioned seam
for pointing resolution at a fixture catalogue, mirroring
``override_settings()``; the packaged resources stay the sole source
otherwise.
"""
token = _I18N_LOCALES_ROOT.set(root)
try:
yield
finally:
_I18N_LOCALES_ROOT.reset(token)
[docs]
def locale_map(locale: str) -> Mapping[str, str | None]:
from ._lazy_catalogue import LazyLocaleCatalogue
override = _I18N_LOCALES_ROOT.get()
if override is not None:
shard_dir = override / locale
if shard_dir.is_dir():
return LazyLocaleCatalogue(locale, shard_dir=shard_dir)
monolith = override / f"{locale}.yml"
if monolith.is_file():
with monolith.open("r", encoding="utf-8") as handle:
return _flatten_translations(load_yaml_handle(handle))
return _packaged_locale_map(locale)
@lru_cache(maxsize=len(SUPPORTED_OUTPUT_LANGUAGES))
def _packaged_locale_map(locale: str) -> LazyLocaleCatalogue:
from ._lazy_catalogue import LazyLocaleCatalogue
resource = importlib.resources.files(PRODUCT_IDENTITY.python_package).joinpath("locales")
locales_root = Path(str(resource))
shard_dir = locales_root / locale
return LazyLocaleCatalogue(locale, shard_dir=shard_dir)
def _flatten_translations(value: object, prefix: str = "") -> dict[str, str | None]:
if is_object_mapping(value):
flattened: dict[str, str | None] = {}
for key, child in value.items():
child_prefix = f"{prefix}.{key}" if prefix else str(key)
flattened.update(_flatten_translations(child, child_prefix))
return flattened
return {prefix: None if value is None else str(value)}
[docs]
def lookup_translation(translation_key: str, /, *, locale: str) -> str | None:
"""Return one authored locale scalar without humanising a missing value.
``None`` and the catalogue scaffold convention ``value == key`` both mean
that the selected locale has no authored value. Domain resolvers use this
primitive when they own a stricter fallback policy than the operator-facing
:func:`tr` convenience surface.
"""
_present, value = lookup_translation_entry(translation_key, locale=locale)
return value
[docs]
def lookup_translation_entry(translation_key: str, /, *, locale: str) -> tuple[bool, str | None]:
"""Return catalogue membership separately from its optional scalar value."""
normalized_locale = normalise_supported_language(locale)
if normalized_locale is None:
return False, None
try:
catalogue_entries = locale_map(normalized_locale)
except (OSError, yaml.YAMLError, IndexError):
return False, None
if translation_key not in catalogue_entries:
return False, None
rendered = catalogue_entries[translation_key]
if rendered is None or rendered == translation_key:
return True, None
return True, rendered
def _lookup_translation(locale: str, translation_key: str, *, default: object | None = None) -> str:
rendered: str | None
try:
rendered = locale_map(locale).get(translation_key)
except (OSError, yaml.YAMLError, IndexError) as exc:
_log.debug(
"i18n: unable to load locale %s; falling back to python-i18n (%s)",
locale,
type(exc).__name__,
exc_info=True,
)
_ensure_initialised()
fallback = i18n.t(translation_key, locale=locale)
rendered = fallback if isinstance(fallback, str) else None
# A value equal to its own key is the scaffold placeholder for "declared
# but not translated yet", so it is a miss just as an absent key is.
if rendered is None or rendered == translation_key:
return _missing_translation(locale, translation_key, default=default)
return rendered
def _missing_translation(locale: str, translation_key: str, *, default: object | None = None) -> str:
"""Resolve a key the catalogue does not carry, or refuse it in strict mode.
An explicit ``default`` is the caller opting into a fallback, so it is
honoured even under strict mode. Otherwise strict mode refuses and
production returns a humanised label — a missing string must never abort
a filing.
Raises:
MissingTranslationError: When strict-missing-key mode is active and
the caller supplied no explicit ``default``.
"""
if default is not None:
return str(default)
if I18N_STRICT_MISSING_KEYS.get():
raise MissingTranslationError(key=translation_key, locale=locale)
return _humanise_key(translation_key)
def _humanise_key(translation_key: str) -> str:
"""Derive an operator-readable fallback from a dotted translation key.
The catalogue contains scaffolded self-referencing placeholders
where the value equals the key (e.g. ``cli.config.google.folder.help:
cli.config.google.folder.help``). Surfacing those raw to users
leaks internal namespaces into typer help output. When no explicit
``default`` is supplied, derive a sentence-cased label from the
final segment so the help screen stays operator-readable until a
real translation is written.
"""
last = translation_key.rsplit(".", 1)[-1]
stripped = last.removesuffix("_help")
if not stripped:
return translation_key
return stripped.replace("_", " ").capitalize()
def _interpolate_with_status(
translation_key: str,
rendered: str,
values: Mapping[str, object],
) -> tuple[str, bool]:
"""Interpolate a value and report whether the format pass completed."""
def _replace(match: re.Match[str]) -> str:
name = _match_group_text(match, "name")
if name not in values:
return match.group(0)
return str(values[name])
rendered = _PLACEHOLDER_RE.sub(_replace, rendered)
try:
return rendered.format(**values), True
except (KeyError, IndexError, ValueError) as exc:
_log.debug(
"i18n: unable to interpolate locale key %s; returning partially rendered value (%s)",
translation_key,
type(exc).__name__,
exc_info=True,
)
return rendered, False
__all__ = [
"DEFAULT_OUTPUT_LANGUAGE",
"SUPPORTED_OUTPUT_LANGUAGES",
"MissingTranslationError",
"UnmatchedPlaceholderError",
"extract_placeholders",
"output_language",
"register_profile_language_resolver",
"tr",
]