| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -14,23 +14,30 @@ on: | |||
| 14 | 14 | jobs: | |
| 15 | 15 | lint: | |
| 16 | 16 | runs-on: ubuntu-latest | |
| 17 | - strategy: | ||
| 18 | - fail-fast: false | ||
| 19 | - continue-on-error: true | ||
| 20 | 17 | steps: | |
| 21 | 18 | - name: Checkout code | |
| 22 | 19 | uses: actions/checkout@v4 | |
| 23 | - | ||
| 20 | + | ||
| 24 | 21 | - name: Set up Python | |
| 25 | 22 | uses: actions/setup-python@v4 | |
| 26 | 23 | with: | |
| 27 | 24 | python-version: '3.11' | |
| 28 | - | ||
| 25 | + | ||
| 29 | 26 | - name: Install sphinx-lint | |
| 30 | 27 | run: pip install sphinx-lint | |
| 31 | - | ||
| 28 | + | ||
| 29 | + - name: Install gettext tools | ||
| 30 | + run: sudo apt-get install -y gettext | ||
| 31 | + | ||
| 32 | 32 | - name: Setup problem matcher | |
| 33 | 33 | uses: rffontenelle/sphinx-lint-problem-matcher@v1.0.0 | |
| 34 | - | ||
| 34 | + | ||
| 35 | 35 | - name: Run sphinx-lint | |
| 36 | 36 | run: sphinx-lint | |
| 37 | + continue-on-error: true | ||
| 38 | + | ||
| 39 | + - name: Check PO file validity | ||
| 40 | + run: find . -name '*.po' -exec msgfmt --check {} \; | ||
| 41 | + | ||
| 42 | + - name: Check markup preservation | ||
| 43 | + run: python3 scripts/check_markup.py . | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,127 @@ | |||
| 1 | + #!/usr/bin/env python3 | ||
| 2 | + """ | ||
| 3 | + scripts/check_markup.py | ||
| 4 | + | ||
| 5 | + Verify that Sphinx roles, inline literals, and format placeholders in the | ||
| 6 | + English msgid are preserved verbatim in the Persian msgstr. Catches the most | ||
| 7 | + common review slip: translating or mangling `:class:`int``-style markup, | ||
| 8 | + ``code`` spans, %s/{0} placeholders, or |substitution| refs. | ||
| 9 | + | ||
| 10 | + Usage: | ||
| 11 | + python3 scripts/check_markup.py library/functions.po | ||
| 12 | + python3 scripts/check_markup.py tutorial/*.po | ||
| 13 | + python3 scripts/check_markup.py . # recurse a whole directory | ||
| 14 | + """ | ||
| 15 | + import re | ||
| 16 | + import sys | ||
| 17 | + from pathlib import Path | ||
| 18 | + | ||
| 19 | + PATTERNS = [ | ||
| 20 | + ("sphinx role", re.compile(r":[\w.-]+:`.*?`")), | ||
| 21 | + ("literal/code span", re.compile(r"``.*?``")), | ||
| 22 | + ("substitution ref", re.compile(r"\|[\w.-]+\|")), | ||
| 23 | + ("percent placeholder", re.compile(r"%\(\w+\)[a-zA-Z]|%[a-zA-Z]")), | ||
| 24 | + ("brace placeholder", re.compile(r"\{[^{}\s]*\}")), | ||
| 25 | + ] | ||
| 26 | + | ||
| 27 | + | ||
| 28 | + def unescape(raw: str) -> str: | ||
| 29 | + """Undo PO string escaping (raw includes the surrounding quotes).""" | ||
| 30 | + inner = raw[1:-1] | ||
| 31 | + out = [] | ||
| 32 | + i = 0 | ||
| 33 | + while i < len(inner): | ||
| 34 | + c = inner[i] | ||
| 35 | + if c == "\\" and i + 1 < len(inner): | ||
| 36 | + nxt = inner[i + 1] | ||
| 37 | + out.append({"n": "\n", "t": "\t", '"': '"', "\\": "\\"}.get(nxt, nxt)) | ||
| 38 | + i += 2 | ||
| 39 | + else: | ||
| 40 | + out.append(c) | ||
| 41 | + i += 1 | ||
| 42 | + return "".join(out) | ||
| 43 | + | ||
| 44 | + | ||
| 45 | + def parse_po(path: Path): | ||
| 46 | + """Yield (location, flags, msgid, msgstr) for each entry in a .po file.""" | ||
| 47 | + lines = path.read_text(encoding="utf-8").splitlines() | ||
| 48 | + i, n = 0, len(lines) | ||
| 49 | + | ||
| 50 | + def read_block(keyword_line): | ||
| 51 | + nonlocal i | ||
| 52 | + parts = [unescape(keyword_line.split(" ", 1)[1].strip())] | ||
| 53 | + i += 1 | ||
| 54 | + while i < n and lines[i].strip().startswith('"'): | ||
| 55 | + parts.append(unescape(lines[i].strip())) | ||
| 56 | + i += 1 | ||
| 57 | + return "".join(parts) | ||
| 58 | + | ||
| 59 | + while i < n: | ||
| 60 | + location, flags = "", [] | ||
| 61 | + while i < n and lines[i].startswith("#"): | ||
| 62 | + if lines[i].startswith("#:"): | ||
| 63 | + location = lines[i][2:].strip() | ||
| 64 | + elif lines[i].startswith("#,"): | ||
| 65 | + flags = [f.strip() for f in lines[i][2:].split(",")] | ||
| 66 | + i += 1 | ||
| 67 | + if i >= n or not lines[i].startswith("msgid"): | ||
| 68 | + i += 1 | ||
| 69 | + continue | ||
| 70 | + | ||
| 71 | + msgid = read_block(lines[i]) | ||
| 72 | + msgid_plural = read_block(lines[i]) if i < n and lines[i].startswith("msgid_plural") else None | ||
| 73 | + | ||
| 74 | + if i < n and lines[i].startswith("msgstr["): | ||
| 75 | + msgstrs = {} | ||
| 76 | + while i < n and lines[i].startswith("msgstr["): | ||
| 77 | + idx = int(lines[i][7:lines[i].index("]")]) | ||
| 78 | + msgstrs[idx] = read_block(lines[i]) | ||
| 79 | + yield location, flags, msgid, msgstrs.get(0, "") | ||
| 80 | + if msgid_plural is not None and 1 in msgstrs: | ||
| 81 | + yield location, flags, msgid_plural, msgstrs[1] | ||
| 82 | + continue | ||
| 83 | + | ||
| 84 | + msgstr = read_block(lines[i]) if i < n and lines[i].startswith("msgstr") else "" | ||
| 85 | + yield location, flags, msgid, msgstr | ||
| 86 | + | ||
| 87 | + | ||
| 88 | + def check_file(path: Path) -> int: | ||
| 89 | + problems = 0 | ||
| 90 | + for location, flags, msgid, msgstr in parse_po(path): | ||
| 91 | + if not msgid or not msgstr: | ||
| 92 | + continue # header entry or still untranslated | ||
| 93 | + for label, pattern in PATTERNS: | ||
| 94 | + expected = pattern.findall(msgid) | ||
| 95 | + if not expected: | ||
| 96 | + continue | ||
| 97 | + missing = [tok for tok in expected if tok not in msgstr] | ||
| 98 | + if missing: | ||
| 99 | + problems += 1 | ||
| 100 | + loc = f" ({location})" if location else "" | ||
| 101 | + tag = " [fuzzy]" if "fuzzy" in flags else "" | ||
| 102 | + print(f"{path}{loc}{tag}: missing {label}: {missing}") | ||
| 103 | + print(f" msgid : {msgid[:100]}") | ||
| 104 | + print(f" msgstr: {msgstr[:100]}") | ||
| 105 | + return problems | ||
| 106 | + | ||
| 107 | + | ||
| 108 | + def main(): | ||
| 109 | + if len(sys.argv) < 2: | ||
| 110 | + print(__doc__) | ||
| 111 | + sys.exit(1) | ||
| 112 | + | ||
| 113 | + files = [] | ||
| 114 | + for arg in sys.argv[1:]: | ||
| 115 | + p = Path(arg) | ||
| 116 | + files.extend(sorted(p.rglob("*.po"))) if p.is_dir() else files.append(p) | ||
| 117 | + | ||
| 118 | + total = sum(check_file(f) for f in files) | ||
| 119 | + | ||
| 120 | + if total: | ||
| 121 | + print(f"\n{total} markup mismatch(es) found.") | ||
| 122 | + sys.exit(1) | ||
| 123 | + print("No markup mismatches found.") | ||
| 124 | + | ||
| 125 | + | ||
| 126 | + if __name__ == "__main__": | ||
| 127 | + main() | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,127 @@ | |||
| 1 | + #!/usr/bin/env python3 | ||
| 2 | + """ | ||
| 3 | + scripts/update_python_version.py | ||
| 4 | + | ||
| 5 | + Sync this repo's .po files against a target CPython release tag. | ||
| 6 | + | ||
| 7 | + Usage: | ||
| 8 | + python scripts/update_python_version.py v3.14.6 | ||
| 9 | + python scripts/update_python_version.py v3.15.0 --keep-src | ||
| 10 | + | ||
| 11 | + Run this from anywhere inside the repo (it locates the repo root from | ||
| 12 | + this file's location, assuming it lives in <repo>/scripts/). | ||
| 13 | + """ | ||
| 14 | + import argparse | ||
| 15 | + import shutil | ||
| 16 | + import subprocess | ||
| 17 | + import sys | ||
| 18 | + from pathlib import Path | ||
| 19 | + | ||
| 20 | + REPO_ROOT = Path(__file__).resolve().parent.parent | ||
| 21 | + WORKDIR = REPO_ROOT / ".cpython-src" # scratch clone, deleted by default when done | ||
| 22 | + | ||
| 23 | + | ||
| 24 | + def run(cmd, cwd=None): | ||
| 25 | + print(f"$ {' '.join(str(c) for c in cmd)}") | ||
| 26 | + subprocess.run(cmd, cwd=cwd, check=True) | ||
| 27 | + | ||
| 28 | + | ||
| 29 | + def fetch_cpython(tag: str) -> None: | ||
| 30 | + if WORKDIR.exists(): | ||
| 31 | + shutil.rmtree(WORKDIR) | ||
| 32 | + run([ | ||
| 33 | + "git", "clone", "--depth", "1", "--branch", tag, | ||
| 34 | + "https://github.com/python/cpython.git", str(WORKDIR), | ||
| 35 | + ]) | ||
| 36 | + | ||
| 37 | + | ||
| 38 | + def build_gettext(tag: str) -> Path: | ||
| 39 | + """Build .pot templates from the CPython docs at `tag`, return their root dir.""" | ||
| 40 | + doc_dir = WORKDIR / "Doc" | ||
| 41 | + venv_dir = doc_dir / "venv" | ||
| 42 | + run([sys.executable, "-m", "venv", str(venv_dir)]) | ||
| 43 | + pip = venv_dir / "bin" / "pip" | ||
| 44 | + sphinx_build = venv_dir / "bin" / "sphinx-build" | ||
| 45 | + run([str(pip), "install", "-r", "requirements.txt"], cwd=doc_dir) | ||
| 46 | + run([str(sphinx_build), "-b", "gettext", ".", "build/gettext"], cwd=doc_dir) | ||
| 47 | + return doc_dir / "build" / "gettext" | ||
| 48 | + | ||
| 49 | + | ||
| 50 | + def merge_all(pot_root: Path) -> None: | ||
| 51 | + updated = new_po = missing_pot = 0 | ||
| 52 | + | ||
| 53 | + # existing .po files -> merge against matching .pot by relative path | ||
| 54 | + for po_path in sorted(REPO_ROOT.rglob("*.po")): | ||
| 55 | + if ".cpython-src" in po_path.parts or ".git" in po_path.parts: | ||
| 56 | + continue | ||
| 57 | + rel = po_path.relative_to(REPO_ROOT) | ||
| 58 | + pot_path = pot_root / rel.with_suffix(".pot") | ||
| 59 | + if not pot_path.exists(): | ||
| 60 | + print(f" ! no matching .pot for {rel} " | ||
| 61 | + f"(page may have been removed/renamed upstream — review manually)") | ||
| 62 | + missing_pot += 1 | ||
| 63 | + continue | ||
| 64 | + run(["msgmerge", "--update", "--backup=off", str(po_path), str(pot_path)]) | ||
| 65 | + updated += 1 | ||
| 66 | + | ||
| 67 | + # brand-new .pot files with no .po counterpart yet -> create empty .po via msginit | ||
| 68 | + for pot_path in sorted(pot_root.rglob("*.pot")): | ||
| 69 | + rel = pot_path.relative_to(pot_root) | ||
| 70 | + po_path = REPO_ROOT / rel.with_suffix(".po") | ||
| 71 | + if not po_path.exists(): | ||
| 72 | + po_path.parent.mkdir(parents=True, exist_ok=True) | ||
| 73 | + run(["msginit", "--no-translator", "-l", "fa", "-i", str(pot_path), "-o", str(po_path)]) | ||
| 74 | + new_po += 1 | ||
| 75 | + | ||
| 76 | + print(f"\nSummary: {updated} .po files merged, {new_po} new .po files created, " | ||
| 77 | + f"{missing_pot} .po files with no matching upstream source.") | ||
| 78 | + | ||
| 79 | + | ||
| 80 | + def check_po_files() -> None: | ||
| 81 | + bad = [] | ||
| 82 | + for po_path in sorted(REPO_ROOT.rglob("*.po")): | ||
| 83 | + if ".cpython-src" in po_path.parts or ".git" in po_path.parts: | ||
| 84 | + continue | ||
| 85 | + result = subprocess.run( | ||
| 86 | + ["msgfmt", "--check", "-o", "/dev/null", str(po_path)], | ||
| 87 | + capture_output=True, text=True, | ||
| 88 | + ) | ||
| 89 | + if result.returncode != 0: | ||
| 90 | + bad.append((po_path, result.stderr.strip())) | ||
| 91 | + | ||
| 92 | + if bad: | ||
| 93 | + print("\nBroken .po files (fix before committing):") | ||
| 94 | + for path, err in bad: | ||
| 95 | + print(f" {path}:\n {err}") | ||
| 96 | + sys.exit(1) | ||
| 97 | + print("\nAll .po files pass `msgfmt --check`.") | ||
| 98 | + | ||
| 99 | + | ||
| 100 | + def main() -> None: | ||
| 101 | + parser = argparse.ArgumentParser(description=__doc__) | ||
| 102 | + parser.add_argument("tag", help="CPython git tag to sync against, e.g. v3.14.6") | ||
| 103 | + parser.add_argument("--keep-src", action="store_true", | ||
| 104 | + help="keep the scratch CPython checkout instead of deleting it") | ||
| 105 | + args = parser.parse_args() | ||
| 106 | + | ||
| 107 | + print(f"== Fetching CPython {args.tag} ==") | ||
| 108 | + fetch_cpython(args.tag) | ||
| 109 | + | ||
| 110 | + print("\n== Building gettext templates ==") | ||
| 111 | + pot_root = build_gettext(args.tag) | ||
| 112 | + | ||
| 113 | + print("\n== Merging into this repo's .po files ==") | ||
| 114 | + merge_all(pot_root) | ||
| 115 | + | ||
| 116 | + print("\n== Validating .po files ==") | ||
| 117 | + check_po_files() | ||
| 118 | + | ||
| 119 | + if not args.keep_src: | ||
| 120 | + shutil.rmtree(WORKDIR, ignore_errors=True) | ||
| 121 | + | ||
| 122 | + print(f"\nDone. Review the diff, then commit as something like:\n" | ||
| 123 | + f' git commit -am "Sync translations with CPython {args.tag}"') | ||
| 124 | + | ||
| 125 | + | ||
| 126 | + if __name__ == "__main__": | ||
| 127 | + main() | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments