Skip to content

Commit 47b5dd1

Browse files
committed
Add Markdown review reports for local PO changes
1 parent c22157c commit 47b5dd1

4 files changed

Lines changed: 449 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,39 @@ Repository to store PO Files
33
https://www.python.org/dev/peps/pep-0545/#repository-for-po-files
44

55
Please see [CONTRIBUTING.md](.github/CONTRIBUTING.md)
6+
7+
## ローカルの翻訳差分をレビューする
8+
9+
`scripts/po_review.py` は PO の差分を原文・変更前・変更後を並べた
10+
Markdown に変換します。PO ファイルや Git のインデックスは変更しません。
11+
実行には Python と `polib` が必要です(`python -m pip install polib`、
12+
または既存の `requirements.txt` でインストールできます)。
13+
14+
```sh
15+
# git diff と同じ、未ステージの変更
16+
python scripts/po_review.py -o translation-review.md
17+
18+
# ステージ済み・未ステージの変更をまとめて確認
19+
python scripts/po_review.py --base HEAD -o translation-review.md
20+
21+
# git diff --cached と同じ、ステージ済みの変更
22+
python scripts/po_review.py --cached -o translation-review.md
23+
24+
# 対象を絞る(パスはリポジトリルートから指定)
25+
python scripts/po_review.py --base HEAD builtins/functions.po
26+
```
27+
28+
`-o` を省略すると標準出力に書き出します。生成した Markdown を開くか、
29+
PR の説明に貼り付けてレビューできます。PR に貼り付ける場合は、
30+
リポジトリルートに出力した資料の相対 PO リンクを適切な GitHub URL に
31+
置き換えてください。PO リンクの行番号は比較対象の版を指します。
32+
33+
複数形、`msgctxt`、`fuzzy` の変更、翻訳の削除も表示します。
34+
PO の折り返し・ヘッダー・コメント・参照行番号だけの変更は省略します。
35+
ファイル全体の削除は一覧にまとめます。未追跡ファイルは `git diff` と同様に
36+
含めません。原文の変更は別エントリーの追加・削除として扱い、
37+
`#~` の obsolete エントリーは翻訳対象から除外します。
38+
39+
レポートは生成物なのでコミットする必要はありません。
40+
`-o` では既存の Git 管理対象ファイルへの上書きを拒否します。
41+
Transifex への送信・取得は行いません。

‎requirements.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ git+https://github.com/sphinx-doc/sphinx-intl@6b3913fea5a8c9b819818b9b28b6d0d0b6
33
powrap
44
pospell
55
pomerge
6+
polib>=1.2.0

‎scripts/po_review.py‎

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
"""Render local PO translation changes as a Markdown review report."""
2+
3+
import argparse
4+
import hashlib
5+
import json
6+
import os
7+
from pathlib import Path
8+
import re
9+
import subprocess
10+
import sys
11+
from urllib.parse import quote
12+
13+
import polib
14+
15+
16+
def git(root, *args):
17+
return subprocess.run(
18+
["git", "-C", str(root), *args],
19+
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
20+
).stdout
21+
22+
23+
def entries(data):
24+
if data is None:
25+
return {}
26+
encoding = polib.detect_encoding(data)
27+
catalog = polib.pofile(data.decode(encoding), encoding=encoding)
28+
result = {}
29+
for entry in catalog:
30+
if entry.obsolete or not entry.msgid:
31+
continue
32+
key = (entry.msgctxt, entry.msgid)
33+
if key in result:
34+
raise ValueError(f"Duplicate PO entry: {key!r}")
35+
result[key] = entry
36+
return result
37+
38+
39+
def signature(entry):
40+
if entry is None:
41+
return None
42+
return (
43+
entry.msgid_plural, entry.msgstr, entry.msgstr_plural,
44+
"fuzzy" in entry.flags,
45+
)
46+
47+
48+
def changes(before, after):
49+
# Keep document order; entries removed from a catalog follow surviving ones.
50+
for key in dict.fromkeys([*after, *before]):
51+
old, new = before.get(key), after.get(key)
52+
if signature(old) != signature(new):
53+
yield old, new
54+
55+
56+
def escape(text):
57+
# Ordinary punctuation such as dots, hyphens and parentheses needs no escape.
58+
return re.sub(r"([\\`*_\[\]<>#|~])", r"\\\1", text.replace("&", "&amp;"))
59+
60+
61+
INLINE_RST = re.compile(
62+
r"(?<![\\`])(?::[\w.-]+(?::[\w.-]+)*:)?"
63+
r"(?P<ticks>`{1,2})(?!`)(?:\\.|[^`\\\n])+(?P=ticks)(?!`)"
64+
)
65+
66+
67+
def inline_text(text):
68+
# Preserve the whole reST token in a Markdown code span, including its
69+
# role and backticks. This avoids backslash noise in the Markdown source.
70+
parts = []
71+
start = 0
72+
for match in INLINE_RST.finditer(text):
73+
parts.append(escape(text[start:match.start()]))
74+
token = match.group()
75+
fence = "`" * (1 + max(len(run) for run in re.findall(r"`+", token)))
76+
parts.append(f"{fence} {token} {fence}")
77+
start = match.end()
78+
parts.append(escape(text[start:]))
79+
result = "".join(parts)
80+
# Prevent prose lines from becoming Markdown lists or thematic breaks.
81+
return re.sub(r"^(\s*)([-+]|\d+[.)])(?=\s|$)",
82+
lambda match: match[1] + match[2][:-1] + "\\" + match[2][-1], result)
83+
84+
85+
def paragraph(text):
86+
return "\n".join(
87+
"> " + inline_text(line) + (" " if line else "")
88+
for line in text.split("\n")
89+
)
90+
91+
92+
def field(label, text):
93+
# Keep indentation and alignment in code examples, while allowing prose
94+
# to wrap naturally in the Markdown viewer.
95+
if text.startswith((">>> ", ".. code-block::", ".. code::")):
96+
fence = "`" * max(3, 1 + max((len(run) for run in re.findall(r"`+", text)), default=0))
97+
return f"**{label}**\n\n{fence}text\n{text}\n{fence}\n"
98+
return f"**{label}**\n\n{paragraph(text)}\n"
99+
100+
101+
def translation(entry):
102+
if entry is None:
103+
return "(エントリーなし)"
104+
if entry.msgid_plural:
105+
return "\n\n".join(
106+
f"msgstr[{index}]: {text or '(未翻訳)'}"
107+
for index, text in sorted(entry.msgstr_plural.items())
108+
) or "(未翻訳)"
109+
return entry.msgstr or "(未翻訳)"
110+
111+
112+
def status(entry):
113+
if entry is None:
114+
return "なし"
115+
if "fuzzy" in entry.flags:
116+
return "fuzzy(要確認)"
117+
return "翻訳済み" if entry.translated() else "未翻訳"
118+
119+
120+
def render_entry(path, old, new, number, link):
121+
entry = new if new is not None else old
122+
identity = json.dumps([path, entry.msgctxt, entry.msgid], ensure_ascii=False)
123+
identifier = hashlib.sha256(identity.encode()).hexdigest()[:12]
124+
kind = "追加" if old is None else "削除" if new is None else "変更"
125+
lines = [f"### {number}. {kind} · {identifier}", ""]
126+
if new is not None:
127+
lines.extend([f"[PO の該当箇所]({link}#L{new.linenum})", ""])
128+
if entry.occurrences:
129+
locations = ", ".join(
130+
f"{name}:{line}" if line else name for name, line in entry.occurrences
131+
)
132+
lines.extend(["原文の位置: " + escape(locations), ""])
133+
if entry.msgctxt is not None:
134+
lines.append(field("文脈(msgctxt)", entry.msgctxt))
135+
lines.append(field("原文", entry.msgid))
136+
if entry.msgid_plural:
137+
lines.append(field("原文(複数形)", entry.msgid_plural))
138+
if old is not None and old.msgid_plural != entry.msgid_plural:
139+
lines.append(field("変更前の原文(複数形)", old.msgid_plural or "(なし)"))
140+
lines.append(field("変更前", translation(old)))
141+
lines.append(field("変更後", translation(new)))
142+
lines.extend([f"状態: {status(old)} → {status(new)}", ""])
143+
if entry.tcomment:
144+
lines.append(field("翻訳者コメント", entry.tcomment))
145+
if entry.comment:
146+
lines.append(field("抽出コメント", entry.comment))
147+
return "\n".join(lines)
148+
149+
150+
def report(root, *, cached=False, base=None, paths=(), output_dir=None):
151+
diff_args = ["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z"]
152+
revision = None
153+
if cached:
154+
diff_args.append("--cached")
155+
comparison = "HEAD → ステージ済み(git diff --cached)"
156+
elif base:
157+
revision = git(root, "rev-parse", "--verify", "--end-of-options", base + "^{commit}").decode().strip()
158+
diff_args.append(revision)
159+
comparison = f"{base} ({revision[:12]}) → 作業ツリー"
160+
else:
161+
comparison = "インデックス → 作業ツリー(git diff)"
162+
diff_args.extend(["--", *(paths or ("*.po",))])
163+
records = git(root, *diff_args).split(b"\0")
164+
sections, deleted = [], []
165+
count = 0
166+
for offset in range(0, len(records) - 1, 2):
167+
change = records[offset].decode()
168+
path = os.fsdecode(records[offset + 1])
169+
if not path.endswith(".po"):
170+
continue
171+
if change == "D":
172+
deleted.append(path)
173+
continue
174+
if change not in ("A", "M"):
175+
raise ValueError(f"Unsupported file change {change}: {path}")
176+
old_ref = "HEAD" if cached else revision or ""
177+
before = entries(None if change == "A" else git(root, "show", f"{old_ref}:{path}"))
178+
after = entries(git(root, "show", f":{path}") if cached else (root / path).read_bytes())
179+
modified = list(changes(before, after))
180+
if not modified:
181+
continue
182+
link = quote(Path(os.path.relpath(root / path, output_dir or root)).as_posix(), safe="/")
183+
section = ["## " + escape(path), ""]
184+
for old, new in modified:
185+
count += 1
186+
section.append(render_entry(path, old, new, count, link))
187+
sections.append("\n".join(section))
188+
header = [
189+
"# 翻訳レビュー", "", "比較: " + escape(comparison), "",
190+
f"翻訳・状態の変更: {count} エントリー / {len(sections)} ファイル。"
191+
f"ファイル全体の削除: {len(deleted)} 件。", "",
192+
"PO ヘッダー、折り返し、参照行番号だけの変更は省略しています。"
193+
"原文が変わったエントリーは追加・削除として表示します。", "",
194+
]
195+
if not count:
196+
header.extend(["翻訳・状態の変更はありません。", ""])
197+
if deleted:
198+
sections.append("## 削除された PO ファイル\n\n" + "\n".join(
199+
"- " + escape(path) for path in deleted
200+
) + "\n")
201+
return "\n".join([*header, *sections]).rstrip() + "\n"
202+
203+
204+
def main():
205+
parser = argparse.ArgumentParser(description=__doc__)
206+
selection = parser.add_mutually_exclusive_group()
207+
selection.add_argument("--cached", action="store_true", help="compare HEAD to the index")
208+
selection.add_argument("--base", metavar="REV", help="compare REV to the working tree, including staged changes")
209+
parser.add_argument("-o", "--output", type=Path, help="write UTF-8 Markdown to this file (default: stdout)")
210+
parser.add_argument("paths", nargs="*", help="Git pathspecs relative to the repository root (default: all PO files)")
211+
args = parser.parse_args()
212+
try:
213+
root = Path(os.fsdecode(git(Path.cwd(), "rev-parse", "--show-toplevel")).rstrip("\n"))
214+
output = args.output.resolve() if args.output else None
215+
# A report must never overwrite a translation or another tracked file.
216+
if output:
217+
if output.suffix.lower() != ".md":
218+
raise ValueError("Output must have a .md extension")
219+
tracked = git(root, "ls-files", "-z").split(b"\0")
220+
if any((root / os.fsdecode(path)).resolve() == output for path in tracked if path):
221+
raise ValueError(f"Output is a tracked file: {output}")
222+
markdown = report(root, cached=args.cached, base=args.base, paths=args.paths,
223+
output_dir=output.parent if output else root)
224+
if output:
225+
output.write_text(markdown, encoding="utf-8")
226+
else:
227+
sys.stdout.write(markdown)
228+
except subprocess.CalledProcessError as error:
229+
parser.exit(1, error.stderr.decode(errors="replace"))
230+
except (OSError, ValueError) as error:
231+
parser.exit(1, f"{error}\n")
232+
233+
234+
if __name__ == "__main__":
235+
main()

0 commit comments

Comments
 (0)