#!/usr/bin/env python3
"""
scripts/review_report.py
Builds the markdown body of the "translation review needed" issue from the
changes currently STAGED in git (run it after `git add --all` and
`unstage_cosmetic.py`). Writes an empty file if there is nothing to report.
Sections:
- Summary
- New PO files (counts only; every string in them is untranslated)
- Newly fuzzy strings (old msgid -> new msgid, plus the current msgstr)
- New untranslated strings (new msgids that matched nothing, not fuzzy)
"New" always means "not present in HEAD", so re-running never re-reports
strings from earlier syncs.
Requires: polib (pip install polib)
"""
from __future__ import annotations
import argparse
import subprocess
from pathlib import Path
import polib
# GitHub rejects issue bodies over 65,536 characters.
MAX_BODY = 60_000
FUZZY_PER_FILE = 10
UNTRANSLATED_PER_FILE = 25
MAX_LINE = 160
def git(*args: str) -> str:
return subprocess.run(
["git", *args], capture_output=True, text=True, check=True
).stdout
def staged(diff_filter: str) -> list:
out = git(
"diff",
"--staged",
"--no-renames",
"-z",
"--name-only",
f"--diff-filter={diff_filter}",
"--",
"*.po",
)
return [p for p in out.split("\0") if p]
def key(entry: polib.POEntry) -> tuple:
return (entry.msgctxt, entry.msgid)
def live(po: polib.POFile) -> list:
return [e for e in po if not e.obsolete]
def is_fuzzy(entry: polib.POEntry) -> bool:
return "fuzzy" in entry.flags
def is_untranslated(entry: polib.POEntry) -> bool:
if entry.msgid_plural:
return not any(entry.msgstr_plural.values())
return not entry.msgstr
def clip(text: str, limit: int = MAX_LINE) -> str:
# Keep everything on one line and make sure we can't close a code fence.
text = text.replace("\n", "\\n").replace("```", "'''")
return text if len(text) str:
lines = [f"### `{path}` ({len(entries)} new fuzzy)", "", "```diff"]
for e in entries[:FUZZY_PER_FILE]:
if e.previous_msgid:
lines.append("- " + clip(e.previous_msgid))
else:
lines.append(" (previous msgid unavailable)")
lines.append("+ " + clip(e.msgid))
if e.msgstr:
lines.append(" msgstr: " + clip(e.msgstr))
lines.append("")
if len(entries) > FUZZY_PER_FILE:
lines.append(f" and {len(entries) - FUZZY_PER_FILE} more")
lines.append("```")
return "\n".join(lines) + "\n"
def untranslated_block(path: str, entries: list) -> str:
lines = [f"### `{path}` ({len(entries)} new untranslated)", "", "```text"]
lines += [clip(e.msgid) for e in entries[:UNTRANSLATED_PER_FILE]]
if len(entries) > UNTRANSLATED_PER_FILE:
lines.append(f" and {len(entries) - UNTRANSLATED_PER_FILE} more")
lines.append("```")
return "\n".join(lines) + "\n"
class Body:
"""Accumulates blocks until the size budget runs out."""
def __init__(self, limit: int):
self.parts: list = []
self.size = 0
self.limit = limit
self.omitted = 0
def add(self, text: str) -> None:
if self.size + len(text) > self.limit:
self.omitted += 1
return
self.parts.append(text)
self.size += len(text) + 1
def render(new_files: list, changes: list) -> str:
fuzzy_total = sum(len(f) for _, f, _ in changes)
untr_total = sum(len(u) for _, _, u in changes)
if not (new_files or fuzzy_total or untr_total):
return ""
body = Body(MAX_BODY)
summary = ["## Summary", ""]
if new_files:
strings = sum(n for _, n in new_files)
summary.append(
f"- **{len(new_files)}** new PO files ({strings} strings to translate)"
)
if fuzzy_total:
n = sum(1 for _, f, _ in changes if f)
summary.append(f"- **{fuzzy_total}** newly fuzzy strings in {n} files")
if untr_total:
n = sum(1 for _, _, u in changes if u)
summary.append(f"- **{untr_total}** new untranslated strings in {n} files")
body.add("\n".join(summary) + "\n")
if new_files:
lines = [f"## New PO files ({len(new_files)})"]
lines += [f"- `{p}` ({n} strings)" for p, n in new_files]
body.add("\n".join(lines) + "\n")
if fuzzy_total:
body.add(
"## Newly fuzzy strings\n\n"
"The source text changed slightly, so the existing translation "
"(`msgstr`) may need updating. `-` is the old source, `+` the new.\n"
)
for path, fuzzy, _ in changes:
if fuzzy:
body.add(fuzzy_block(path, fuzzy))
if untr_total:
body.add(
"## New untranslated strings\n\n"
"Brand-new source strings with no close match to an existing "
"translation.\n"
)
for path, _, untr in changes:
if untr:
body.add(untranslated_block(path, untr))
if body.omitted:
body.parts.append(
f"_{body.omitted} section(s) omitted to fit GitHub's issue size "
f"limit see the sync commit's diff for the full list._\n"
)
return "\n".join(body.parts)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
new_files, changes = collect()
report = render(new_files, changes)
args.output.write_text(report, encoding="utf-8")
print(
f"Report: {len(report)} chars, {len(new_files)} new files, "
f"{len(changes)} changed files with new strings"
)
if __name__ == "__main__":
main()