feat: add update command for rc render

This commit is contained in:
2026-09-15 16:57:29 +08:00
parent b4dbd34c3b
commit 0350e139c1
4 changed files with 133 additions and 53 deletions
+46 -1
View File
@@ -26,15 +26,24 @@ class RcRenderOpts:
"""The path to the output rendered Windows RC file."""
@dataclass(frozen=True)
class RcUpdateOpts:
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 RcOpts:
opts: RcPotOpts | RcRenderOpts
opts: RcPotOpts | RcRenderOpts | RcUpdateOpts
"""The option of one of subcommand."""
class RcCommand(enum.StrEnum):
Pot = "pot"
Render = "render"
Update = "update"
@dataclass(frozen=True)
@@ -65,6 +74,9 @@ _RC_RDR_PO_DEST = "po"
_RC_RDR_TEMPLATE_DEST = "template"
_RC_RDR_OUTPUT_DEST = "output"
_RC_UPD_POT_DEST = "pot"
_RC_UPD_PO_DEST = "po"
def _register_rc_pot_param(parser: ArgumentParser) -> None:
parser.add_argument(
@@ -133,6 +145,30 @@ def _register_rc_render_param(parser: ArgumentParser) -> None:
)
def _register_rc_update_param(parser: ArgumentParser) -> None:
parser.add_argument(
"-t",
"--pot",
dest=_RC_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=_RC_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_rc_param(parser: ArgumentParser) -> None:
cmds = parser.add_subparsers(dest=_RC_SUBCMD_DEST, required=True)
pot_subcmd = cmds.add_parser(
@@ -144,6 +180,10 @@ def _register_rc_param(parser: ArgumentParser) -> None:
help="Render a user-provided template with manifest data and PO translations.",
)
_register_rc_render_param(render_subcmd)
update_subcmd = cmds.add_parser(
RcCommand.Update.value, help="Update PO files against a POT template."
)
_register_rc_update_param(update_subcmd)
def _register_param(parser: ArgumentParser) -> None:
@@ -187,6 +227,11 @@ def parse() -> Opts:
args[_RC_RDR_OUTPUT_DEST],
)
rc_opts = RcOpts(rc_rdr_opts)
case RcCommand.Update:
rc_upd_opts = RcUpdateOpts(
args[_RC_UPD_POT_DEST], args[_RC_UPD_PO_DEST]
)
rc_opts = RcOpts(rc_upd_opts)
case _:
raise ValueError(f"unhandled rc command: {args[_RC_SUBCMD_DEST]}")
opts = Opts(rc_opts)
+4 -3
View File
@@ -1,5 +1,5 @@
from ...cli import RcOpts, RcPotOpts, RcRenderOpts
from . import pot, render
from ...cli import RcOpts, RcPotOpts, RcRenderOpts, RcUpdateOpts
from . import pot, render, update
def run(opts: RcOpts) -> None:
match opts.opts:
@@ -7,6 +7,7 @@ def run(opts: RcOpts) -> None:
pot.run(pot_opts)
case RcRenderOpts() as render_opts:
render.run(render_opts)
case RcUpdateOpts() as update_opts:
update.run(update_opts)
case _:
raise RuntimeError(f"unhandled rc options: {opts.opts!r}")
+6
View File
@@ -0,0 +1,6 @@
from ...cli import RcUpdateOpts
from ...pofile import resolve_glob_files, update_po_files
def run(opts: RcUpdateOpts) -> None:
update_po_files(opts.in_pot, resolve_glob_files(opts.in_po))
+77 -49
View File
@@ -1,4 +1,6 @@
import glob
import os
import subprocess
import logging
from dataclasses import dataclass
from pathlib import Path
@@ -48,6 +50,58 @@ def resolve_glob_files(patterns: Iterable[Path]) -> Iterator[Path]:
yield name.resolve()
def _load_po(path: Path) -> polib.POFile:
"""Load and parse a PO file.
:param path: The path of the PO file to load.
:return: The parsed PO file.
:raises RuntimeError: If the file cannot be read or parsed.
"""
try:
return polib.pofile(str(path))
except OSError as exc:
raise RuntimeError(f"failed to read PO file '{path}': {exc}") from exc
def _extract_lang(path: Path, po: polib.POFile) -> PoLang:
"""Extract the language of a loaded PO file.
The ``Language`` metadata field is tried first, then the file name
without extension.
:param path: The path of the PO file, used for fallback and reporting.
:param po: The loaded PO file.
:return: The language of the PO file.
:raises RuntimeError: If no valid language can be extracted.
"""
metadata_language = po.metadata.get("Language")
if metadata_language is not None:
try:
return PoLang(metadata_language)
except ValueError:
pass
try:
return PoLang(path.stem)
except ValueError:
pass
raise RuntimeError(f"can't fetch valid language id in PO file '{path}'")
def _translated_text(entry: Optional[polib.POEntry], fallback: str) -> str:
"""Return the usable translation of an entry.
:param entry: The PO entry to read, or ``None`` when absent.
:param fallback: The source string used when the entry is missing,
untranslated or marked fuzzy.
:return: The usable translation.
"""
if entry is None or entry.msgstr == "" or entry.fuzzy:
return fallback
return entry.msgstr
def resolve_translations(
manifest: Manifest, po_paths: Iterable[Path]
) -> dict[PoLang, LanguagePack]:
@@ -129,56 +183,30 @@ def generate_pot(manifest: Manifest, output_path: Path) -> None:
)
)
po.save(str(output_path))
logging.info("Wrote %d entries to POT file '%s'.", len(po), output_path)
logging.info("Wrote %d entries to POT file %s.", len(po), output_path)
def _load_po(path: Path) -> polib.POFile:
"""Load and parse a PO file.
def update_po_files(pot_path: Path, po_paths: Iterable[Path]) -> None:
"""Update the given PO files in place against a POT template.
:param path: The path of the PO file to load.
:return: The parsed PO file.
:raises RuntimeError: If the file cannot be read or parsed.
Each PO file is merged with the POT using the ``msgmerge`` tool from GNU
gettext: entries are added, removed and marked fuzzy as needed, and the
result is written back over the PO file without keeping a backup.
The msgmerge binary is looked up on PATH; set the ``METAGLOT_MSGMERGE``
environment variable to use a specific executable instead.
:param pot_path: The path of the POT template file.
:param po_paths: The PO file paths to update.
:raises RuntimeError: If msgmerge fails on a PO file.
"""
try:
return polib.pofile(str(path))
except OSError as exc:
raise RuntimeError(f"failed to read PO file '{path}': {exc}") from exc
def _extract_lang(path: Path, po: polib.POFile) -> PoLang:
"""Extract the language of a loaded PO file.
The ``Language`` metadata field is tried first, then the file name
without extension.
:param path: The path of the PO file, used for fallback and reporting.
:param po: The loaded PO file.
:return: The language of the PO file.
:raises RuntimeError: If no valid language can be extracted.
"""
metadata_language = po.metadata.get("Language")
if metadata_language is not None:
try:
return PoLang(metadata_language)
except ValueError:
pass
try:
return PoLang(path.stem)
except ValueError:
pass
raise RuntimeError(f"can't fetch valid language id in PO file '{path}'")
def _translated_text(entry: Optional[polib.POEntry], fallback: str) -> str:
"""Return the usable translation of an entry.
:param entry: The PO entry to read, or ``None`` when absent.
:param fallback: The source string used when the entry is missing,
untranslated or marked fuzzy.
:return: The usable translation.
"""
if entry is None or entry.msgstr == "" or entry.fuzzy:
return fallback
return entry.msgstr
msgmerge_bin = os.getenv("METAGLOT_MSGMERGE", "msgmerge")
for po_path in po_paths:
logging.info("Updating PO by POT: %s -> %s", pot_path, po_path)
cmd = [msgmerge_bin, "-U", str(po_path), str(pot_path), "--backup=none"]
proc = subprocess.run(cmd, capture_output=False)
if proc.returncode != 0:
raise RuntimeError(
f"failed to update PO file '{po_path}' with msgmerge "
f"(return code {proc.returncode})"
)