refactor: refactor language pack for future plist work

This commit is contained in:
2026-09-18 12:34:37 +08:00
parent 650f7cd6d7
commit 7e9f4323c1
5 changed files with 267 additions and 60 deletions
+73 -18
View File
@@ -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)
+73 -18
View File
@@ -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)
+71 -15
View File
@@ -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)
+24 -6
View File
@@ -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)
+26 -3
View File
@@ -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}"