From 586b93bcd3b15e24cfdeede651e3019c818f606d Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Thu, 17 Sep 2026 09:55:43 +0800 Subject: [PATCH] fix: add source language field in manifest add source language field in manifest to fix the bug that source string is not included in render context --- doc/manifest.md | 3 +++ doc/rc-render-context.md | 7 ++++++- example/example.rc.toml | 1 + src/metaglot/manifest.py | 34 ++++++++++++++++++++++++++++++++-- src/metaglot/pofile.py | 28 +++++++++++++++++++++++++++- 5 files changed, 69 insertions(+), 4 deletions(-) diff --git a/doc/manifest.md b/doc/manifest.md index 9ba948b..c3e500a 100644 --- a/doc/manifest.md +++ b/doc/manifest.md @@ -9,6 +9,7 @@ The manifest is platform-neutral. It only describes *what* can be translated, no | Field | Type | Description | |---|---|---| | `version` | int | The manifest format version. Currently `1`; any other value is rejected. | +| `source_language` | string | Required. The language of the source strings (`msgid`). Must be a valid Gettext `Language` field value, e.g. `en`, `en_US`. | | `strings` | table | All translatable strings. Each item maps a string entry key to a string entry, see below. | ## String Entry @@ -26,6 +27,7 @@ The string entry key identifies the entry inside the manifest and in generated o A manifest is rejected when: - `version` is not matched with current value introduced above. +- `source_language` is missing or not a valid language tag. - Any string entry key is empty. - Two entries share both `msgid` and `context`. - An entry or the document root contains unknown fields. @@ -34,6 +36,7 @@ A manifest is rejected when: ```toml version = 1 +source_language = "en" [strings.panel_title] msgid = "Example - Image Viewer" diff --git a/doc/rc-render-context.md b/doc/rc-render-context.md index d6e2ef8..f0beb55 100644 --- a/doc/rc-render-context.md +++ b/doc/rc-render-context.md @@ -12,7 +12,12 @@ The renderer runs in strict mode: referencing a variable or a property that does |---|---|---| | `languages` | array of language | One entry per language, see below. | -The order of the entries is not guaranteed: they appear in the order their PO files are matched. The presence of an English entry is not guaranteed either. An English entry exists only if an English PO file is provided; otherwise it is simply absent, and templates must handle that themselves (the text of every entry already falls back to the manifest source string, which is typically English). +The order of the entries is not guaranteed: they appear in the order their PO files +are matched, with a synthesized entry (see below) appended last. The manifest's +source language is always present: it comes from a PO file when one provides it, +and is otherwise synthesized from the manifest's source strings — a manifest +declaring `source_language = "en"` yields a neutral `en` entry (`0x0009`), while +`"en_US"` yields `0x0409`. 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. Languages without a Windows language mapping, two PO files describing the same language, and PO entries unknown to the manifest are rejected. Languages without a country resolve to a neutral language identifier (sublanguage `0x00`), e.g. `en` resolves to `0x0009`. diff --git a/example/example.rc.toml b/example/example.rc.toml index 47c482a..16308a1 100644 --- a/example/example.rc.toml +++ b/example/example.rc.toml @@ -1,4 +1,5 @@ version = 1 +source_language = "en" [strings.1000] msgid = "Example - Image Viewer" diff --git a/src/metaglot/manifest.py b/src/metaglot/manifest.py index 372cc46..ff0310e 100644 --- a/src/metaglot/manifest.py +++ b/src/metaglot/manifest.py @@ -1,7 +1,16 @@ import tomllib -from typing import Optional +from typing import Annotated, Optional from pathlib import Path -from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + GetPydanticSchema, + field_validator, + model_validator, +) +from pydantic_core import core_schema + +from .langid import PoLang MANIFEST_VERSION = 1 """The manifest format version this build of MetaGlot understands.""" @@ -18,11 +27,32 @@ class StringEntry(BaseModel, frozen=True): """Translator-oriented comment of this entry. ``None`` if nothing.""" +def _validate_po_lang(value) -> PoLang: + if isinstance(value, PoLang): + return value + return PoLang(value) + + +def _po_lang_schema(source_type, handler) -> core_schema.CoreSchema: + """Describe how pydantic validates a ``PoLang`` field. + + The whole validation is delegated to :func:`_validate_po_lang`, which + accepts any value parseable by ``PoLang``. This keeps the field typed + as ``PoLang`` without enabling ``arbitrary_types_allowed`` on the model. + """ + return core_schema.no_info_plain_validator_function( + _validate_po_lang, + serialization=core_schema.to_string_ser_schema(), + ) + + class Manifest(BaseModel, frozen=True): model_config = ConfigDict(extra="forbid", strict=True) version: int """The version of this manifest file.""" + source_language: Annotated[PoLang, GetPydanticSchema(_po_lang_schema)] + """The language of the source strings (``msgid``).""" strings: dict[str, StringEntry] """The list holding all strings to be translated.""" diff --git a/src/metaglot/pofile.py b/src/metaglot/pofile.py index 011ba3b..fcd0afe 100644 --- a/src/metaglot/pofile.py +++ b/src/metaglot/pofile.py @@ -109,7 +109,10 @@ def resolve_translations( Each PO file is loaded lazily and resolved to a :class:`LanguagePack` holding the text of every manifest entry. Entries that are missing, - untranslated or marked fuzzy fall back to their source string. + untranslated or marked fuzzy fall back to their source string. The + manifest's source language is always present in the result: it comes + from a PO file when one provides it, and is otherwise synthesized from + the manifest's source strings and appended last. :param manifest: The manifest describing all translatable entries. :param po_paths: The PO file paths to resolve. @@ -162,9 +165,32 @@ def resolve_translations( for key, entry in manifest.strings.items() } packs[lang] = LanguagePack(lang, translations) + + # The manifest's source language is always represented: provided by a PO + # file when available, synthesized from the source strings otherwise. + if manifest.source_language not in packs: + packs[manifest.source_language] = build_default_pack( + manifest, manifest.source_language + ) return packs +def build_default_pack(manifest: Manifest, lang: PoLang) -> LanguagePack: + """Build the default language pack of a manifest. + + The pack is built directly from the manifest without reading any PO + file: every entry resolves to its own source string. The language of + the pack is not derived from a PO file either; callers pass whatever + value fits their use. + + :param manifest: The manifest describing all translatable entries. + :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()} + return LanguagePack(lang, translations) + + def generate_pot(manifest: Manifest, output_path: Path) -> None: """Generate a POT template file from a manifest.