From 7e9f4323c1c4a71872e536852d0b58461929a3a5 Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Fri, 18 Sep 2026 12:34:37 +0800 Subject: [PATCH] refactor: refactor language pack for future plist work --- src/metaglot/cmds/appstream/render.py | 91 +++++++++++++++++++++------ src/metaglot/cmds/desktop/render.py | 91 +++++++++++++++++++++------ src/metaglot/cmds/rc/render.py | 86 ++++++++++++++++++++----- src/metaglot/pofile.py | 30 +++++++-- src/metaglot/render.py | 29 ++++++++- 5 files changed, 267 insertions(+), 60 deletions(-) diff --git a/src/metaglot/cmds/appstream/render.py b/src/metaglot/cmds/appstream/render.py index ca5a076..581d554 100644 --- a/src/metaglot/cmds/appstream/render.py +++ b/src/metaglot/cmds/appstream/render.py @@ -4,8 +4,9 @@ from dataclasses import dataclass from pathlib import Path from typing import Any from ...filters import register_appstream_filters, register_general_filters +from ...langid import AppStreamLang, PoLang from ...langmap import appstream_lang_map -from ...manifest import Manifest, load_manifest +from ...manifest import load_manifest from ...pofile import ( LanguagePack, build_default_pack, @@ -85,31 +86,85 @@ def parse(args: dict[str, Any]) -> AppStreamRenderOpts: ) -def _language_context(pack: LanguagePack, manifest: Manifest) -> dict: - lang = appstream_lang_map.convert(pack.lang) - return { - "name": pack.lang.value, - "lang": lang.value, - "strings": pack.translations, - } +class _AppStreamStringsView: + """Route template string key lookups to their resolved text.""" + + __pack: LanguagePack + """The language pack the lookups route to.""" + + def __init__(self, pack: LanguagePack): + self.__pack = pack + + def __getitem__(self, key: str) -> str: + return self.__pack.translations[key].text + + +class _AppStreamLanguageView: + """The Liquid-facing view of one language for AppStream rendering.""" + + __pack: LanguagePack + """The wrapped language pack.""" + __strings: _AppStreamStringsView + """The view of the pack's translations.""" + __lang: AppStreamLang + """The BCP 47 language tag of the pack's language.""" + + def __init__(self, pack: LanguagePack): + self.__pack = pack + self.__strings = _AppStreamStringsView(pack) + self.__lang = appstream_lang_map.convert(pack.lang) + + def __getitem__(self, key: str) -> object: + match key: + case "name": + return self.__pack.lang.value + case "strings": + return self.__strings + case "lang": + return self.__lang.value + case _: + raise KeyError(key) + + +class _AppStreamRenderContext: + """The Liquid-facing top-level context for AppStream rendering.""" + + __languages: tuple[_AppStreamLanguageView, ...] + """The views of every translation language.""" + __default: _AppStreamLanguageView + """The view of the manifest's source language.""" + + def __init__(self, packs: dict[PoLang, LanguagePack], default: LanguagePack): + self.__languages = tuple( + _AppStreamLanguageView(pack) for pack in packs.values() + ) + self.__default = _AppStreamLanguageView(default) + + def keys(self) -> tuple[str, ...]: + """Return the names of the top-level template variables.""" + return ("languages", "default") + + def __getitem__(self, key: str) -> object: + match key: + case "languages": + return self.__languages + case "default": + return self.__default + case _: + raise KeyError(key) def run(opts: AppStreamRenderOpts) -> None: manifest = load_manifest(opts.in_manifest) packs = resolve_translations(manifest, resolve_glob_files(opts.in_po)) - default = _language_context( - build_default_pack(manifest, manifest.source_language), manifest - ) - entries = [_language_context(pack, manifest) for pack in packs.values()] + default = build_default_pack(manifest, manifest.source_language) + context = _AppStreamRenderContext(packs, default) logging.info( "rendering languages: %s (default %s)", - ", ".join(entry["name"] for entry in entries), - default["name"], + ", ".join(str(pack.lang) for pack in packs.values()), + default.lang, ) env = create_environment() register_general_filters(env) register_appstream_filters(env) - render( - env, opts.in_template, {"languages": entries, "default": default}, - opts.out_appstream, - ) + render(env, opts.in_template, context, opts.out_appstream) diff --git a/src/metaglot/cmds/desktop/render.py b/src/metaglot/cmds/desktop/render.py index 4555893..e15b856 100644 --- a/src/metaglot/cmds/desktop/render.py +++ b/src/metaglot/cmds/desktop/render.py @@ -4,8 +4,9 @@ from dataclasses import dataclass from pathlib import Path from typing import Any from ...filters import register_desktop_filters, register_general_filters +from ...langid import DesktopLang, PoLang from ...langmap import desktop_lang_map -from ...manifest import Manifest, load_manifest +from ...manifest import load_manifest from ...pofile import ( LanguagePack, build_default_pack, @@ -85,31 +86,85 @@ def parse(args: dict[str, Any]) -> DesktopRenderOpts: ) -def _language_context(pack: LanguagePack, manifest: Manifest) -> dict: - locale = desktop_lang_map.convert(pack.lang) - return { - "name": pack.lang.value, - "locale": locale.value, - "strings": pack.translations, - } +class _DesktopStringsView: + """Route template string key lookups to their resolved text.""" + + __pack: LanguagePack + """The language pack the lookups route to.""" + + def __init__(self, pack: LanguagePack): + self.__pack = pack + + def __getitem__(self, key: str) -> str: + return self.__pack.translations[key].text + + +class _DesktopLanguageView: + """The Liquid-facing view of one language for desktop entry rendering.""" + + __pack: LanguagePack + """The wrapped language pack.""" + __strings: _DesktopStringsView + """The view of the pack's translations.""" + __locale: DesktopLang + """The desktop entry locale of the pack's language.""" + + def __init__(self, pack: LanguagePack): + self.__pack = pack + self.__strings = _DesktopStringsView(pack) + self.__locale = desktop_lang_map.convert(pack.lang) + + def __getitem__(self, key: str) -> object: + match key: + case "name": + return self.__pack.lang.value + case "strings": + return self.__strings + case "locale": + return self.__locale.value + case _: + raise KeyError(key) + + +class _DesktopRenderContext: + """The Liquid-facing top-level context for desktop entry rendering.""" + + __languages: tuple[_DesktopLanguageView, ...] + """The views of every translation language.""" + __default: _DesktopLanguageView + """The view of the manifest's source language.""" + + def __init__(self, packs: dict[PoLang, LanguagePack], default: LanguagePack): + self.__languages = tuple( + _DesktopLanguageView(pack) for pack in packs.values() + ) + self.__default = _DesktopLanguageView(default) + + def keys(self) -> tuple[str, ...]: + """Return the names of the top-level template variables.""" + return ("languages", "default") + + def __getitem__(self, key: str) -> object: + match key: + case "languages": + return self.__languages + case "default": + return self.__default + case _: + raise KeyError(key) def run(opts: DesktopRenderOpts) -> None: manifest = load_manifest(opts.in_manifest) packs = resolve_translations(manifest, resolve_glob_files(opts.in_po)) - default = _language_context( - build_default_pack(manifest, manifest.source_language), manifest - ) - entries = [_language_context(pack, manifest) for pack in packs.values()] + default = build_default_pack(manifest, manifest.source_language) + context = _DesktopRenderContext(packs, default) logging.info( "rendering languages: %s (default %s)", - ", ".join(entry["name"] for entry in entries), - default["name"], + ", ".join(str(pack.lang) for pack in packs.values()), + default.lang, ) env = create_environment() register_general_filters(env) register_desktop_filters(env) - render( - env, opts.in_template, {"languages": entries, "default": default}, - opts.out_desktop, - ) + render(env, opts.in_template, context, opts.out_desktop) diff --git a/src/metaglot/cmds/rc/render.py b/src/metaglot/cmds/rc/render.py index c8101c4..ab8f5d9 100644 --- a/src/metaglot/cmds/rc/render.py +++ b/src/metaglot/cmds/rc/render.py @@ -6,7 +6,7 @@ from typing import Any from ...filters import register_general_filters, register_rc_filters from ...langid import PoLang, WinLcid from ...langmap import win_lcid_map -from ...manifest import Manifest, load_manifest +from ...manifest import load_manifest from ...pofile import ( LanguagePack, build_default_pack, @@ -100,17 +100,73 @@ def _lookup_langid(lang: PoLang) -> WinLcid: raise ValueError(f"language '{lang}' has no Windows language mapping") from None -def _language_context(pack: LanguagePack, manifest: Manifest) -> dict: - lcid = _lookup_langid(pack.lang) - return { - "name": pack.lang.value, - "langid": f"0x{lcid.value:04X}", - "primary": f"0x{lcid.primary:02X}", - "sublanguage": f"0x{lcid.sublanguage:02X}", - "block_key": f"{lcid.value:04X}{_CODE_PAGE:04X}", - "code_page": f"{_CODE_PAGE}", - "strings": pack.translations, - } +class _RcStringsView: + """Route template string key lookups to their resolved text.""" + + __pack: LanguagePack + """The language pack the lookups route to.""" + + def __init__(self, pack: LanguagePack): + self.__pack = pack + + def __getitem__(self, key: str) -> str: + return self.__pack.translations[key].text + + +class _RcLanguageView: + """The Liquid-facing view of one language for RC rendering.""" + + __pack: LanguagePack + """The wrapped language pack.""" + __strings: _RcStringsView + """The view of the pack's translations.""" + __lcid: WinLcid + """The Windows language identifier of the pack's language.""" + + def __init__(self, pack: LanguagePack): + self.__pack = pack + self.__strings = _RcStringsView(pack) + self.__lcid = _lookup_langid(pack.lang) + + def __getitem__(self, key: str) -> object: + match key: + case "name": + return self.__pack.lang.value + case "strings": + return self.__strings + case "langid": + return f"0x{self.__lcid.value:04X}" + case "primary": + return f"0x{self.__lcid.primary:02X}" + case "sublanguage": + return f"0x{self.__lcid.sublanguage:02X}" + case "block_key": + return f"{self.__lcid.value:04X}{_CODE_PAGE:04X}" + case "code_page": + return f"{_CODE_PAGE}" + case _: + raise KeyError(key) + + +class _RcRenderContext: + """The Liquid-facing top-level context for RC rendering.""" + + __languages: tuple[_RcLanguageView, ...] + """The views of every rendered language.""" + + def __init__(self, packs: dict[PoLang, LanguagePack]): + self.__languages = tuple(_RcLanguageView(pack) for pack in packs.values()) + + def keys(self) -> tuple[str, ...]: + """Return the names of the top-level template variables.""" + return ("languages",) + + def __getitem__(self, key: str) -> object: + match key: + case "languages": + return self.__languages + case _: + raise KeyError(key) def run(opts: RcRenderOpts) -> None: @@ -122,12 +178,12 @@ def run(opts: RcRenderOpts) -> None: packs[manifest.source_language] = build_default_pack( manifest, manifest.source_language ) - languages = [_language_context(pack, manifest) for pack in packs.values()] + context = _RcRenderContext(packs) logging.info( "rendering languages: %s", - ", ".join(language["name"] for language in languages), + ", ".join(str(pack.lang) for pack in packs.values()), ) env = create_environment() register_general_filters(env) register_rc_filters(env) - render(env, opts.in_template, {"languages": languages}, opts.out_rc) + render(env, opts.in_template, context, opts.out_rc) diff --git a/src/metaglot/pofile.py b/src/metaglot/pofile.py index da01378..aca7182 100644 --- a/src/metaglot/pofile.py +++ b/src/metaglot/pofile.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Iterable, Iterator, Optional import polib from .langid import PoLang -from .manifest import Manifest +from .manifest import Manifest, StringEntry _POT_HEADER = { "Project-Id-Version": "PACKAGE VERSION", @@ -22,6 +22,18 @@ _POT_HEADER = { } +@dataclass(frozen=True) +class Translation: + """The resolution of one manifest entry in one language.""" + + entry: StringEntry + """The manifest entry this translation resolves.""" + + text: str + """The usable text of the entry. It falls back to the manifest source + string when the entry is untranslated, missing or marked fuzzy.""" + + @dataclass(frozen=True) class LanguagePack: """Resolved translations of one language against a manifest.""" @@ -29,8 +41,9 @@ class LanguagePack: lang: PoLang """The language of this pack.""" - translations: dict[str, str] - """The resolved text of every manifest entry, keyed by manifest entry key.""" + translations: dict[str, Translation] + """The resolved translation of every manifest entry, keyed by manifest + entry key.""" def resolve_glob_files(patterns: Iterable[Path]) -> Iterator[Path]: @@ -108,7 +121,7 @@ def resolve_translations( """Resolve the translations of the given PO files against a manifest. Each PO file is loaded lazily and resolved to a :class:`LanguagePack` - holding the text of every manifest entry. Entries that are missing, + holding the resolution of every manifest entry. Entries that are missing, untranslated or marked fuzzy fall back to their source string. :param manifest: The manifest describing all translatable entries. @@ -158,7 +171,10 @@ def resolve_translations( # Resolve every manifest entry against the index. Entries that are # missing, untranslated or fuzzy fall back to their source string. translations = { - key: _translated_text(index.get((entry.msgid, entry.context)), entry.msgid) + key: Translation( + entry, + _translated_text(index.get((entry.msgid, entry.context)), entry.msgid), + ) for key, entry in manifest.strings.items() } packs[lang] = LanguagePack(lang, translations) @@ -177,7 +193,9 @@ def build_default_pack(manifest: Manifest, lang: PoLang) -> LanguagePack: :param lang: The language stamped on the returned pack. :return: The default language pack. """ - translations = {key: entry.msgid for key, entry in manifest.strings.items()} + translations = { + key: Translation(entry, entry.msgid) for key, entry in manifest.strings.items() + } return LanguagePack(lang, translations) diff --git a/src/metaglot/render.py b/src/metaglot/render.py index ba15f01..a6bf2cb 100644 --- a/src/metaglot/render.py +++ b/src/metaglot/render.py @@ -1,10 +1,30 @@ import logging +from collections.abc import Iterable from pathlib import Path +from typing import Protocol from liquid import Environment, StrictUndefined from liquid.exceptions import LiquidError +class RenderContext(Protocol): + """The interface required of a top-level rendering context. + + A render context routes template variable lookups dynamically (Liquid + performs item access only), and enumerates its variable names the same + way the ``dict`` constructor does, so an instance can be passed to + ``BoundTemplate.render`` directly. + """ + + def keys(self) -> Iterable[str]: + """Return the names of the top-level template variables.""" + ... + + def __getitem__(self, key: str) -> object: + """Return the value of the named top-level template variable.""" + ... + + def create_environment() -> Environment: """Create a Liquid environment for rendering. @@ -18,7 +38,10 @@ def create_environment() -> Environment: def render( - env: Environment, template_path: Path, context: dict, output_path: Path + env: Environment, + template_path: Path, + context: RenderContext, + output_path: Path, ) -> None: """Render a Liquid template and write the result to a file. @@ -27,7 +50,7 @@ def render( :param env: The environment to render with. :param template_path: The path of the template file. - :param context: The top-level variables passed to the template. + :param context: The top-level render context passed to the template. :param output_path: The path of the rendered file to write. :raises ValueError: If the template file does not exist. :raises RuntimeError: If the template cannot be read or rendered. @@ -43,7 +66,7 @@ def render( template = env.from_string( template_source, name=str(template_path), path=template_path ) - content = template.render(**context) + content = template.render(context) except LiquidError as exc: raise RuntimeError( f"failed to render template '{template_path}': {exc}"