feat: add AppStream file support
This commit is contained in:
@@ -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
|
||||
<name>{{ default.strings.name | xml_escape }}</name>
|
||||
```
|
||||
|
||||
## 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. `<em>`); such values must not be passed through `xml_escape`, and deciding what to escape is the template author's responsibility.
|
||||
@@ -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. `<em>` 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
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>org.example.Example</id>
|
||||
<name>{{ default.strings.name | xml_escape }}</name>
|
||||
{% for lang in languages %} <name xml:lang="{{ lang.lang }}">{{ lang.strings.name | xml_escape }}</name>
|
||||
{% endfor %} <summary>{{ default.strings.summary | xml_escape }}</summary>
|
||||
{% for lang in languages %} <summary xml:lang="{{ lang.lang }}">{{ lang.strings.summary | xml_escape }}</summary>
|
||||
{% endfor %} <metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<description>
|
||||
<p>{{ default.strings.description | xml_escape }}</p>
|
||||
{% for lang in languages %} <p xml:lang="{{ lang.lang }}">{{ lang.strings.description | xml_escape }}</p>
|
||||
{% endfor %} </description>
|
||||
<developer id="org.example">
|
||||
<name>{{ default.strings.developer_name | xml_escape }}</name>
|
||||
{% for lang in languages %} <name xml:lang="{{ lang.lang }}">{{ lang.strings.developer_name | xml_escape }}</name>
|
||||
{% endfor %} </developer>
|
||||
<launchable type="desktop-id">org.example.Example.desktop</launchable>
|
||||
<url type="homepage">https://example.org/example</url>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>{{ default.strings.screenshot_caption | xml_escape }}</caption>
|
||||
{% for lang in languages %} <caption xml:lang="{{ lang.lang }}">{{ lang.strings.screenshot_caption | xml_escape }}</caption>
|
||||
{% endfor %} <image type="source">https://example.org/example/screenshot.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<content_rating type="oars-1.1"/>
|
||||
</component>
|
||||
@@ -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."
|
||||
@@ -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",
|
||||
|
||||
+194
-1
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user