refactor: strip manifest out from catalog
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
# String Manifest
|
||||
|
||||
This document describes how to write a MetaGlot string manifest: the TOML file that
|
||||
declares the translatable strings of an application's metadata.
|
||||
|
||||
The manifest is platform-neutral. It only describes *what* can be translated, not how
|
||||
translations end up in any particular platform format; MetaGlot reads it to produce
|
||||
Gettext translation catalogs and to fill platform-native output files. The file must
|
||||
be encoded in UTF-8.
|
||||
|
||||
## Top-level fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `version` | int | The manifest format version. Currently `1`; any other value is rejected. |
|
||||
| `strings` | table | All translatable strings. Each item maps a string entry key to a string entry, see below. |
|
||||
|
||||
## String entry
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `msgid` | string | Required. The untranslated source string. |
|
||||
| `context` | string | Optional. Disambiguates entries that share the same `msgid` but are used differently, mirroring the context (`msgctxt`) mechanism of Gettext. Defaults to empty. |
|
||||
| `comment` | string | Optional. A note for translators explaining where and how the string is used. Defaults to empty. |
|
||||
|
||||
The string entry key identifies the entry inside the manifest and in generated
|
||||
outputs. It must be non-empty; both numeric IDs (e.g. `1000`) and names
|
||||
(e.g. `file_description`) are acceptable, and numeric IDs are represented as strings.
|
||||
|
||||
## Validation rules
|
||||
|
||||
A manifest is rejected when:
|
||||
|
||||
- `version` is not `1`.
|
||||
- Any string entry key is empty.
|
||||
- Two entries share both `msgid` and `context`.
|
||||
- An entry or the document root contains unknown fields.
|
||||
|
||||
## Example
|
||||
|
||||
```toml
|
||||
version = 1
|
||||
|
||||
[strings.panel_title]
|
||||
msgid = "Example - Image Viewer"
|
||||
context = "Explorer"
|
||||
comment = "Shown by the file manager in the properties panel."
|
||||
|
||||
[strings.file_type_jpeg]
|
||||
msgid = "JPEG Image"
|
||||
comment = "File type name for: jpg, jpeg, jfif"
|
||||
|
||||
[strings.file_type_png]
|
||||
msgid = "PNG Image"
|
||||
comment = "File type name for: png"
|
||||
|
||||
[strings.file_description]
|
||||
msgid = "Example - Image Viewer"
|
||||
context = "Self"
|
||||
comment = "File description of the executable."
|
||||
|
||||
[strings.product_name]
|
||||
msgid = "Example"
|
||||
comment = "Display name of the product."
|
||||
```
|
||||
|
||||
Note that entries `panel_title` and `file_description` share the same `msgid`. This
|
||||
is allowed because their `context` values differ; without distinct contexts the
|
||||
combination would be rejected as ambiguous.
|
||||
@@ -1,51 +1,7 @@
|
||||
import glob
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
import polib
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
|
||||
|
||||
class StringEntry(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
msgid: str
|
||||
context: str = ""
|
||||
|
||||
|
||||
class Manifest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
strings: dict[str, StringEntry]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_entries(self) -> "Manifest":
|
||||
empty_keys = sorted(key for key in self.strings if not key)
|
||||
if empty_keys:
|
||||
raise ValueError("string key(s) must not be empty")
|
||||
msgids = [entry.msgid for entry in self.strings.values()]
|
||||
duplicated_msgids = sorted({msgid for msgid in msgids if msgids.count(msgid) > 1})
|
||||
if duplicated_msgids:
|
||||
raise ValueError(f"duplicated msgid(s): {', '.join(duplicated_msgids)}")
|
||||
return self
|
||||
|
||||
@property
|
||||
def entries(self) -> list[tuple[str, StringEntry]]:
|
||||
return list(self.strings.items())
|
||||
|
||||
@property
|
||||
def msgids(self) -> set[str]:
|
||||
return {entry.msgid for entry in self.strings.values()}
|
||||
|
||||
|
||||
def load_manifest(path: Path) -> Manifest:
|
||||
try:
|
||||
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
raise ValueError(f"failed to parse manifest '{path}': {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"failed to read manifest '{path}': {exc}") from exc
|
||||
return Manifest.model_validate(raw)
|
||||
|
||||
|
||||
def collect_po_files(patterns: list[Path]) -> list[Path]:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
|
||||
MANIFEST_VERSION = 1
|
||||
"""The manifest format version this build of MetaGlot understands."""
|
||||
|
||||
|
||||
class StringEntry(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
msgid: str
|
||||
context: str = ""
|
||||
comment: str = ""
|
||||
|
||||
|
||||
class Manifest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
version: int
|
||||
strings: dict[str, StringEntry]
|
||||
|
||||
@field_validator("version")
|
||||
@classmethod
|
||||
def _check_version(cls, value: int) -> int:
|
||||
if value != MANIFEST_VERSION:
|
||||
raise ValueError(
|
||||
f"unsupported manifest version: {value} (expected {MANIFEST_VERSION})"
|
||||
)
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_entries(self) -> "Manifest":
|
||||
empty_keys = sorted(key for key in self.strings if not key)
|
||||
if empty_keys:
|
||||
raise ValueError("string key(s) must not be empty")
|
||||
pairs = [(entry.msgid, entry.context) for entry in self.strings.values()]
|
||||
duplicated = sorted({pair for pair in pairs if pairs.count(pair) > 1})
|
||||
if duplicated:
|
||||
rendered = ", ".join(
|
||||
f"{msgid!r}" if not context else f"{msgid!r} (context {context!r})"
|
||||
for msgid, context in duplicated
|
||||
)
|
||||
raise ValueError(f"duplicated msgid and context combination(s): {rendered}")
|
||||
return self
|
||||
|
||||
@property
|
||||
def entries(self) -> list[tuple[str, StringEntry]]:
|
||||
return list(self.strings.items())
|
||||
|
||||
@property
|
||||
def msgids(self) -> set[str]:
|
||||
return {entry.msgid for entry in self.strings.values()}
|
||||
|
||||
|
||||
def load_manifest(path: Path) -> Manifest:
|
||||
try:
|
||||
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
raise ValueError(f"failed to parse manifest '{path}': {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"failed to read manifest '{path}': {exc}") from exc
|
||||
return Manifest.model_validate(raw)
|
||||
+9
-2
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
|
||||
import polib
|
||||
|
||||
from .catalog import Manifest, load_manifest
|
||||
from .manifest import Manifest, load_manifest
|
||||
|
||||
_POT_HEADER = {
|
||||
"Project-Id-Version": "PACKAGE VERSION",
|
||||
@@ -21,7 +21,14 @@ def generate(manifest_path: Path, output_path: Path) -> None:
|
||||
po = polib.POFile()
|
||||
po.metadata = _POT_HEADER
|
||||
for _, entry in manifest.entries:
|
||||
po.append(polib.POEntry(msgid=entry.msgid, msgstr="", comment=entry.context))
|
||||
po.append(
|
||||
polib.POEntry(
|
||||
msgid=entry.msgid,
|
||||
msgstr="",
|
||||
msgctxt=entry.context or None,
|
||||
comment=entry.comment or None,
|
||||
)
|
||||
)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
po.save(str(output_path))
|
||||
logging.info("wrote %d entries to %s", len(po), output_path)
|
||||
|
||||
@@ -5,7 +5,8 @@ from liquid import Environment, StrictUndefined
|
||||
from liquid.exceptions import LiquidError
|
||||
|
||||
from . import winlang
|
||||
from .catalog import Manifest, collect_po_files, load_manifest, load_po
|
||||
from .catalog import collect_po_files, load_po
|
||||
from .manifest import Manifest, load_manifest
|
||||
|
||||
|
||||
def _rc_escape(value: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user