FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

GitHub Viewer

#!/usr/bin/env python3 """ scripts/update_po_headers.py Regenerate the gettext header credits of .po files from git history: * the ``# Translators:`` comment block -- every unique author who has ever committed to the file, oldest first, with the year of their most recent change to it; * the ``Last-Translator:`` metadata field -- the author of the most recent commit. Because contributors come and go through pull requests, attribution can be rebuilt from the real git history instead of being hand-maintained. With ``--merge`` the existing ``# Translators:`` entries are kept and git identities are only *added*, so credits recorded before the git era (e.g. on Transifex) are preserved alongside the git-derived ones. Optionally rewrite the ``Language-Team:`` field too, e.g. to drop a stale Transifex URL after abandoning Transifex. Use ``--no-credits`` for a header-only fix that leaves the translator notes untouched. Unlike a polib round-trip (which re-wraps every msgid/msgstr), this script edits only the header, so translations and their line wrapping are preserved byte-for-byte. Automated accounts (GitHub Actions, Transifex sync jobs, ``[bot]`` users) are excluded from the credits. Real contributors who commit with a GitHub ``@users.noreply.github.com`` address (email privacy enabled) are credited like anyone else. Requires: git history for the repo. Usage: python3 scripts/update_po_headers.py # whole repo python3 scripts/update_po_headers.py library/functions.po # specific path(s) python3 scripts/update_po_headers.py tutorial/ # a directory python3 scripts/update_po_headers.py --dry-run # just show changes python3 scripts/update_po_headers.py --merge # keep existing names, add git ones python3 scripts/update_po_headers.py --no-credits \\ --language-team "Persian (https://github.com/python/python-docs-fa/)" \\ bugs.po tutorial/ library/functions.po """ import argparse import re import subprocess from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent # Bots only. Note there is deliberately no blanket "@users.noreply.github.com" # clause: that is also the address real users commit with when GitHub's # "keep my email private" setting is on, and excluding it silently drops them # from the credits. Bot noreply addresses (e.g. # 41898282+github-actions[bot]@users.noreply.github.com) are still caught by # the "[bot]" / "+github-actions" patterns. BOT_AUTHOR_RE = re.compile(r"(?i)(github[^\w]*action|\[bot\]|transifex)") BOT_EMAIL_RE = re.compile(r"(?i)(\[bot\]|\+github-actions|transifex)") TRANSLATOR_LINE_RE = re.compile(r"^# .+, \d{4}$") LAST_TRANSLATOR_RE = re.compile(r'^(\s*)"Last-Translator: .*\\n"\s*$') LANGUAGE_TEAM_START_RE = re.compile(r'^\s*"Language-Team: (.*)$') def git_history(rel_path: str): """Return chronological (oldest first) ``(name , year)`` rows for a file.""" result = subprocess.run( [ "git", "-C", str(REPO_ROOT), "log", "--format=%an|%ae|%ad", "--date=short", "--", rel_path, ], capture_output=True, text=True, ) if result.returncode != 0: raise RuntimeError(f"git log failed for {rel_path}: {result.stderr.strip()}") rows = [] for line in (l for l in result.stdout.splitlines() if l): name, email, date = line.rsplit("|", 2) if BOT_AUTHOR_RE.search(name) or BOT_EMAIL_RE.search(email): continue rows.append((f"{name} ", date[:4])) rows.reverse() return rows def translate_block(rows): """Split history into (ordered identity -> year-of-last-change, last author).""" seen = {} for identity, year in rows: seen[identity] = year # dict keeps insertion (oldest-first) order return list(seen.items()), (rows[-1] if rows else None) def update_comment_block(comment_lines, translators): """Rebuild the ``# Translators:`` section of the header comments.""" lines = list(comment_lines) idx = next((i for i, l in enumerate(lines) if l.strip() == "# Translators:"), None) listing = ["# Translators:"] + [ f"# {identity}, {year}" for identity, year in translators ] if idx is not None: j = idx + 1 while j < len(lines) and TRANSLATOR_LINE_RE.match(lines[j]): j += 1 lines[idx:j] = listing if translators else [] return lines if not translators: return lines # No Translators section yet: drop it in just before the "#, fuzzy" flag # (if any), otherwise at the end of the comment block. flag = next( (i for i, l in enumerate(lines) if l.lstrip().startswith("#,")), len(lines) ) separated = flag > 0 and lines[flag - 1] in ("", "#") block = listing + ["#"] if not separated: block.insert(0, "#") lines[flag:flag] = block return lines def existing_translators(comment_lines): """Parse the current ``# Translators:`` entries. Returns ``(entries, idx, end)`` where ``entries`` is a list of ``(raw_line, key)`` pairs and ``idx``/``end`` delimit the comment-line span of the section. ``key`` is ``("email", ...)`` when the entry has an email address, otherwise ``("name", ...)``. """ entries = [] idx = next( (i for i, l in enumerate(comment_lines) if l.strip() == "# Translators:"), None ) if idx is None: return [], None, None j = idx + 1 while j < len(comment_lines) and TRANSLATOR_LINE_RE.match(comment_lines[j]): m = re.match(r"^# (.+?), \d{4}$", comment_lines[j]) if m: identity = m.group(1).strip() em = re.search(r"]+)>", identity) key = ( ("email", em.group(1).strip().lower()) if em else ("name", identity.lower()) ) entries.append((comment_lines[j], key)) j += 1 return entries, idx, j def merge_comment_block(comment_lines, translators): """Merge git-derived credits into the existing ``# Translators:`` block. Existing entries are kept verbatim (preserving Transifex-era credits that git history no longer records); git identities not already present are appended. Matching is by email address, or by name when an existing entry has no email. A missing section is created if needed. """ lines = list(comment_lines) existing, idx, end = existing_translators(lines) known = {key for _, key in existing} additions = [] for identity, year in translators: em = re.search(r"]+)>", identity) key = ( ("email", em.group(1).strip().lower()) if em else ("name", identity.lower()) ) if key in known: continue known.add(key) additions.append(f"# {identity}, {year}") listing = ["# Translators:"] + [line for line, _ in existing] + additions if idx is not None: lines[idx:end] = listing if existing or additions else [] return lines if not additions: return lines flag = next( (i for i, l in enumerate(lines) if l.lstrip().startswith("#,")), len(lines) ) separated = flag > 0 and lines[flag - 1] in ("", "#") block = listing + ["#"] if not separated: block.insert(0, "#") lines[flag:flag] = block return lines def strip_quotes(segment: str) -> str: """Strip the surrounding double quotes of one raw header line.""" s = segment.strip() if s.startswith('"'): s = s[1:] if s.endswith('"'): s = s[:-1] return s def update_header_entry(entry_lines, last_translator, language_team): """Update ``Last-Translator:`` and/or ``Language-Team:`` in the msgstr header.""" unchanged = "\n".join(entry_lines) lines = list(entry_lines) if last_translator is not None: for i, line in enumerate(lines): m = LAST_TRANSLATOR_RE.match(line) if m: identity, year = last_translator lines[i] = f'{m.group(1)}"Last-Translator: {identity}, {year}\\n"' break if language_team is not None: for start, line in enumerate(lines): m = LANGUAGE_TEAM_START_RE.match(line) if m: indent = re.match(r"^\s*", line).group(0) value = m.group(1).rstrip('"') end = start + 1 while ( end < len(lines) and not value.endswith(")") and not value.endswith(")\\n") ): value += strip_quotes(lines[end]) end += 1 lines[start:end] = [f'{indent}"Language-Team: {language_team}\\n"'] break return lines if "\n".join(lines) != unchanged else None def collect_files(paths): files = [] for arg in paths: p = Path(arg) if Path(arg).is_absolute() else REPO_ROOT / arg if p.is_dir(): for f in sorted(p.rglob("*.po")): rel = f.relative_to(REPO_ROOT) parts = set(rel.parts) if ( ".git" in parts or ".cpython-src" in parts or "venv" in parts or ".venv" in parts ): continue if any(part.startswith(".") for part in rel.parts): continue files.append(rel) elif p.suffix == ".po": files.append(p.relative_to(REPO_ROOT)) return sorted(files) def main(): parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument( "paths", nargs="*", default=["."], help=".po files or directories to update (default: whole repo)", ) parser.add_argument( "--language-team", metavar="VALUE", help="set the Language-Team: header field to VALUE, e.g. " "'Persian (https://github.com/python/python-docs-fa/)'", ) parser.add_argument( "--no-credits", action="store_true", help="skip regenerating the # Translators: / Last-Translator: credits", ) parser.add_argument( "--merge", action="store_true", help="merge git identities into the existing # Translators: " "list instead of rebuilding it (preserves older credits)", ) parser.add_argument( "--dry-run", action="store_true", help="show what would change without writing files", ) parser.add_argument( "--no-last-translator", action="store_true", help="skip updating the Last-Translator: header field", ) args = parser.parse_args() files = collect_files(args.paths) if not files: parser.error("no .po files found") for rel in files: path = REPO_ROOT / rel text = path.read_text(encoding="utf-8") lines = text.split("\n") mi = next((i for i, l in enumerate(lines) if l == 'msgid ""'), len(lines)) ei = mi while ei < len(lines) and lines[ei] != "": ei += 1 comment = lines[:mi] entry = lines[mi + 1 : ei] # skip the 'msgid ""' line itself new_comment, new_entry = comment, entry if not args.no_credits: rows = git_history(str(rel)) if rows: translators, last = translate_block(rows) if args.merge: new_comment = merge_comment_block(comment, translators) else: new_comment = update_comment_block(comment, translators) rebuilt = update_header_entry( entry, None if args.no_last_translator else last, None ) if rebuilt is not None: new_entry = rebuilt if args.language_team: rebuilt = update_header_entry(new_entry, None, args.language_team) if rebuilt is not None: new_entry = rebuilt rebuilt_lines = new_comment + ['msgid ""'] + new_entry + lines[ei:] new_text = "\n".join(rebuilt_lines) if new_text.rstrip("\n") == text.rstrip("\n"): continue if args.dry_run: print(f"[dry-run] would update {rel}") else: path.write_text(new_text, encoding="utf-8") print(f"updated {rel}") if __name__ == "__main__": main()

Back | FazBrowse Home | New Git URL