From b33d634020b8ea658775b1ad0cd0fdfc3b02a5fc Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Thu, 17 Sep 2026 16:50:28 +0800 Subject: [PATCH] feat: add AppStream file support --- doc/appstream-render-context.md | 48 +++++ doc/filters.md | 14 ++ example/example.metainfo.xml.liquid | 28 +++ example/example.metainfo.xml.toml | 22 +++ pyproject.toml | 1 + src/metaglot/cli.py | 195 ++++++++++++++++++++- src/metaglot/cmds/__init__.py | 6 +- src/metaglot/cmds/appstream/__init__.py | 18 ++ src/metaglot/cmds/appstream/pot.py | 7 + src/metaglot/cmds/appstream/render.py | 43 +++++ src/metaglot/cmds/appstream/update.py | 6 + src/metaglot/filters.py | 24 +++ src/metaglot/langid.py | 96 ++++++++++ src/metaglot/langmap/appstream_lang_map.py | 44 +++++ uv.lock | 11 ++ 15 files changed, 560 insertions(+), 3 deletions(-) create mode 100644 doc/appstream-render-context.md create mode 100644 example/example.metainfo.xml.liquid create mode 100644 example/example.metainfo.xml.toml create mode 100644 src/metaglot/cmds/appstream/__init__.py create mode 100644 src/metaglot/cmds/appstream/pot.py create mode 100644 src/metaglot/cmds/appstream/render.py create mode 100644 src/metaglot/cmds/appstream/update.py create mode 100644 src/metaglot/langmap/appstream_lang_map.py diff --git a/doc/appstream-render-context.md b/doc/appstream-render-context.md new file mode 100644 index 0000000..784f248 --- /dev/null +++ b/doc/appstream-render-context.md @@ -0,0 +1,48 @@ +# AppStream Render Context + +This document describes the data that `metaglot appstream render` provides to a user-provided [Liquid](https://shopify.github.io/liquid/) template when rendering AppStream metainfo XML files. + +MetaGlot reads templates as UTF-8 and writes the rendered file as UTF-8 (without BOM) using the system's native line endings. This encoding is a built-in constraint of the tool and is not configurable. AppStream XML files are UTF-8 by definition, so the XML declaration should say so (see the `example` folder for a complete sample). + +The renderer runs in strict mode: referencing a variable or a property that does not exist fails the render. + +## Top-Level Variables + +| Variable | Type | Description | +|---|---|---| +| `languages` | array of language | One entry per translation language, see below. | +| `default` | language | The manifest's source language entry, see below. | + +The order of the `languages` entries is not guaranteed: they appear in the order their PO files are matched. The manifest's source language is **not** merged into the array (unlike the RC rendering, which always synthesizes it): `languages` holds exactly the languages of the provided PO files, and the source language is exposed separately as the top-level `default` variable, built directly from the manifest's source strings without reading any PO file. If a PO file for the source language is provided, its language appears in the array as a regular entry alongside `default`. + +The language of a PO file is resolved from its `Language:` header, falling back to the file name without extension; only values valid as Gettext `Language` fields are accepted. Two PO files describing the same language, and PO entries unknown to the manifest, are rejected. + +## Language Object + +| Property | Type | Description | +|---|---|---| +| `name` | string | The language tag of the PO file, e.g. `en_US`, `zh_CN`. | +| `lang` | string | The BCP 47 language tag for `xml:lang`, e.g. `en-US`, `zh-CN`, `sr-Latn-RS`, `ca-ES-valencia`. | +| `strings` | mapping of string to string | Maps every manifest entry key to its resolved text, e.g. `lang.strings.name`. | + +The resolved text of an entry falls back to the manifest source string when the entry is untranslated, missing from the PO file, or marked fuzzy. + +### Language Tag Conversion + +The `lang` property holds a BCP 47 language tag, as required by the `xml:lang` attribute: the language and country codes of the PO language carry over unchanged, hyphenated. The Gettext variant slot fills either the script subtag or the variant subtag, whichever interpretation the variant word supports: known script words (`latin`, `cyrillic`, `arabic`) become script subtags placed before the country (`sr_RS@latin` becomes `sr-Latn-RS`), while other words become variant subtags placed after it (`ca_ES@valencia` becomes `ca-ES-valencia`). + +Tags are validated against BCP 47 with the IANA subtag registry: legacy Gettext variants that are not registered there (e.g. `euro`) are rejected with an error instead of being emitted as invalid `xml:lang` values. The conversion never rewrites a tag beyond hyphenation: no script is injected or removed, and no macrolanguage folding happens, so an emitted `xml:lang` is always a faithful hyphenation of its Gettext source tag. + +## Default Object + +The top-level `default` is a language object of the same shape as the entries of `languages`. It describes the manifest's source language (`source_language`) and is always present. It is built directly from the manifest's source strings — never from PO files — and is not part of the `languages` array: iterating `languages` yields the translation languages only. + +AppStream uses the element without `xml:lang` as the untranslated default, mirroring how desktop entry files use unlocalized keys. `default.strings` is the intended source for those elements: + +```liquid +{{ default.strings.name | xml_escape }} +``` + +## Filters + +All filters described in [filters.md](filters.md) are available when rendering AppStream XML files: the general filters available in every rendering, and the AppStream filters such as `xml_escape`. Note that translated descriptions may legitimately carry AppStream markup (e.g. ``); such values must not be passed through `xml_escape`, and deciding what to escape is the template author's responsibility. diff --git a/doc/filters.md b/doc/filters.md index 1556e31..88f9c7f 100644 --- a/doc/filters.md +++ b/doc/filters.md @@ -40,6 +40,20 @@ The following filters are available only when rendering desktop entry files. |---|---| | `desktop_escape` | Escapes a string for use as a desktop entry `string`, `localestring` or `iconstring` value: `\` becomes `\\`, newline becomes `\n`, carriage return becomes `\r` and tab becomes `\t` anywhere; leading and trailing spaces become `\s`. Semicolons are not escaped, because they separate the values of plural keys (e.g. `Keywords`); escape them with the standard `replace` filter when composing such values item by item. | +## AppStream Filters + +The following filters are available only when rendering AppStream XML files. + +| Filter | Description | +|---|---| +| `xml_escape` | Escapes a string for use in XML text content or attribute values: `&` becomes `&`, `<` becomes `<`, `>` becomes `>`, `"` becomes `"` and `'` becomes `'`. Values that intentionally carry AppStream markup (e.g. `` inside descriptions) must not be passed through this filter; whether to escape is the template author's decision. | + +Examples: + +```liquid +{{ "Example" | xml_escape }} {% comment %}renders "Example"{% endcomment %} +``` + Examples: ```liquid diff --git a/example/example.metainfo.xml.liquid b/example/example.metainfo.xml.liquid new file mode 100644 index 0000000..76b5593 --- /dev/null +++ b/example/example.metainfo.xml.liquid @@ -0,0 +1,28 @@ + + + org.example.Example + {{ default.strings.name | xml_escape }} +{% for lang in languages %} {{ lang.strings.name | xml_escape }} +{% endfor %} {{ default.strings.summary | xml_escape }} +{% for lang in languages %} {{ lang.strings.summary | xml_escape }} +{% endfor %} CC0-1.0 + MIT + +

{{ default.strings.description | xml_escape }}

+{% for lang in languages %}

{{ lang.strings.description | xml_escape }}

+{% endfor %}
+ + {{ default.strings.developer_name | xml_escape }} +{% for lang in languages %} {{ lang.strings.developer_name | xml_escape }} +{% endfor %} + org.example.Example.desktop + https://example.org/example + + + {{ default.strings.screenshot_caption | xml_escape }} +{% for lang in languages %} {{ lang.strings.screenshot_caption | xml_escape }} +{% endfor %} https://example.org/example/screenshot.png + + + +
diff --git a/example/example.metainfo.xml.toml b/example/example.metainfo.xml.toml new file mode 100644 index 0000000..8b399c9 --- /dev/null +++ b/example/example.metainfo.xml.toml @@ -0,0 +1,22 @@ +version = 1 +source_language = "en" + +[strings.name] +msgid = "Example" +comment = "Display name of the application." + +[strings.summary] +msgid = "A lightweight image viewer" +comment = "One-line summary shown in software centers." + +[strings.description] +msgid = "Example is a lightweight and easy-to-use image viewer. It focuses on viewing images and does not include any image management features." +comment = "Description paragraph shown in the details page." + +[strings.developer_name] +msgid = "Example contributors" +comment = "Name of the application developers." + +[strings.screenshot_caption] +msgid = "Main window when an image file is loaded" +comment = "Caption of the primary screenshot." diff --git a/pyproject.toml b/pyproject.toml index c6e7aa3..6997230 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ authors = [ requires-python = ">=3.13" license = { text = "MIT" } dependencies = [ + "langcodes>=3.5.1", "polib>=1.2.0", "pycountry>=26.2.16", "pydantic>=2.11.7", diff --git a/src/metaglot/cli.py b/src/metaglot/cli.py index 795ed4d..6fe22e4 100644 --- a/src/metaglot/cli.py +++ b/src/metaglot/cli.py @@ -86,17 +86,58 @@ class DesktopCommand(enum.StrEnum): Update = "update" +@dataclass(frozen=True) +class AppStreamPotOpts: + in_manifest: Path + """The path to input manifest file including translation strings.""" + out_pot: Path + """The path to output POT file.""" + + +@dataclass(frozen=True) +class AppStreamRenderOpts: + in_manifest: Path + """The path to input manifest file including translation strings.""" + in_po: list[Path] + """The path to input PO files to read, each may be a glob pattern.""" + in_template: Path + """The path to the input user-provided Liquid for rendering.""" + out_appstream: Path + """The path to the output rendered AppStream XML file.""" + + +@dataclass(frozen=True) +class AppStreamUpdateOpts: + in_pot: Path + """The path to the input POT template file.""" + in_po: list[Path] + """The path to input PO files to update, each may be a glob pattern.""" + + +@dataclass(frozen=True) +class AppStreamOpts: + opts: AppStreamPotOpts | AppStreamRenderOpts | AppStreamUpdateOpts + """The option of one of subcommand.""" + + +class AppStreamCommand(enum.StrEnum): + Pot = "pot" + Render = "render" + Update = "update" + + @dataclass(frozen=True) class Opts: """The root options.""" - opts: RcOpts | DesktopOpts + opts: RcOpts | DesktopOpts | AppStreamOpts """The option of one of subcommand.""" class Command(enum.StrEnum): Rc = "rc" Desktop = "desktop" + Appstream = "appstream" # endregion @@ -130,6 +171,19 @@ _DESKTOP_RDR_OUTPUT_DEST = "output" _DESKTOP_UPD_POT_DEST = "pot" _DESKTOP_UPD_PO_DEST = "po" +_APPSTREAM_SUBCMD_DEST = "appstream_command" + +_APPSTREAM_POT_MANIFEST_DEST = "manifest" +_APPSTREAM_POT_OUTPUT_DEST = "output" + +_APPSTREAM_RDR_MANIFEST_DEST = "manifest" +_APPSTREAM_RDR_PO_DEST = "po" +_APPSTREAM_RDR_TEMPLATE_DEST = "template" +_APPSTREAM_RDR_OUTPUT_DEST = "output" + +_APPSTREAM_UPD_POT_DEST = "pot" +_APPSTREAM_UPD_PO_DEST = "po" + def _register_rc_pot_param(parser: ArgumentParser) -> None: parser.add_argument( @@ -348,6 +402,115 @@ def _register_desktop_param(parser: ArgumentParser) -> None: _register_desktop_update_param(update_subcmd) +def _register_appstream_pot_param(parser: ArgumentParser) -> None: + parser.add_argument( + "-m", + "--manifest", + dest=_APPSTREAM_POT_MANIFEST_DEST, + action="store", + type=Path, + required=True, + help="The path to input manifest file including translation strings.", + metavar="FILE", + ) + parser.add_argument( + "-o", + "--output", + dest=_APPSTREAM_POT_OUTPUT_DEST, + action="store", + type=Path, + required=True, + help="The path to output POT file.", + metavar="FILE", + ) + + +def _register_appstream_render_param(parser: ArgumentParser) -> None: + parser.add_argument( + "-m", + "--manifest", + dest=_APPSTREAM_RDR_MANIFEST_DEST, + action="store", + type=Path, + required=True, + help="The path to input manifest file including translation strings.", + metavar="FILE", + ) + parser.add_argument( + "-p", + "--po", + dest=_APPSTREAM_RDR_PO_DEST, + action="extend", + nargs="+", + type=Path, + required=True, + help="The PO files to read, each may be a glob pattern.", + metavar="GLOB", + ) + parser.add_argument( + "-t", + "--template", + dest=_APPSTREAM_RDR_TEMPLATE_DEST, + action="store", + type=Path, + required=True, + help="The path to the input user-provided Liquid for rendering.", + metavar="FILE", + ) + parser.add_argument( + "-o", + "--output", + dest=_APPSTREAM_RDR_OUTPUT_DEST, + action="store", + type=Path, + required=True, + help="The path to the output rendered AppStream XML file.", + metavar="FILE", + ) + + +def _register_appstream_update_param(parser: ArgumentParser) -> None: + parser.add_argument( + "-t", + "--pot", + dest=_APPSTREAM_UPD_POT_DEST, + action="store", + type=Path, + required=True, + help="The path to the input POT template file.", + metavar="FILE", + ) + parser.add_argument( + "-p", + "--po", + dest=_APPSTREAM_UPD_PO_DEST, + action="extend", + nargs="+", + type=Path, + required=True, + help="The PO files to update, each may be a glob pattern.", + metavar="GLOB", + ) + + +def _register_appstream_param(parser: ArgumentParser) -> None: + cmds = parser.add_subparsers(dest=_APPSTREAM_SUBCMD_DEST, required=True) + pot_subcmd = cmds.add_parser( + AppStreamCommand.Pot.value, + help="Generate a POT template from given manifest.", + ) + _register_appstream_pot_param(pot_subcmd) + render_subcmd = cmds.add_parser( + AppStreamCommand.Render.value, + help="Render a user-provided template with manifest data and PO translations.", + ) + _register_appstream_render_param(render_subcmd) + update_subcmd = cmds.add_parser( + AppStreamCommand.Update.value, help="Update PO files against a POT template." + ) + _register_appstream_update_param(update_subcmd) + + def _register_param(parser: ArgumentParser) -> None: cmds = parser.add_subparsers(dest=_SUBCMD_DEST, required=True) rc_subcmd = cmds.add_parser( @@ -358,6 +521,10 @@ def _register_param(parser: ArgumentParser) -> None: Command.Desktop.value, help="Commands for freedesktop desktop entry files." ) _register_desktop_param(desktop_subcmd) + appstream_subcmd = cmds.add_parser( + Command.Appstream.value, help="Commands for AppStream metainfo XML files." + ) + _register_appstream_param(appstream_subcmd) # endregion @@ -426,6 +593,32 @@ def parse() -> Opts: f"unhandled desktop command: {args[_DESKTOP_SUBCMD_DEST]}" ) opts = Opts(desktop_opts) + case Command.Appstream: + match AppStreamCommand(args[_APPSTREAM_SUBCMD_DEST]): + case AppStreamCommand.Pot: + appstream_pot_opts = AppStreamPotOpts( + args[_APPSTREAM_POT_MANIFEST_DEST], + args[_APPSTREAM_POT_OUTPUT_DEST], + ) + appstream_opts = AppStreamOpts(appstream_pot_opts) + case AppStreamCommand.Render: + appstream_rdr_opts = AppStreamRenderOpts( + args[_APPSTREAM_RDR_MANIFEST_DEST], + args[_APPSTREAM_RDR_PO_DEST], + args[_APPSTREAM_RDR_TEMPLATE_DEST], + args[_APPSTREAM_RDR_OUTPUT_DEST], + ) + appstream_opts = AppStreamOpts(appstream_rdr_opts) + case AppStreamCommand.Update: + appstream_upd_opts = AppStreamUpdateOpts( + args[_APPSTREAM_UPD_POT_DEST], args[_APPSTREAM_UPD_PO_DEST] + ) + appstream_opts = AppStreamOpts(appstream_upd_opts) + case _: + raise ValueError( + f"unhandled appstream command: {args[_APPSTREAM_SUBCMD_DEST]}" + ) + opts = Opts(appstream_opts) case _: raise ValueError(f"unhandled command: {args[_SUBCMD_DEST]}") return opts diff --git a/src/metaglot/cmds/__init__.py b/src/metaglot/cmds/__init__.py index 876a8fc..44bcf0d 100644 --- a/src/metaglot/cmds/__init__.py +++ b/src/metaglot/cmds/__init__.py @@ -1,5 +1,5 @@ -from ..cli import Opts, RcOpts, DesktopOpts -from . import desktop, rc +from ..cli import Opts, RcOpts, DesktopOpts, AppStreamOpts +from . import appstream, desktop, rc def run(opts: Opts) -> None: match opts.opts: @@ -7,5 +7,7 @@ def run(opts: Opts) -> None: rc.run(rc_opts) case DesktopOpts() as desktop_opts: desktop.run(desktop_opts) + case AppStreamOpts() as appstream_opts: + appstream.run(appstream_opts) case _: raise RuntimeError(f"unhandled options: {opts.opts!r}") diff --git a/src/metaglot/cmds/appstream/__init__.py b/src/metaglot/cmds/appstream/__init__.py new file mode 100644 index 0000000..1ddd789 --- /dev/null +++ b/src/metaglot/cmds/appstream/__init__.py @@ -0,0 +1,18 @@ +from ...cli import ( + AppStreamOpts, + AppStreamPotOpts, + AppStreamRenderOpts, + AppStreamUpdateOpts, +) +from . import pot, render, update + +def run(opts: AppStreamOpts) -> None: + match opts.opts: + case AppStreamPotOpts() as pot_opts: + pot.run(pot_opts) + case AppStreamRenderOpts() as render_opts: + render.run(render_opts) + case AppStreamUpdateOpts() as update_opts: + update.run(update_opts) + case _: + raise RuntimeError(f"unhandled appstream options: {opts.opts!r}") diff --git a/src/metaglot/cmds/appstream/pot.py b/src/metaglot/cmds/appstream/pot.py new file mode 100644 index 0000000..9f440e9 --- /dev/null +++ b/src/metaglot/cmds/appstream/pot.py @@ -0,0 +1,7 @@ +from ...cli import AppStreamPotOpts +from ...manifest import load_manifest +from ...pofile import generate_pot + +def run(opts: AppStreamPotOpts) -> None: + manifest = load_manifest(opts.in_manifest) + generate_pot(manifest, opts.out_pot) diff --git a/src/metaglot/cmds/appstream/render.py b/src/metaglot/cmds/appstream/render.py new file mode 100644 index 0000000..3e47663 --- /dev/null +++ b/src/metaglot/cmds/appstream/render.py @@ -0,0 +1,43 @@ +import logging + +from ...cli import AppStreamRenderOpts +from ...filters import register_appstream_filters, register_general_filters +from ...langmap import appstream_lang_map +from ...manifest import Manifest, load_manifest +from ...pofile import ( + LanguagePack, + build_default_pack, + resolve_glob_files, + resolve_translations, +) +from ...render import create_environment, render + + +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, + } + + +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()] + logging.info( + "rendering languages: %s (default %s)", + ", ".join(entry["name"] for entry in entries), + default["name"], + ) + env = create_environment() + register_general_filters(env) + register_appstream_filters(env) + render( + env, opts.in_template, {"languages": entries, "default": default}, + opts.out_appstream, + ) diff --git a/src/metaglot/cmds/appstream/update.py b/src/metaglot/cmds/appstream/update.py new file mode 100644 index 0000000..2085ec7 --- /dev/null +++ b/src/metaglot/cmds/appstream/update.py @@ -0,0 +1,6 @@ +from ...cli import AppStreamUpdateOpts +from ...pofile import resolve_glob_files, update_po_files + + +def run(opts: AppStreamUpdateOpts) -> None: + update_po_files(opts.in_pot, resolve_glob_files(opts.in_po)) diff --git a/src/metaglot/filters.py b/src/metaglot/filters.py index 1803cfb..5842a55 100644 --- a/src/metaglot/filters.py +++ b/src/metaglot/filters.py @@ -68,3 +68,27 @@ def register_desktop_filters(env: Environment) -> None: :param env: The environment to register the filters on. """ env.add_filter("desktop_escape", _desktop_escape) + + +def _xml_escape(value: str) -> str: + """Escape a string for use in XML text content or attribute values. + + The five predefined XML entities are produced: ``&`` becomes ``&``, + ``<`` becomes ``<``, ``>`` becomes ``>``, ``"`` becomes ``"`` + and ``'`` becomes ``'``. + """ + return ( + value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'") + ) + + +def register_appstream_filters(env: Environment) -> None: + """Register the Liquid filters specific to AppStream XML file rendering. + + :param env: The environment to register the filters on. + """ + env.add_filter("xml_escape", _xml_escape) diff --git a/src/metaglot/langid.py b/src/metaglot/langid.py index 3f7b7cc..557c155 100644 --- a/src/metaglot/langid.py +++ b/src/metaglot/langid.py @@ -1,5 +1,6 @@ import re from typing import ClassVar, Optional +import langcodes import pycountry @@ -254,6 +255,101 @@ class DesktopLang: # endregion +class AppStreamLang: + """ + Represents a BCP 47 language tag, as required by the ``xml:lang`` + attribute values of AppStream XML files. + + Validation and serialization are delegated to the core of the + ``langcodes`` library (without its optional ``data`` extra). The whole + of BCP 47 is therefore accepted, and subtags are checked against the + IANA registry: ``zh-CN``, ``sr-Latn-RS`` and ``ca-ES-valencia`` are + valid, while ``jp`` and the unregistered variant ``euro`` are not. + + Tags are case-insensitive per BCP 47, and close-but-nonstandard + spellings are normalized: ``zh-cn`` and ``zh_CN`` both canonicalize + to ``zh-CN``. + + Deliberate usage policy: MetaGlot only parses, validates and + serializes tags through this class. It never calls + ``langcodes.standardize_tag`` nor enables macro language collapsing, + so an emitted ``xml:lang`` is always a faithful hyphenation of its + Gettext source tag - no script injection, no redundant-script + removal, no macrolanguage folding. + + Examples: + AppStreamLang("zh-CN") # valid + AppStreamLang("sr-Latn-RS") # valid + AppStreamLang("zh_CN") # valid, normalizes to "zh-CN" + AppStreamLang("de-DE-euro") # invalid (unregistered variant) + AppStreamLang("jp") # invalid (not a language code) + """ + + __lang: langcodes.Language + """The underlying langcodes language object.""" + + def __init__(self, value: str): + try: + lang = langcodes.Language.get(value) + except ValueError as exc: + raise ValueError( + f"Invalid BCP 47 language tag: {value!r}. ({exc})" + ) from exc + if not lang.is_valid(): + raise ValueError(f"Invalid BCP 47 language tag: {value!r}") + self.__lang = lang + + # region: Properties + + @property + def language(self) -> Optional[str]: + """The lowercase language subtag, or None if unspecified.""" + return self.__lang.language + + @property + def script(self) -> Optional[str]: + """The title-case script subtag, or None if not specified.""" + return self.__lang.script + + @property + def territory(self) -> Optional[str]: + """The uppercase territory subtag, or None if not specified.""" + return self.__lang.territory + + @property + def variant(self) -> Optional[str]: + """The first variant subtag, or None if not specified.""" + variants = self.__lang.variants or [] + return variants[0] if variants else None + + @property + def value(self) -> str: + """The canonical string form, in BCP 47 conventional casing.""" + return str(self.__lang) + + # endregion + + # region: Object methods + + def __str__(self) -> str: + return self.value + + def __repr__(self) -> str: + return f"AppStreamLang({self.value!r})" + + def __eq__(self, other) -> bool: + if self is other: + return True + if not isinstance(other, AppStreamLang): + return NotImplemented + return self.value == other.value + + def __hash__(self) -> int: + return hash(self.value) + + # endregion + + class WinLcid: """ Represents a Windows language identifier (LANGID), the 2-byte language diff --git a/src/metaglot/langmap/appstream_lang_map.py b/src/metaglot/langmap/appstream_lang_map.py new file mode 100644 index 0000000..cbeeab0 --- /dev/null +++ b/src/metaglot/langmap/appstream_lang_map.py @@ -0,0 +1,44 @@ +from ..langid import AppStreamLang, PoLang + +# YYC MARK: Gettext's variant slot may hold either a script designator or a +# genuine variant, and BCP 47 places the two at different positions with +# different vocabularies ('latin' != 'Latn'), so the conversion cannot be +# done mechanically. Candidate matching is done here as a workaround, with +# the script interpretation tried first: 'sr_RS@latin' becomes the +# semantically correct 'sr-Latn-RS', not 'sr-RS-latin'. This is not the +# full ISO 15924 table, only the words Gettext uses for scripts; extend it +# as needed. +_SCRIPT_WORDS: dict[str, str] = { + "latin": "Latn", + "cyrillic": "Cyrl", + "arabic": "Arab", +} + + +def convert(lang: PoLang) -> AppStreamLang: + """ + Convert a PO file language to a BCP 47 language tag. + + The language and country codes carry over unchanged (hyphenated), and + the PO variant slot fills either the script subtag or the variant + subtag, whichever interpretation the variant word supports. Variants + matching no script word are emitted as BCP 47 variant subtags and are + rejected by :class:`AppStreamLang` when the IANA registry does not + register them (e.g. the legacy Gettext variant 'euro'). + + :param lang: The PO file language. + :return: The BCP 47 language tag for the language. + :raises ValueError: If the composed tag is not a valid BCP 47 tag. + """ + segments = [lang.language] + script = _SCRIPT_WORDS.get(lang.variant) if lang.variant is not None else None + if script is not None: + segments.append(script) + if lang.country is not None: + segments.append(lang.country) + else: + if lang.country is not None: + segments.append(lang.country) + if lang.variant is not None: + segments.append(lang.variant) + return AppStreamLang("-".join(segments)) diff --git a/uv.lock b/uv.lock index 46896ec..a6a4397 100644 --- a/uv.lock +++ b/uv.lock @@ -29,6 +29,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, ] +[[package]] +name = "langcodes" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/75/f9edc5d72945019312f359e69ded9f82392a81d49c5051ed3209b100c0d2/langcodes-3.5.1.tar.gz", hash = "sha256:40bff315e01b01d11c2ae3928dd4f5cbd74dd38f9bd912c12b9a3606c143f731", size = 191084, upload-time = "2025-12-02T16:22:01.627Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/c1/d10b371bcba7abce05e2b33910e39c33cfa496a53f13640b7b8e10bb4d2b/langcodes-3.5.1-py3-none-any.whl", hash = "sha256:b6a9c25c603804e2d169165091d0cdb23934610524a21d226e4f463e8e958a72", size = 183050, upload-time = "2025-12-02T16:21:59.954Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -86,6 +95,7 @@ name = "metaglot" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "langcodes" }, { name = "polib" }, { name = "pycountry" }, { name = "pydantic" }, @@ -94,6 +104,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "langcodes", specifier = ">=3.5.1" }, { name = "polib", specifier = ">=1.2.0" }, { name = "pycountry", specifier = ">=26.2.16" }, { name = "pydantic", specifier = ">=2.11.7" },