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