| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -107,10 +107,8 @@ jobs: | |||
| 107 | 107 | pip install pomerge powrap | |
| 108 | 108 | ||
| 109 | 109 | - name: Merge overwriting on stable release branch | |
| 110 | - shell: bash | ||
| 111 | 110 | run: | | |
| 112 | - shopt -s globstar nullglob | ||
| 113 | - pomerge --from "${{ env.CPYTHON_BRANCH }}"/**/*.po --to "${{ matrix.branch }}"/**/*.po | ||
| 111 | + python "${{ env.CPYTHON_BRANCH }}/scripts/translations.py" merge "${{ env.CPYTHON_BRANCH }}" "${{ matrix.branch }}" | ||
| 114 | 112 | ||
| 115 | 113 | - name: Wrap catalog message files | |
| 116 | 114 | run: | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,100 @@ | |||
| 1 | + """Sync and merge the PO catalogs listed in a generated Transifex config.""" | ||
| 2 | + | ||
| 3 | + import argparse | ||
| 4 | + import configparser | ||
| 5 | + import os | ||
| 6 | + from pathlib import Path, PurePosixPath | ||
| 7 | + import shutil | ||
| 8 | + import subprocess | ||
| 9 | + | ||
| 10 | + | ||
| 11 | + def active_catalogs(config_path): | ||
| 12 | + config = configparser.ConfigParser(interpolation=None) | ||
| 13 | + with config_path.open(encoding="utf-8") as stream: | ||
| 14 | + config.read_file(stream) | ||
| 15 | + catalogs = set() | ||
| 16 | + for section in config.sections(): | ||
| 17 | + if section == "main": | ||
| 18 | + continue | ||
| 19 | + source = PurePosixPath(config[section]["source_file"]) | ||
| 20 | + relative = source.relative_to("pot") | ||
| 21 | + if ".." in relative.parts or relative.suffix != ".pot": | ||
| 22 | + raise ValueError(f"Invalid catalog path: {source}") | ||
| 23 | + catalogs.add(Path(relative.with_suffix(".po"))) | ||
| 24 | + if not catalogs: | ||
| 25 | + raise ValueError(f"No catalogs listed in {config_path}") | ||
| 26 | + return sorted(catalogs) | ||
| 27 | + | ||
| 28 | + | ||
| 29 | + def tracked_catalogs(root): | ||
| 30 | + result = subprocess.run( | ||
| 31 | + ["git", "-C", str(root), "ls-files", "-z", "--", "*.po"], | ||
| 32 | + check=True, | ||
| 33 | + stdout=subprocess.PIPE, | ||
| 34 | + ) | ||
| 35 | + return [Path(os.fsdecode(name)) for name in result.stdout.split(b"\0") if name] | ||
| 36 | + | ||
| 37 | + | ||
| 38 | + def require_catalogs(root, catalogs): | ||
| 39 | + missing = [str(path) for path in catalogs if not (root / path).is_file()] | ||
| 40 | + if missing: | ||
| 41 | + raise FileNotFoundError(f"Missing PO catalogs in {root}: {', '.join(missing)}") | ||
| 42 | + | ||
| 43 | + | ||
| 44 | + def sync(source, destination, language): | ||
| 45 | + config = source / ".tx/config" | ||
| 46 | + catalogs = active_catalogs(config) | ||
| 47 | + downloaded = source / language / "LC_MESSAGES" | ||
| 48 | + require_catalogs(downloaded, catalogs) | ||
| 49 | + obsolete = set(tracked_catalogs(destination)) - set(catalogs) | ||
| 50 | + | ||
| 51 | + for path in catalogs: | ||
| 52 | + target = destination / path | ||
| 53 | + target.parent.mkdir(parents=True, exist_ok=True) | ||
| 54 | + shutil.copyfile(downloaded / path, target) | ||
| 55 | + for path in sorted(obsolete): | ||
| 56 | + (destination / path).unlink(missing_ok=True) | ||
| 57 | + print(f"Removed obsolete catalog: {path}") | ||
| 58 | + (destination / ".tx").mkdir(exist_ok=True) | ||
| 59 | + shutil.copyfile(config, destination / ".tx/config") | ||
| 60 | + | ||
| 61 | + | ||
| 62 | + def merge(source, destination): | ||
| 63 | + # Ignore stale PO files even if a source checkout still contains them. | ||
| 64 | + catalogs = active_catalogs(source / ".tx/config") | ||
| 65 | + targets = tracked_catalogs(destination) | ||
| 66 | + if not targets: | ||
| 67 | + raise ValueError(f"No target catalogs in {destination}") | ||
| 68 | + require_catalogs(source, catalogs) | ||
| 69 | + require_catalogs(destination, targets) | ||
| 70 | + subprocess.run( | ||
| 71 | + [ | ||
| 72 | + "pomerge", | ||
| 73 | + "--from", | ||
| 74 | + *(str(source / path) for path in catalogs), | ||
| 75 | + "--to", | ||
| 76 | + *(str(destination / path) for path in targets), | ||
| 77 | + ], | ||
| 78 | + check=True, | ||
| 79 | + ) | ||
| 80 | + | ||
| 81 | + | ||
| 82 | + def main(): | ||
| 83 | + parser = argparse.ArgumentParser(description=__doc__) | ||
| 84 | + commands = parser.add_subparsers(dest="command", required=True) | ||
| 85 | + sync_parser = commands.add_parser("sync") | ||
| 86 | + sync_parser.add_argument("source", type=Path) | ||
| 87 | + sync_parser.add_argument("destination", type=Path) | ||
| 88 | + sync_parser.add_argument("--language", default="ja") | ||
| 89 | + merge_parser = commands.add_parser("merge") | ||
| 90 | + merge_parser.add_argument("source", type=Path) | ||
| 91 | + merge_parser.add_argument("destination", type=Path) | ||
| 92 | + args = parser.parse_args() | ||
| 93 | + if args.command == "sync": | ||
| 94 | + sync(args.source, args.destination, args.language) | ||
| 95 | + else: | ||
| 96 | + merge(args.source, args.destination) | ||
| 97 | + | ||
| 98 | + | ||
| 99 | + if __name__ == "__main__": | ||
| 100 | + main() | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -7,7 +7,7 @@ set -e | |||
| 7 | 7 | # Allow language being passed as 1st argument, defaults to ja | |
| 8 | 8 | LANGUAGE=${1:-ja} | |
| 9 | 9 | ||
| 10 | - ROOTDIR=$(realpath "$(dirname $0)/..") | ||
| 10 | + ROOTDIR=$(realpath "$(dirname "$0")/..") | ||
| 11 | 11 | ||
| 12 | 12 | cd "${ROOTDIR}" | |
| 13 | 13 | ||
@@ -16,21 +16,23 @@ if ! test -f cpython/Doc/conf.py; then | |||
| 16 | 16 | exit 1 | |
| 17 | 17 | fi | |
| 18 | 18 | ||
| 19 | + # Use fresh catalogs so renamed or removed resources cannot survive a prior run. | ||
| 20 | + mkdir -p cpython/Doc/locales | ||
| 21 | + WORKDIR=$(mktemp -d "${ROOTDIR}/cpython/Doc/locales/update.XXXXXX") | ||
| 22 | + trap 'rm -rf "$WORKDIR"' EXIT | ||
| 23 | + trap 'exit 1' HUP INT TERM | ||
| 24 | + | ||
| 19 | 25 | # Create POT Files | |
| 20 | 26 | cd cpython/Doc | |
| 21 | - sphinx-build -E -b gettext -D gettext_compact=0 -d build/.doctrees . locales/pot | ||
| 27 | + sphinx-build -E -b gettext -D gettext_compact=0 -d "${WORKDIR}/.doctrees" . "${WORKDIR}/pot" | ||
| 22 | 28 | ||
| 23 | - # Update CPython's .tx/config | ||
| 24 | - cd locales | ||
| 29 | + # Generate a fresh .tx/config from the current POT files. | ||
| 30 | + cd "${WORKDIR}" | ||
| 25 | 31 | sphinx-intl create-txconfig | |
| 26 | 32 | sphinx-intl update-txconfig-resources -p pot -d . --transifex-organization-name python-doc --transifex-project-name python-newest | |
| 27 | - install -Dm644 .tx/config "${ROOTDIR}/.tx/config" | ||
| 28 | 33 | ||
| 29 | - # Pull translations into cpython/Doc/locales/LANGUAGE/LC_MESSAGES/ | ||
| 30 | - tx pull -l ${LANGUAGE} -t --use-git-timestamps | ||
| 34 | + # Pull translations into the temporary LANGUAGE/LC_MESSAGES directory. | ||
| 35 | + tx pull -l "${LANGUAGE}" -t --use-git-timestamps | ||
| 31 | 36 | ||
| 32 | - # Finally, move downloaded translation files to the language's repository | ||
| 33 | - cd "${LANGUAGE}/LC_MESSAGES/" | ||
| 34 | - for po in $(find . -type f -name '*.po' | sort | sed 's|^\./||'); do | ||
| 35 | - install -Dm644 ${po} "${ROOTDIR}/${po}" | ||
| 36 | - done | ||
| 37 | + # Validate the download before copying catalogs or removing obsolete files. | ||
| 38 | + python "${ROOTDIR}/scripts/translations.py" sync "${WORKDIR}" "${ROOTDIR}" --language "${LANGUAGE}" | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,126 @@ | |||
| 1 | + import json | ||
| 2 | + from pathlib import Path | ||
| 3 | + import shutil | ||
| 4 | + import subprocess | ||
| 5 | + import sys | ||
| 6 | + import tempfile | ||
| 7 | + import unittest | ||
| 8 | + | ||
| 9 | + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) | ||
| 10 | + import translations | ||
| 11 | + | ||
| 12 | + | ||
| 13 | + def write_config(root, catalogs): | ||
| 14 | + path = root / ".tx/config" | ||
| 15 | + path.parent.mkdir(parents=True, exist_ok=True) | ||
| 16 | + text = "[main]\nhost = https://www.transifex.com\n" | ||
| 17 | + for index, catalog in enumerate(catalogs): | ||
| 18 | + template = Path(catalog).with_suffix(".pot").as_posix() | ||
| 19 | + text += f"\n[resource{index}]\nsource_file = pot/{template}\n" | ||
| 20 | + path.write_text(text, encoding="utf-8") | ||
| 21 | + | ||
| 22 | + | ||
| 23 | + def write_po(root, name, translation): | ||
| 24 | + path = root / name | ||
| 25 | + path.parent.mkdir(parents=True, exist_ok=True) | ||
| 26 | + path.write_text( | ||
| 27 | + 'msgid ""\nmsgstr "Content-Type: text/plain; charset=UTF-8\\n"\n\n' | ||
| 28 | + 'msgid "example"\nmsgstr ' + json.dumps(translation, ensure_ascii=False) + "\n", | ||
| 29 | + encoding="utf-8", | ||
| 30 | + ) | ||
| 31 | + return path | ||
| 32 | + | ||
| 33 | + | ||
| 34 | + class TranslationTests(unittest.TestCase): | ||
| 35 | + def setUp(self): | ||
| 36 | + temporary = tempfile.TemporaryDirectory() | ||
| 37 | + self.addCleanup(temporary.cleanup) | ||
| 38 | + self.root = Path(temporary.name) | ||
| 39 | + self.source = self.root / "source" | ||
| 40 | + self.destination = self.root / "destination" | ||
| 41 | + self.source.mkdir() | ||
| 42 | + self.destination.mkdir() | ||
| 43 | + subprocess.run( | ||
| 44 | + ["git", "init", "--quiet", str(self.destination)], check=True | ||
| 45 | + ) | ||
| 46 | + | ||
| 47 | + def track(self): | ||
| 48 | + subprocess.run( | ||
| 49 | + ["git", "-C", str(self.destination), "add", "."], check=True | ||
| 50 | + ) | ||
| 51 | + | ||
| 52 | + def test_sync_removes_old_path_and_preserves_untracked_files(self): | ||
| 53 | + catalogs = ["builtins/functions.po", "library/builtins.po", "bugs.po"] | ||
| 54 | + write_config(self.source, catalogs) | ||
| 55 | + download = self.source / "ja/LC_MESSAGES" | ||
| 56 | + for path in catalogs: | ||
| 57 | + write_po(download, path, "新しい訳") | ||
| 58 | + # A stale downloaded file must not be copied back into the repository. | ||
| 59 | + write_po(download, "library/functions.po", "古い訳") | ||
| 60 | + old = write_po(self.destination, "library/functions.po", "古い訳") | ||
| 61 | + write_po(self.destination, "library/builtins.po", "モジュール") | ||
| 62 | + self.track() | ||
| 63 | + scratch = write_po(self.destination, "scratch.po", "作業中") | ||
| 64 | + scratch_before = scratch.read_bytes() | ||
| 65 | + | ||
| 66 | + translations.sync(self.source, self.destination, "ja") | ||
| 67 | + | ||
| 68 | + self.assertFalse(old.exists()) | ||
| 69 | + for path in catalogs: | ||
| 70 | + self.assertEqual( | ||
| 71 | + (download / path).read_bytes(), | ||
| 72 | + (self.destination / path).read_bytes(), | ||
| 73 | + ) | ||
| 74 | + self.assertEqual(scratch_before, scratch.read_bytes()) | ||
| 75 | + self.assertEqual( | ||
| 76 | + (self.source / ".tx/config").read_bytes(), | ||
| 77 | + (self.destination / ".tx/config").read_bytes(), | ||
| 78 | + ) | ||
| 79 | + | ||
| 80 | + def test_incomplete_download_does_not_change_repository(self): | ||
| 81 | + write_config(self.source, ["builtins/functions.po", "bugs.po"]) | ||
| 82 | + write_po(self.source / "ja/LC_MESSAGES", "builtins/functions.po", "新しい訳") | ||
| 83 | + write_config(self.destination, ["library/functions.po"]) | ||
| 84 | + old = write_po(self.destination, "library/functions.po", "古い訳") | ||
| 85 | + self.track() | ||
| 86 | + before = old.read_bytes() | ||
| 87 | + config_before = (self.destination / ".tx/config").read_bytes() | ||
| 88 | + | ||
| 89 | + with self.assertRaises(FileNotFoundError): | ||
| 90 | + translations.sync(self.source, self.destination, "ja") | ||
| 91 | + | ||
| 92 | + self.assertEqual(before, old.read_bytes()) | ||
| 93 | + self.assertEqual(config_before, (self.destination / ".tx/config").read_bytes()) | ||
| 94 | + self.assertFalse((self.destination / "builtins/functions.po").exists()) | ||
| 95 | + | ||
| 96 | + def test_empty_config_does_not_delete_catalogs(self): | ||
| 97 | + write_config(self.source, []) | ||
| 98 | + old = write_po(self.destination, "library/functions.po", "古い訳") | ||
| 99 | + self.track() | ||
| 100 | + with self.assertRaises(ValueError): | ||
| 101 | + translations.sync(self.source, self.destination, "ja") | ||
| 102 | + self.assertTrue(old.exists()) | ||
| 103 | + | ||
| 104 | + def test_config_cannot_escape_catalog_directory(self): | ||
| 105 | + write_config(self.source, ["../outside.po"]) | ||
| 106 | + with self.assertRaises(ValueError): | ||
| 107 | + translations.active_catalogs(self.source / ".tx/config") | ||
| 108 | + | ||
| 109 | + @unittest.skipUnless(shutil.which("pomerge"), "pomerge is required") | ||
| 110 | + def test_merge_uses_current_translation_across_renamed_paths(self): | ||
| 111 | + write_config(self.source, ["builtins/functions.po"]) | ||
| 112 | + write_po(self.source, "builtins/functions.po", "新しい訳") | ||
| 113 | + write_po(self.source, "library/functions.po", "古い訳") | ||
| 114 | + old = write_po(self.destination, "library/functions.po", "以前の訳") | ||
| 115 | + self.track() | ||
| 116 | + | ||
| 117 | + translations.merge(self.source, self.destination) | ||
| 118 | + | ||
| 119 | + text = old.read_text(encoding="utf-8") | ||
| 120 | + self.assertIn('msgstr "新しい訳"', text) | ||
| 121 | + self.assertNotIn("古い訳", text) | ||
| 122 | + self.assertFalse((self.destination / "builtins/functions.po").exists()) | ||
| 123 | + | ||
| 124 | + | ||
| 125 | + if __name__ == "__main__": | ||
| 126 | + unittest.main() | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments