Source code for aeat.core.i18n._render

"""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
from collections.abc import Callable, Mapping
from contextvars import ContextVar
from functools import lru_cache

import i18n
import yaml

from ..config import PROJECT_ROOT, _settings_override, load_settings
from ..errors import CoreError
from ..external_constants import DEFAULT_OUTPUT_LANGUAGE, OUTPUT_LANGUAGE_ENV_VAR, SUPPORTED_OUTPUT_LANGUAGES
from ..logging import get_logger

_log = get_logger(__name__)
_INITIALISED = False
_PLACEHOLDER_RE = re.compile(r"%\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)\}")
_SURVIVING_PLACEHOLDER_RE = re.compile(r"\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)\}")
_OUTPUT_LANGUAGE_CACHE_VERSION = 0

# Test-scope flag: when True, _interpolate raises UnmatchedPlaceholderError for
# any {name} token that survives substitution.  Production code leaves this False.
_I18N_STRICT_PLACEHOLDERS: ContextVar[bool] = ContextVar("aeat_i18n_strict_placeholders", 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
# Application-layer hook: set by aeat.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("aeat").joinpath("locales"))) i18n.set("filename_format", "{locale}.{format}") i18n.set("file_format", "yml") i18n.set("skip_locale_root_data", True) _INITIALISED = True def _normalise_supported_language(value: object) -> str | None: raw = str(value).lower().strip() if raw in SUPPORTED_OUTPUT_LANGUAGES: return raw return None
[docs] def output_language() -> str: """Resolve the operator-facing output language. An explicit ``aeat_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()
_OUTPUT_LANGUAGE_KEY_ENV_VARS: tuple[str, ...] = ( OUTPUT_LANGUAGE_ENV_VAR, "AEAT_DATABASE_URL", "AEAT_SECRET_STORE_BACKEND", "AEAT_ALLOW_UNENCRYPTED", ) def _output_language_cache_key() -> tuple[object, ...]: override = _settings_override.get() if override is not None: return ("override", id(override), _OUTPUT_LANGUAGE_CACHE_VERSION) env_file = PROJECT_ROOT / "env" / ".env" try: env_mtime_ns = env_file.stat().st_mtime_ns except OSError: env_mtime_ns = None # The cache key is computed from raw ``os.environ`` plus the ``.env`` # file mtime — the two inputs Pydantic merges into ``Settings``. The # prior implementation constructed a full ``Settings`` instance on # every ``tr()`` call purely to read four field values; a help-screen # render fires ~100 ``tr()`` calls, so that was ~100 Settings builds # (the disaster-ADR Ruling 4 fast-path regression for ``--help``). # Sampling the raw env vars + the ``.env`` mtime varies the key # whenever either input changes, so a cache miss still rebuilds # ``Settings`` inside ``_cached_output_language`` with the correct # .env+os.environ merge order. The key only needs to *change* when # the effective value could change; it does not need the merged value. # os.environ.get allowlist: the reads below compute a cache-key signature, # not a settings value. Constructing a full Settings instance on every # tr() call is prohibitively expensive (~100 calls per --help render). # The variables sampled here are exactly those Pydantic-settings merges # from os.environ; reading them raw to detect *change* does not bypass the # merge order — the cache miss path still builds Settings normally. env_signature = tuple(os.environ.get(name) for name in _OUTPUT_LANGUAGE_KEY_ENV_VARS) return ( "env", *env_signature, env_mtime_ns, _OUTPUT_LANGUAGE_CACHE_VERSION, ) @lru_cache(maxsize=128) def _cached_output_language(_cache_key: tuple[object, ...]) -> str: try: settings = load_settings() except (CoreError, 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 "aeat_output_language" in settings.model_fields_set: explicit = _normalise_supported_language(settings.aeat_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.aeat_output_language) 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. """ 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) rendered = _lookup_translation(locale, translation_key, default=default) interpolation = {key: value for key, value in kwargs.items() if key not in {"locale", "default"}} if interpolation: rendered = _interpolate(translation_key, rendered, interpolation) if _I18N_STRICT_PLACEHOLDERS.get() and (match := _SURVIVING_PLACEHOLDER_RE.search(rendered)): raise UnmatchedPlaceholderError( key=translation_key, name=match.group("name"), rendered=rendered, ) return rendered
@lru_cache(maxsize=len(SUPPORTED_OUTPUT_LANGUAGES)) def _locale_map(locale: str) -> dict[str, str]: resource = importlib.resources.files("aeat").joinpath("locales", f"{locale}.yml") with resource.open("r", encoding="utf-8") as handle: loaded = yaml.safe_load(handle) or {} return _flatten_translations(loaded) def _flatten_translations(value: object, prefix: str = "") -> dict[str, str]: if isinstance(value, Mapping): flattened: dict[str, str] = {} 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: str(value)} def _lookup_translation(locale: str, translation_key: str, *, default: object | None = None) -> str: fallback = str(default) if default is not None else _humanise_key(translation_key) try: rendered = _locale_map(locale).get(translation_key, fallback) 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() rendered = i18n.t(translation_key, locale=locale) if rendered == translation_key: return fallback return rendered 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(translation_key: str, rendered: str, values: Mapping[str, object]) -> str: def _replace(match: re.Match[str]) -> str: name = match.group("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) 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 __all__ = [ "DEFAULT_OUTPUT_LANGUAGE", "SUPPORTED_OUTPUT_LANGUAGES", "UnmatchedPlaceholderError", "output_language", "register_profile_language_resolver", "tr", ]