Source code for os_helper.i18n_utils

"""
Localization Utilities

A package that shows text to a person in more than one language needs one
place where that text lives, and one rule for which version comes out. Spread
the strings through the source and you get the two failures this module
exists to prevent: a sentence that exists in French and not in English, found
by a user rather than by a test, and a sentence edited in one language whose
twin silently keeps the old wording.

:class:`Locales` loads a ``locales/i18n.yaml`` file — the suite's canonical
shape, described in ``CODING.md`` section 21.3 — and answers two questions:
what is the text for this key in this language, and is this file complete.

The file is organised in **sections** of **semantic keys**, each key carrying
one string per locale::

    meta:
      default_locale: fr
      supported_locales: [fr, en]
    gui:
      submit_button:
        fr: Envoyer
        en: Send

Sections are named by the caller and carry no meaning here: this module
parses, resolves and validates, and never knows what any one section is for.
That is deliberate — it is what lets a single loader serve every member of
the suite whatever each one keeps in its file.

Keys are semantic, not the English text (section 21.3.1): wording changes
more often than meaning, so ``submit_button`` keeps the code untouched when
the copy is rewritten.

Resolution is deterministic (section 21.3.4). A key missing in the requested
locale falls back to ``meta.default_locale``; a key missing there too raises,
because a raw key reaching a user is worse than a loud failure in testing.

Usage example
-------------
>>> import os_helper as osh
>>> loc = osh.Locales.from_path("locales/i18n.yaml")  # doctest: +SKIP
>>> loc.text("gui", "submit_button", "en")            # doctest: +SKIP
'Send'
>>> loc.render("gui", "greeting", "fr", name="Alice")  # doctest: +SKIP
'Bonjour Alice'

Author
------
Warith HARCHAOUI, https://linkedin.com/in/warith-harchaoui
"""

# Postpone annotation evaluation for consistent modern typing on Python 3.10.
from __future__ import annotations

import string
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import yaml

from .logging_utils import warning
from .path_utils import file_exists

#: The section every file must carry: it declares which locales exist and
#: which one answers when the asked-for locale has nothing.
META_SECTION = "meta"


def _placeholders(template: str) -> set[str]:
    """Return the named ``{placeholder}`` fields of a ``str.format`` template.

    Parameters
    ----------
    template : str
        A template string.

    Returns
    -------
    set of str
        Field names. Positional (``{}``) and index (``{0}``) fields are not
        named and are left out; ``{{`` escapes are not fields at all.

    Examples
    --------
    >>> sorted(_placeholders("Hi {name}, you have {count} messages"))
    ['count', 'name']
    >>> _placeholders("JSON stays put: {{\\"a\\": 1}}")
    set()
    """
    names: set[str] = set()
    for _, field, _, _ in string.Formatter().parse(template):
        if field:
            # "a.b" / "a[0]" address into a value; the placeholder is "a".
            names.add(field.split(".")[0].split("[")[0])
    return names


[docs] @dataclass(frozen=True) class Locales: """A parsed ``locales/i18n.yaml``: sections of keys, each with one text per locale. Build one with :meth:`from_path` rather than calling the constructor, so the file is validated as it is read. Attributes ---------- document : dict The whole parsed file, sections included. path : pathlib.Path Where it was read from. Used in error messages, so a failure names the file to fix. """ document: dict[str, Any] path: Path # -- construction ----------------------------------------------------
[docs] @classmethod def from_path(cls, path: str | Path) -> Locales: """Parse a locale file, failing loudly if it cannot serve its purpose. Parameters ---------- path : str or pathlib.Path Path to the YAML file, conventionally ``locales/i18n.yaml`` at the package root. Returns ------- Locales The parsed file. Raises ------ FileNotFoundError If the file is absent. RuntimeError If it does not parse as a YAML mapping, or lacks a usable ``meta`` section. A malformed locale file must fail at load rather than surface later as a puzzling ``KeyError`` deep in a caller. """ target = Path(path) if not file_exists(str(target)): raise FileNotFoundError(f"locale file not found: {target}") raw = yaml.safe_load(target.read_text(encoding="utf-8")) if not isinstance(raw, dict): raise RuntimeError(f"{target} must parse as a YAML mapping, got {type(raw).__name__}") meta = raw.get(META_SECTION) if not isinstance(meta, dict): raise RuntimeError(f"{target} is missing its '{META_SECTION}' section") default = meta.get("default_locale") if not isinstance(default, str) or not default: raise RuntimeError( f"{target}: {META_SECTION}.default_locale must be a non-empty string" ) supported = meta.get("supported_locales") if not isinstance(supported, list) or not supported: raise RuntimeError( f"{target}: {META_SECTION}.supported_locales must be a non-empty list" ) if default not in supported: raise RuntimeError( f"{target}: default_locale {default!r} is not among supported_locales {supported!r}; " "the fallback locale has to be one the file actually carries" ) return cls(document=raw, path=target)
# -- metadata -------------------------------------------------------- @property def default_locale(self) -> str: """The locale that answers when the asked-for one has nothing.""" return str(self.document[META_SECTION]["default_locale"]) @property def supported_locales(self) -> list[str]: """Every locale this file declares, in declaration order.""" return [str(code) for code in self.document[META_SECTION]["supported_locales"]]
[docs] def sections(self) -> list[str]: """Section names, ``meta`` excluded — the content sections.""" return [name for name in self.document if name != META_SECTION]
@property def unlocalized_sections(self) -> list[str]: """Sections whose keys are deliberately **not** indexed by locale. A product may keep one section in a single language on purpose, and then its sub-keys mean something else entirely — a role, a variant, a field name. Declaring them here is what stops :meth:`problems` from reading those sub-keys as locale codes and reporting a wall of translations that were never meant to exist. Declared in the file, so the loader stays ignorant of what any section is for:: meta: unlocalized_sections: [templates] """ declared = self.document[META_SECTION].get("unlocalized_sections") or [] return [str(name) for name in declared]
[docs] def raw(self, section: str, key: str) -> Any: """Return one entry exactly as written, with no locale resolution. The accessor for an unlocalized section, whose sub-keys are not locales and so cannot be resolved by :meth:`text`. Parameters ---------- section : str Section name. key : str Key within that section. Returns ------- Any Whatever the file holds there — usually a string or a mapping. Raises ------ KeyError If the section or the key is absent. """ entries = self.section(section) if key not in entries: raise KeyError(f"{self.path}: section {section!r} has no key {key!r}") return entries[key]
[docs] def section(self, name: str) -> dict[str, Any]: """Return one whole section: semantic key -> ``{locale: text}``. Parameters ---------- name : str Section name, as spelled in the file. Returns ------- dict The section's entries. Raises ------ KeyError If the section is absent. """ if name not in self.document or name == META_SECTION: raise KeyError(f"{self.path}: no section named {name!r}; it has {self.sections()}") entry: dict[str, Any] = self.document[name] return entry
# -- resolution ------------------------------------------------------
[docs] def text(self, section: str, key: str, locale: str | None = None) -> str: """Return the text for one key, falling back to the default locale. Parameters ---------- section : str Section name. key : str Semantic key within that section. locale : str, optional Wanted locale. Defaults to :attr:`default_locale`. Returns ------- str The text in *locale*, or in the default locale when *locale* has no entry for this key. Raises ------ KeyError If the key is absent, or carries neither the wanted locale nor the default one. Returning the raw key instead would push a developer's identifier in front of a user (section 21.3.4). """ entries = self.section(section) if key not in entries: raise KeyError(f"{self.path}: section {section!r} has no key {key!r}") value = entries[key] # A key may hold a bare string when the text is the same everywhere — # a product name, a symbol — rather than a mapping of locales. if isinstance(value, str): return value if not isinstance(value, dict): raise KeyError( f"{self.path}: {section}.{key} must be a string or a mapping of locales, " f"got {type(value).__name__}" ) wanted = locale or self.default_locale if wanted in value: return str(value[wanted]) fallback = self.default_locale if fallback in value: # Loud enough to be fixed, quiet enough not to break a release: # the user still reads a real sentence, in the wrong language. warning( f"{self.path}: {section}.{key} has no {wanted!r} entry; " f"falling back to {fallback!r}" ) return str(value[fallback]) raise KeyError( f"{self.path}: {section}.{key} carries neither {wanted!r} nor the default " f"{fallback!r}; it has {sorted(value)}" )
[docs] def render(self, section: str, key: str, locale: str | None = None, **values: Any) -> str: """Return a key's text with its ``{placeholder}`` fields filled in. Templates are data, never code (section 21.3.5): this formats, it never evaluates, and it checks the placeholder names first so a caller learns what it forgot instead of shipping a half-filled sentence. Parameters ---------- section : str Section name. key : str Semantic key within that section. locale : str, optional Wanted locale. Defaults to :attr:`default_locale`. **values One argument per placeholder in the template. Returns ------- str The filled text. Raises ------ KeyError If the template needs a placeholder that *values* does not carry. """ template = self.text(section, key, locale) needed = _placeholders(template) missing = sorted(needed - set(values)) if missing: raise KeyError( f"{self.path}: {section}.{key} needs placeholder(s) {missing} " f"that were not supplied" ) return template.format(**values)
# -- validation ------------------------------------------------------
[docs] def problems( self, *, require_parity: bool = True, sections: list[str] | None = None, ) -> list[str]: """Return everything wrong with this file, as sentences, worst first. Built for a test to assert on (section 21.3.7). It reports *all* the problems rather than raising on the first, because a translator fixing a file wants the whole list in one pass. Parameters ---------- require_parity : bool, optional When true (the default), a key whose translations do not all take the same ``{placeholder}`` fields is reported. Parity matters because a template missing a field renders a sentence with a hole in it, in that language only. sections : list of str, optional Which sections to check. Defaults to every content section except those listed in :attr:`unlocalized_sections`, since a section that is deliberately single-language has no translations to miss. Returns ------- list of str One sentence per problem. Empty when the file is sound. Examples -------- >>> import os_helper as osh >>> osh.Locales.from_path("locales/i18n.yaml").problems() # doctest: +SKIP [] """ found: list[str] = [] declared = self.supported_locales default = self.default_locale skipped = set(self.unlocalized_sections) wanted = ( sections if sections is not None else [s for s in self.sections() if s not in skipped] ) for name in wanted: entries = self.document[name] if not isinstance(entries, dict): found.append( f"section {name!r} must be a mapping of keys, got {type(entries).__name__}" ) continue for key, value in entries.items(): if isinstance(value, str): # A deliberate single-language entry; nothing to compare. continue if not isinstance(value, dict): found.append(f"{name}.{key} must be a string or a mapping of locales") continue if default not in value: found.append(f"{name}.{key} has no {default!r} entry, the declared default") for code in sorted(set(value) - set(declared)): found.append( f"{name}.{key} carries locale {code!r}, which " f"{META_SECTION}.supported_locales does not declare" ) for code in declared: if code not in value: found.append(f"{name}.{key} is missing its {code!r} translation") if require_parity: fields = { code: _placeholders(str(text)) for code, text in value.items() if isinstance(text, str) } reference = fields.get(default) if reference is not None: for code, names in sorted(fields.items()): if code != default and names != reference: found.append( f"{name}.{key}: {code!r} takes {sorted(names)} but " f"{default!r} takes {sorted(reference)} — " "a placeholder that differs renders a hole in one language" ) return found