diff --git a/src/metaglot/cli.py b/src/metaglot/cli.py index 963da98..22d2637 100644 --- a/src/metaglot/cli.py +++ b/src/metaglot/cli.py @@ -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) diff --git a/src/metaglot/cmds/rc/__init__.py b/src/metaglot/cmds/rc/__init__.py index 9c16506..4f863a0 100644 --- a/src/metaglot/cmds/rc/__init__.py +++ b/src/metaglot/cmds/rc/__init__.py @@ -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}") - diff --git a/src/metaglot/cmds/rc/update.py b/src/metaglot/cmds/rc/update.py new file mode 100644 index 0000000..3b3ebec --- /dev/null +++ b/src/metaglot/cmds/rc/update.py @@ -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)) diff --git a/src/metaglot/pofile.py b/src/metaglot/pofile.py index 79308ab..011ba3b 100644 --- a/src/metaglot/pofile.py +++ b/src/metaglot/pofile.py @@ -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})" + )