feat: add AppStream file support

This commit is contained in:
2026-09-17 16:50:28 +08:00
parent f1bb0e2a0c
commit b33d634020
15 changed files with 560 additions and 3 deletions
+48
View File
@@ -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.
+14
View File
@@ -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 `&amp;`, `<` becomes `&lt;`, `>` becomes `&gt;`, `"` becomes `&quot;` and `'` becomes `&apos;`. 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
+28
View File
@@ -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>
+22
View File
@@ -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."
+1
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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}")
+18
View File
@@ -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}")
+7
View File
@@ -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)
+43
View File
@@ -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,
)
+6
View File
@@ -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))
+24
View File
@@ -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 ``&amp;``,
``<`` becomes ``&lt;``, ``>`` becomes ``&gt;``, ``"`` becomes ``&quot;``
and ``'`` becomes ``&apos;``.
"""
return (
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;")
)
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)
+96
View File
@@ -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))
Generated
+11
View File
@@ -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" },