Skip to content

Commit 25ca17f

Browse files
committed
Add update_po_headers.py script
1 parent 7319955 commit 25ca17f

1 file changed

Lines changed: 296 additions & 0 deletions

File tree

scripts/update_po_headers.py

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
#!/usr/bin/env python3
2+
"""
3+
scripts/update_po_headers.py
4+
5+
Regenerate the gettext header credits of .po files from git history:
6+
7+
* the ``# Translators:`` comment block -- every unique author who has ever
8+
committed to the file, oldest first, with the year of their most recent
9+
change to it;
10+
* the ``Last-Translator:`` metadata field -- the author of the most recent
11+
commit.
12+
13+
Because contributors come and go through pull requests, attribution can be
14+
rebuilt from the real git history instead of being hand-maintained.
15+
16+
With ``--merge`` the existing ``# Translators:`` entries are kept and git
17+
identities are only *added*, so credits recorded before the git era (e.g.
18+
on Transifex) are preserved alongside the git-derived ones.
19+
20+
Optionally rewrite the ``Language-Team:`` field too, e.g. to drop a stale
21+
Transifex URL after abandoning Transifex. Use ``--no-credits`` for a
22+
header-only fix that leaves the translator notes untouched.
23+
24+
Unlike a polib round-trip (which re-wraps every msgid/msgstr), this script
25+
edits only the header, so translations and their line wrapping are
26+
preserved byte-for-byte.
27+
28+
Automated accounts (GitHub Actions, Transifex sync jobs, ``[bot]`` users)
29+
are excluded from the credits.
30+
31+
Requires: git history for the repo.
32+
33+
Usage:
34+
python3 scripts/update_po_headers.py # whole repo
35+
python3 scripts/update_po_headers.py library/functions.po # specific path(s)
36+
python3 scripts/update_po_headers.py tutorial/ # a directory
37+
python3 scripts/update_po_headers.py --dry-run # just show changes
38+
python3 scripts/update_po_headers.py --merge # keep existing names, add git ones
39+
python3 scripts/update_po_headers.py --no-credits \\
40+
--language-team "Persian (https://github.com/revisto/python-docs-fa/)" \\
41+
bugs.po tutorial/ library/functions.po
42+
"""
43+
import argparse
44+
import re
45+
import subprocess
46+
from pathlib import Path
47+
48+
REPO_ROOT = Path(__file__).resolve().parent.parent
49+
50+
BOT_AUTHOR_RE = re.compile(r"(?i)(github[^\w]*action|\[bot\])")
51+
BOT_EMAIL_RE = re.compile(r"(?i)(\[bot\]|@users\.noreply\.github\.com$|\+github-actions)")
52+
TRANSLATOR_LINE_RE = re.compile(r"^# .+, \d{4}$")
53+
LAST_TRANSLATOR_RE = re.compile(r'^(\s*)"Last-Translator: .*\\n"\s*$')
54+
LANGUAGE_TEAM_START_RE = re.compile(r'^\s*"Language-Team: (.*)$')
55+
56+
57+
def git_history(rel_path: str):
58+
"""Return chronological (oldest first) ``(name <email>, year)`` rows for a file."""
59+
result = subprocess.run(
60+
[
61+
"git", "-C", str(REPO_ROOT), "log",
62+
"--format=%an|%ae|%ad", "--date=short", "--", rel_path,
63+
],
64+
capture_output=True, text=True,
65+
)
66+
if result.returncode != 0:
67+
raise RuntimeError(f"git log failed for {rel_path}: {result.stderr.strip()}")
68+
rows = []
69+
for line in (l for l in result.stdout.splitlines() if l):
70+
name, email, date = line.rsplit("|", 2)
71+
if BOT_AUTHOR_RE.search(name) or BOT_EMAIL_RE.search(email):
72+
continue
73+
rows.append((f"{name} <{email}>", date[:4]))
74+
rows.reverse()
75+
return rows
76+
77+
78+
def translate_block(rows):
79+
"""Split history into (ordered identity -> year-of-last-change, last author)."""
80+
seen = {}
81+
for identity, year in rows:
82+
seen[identity] = year # dict keeps insertion (oldest-first) order
83+
return list(seen.items()), (rows[-1] if rows else None)
84+
85+
86+
def update_comment_block(comment_lines, translators):
87+
"""Rebuild the ``# Translators:`` section of the header comments."""
88+
lines = list(comment_lines)
89+
idx = next((i for i, l in enumerate(lines) if l.strip() == "# Translators:"), None)
90+
listing = ["# Translators:"] + [f"# {identity}, {year}" for identity, year in translators]
91+
92+
if idx is not None:
93+
j = idx + 1
94+
while j < len(lines) and TRANSLATOR_LINE_RE.match(lines[j]):
95+
j += 1
96+
lines[idx:j] = listing if translators else []
97+
return lines
98+
99+
if not translators:
100+
return lines
101+
102+
# No Translators section yet: drop it in just before the "#, fuzzy" flag
103+
# (if any), otherwise at the end of the comment block.
104+
flag = next((i for i, l in enumerate(lines) if l.lstrip().startswith("#,")), len(lines))
105+
separated = flag > 0 and lines[flag - 1] in ("", "#")
106+
block = listing + ["#"]
107+
if not separated:
108+
block.insert(0, "#")
109+
lines[flag:flag] = block
110+
return lines
111+
112+
113+
def existing_translators(comment_lines):
114+
"""Parse the current ``# Translators:`` entries.
115+
116+
Returns ``(entries, idx, end)`` where ``entries`` is a list of
117+
``(raw_line, key)`` pairs and ``idx``/``end`` delimit the comment-line
118+
span of the section. ``key`` is ``("email", ...)`` when the entry has
119+
an email address, otherwise ``("name", ...)``.
120+
"""
121+
entries = []
122+
idx = next((i for i, l in enumerate(comment_lines) if l.strip() == "# Translators:"), None)
123+
if idx is None:
124+
return [], None, None
125+
j = idx + 1
126+
while j < len(comment_lines) and TRANSLATOR_LINE_RE.match(comment_lines[j]):
127+
m = re.match(r"^# (.+?), \d{4}$", comment_lines[j])
128+
if m:
129+
identity = m.group(1).strip()
130+
em = re.search(r"<([^>]+)>", identity)
131+
key = ("email", em.group(1).strip().lower()) if em else ("name", identity.lower())
132+
entries.append((comment_lines[j], key))
133+
j += 1
134+
return entries, idx, j
135+
136+
137+
def merge_comment_block(comment_lines, translators):
138+
"""Merge git-derived credits into the existing ``# Translators:`` block.
139+
140+
Existing entries are kept verbatim (preserving Transifex-era credits
141+
that git history no longer records); git identities not already present
142+
are appended. Matching is by email address, or by name when an
143+
existing entry has no email. A missing section is created if needed.
144+
"""
145+
lines = list(comment_lines)
146+
existing, idx, end = existing_translators(lines)
147+
148+
known = {key for _, key in existing}
149+
additions = []
150+
for identity, year in translators:
151+
em = re.search(r"<([^>]+)>", identity)
152+
key = ("email", em.group(1).strip().lower()) if em else ("name", identity.lower())
153+
if key in known:
154+
continue
155+
known.add(key)
156+
additions.append(f"# {identity}, {year}")
157+
158+
listing = ["# Translators:"] + [line for line, _ in existing] + additions
159+
if idx is not None:
160+
lines[idx:end] = listing if existing or additions else []
161+
return lines
162+
if not additions:
163+
return lines
164+
flag = next((i for i, l in enumerate(lines) if l.lstrip().startswith("#,")), len(lines))
165+
separated = flag > 0 and lines[flag - 1] in ("", "#")
166+
block = listing + ["#"]
167+
if not separated:
168+
block.insert(0, "#")
169+
lines[flag:flag] = block
170+
return lines
171+
172+
173+
def strip_quotes(segment: str) -> str:
174+
"""Strip the surrounding double quotes of one raw header line."""
175+
s = segment.strip()
176+
if s.startswith('"'):
177+
s = s[1:]
178+
if s.endswith('"'):
179+
s = s[:-1]
180+
return s
181+
182+
183+
def update_header_entry(entry_lines, last_translator, language_team):
184+
"""Update ``Last-Translator:`` and/or ``Language-Team:`` in the msgstr header."""
185+
unchanged = "\n".join(entry_lines)
186+
lines = list(entry_lines)
187+
188+
if last_translator is not None:
189+
for i, line in enumerate(lines):
190+
m = LAST_TRANSLATOR_RE.match(line)
191+
if m:
192+
identity, year = last_translator
193+
lines[i] = f'{m.group(1)}"Last-Translator: {identity}, {year}\\n"'
194+
break
195+
196+
if language_team is not None:
197+
for start, line in enumerate(lines):
198+
m = LANGUAGE_TEAM_START_RE.match(line)
199+
if m:
200+
indent = re.match(r"^\s*", line).group(0)
201+
value = m.group(1).rstrip('"')
202+
end = start + 1
203+
while end < len(lines) and not value.endswith(")") and not value.endswith(")\\n"):
204+
value += strip_quotes(lines[end])
205+
end += 1
206+
lines[start:end] = [f'{indent}"Language-Team: {language_team}\\n"']
207+
break
208+
209+
return lines if "\n".join(lines) != unchanged else None
210+
211+
212+
def collect_files(paths):
213+
files = []
214+
for arg in paths:
215+
p = Path(arg) if Path(arg).is_absolute() else REPO_ROOT / arg
216+
if p.is_dir():
217+
for f in sorted(p.rglob("*.po")):
218+
rel = f.relative_to(REPO_ROOT)
219+
parts = set(rel.parts)
220+
if (".git" in parts or ".cpython-src" in parts or "venv" in parts
221+
or ".venv" in parts):
222+
continue
223+
if any(part.startswith(".") for part in rel.parts):
224+
continue
225+
files.append(rel)
226+
elif p.suffix == ".po":
227+
files.append(p.relative_to(REPO_ROOT))
228+
return sorted(files)
229+
230+
231+
def main():
232+
parser = argparse.ArgumentParser(description=__doc__,
233+
formatter_class=argparse.RawDescriptionHelpFormatter)
234+
parser.add_argument("paths", nargs="*", default=["."],
235+
help=".po files or directories to update (default: whole repo)")
236+
parser.add_argument("--language-team", metavar="VALUE",
237+
help="set the Language-Team: header field to VALUE, e.g. "
238+
"'Persian (https://github.com/revisto/python-docs-fa/)'")
239+
parser.add_argument("--no-credits", action="store_true",
240+
help="skip regenerating the # Translators: / Last-Translator: credits")
241+
parser.add_argument("--merge", action="store_true",
242+
help="merge git identities into the existing # Translators: "
243+
"list instead of rebuilding it (preserves older credits)")
244+
parser.add_argument("--dry-run", action="store_true",
245+
help="show what would change without writing files")
246+
args = parser.parse_args()
247+
248+
files = collect_files(args.paths)
249+
if not files:
250+
parser.error("no .po files found")
251+
252+
for rel in files:
253+
path = REPO_ROOT / rel
254+
text = path.read_text(encoding="utf-8")
255+
lines = text.split("\n")
256+
257+
mi = next((i for i, l in enumerate(lines) if l == 'msgid ""'), len(lines))
258+
ei = mi
259+
while ei < len(lines) and lines[ei] != "":
260+
ei += 1
261+
262+
comment = lines[:mi]
263+
entry = lines[mi + 1:ei] # skip the 'msgid ""' line itself
264+
new_comment, new_entry = comment, entry
265+
266+
if not args.no_credits:
267+
rows = git_history(str(rel))
268+
if rows:
269+
translators, last = translate_block(rows)
270+
if args.merge:
271+
new_comment = merge_comment_block(comment, translators)
272+
else:
273+
new_comment = update_comment_block(comment, translators)
274+
rebuilt = update_header_entry(entry, last, None)
275+
if rebuilt is not None:
276+
new_entry = rebuilt
277+
278+
if args.language_team:
279+
rebuilt = update_header_entry(new_entry, None, args.language_team)
280+
if rebuilt is not None:
281+
new_entry = rebuilt
282+
283+
rebuilt_lines = new_comment + ['msgid ""'] + new_entry + lines[ei:]
284+
new_text = "\n".join(rebuilt_lines)
285+
if new_text.rstrip("\n") == text.rstrip("\n"):
286+
continue
287+
288+
if args.dry_run:
289+
print(f"[dry-run] would update {rel}")
290+
else:
291+
path.write_text(new_text, encoding="utf-8")
292+
print(f"updated {rel}")
293+
294+
295+
if __name__ == "__main__":
296+
main()

0 commit comments

Comments
 (0)