Source code for pysepal.i18n.loading

"""Read a ``messages/<locale>/*.json`` directory into immutable locale data."""

import json
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
from typing import Dict, FrozenSet, Mapping, Optional, Set, Tuple

from pysepal._locale import normalize_locale
from pysepal.i18n.errors import CatalogError
from pysepal.i18n.flatten import flatten_document
from pysepal.i18n.formatting import target_leaf_problem
from pysepal.i18n.plurals import plural_categories

ENGLISH = "en"
"The authoritative locale. It defines every public key and its shape."


[docs] @dataclass(frozen=True) class LocaleData: """One locale's flattened catalogue. Args: code: The directory name this data was read from. messages: Dotted key to message. Read-only. plural_keys: Base keys of this locale's plural nodes. """ code: str messages: Mapping[str, str] plural_keys: FrozenSet[str]
def _ships_messages(path: Path) -> bool: """A locale is a directory that contains at least one top-level ``*.json``. Not recursive: a ``*.json`` sitting in a nested subdirectory does not count, matching :func:`load_locale`'s own non-recursive glob. This is also what keeps a gitignored ``__pycache__`` out of the language picker, with no name-based special case -- the rule is "ships messages", not "does not start with an underscore". """ return path.is_dir() and any(path.glob("*.json"))
[docs] def discover_locale_codes(folder: Path) -> Tuple[str, ...]: """Return the locale codes ``folder`` ships, English first. Args: folder: The message directory holding one subdirectory per locale. Returns: ``("en", ...)`` with the remaining codes sorted, so the order does not depend on the filesystem. Raises: CatalogError: There is no readable ``en`` directory that ships at least one message file, or two directories normalise to the same locale code. """ if not _ships_messages(folder / ENGLISH): raise CatalogError(f"{folder}: no readable '{ENGLISH}' directory") seen: Dict[str, str] = {} for child in sorted(path for path in folder.iterdir() if _ships_messages(path)): canonical = normalize_locale(child.name) if canonical in seen: raise CatalogError( f"{folder}: '{seen[canonical]}' and '{child.name}' both normalise to " f"'{canonical}'" ) seen[canonical] = child.name codes = sorted(seen.values()) codes.remove(ENGLISH) return (ENGLISH, *codes)
[docs] def load_locale(folder: Path, code: str) -> LocaleData: """Read and merge every JSON file of one locale. Args: folder: The message directory. code: The locale subdirectory name. Returns: The locale's flattened, read-only data. Raises: CatalogError: A file cannot be read, is not valid UTF-8, is not valid JSON, two files produce the same flat key, or a key is both a message and the prefix of another. """ messages: Dict[str, str] = {} plural_keys: Set[str] = set() origin: Dict[str, str] = {} for path in sorted((folder / code).glob("*.json")): try: # JSON is UTF-8 by specification (RFC 8259): read_text()'s platform # default would take cp1252 on Windows, silently breaking every # non-ASCII catalogue there even when the file is perfectly correct. document = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: raise CatalogError(f"{code}/{path.name}: {exc}") from exc except (OSError, UnicodeDecodeError) as exc: raise CatalogError(f"{code}/{path.name}: {exc}") from exc found, plurals = flatten_document( document, locale=code, source=path.name, authoritative=code == ENGLISH ) for key, message in found.items(): if key in messages: raise CatalogError( f"{code}: '{key}' is defined by both {origin[key]} and {path.name}; " "file order would decide the winner" ) messages[key] = message origin[key] = path.name plural_keys |= plurals _refuse_leaf_and_prefix(messages, origin, code) if code != ENGLISH: # Pontoon exports an untranslated string as "". Dropping it lets English # show through, which is what Translator.merge_dict did via # delete_empty(); keeping it blanks the widget instead. Applied after the # collision checks so a duplicate or leaf/prefix clash still reports. messages = {key: text for key, text in messages.items() if text != ""} return LocaleData(code, MappingProxyType(messages), frozenset(plural_keys))
[docs] def overlay(english: LocaleData, target: LocaleData) -> Mapping[str, str]: """Overlay valid translations, leaving missing plural forms unresolved. Plural fallback needs the actual count and English rules. Copying English forms here would make French zero inherit English's literal "1 model". """ if target.code == ENGLISH: return english.messages composite = { key: message for key, message in english.messages.items() if key.rpartition(".")[0] not in english.plural_keys } categories = plural_categories(target.code) for key, message in target.messages.items(): base, _, category = key.rpartition(".") if base in english.plural_keys: if base not in target.plural_keys or category not in categories: continue elif key not in composite or key in target.plural_keys: continue if translation_problem(english, key, message) is None: composite[key] = message return MappingProxyType(composite)
[docs] def translation_problem(english: LocaleData, key: str, message: str) -> Optional[str]: """Validate a target leaf against its English message's argument contract.""" base = key.rpartition(".")[0] plural = base in english.plural_keys reference = english.messages[f"{base}.other"] if plural else english.messages[key] return target_leaf_problem(reference, message, plural=plural)
def _refuse_leaf_and_prefix( messages: Mapping[str, str], origin: Mapping[str, str], code: str ) -> None: """Raise when one key is both a message and an ancestor of another. Names the file behind each side of the collision, so a real multi-file catalogue points the editor at where to fix it, not just what collides. """ for key in messages: segments = key.split(".") for cut in range(1, len(segments)): ancestor = ".".join(segments[:cut]) if ancestor in messages: raise CatalogError( f"{code}: '{ancestor}' ({origin[ancestor]}) is both a message and " f"a prefix of '{key}' ({origin[key]})" )