From cea485f85c4e5274f65281a21a68fd6fedf9dd62 Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Mon, 14 Sep 2026 16:07:59 +0800 Subject: [PATCH] refactor: refactor manifest module --- doc/manifest.md | 2 +- src/metaglot/manifest.py | 64 +++++++++++++++++++++++----------------- src/metaglot/pofile.py | 2 +- src/metaglot/render.py | 4 +-- 4 files changed, 41 insertions(+), 31 deletions(-) diff --git a/doc/manifest.md b/doc/manifest.md index 5dc9bb2..c4d02ad 100644 --- a/doc/manifest.md +++ b/doc/manifest.md @@ -31,7 +31,7 @@ outputs. It must be non-empty; both numeric IDs (e.g. `1000`) and names A manifest is rejected when: -- `version` is not `1`. +- `version` is not matched with current value introduced above. - Any string entry key is empty. - Two entries share both `msgid` and `context`. - An entry or the document root contains unknown fields. diff --git a/src/metaglot/manifest.py b/src/metaglot/manifest.py index 6c405da..a1653ad 100644 --- a/src/metaglot/manifest.py +++ b/src/metaglot/manifest.py @@ -1,29 +1,33 @@ import tomllib +from typing import Optional 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") +class StringEntry(BaseModel, frozen=True): + model_config = ConfigDict(extra="forbid", strict=True) msgid: str - context: str = "" - comment: str = "" + """The string to be translated.""" + context: Optional[str] = None + """The context of this entry. ``None`` if no context.""" + comment: Optional[str] = None + """Translator-oriented comment of this entry. ``None`` if nothing.""" -class Manifest(BaseModel): - model_config = ConfigDict(extra="forbid") +class Manifest(BaseModel, frozen=True): + model_config = ConfigDict(extra="forbid", strict=True) version: int + """The version of this manifest file.""" strings: dict[str, StringEntry] + """The list holding all strings to be translated.""" @field_validator("version") - @classmethod - def _check_version(cls, value: int) -> int: + def validate_version(cls, value: int) -> int: if value != MANIFEST_VERSION: raise ValueError( f"unsupported manifest version: {value} (expected {MANIFEST_VERSION})" @@ -31,34 +35,40 @@ class Manifest(BaseModel): 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: + def validate_string_entries(self) -> "Manifest": + has_empty_key = any(key == "" for key in self.strings.keys()) + if has_empty_key: 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: + + has_empty_value = any(value.msgid == "" for value in self.strings.values()) + if has_empty_value: + raise ValueError("string value(s) must not be empty") + + seen: set[tuple[str, Optional[str]]] = set() + duplicates: set[tuple[str, Optional[str]]] = set() + for entry in self.strings.values(): + pair = (entry.msgid, entry.context) + if pair in seen: + duplicates.add(pair) + seen.add(pair) + if duplicates: rendered = ", ".join( - f"{msgid!r}" if not context else f"{msgid!r} (context {context!r})" - for msgid, context in duplicated + f"{msgid!r}" if context is None else f"{msgid!r} (context {context!r})" + for msgid, context in sorted( + duplicates, key=lambda pair: (pair[0], pair[1] or "") + ) ) 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")) + with open(path, "rb") as f: + raw = tomllib.load(f) + return Manifest.model_validate(raw) 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) diff --git a/src/metaglot/pofile.py b/src/metaglot/pofile.py index 0fee7ee..0608e61 100644 --- a/src/metaglot/pofile.py +++ b/src/metaglot/pofile.py @@ -51,7 +51,7 @@ def generate(manifest_path: Path, output_path: Path) -> None: manifest: Manifest = load_manifest(manifest_path) po = polib.POFile() po.metadata = _POT_HEADER - for _, entry in manifest.entries: + for _, entry in manifest.strings.items(): po.append( polib.POEntry( msgid=entry.msgid, diff --git a/src/metaglot/render.py b/src/metaglot/render.py index fe09060..8e53cc8 100644 --- a/src/metaglot/render.py +++ b/src/metaglot/render.py @@ -25,7 +25,7 @@ def _language_context( translations: dict[str, str], manifest: Manifest, ) -> dict: - entries = manifest.entries + entries = manifest.strings.items() return { "name": language.name, "locale": locale, @@ -51,7 +51,7 @@ def _language_context( def render(manifest_path: Path, po_patterns: list[Path], template_path: Path, output_path: Path) -> None: manifest = load_manifest(manifest_path) - known = manifest.msgids + known = {entry.msgid for entry in manifest.strings.values()} collected = [] locale_sources: dict[str, Path] = {}