Skip to content

Commit ac5c134

Browse files
committed
Update sync-with-cpython workflow
1 parent 14b6d1f commit ac5c134

3 files changed

Lines changed: 248 additions & 47 deletions

File tree

‎.github/workflows/sync-with-cpython.yml‎

Lines changed: 11 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,11 @@ jobs:
7373

7474
- name: Install Python dependencies
7575
if: env.SKIP != 'true'
76-
# Consider installing from CPython's Doc/requirements.txt instead of
77-
# unpinned sphinx, so Sphinx upgrades can't change msgids on their own.
78-
run: pip install sphinx sphinx-intl
76+
# polib is used by scripts/review_report.py to build the issue body.
77+
# Consider installing sphinx from CPython's Doc/requirements.txt
78+
# instead of unpinned, so Sphinx upgrades can't change msgids on
79+
# their own.
80+
run: pip install sphinx sphinx-intl polib
7981

8082
- name: Sync msgids (fetch, build gettext, merge, validate)
8183
if: env.SKIP != 'true'
@@ -90,48 +92,12 @@ jobs:
9092
run: python scripts/unstage_cosmetic.py
9193

9294
# --- Build the report from what is actually staged -------------------
93-
- name: Build review report (new files + newly fuzzy strings)
95+
# New PO files, newly fuzzy strings (old -> new diff) and new
96+
# untranslated strings. Writes an empty file if there is nothing to
97+
# report, in which case no issue is opened.
98+
- name: Build review report (new files, newly fuzzy, new untranslated)
9499
if: env.SKIP != 'true'
95-
run: |
96-
report="$RUNNER_TEMP/report.md"
97-
: > "$report"
98-
work=$(mktemp -d)
99-
100-
# New PO files
101-
new_files=$(git diff --staged --name-only --diff-filter=A -- '*.po')
102-
if [ -n "$new_files" ]; then
103-
{
104-
echo "## 🆕 New PO files ($(echo "$new_files" | wc -l))"
105-
echo "$new_files" | sed 's/^/- `/; s/$/`/'
106-
echo ""
107-
} >> "$report"
108-
fi
109-
110-
# Fuzzy entries that are new in this run (present now, absent at HEAD)
111-
fuzzy_out="$work/fuzzy.md"
112-
: > "$fuzzy_out"
113-
git diff --staged --name-only --diff-filter=M -- '*.po' |
114-
while IFS= read -r f; do
115-
git show "HEAD:$f" > "$work/old_full.po"
116-
msgattrib --only-fuzzy --no-obsolete --no-wrap "$work/old_full.po" > "$work/old.po" 2>/dev/null || : > "$work/old.po"
117-
msgattrib --only-fuzzy --no-obsolete --no-wrap "$f" > "$work/new.po" 2>/dev/null || : > "$work/new.po"
118-
# entries in exactly one of old/new, then keep those also in new
119-
msgcomm --less-than=2 --no-wrap "$work/old.po" "$work/new.po" > "$work/sym.po" 2>/dev/null || : > "$work/sym.po"
120-
msgcomm --no-wrap "$work/new.po" "$work/sym.po" > "$work/added.po" 2>/dev/null || : > "$work/added.po"
121-
count=$(grep -c '^#, .*fuzzy' "$work/added.po" || true)
122-
if [ "${count:-0}" -gt 0 ]; then
123-
{
124-
echo "### \`$f\` ($count new fuzzy)"
125-
grep -E '^msgid ".+"' "$work/added.po" | sed 's/^msgid /- /' | head -10
126-
[ "$count" -gt 10 ] && echo "- … and $((count - 10)) more"
127-
echo ""
128-
} >> "$fuzzy_out"
129-
fi
130-
done
131-
132-
if [ -s "$fuzzy_out" ]; then
133-
{ echo "## 🔍 Newly fuzzy strings"; cat "$fuzzy_out"; } >> "$report"
134-
fi
100+
run: python scripts/review_report.py --output "$RUNNER_TEMP/report.md"
135101

136102
# --- Commit + push ---------------------------------------------------
137103
- name: Commit and push if changed
@@ -151,7 +117,7 @@ jobs:
151117
echo "committed=true" >> "$GITHUB_OUTPUT"
152118
153119
# Only after a successful push, so a failed run can't create duplicates.
154-
- name: Open issue for new files / newly fuzzy strings
120+
- name: Open issue for new files / fuzzy / untranslated strings
155121
if: env.SKIP != 'true' && steps.commit.outputs.committed == 'true'
156122
env:
157123
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

‎scripts/po_sync.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,18 @@
5151
# whole point: previously the script omitted --no-location --no-wrap while
5252
# the workflow included them, so whichever ran second would produce a huge
5353
# rewrap-only diff on top of (and obscuring) any real content changes.
54-
MSGMERGE_FLAGS = ["--update", "--backup=off", "--no-location", "--no-wrap"]
54+
#
55+
# --previous keeps the old msgid (as a `#| msgid` comment) on entries that
56+
# msgmerge marks fuzzy. scripts/review_report.py relies on it to show an
57+
# old -> new diff of each newly fuzzy string in the review issue. msgmerge
58+
# drops these comments again once an entry is no longer fuzzy.
59+
MSGMERGE_FLAGS = [
60+
"--update",
61+
"--backup=off",
62+
"--no-location",
63+
"--no-wrap",
64+
"--previous",
65+
]
5566

5667
DEFAULT_LOCALE = "fa"
5768

@@ -450,4 +461,4 @@ def main() -> None:
450461

451462

452463
if __name__ == "__main__":
453-
main()
464+
main()

‎scripts/review_report.py‎

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
#!/usr/bin/env python3
2+
"""
3+
scripts/review_report.py
4+
5+
Builds the markdown body of the "translation review needed" issue from the
6+
changes currently STAGED in git (run it after `git add --all` and
7+
`unstage_cosmetic.py`). Writes an empty file if there is nothing to report.
8+
9+
Sections:
10+
- Summary
11+
- New PO files (counts only; every string in them is untranslated)
12+
- Newly fuzzy strings (old msgid -> new msgid, plus the current msgstr)
13+
- New untranslated strings (new msgids that matched nothing, not fuzzy)
14+
15+
"New" always means "not present in HEAD", so re-running never re-reports
16+
strings from earlier syncs.
17+
18+
Requires: polib (pip install polib)
19+
"""
20+
from __future__ import annotations
21+
22+
import argparse
23+
import subprocess
24+
from pathlib import Path
25+
26+
import polib
27+
28+
# GitHub rejects issue bodies over 65,536 characters.
29+
MAX_BODY = 60_000
30+
FUZZY_PER_FILE = 10
31+
UNTRANSLATED_PER_FILE = 25
32+
MAX_LINE = 160
33+
34+
35+
def git(*args: str) -> str:
36+
return subprocess.run(
37+
["git", *args], capture_output=True, text=True, check=True
38+
).stdout
39+
40+
41+
def staged(diff_filter: str) -> list:
42+
out = git(
43+
"diff", "--staged", "--no-renames", "-z", "--name-only",
44+
f"--diff-filter={diff_filter}", "--", "*.po",
45+
)
46+
return [p for p in out.split("\0") if p]
47+
48+
49+
def key(entry: polib.POEntry) -> tuple:
50+
return (entry.msgctxt, entry.msgid)
51+
52+
53+
def live(po: polib.POFile) -> list:
54+
return [e for e in po if not e.obsolete]
55+
56+
57+
def is_fuzzy(entry: polib.POEntry) -> bool:
58+
return "fuzzy" in entry.flags
59+
60+
61+
def is_untranslated(entry: polib.POEntry) -> bool:
62+
if entry.msgid_plural:
63+
return not any(entry.msgstr_plural.values())
64+
return not entry.msgstr
65+
66+
67+
def clip(text: str, limit: int = MAX_LINE) -> str:
68+
# Keep everything on one line and make sure we can't close a code fence.
69+
text = text.replace("\n", "\\n").replace("```", "'''")
70+
return text if len(text) <= limit else text[: limit - 1] + "…"
71+
72+
73+
# ---------------------------------------------------------------------------
74+
# Collect
75+
# ---------------------------------------------------------------------------
76+
77+
78+
def collect():
79+
new_files = [] # (path, number of strings)
80+
changes = [] # (path, newly_fuzzy, new_untranslated)
81+
82+
for path in staged("A"):
83+
po = polib.pofile(path)
84+
new_files.append((path, len(live(po))))
85+
86+
for path in staged("M"):
87+
old = polib.pofile(git("show", f"HEAD:{path}"))
88+
new = polib.pofile(path)
89+
old_live = live(old)
90+
old_keys = {key(e) for e in old_live}
91+
old_fuzzy = {key(e) for e in old_live if is_fuzzy(e)}
92+
93+
newly_fuzzy = []
94+
new_untranslated = []
95+
for e in live(new):
96+
if is_fuzzy(e):
97+
if key(e) not in old_fuzzy:
98+
newly_fuzzy.append(e)
99+
elif is_untranslated(e) and key(e) not in old_keys:
100+
new_untranslated.append(e)
101+
102+
if newly_fuzzy or new_untranslated:
103+
changes.append((path, newly_fuzzy, new_untranslated))
104+
105+
return new_files, changes
106+
107+
108+
# ---------------------------------------------------------------------------
109+
# Render
110+
# ---------------------------------------------------------------------------
111+
112+
113+
def fuzzy_block(path: str, entries: list) -> str:
114+
lines = [f"### `{path}` ({len(entries)} new fuzzy)", "", "```diff"]
115+
for e in entries[:FUZZY_PER_FILE]:
116+
if e.previous_msgid:
117+
lines.append("- " + clip(e.previous_msgid))
118+
else:
119+
lines.append(" (previous msgid unavailable)")
120+
lines.append("+ " + clip(e.msgid))
121+
if e.msgstr:
122+
lines.append(" msgstr: " + clip(e.msgstr))
123+
lines.append("")
124+
if len(entries) > FUZZY_PER_FILE:
125+
lines.append(f" … and {len(entries) - FUZZY_PER_FILE} more")
126+
lines.append("```")
127+
return "\n".join(lines) + "\n"
128+
129+
130+
def untranslated_block(path: str, entries: list) -> str:
131+
lines = [f"### `{path}` ({len(entries)} new untranslated)", "", "```text"]
132+
lines += [clip(e.msgid) for e in entries[:UNTRANSLATED_PER_FILE]]
133+
if len(entries) > UNTRANSLATED_PER_FILE:
134+
lines.append(f"… and {len(entries) - UNTRANSLATED_PER_FILE} more")
135+
lines.append("```")
136+
return "\n".join(lines) + "\n"
137+
138+
139+
class Body:
140+
"""Accumulates blocks until the size budget runs out."""
141+
142+
def __init__(self, limit: int):
143+
self.parts: list = []
144+
self.size = 0
145+
self.limit = limit
146+
self.omitted = 0
147+
148+
def add(self, text: str) -> None:
149+
if self.size + len(text) > self.limit:
150+
self.omitted += 1
151+
return
152+
self.parts.append(text)
153+
self.size += len(text) + 1
154+
155+
156+
def render(new_files: list, changes: list) -> str:
157+
fuzzy_total = sum(len(f) for _, f, _ in changes)
158+
untr_total = sum(len(u) for _, _, u in changes)
159+
if not (new_files or fuzzy_total or untr_total):
160+
return ""
161+
162+
body = Body(MAX_BODY)
163+
164+
summary = ["## Summary", ""]
165+
if new_files:
166+
strings = sum(n for _, n in new_files)
167+
summary.append(
168+
f"- 🆕 **{len(new_files)}** new PO files ({strings} strings to translate)"
169+
)
170+
if fuzzy_total:
171+
n = sum(1 for _, f, _ in changes if f)
172+
summary.append(f"- 🔍 **{fuzzy_total}** newly fuzzy strings in {n} files")
173+
if untr_total:
174+
n = sum(1 for _, _, u in changes if u)
175+
summary.append(f"- ✏️ **{untr_total}** new untranslated strings in {n} files")
176+
body.add("\n".join(summary) + "\n")
177+
178+
if new_files:
179+
lines = [f"## 🆕 New PO files ({len(new_files)})"]
180+
lines += [f"- `{p}` ({n} strings)" for p, n in new_files]
181+
body.add("\n".join(lines) + "\n")
182+
183+
if fuzzy_total:
184+
body.add(
185+
"## 🔍 Newly fuzzy strings\n\n"
186+
"The source text changed slightly, so the existing translation "
187+
"(`msgstr`) may need updating. `-` is the old source, `+` the new.\n"
188+
)
189+
for path, fuzzy, _ in changes:
190+
if fuzzy:
191+
body.add(fuzzy_block(path, fuzzy))
192+
193+
if untr_total:
194+
body.add(
195+
"## ✏️ New untranslated strings\n\n"
196+
"Brand-new source strings with no close match to an existing "
197+
"translation.\n"
198+
)
199+
for path, _, untr in changes:
200+
if untr:
201+
body.add(untranslated_block(path, untr))
202+
203+
if body.omitted:
204+
body.parts.append(
205+
f"_{body.omitted} section(s) omitted to fit GitHub's issue size "
206+
f"limit — see the sync commit's diff for the full list._\n"
207+
)
208+
return "\n".join(body.parts)
209+
210+
211+
def main() -> None:
212+
parser = argparse.ArgumentParser(description=__doc__)
213+
parser.add_argument("--output", required=True, type=Path)
214+
args = parser.parse_args()
215+
216+
new_files, changes = collect()
217+
report = render(new_files, changes)
218+
args.output.write_text(report, encoding="utf-8")
219+
print(f"Report: {len(report)} chars, {len(new_files)} new files, "
220+
f"{len(changes)} changed files with new strings")
221+
222+
223+
if __name__ == "__main__":
224+
main()

0 commit comments

Comments
 (0)