Skip to content

Commit 716fc2e

Browse files
authored
Update scripts, CI, and README
2 parents 05d7ca0 + 634180f commit 716fc2e

5 files changed

Lines changed: 459 additions & 8 deletions

File tree

.github/workflows/lint.yml

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,30 @@ on:
1414
jobs:
1515
lint:
1616
runs-on: ubuntu-latest
17-
strategy:
18-
fail-fast: false
19-
continue-on-error: true
2017
steps:
2118
- name: Checkout code
2219
uses: actions/checkout@v4
23-
20+
2421
- name: Set up Python
2522
uses: actions/setup-python@v4
2623
with:
2724
python-version: '3.11'
28-
25+
2926
- name: Install sphinx-lint
3027
run: pip install sphinx-lint
31-
28+
29+
- name: Install gettext tools
30+
run: sudo apt-get install -y gettext
31+
3232
- name: Setup problem matcher
3333
uses: rffontenelle/sphinx-lint-problem-matcher@v1.0.0
34-
34+
3535
- name: Run sphinx-lint
3636
run: sphinx-lint
37+
continue-on-error: true
38+
39+
- name: Check PO file validity
40+
run: find . -name '*.po' -exec msgfmt --check {} \;
41+
42+
- name: Check markup preservation
43+
run: python3 scripts/check_markup.py .

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,27 @@
99

1010
## راهنمای مشارکت 🌱
1111

12-
تمام ترجمه‌ها بر روی [Transifex](https://explore.transifex.com/python-doc/python-newest/) انجام می‌شود. فرآیند ترجمه شامل بازبینی و اصلاح متونی است که به صورت خودکار توسط ابزارهای ترجمه ماشینی ترجمه شده‌اند.
12+
ترجمه‌ها دیگر روی Transifex انجام نمی‌شوند و مستقیماً از طریق پول‌ریکوئست در همین ریپازیتوری مدیریت می‌شوند. فایل‌های `.po` هر کدام به بخشی از مستندات پایتون (مثل `tutorial/`، `library/` یا `c-api/`) مربوط‌اند و می‌توانید هرکدام را جداگانه ویرایش و پول‌ریکوئست بزنید.
13+
14+
خلاصه فرایند مشارکت:
15+
16+
1. ریپازیتوری را فورک و کلون کنید.
17+
2. فایل `.po` مورد نظر را با [Poedit](https://poedit.net/) باز کنید (رشته‌های ترجمه‌نشده یا fuzzy را می‌توانید از پنل فیلتر پیدا کنید).
18+
3. متن انگلیسی (`msgid`) را ترجمه کنید و در بخش ترجمه (`msgstr`) وارد کنید.
19+
4. نشانه‌گذاری‌های Sphinx مثل `` :class:`int` ``، `` :func:`repr` ``، `` ``code`` ``، و جای‌گزین‌هایی مثل `%s` یا `{name}` را دقیقاً بدون تغییر نگه دارید؛ فقط متن اطراف آن‌ها ترجمه می‌شود.
20+
5. پیش از ارسال پول‌ریکوئست، بررسی کنید فایل‌ها مشکلی ندارند:
21+
```bash
22+
msgfmt --check your_file.po
23+
python3 scripts/check_markup.py your_file.po
24+
```
25+
6. پول‌ریکوئست را باز کنید — روی هر پول‌ریکوئست به‌صورت خودکار بررسی‌های لازم اجرا می‌شود.
26+
27+
برای دیدن این‌که کدام فایل‌ها هنوز نیاز به ترجمه دارند و چقدر از هرکدام باقی مانده:
28+
```bash
29+
python3 scripts/translation_status.py --only-incomplete
30+
```
31+
32+
راهنمای کامل‌تر مشارکت (شامل نحوه‌ی همگام‌سازی با نسخه‌های جدید پایتون و اسکریپت‌های موجود در `scripts/`) در [RESOURCE.md](RESOURCE.md) موجود است.
1333

1434
## ارتباط و هماهنگی
1535

scripts/check_markup.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
#!/usr/bin/env python3
2+
"""
3+
scripts/check_markup.py
4+
5+
Verify that Sphinx roles, inline literals, and format placeholders in the
6+
English msgid are preserved verbatim in the Persian msgstr. Catches the most
7+
common review slip: translating or mangling `:class:`int``-style markup,
8+
``code`` spans, %s/{0} placeholders, or |substitution| refs.
9+
10+
Usage:
11+
python3 scripts/check_markup.py library/functions.po
12+
python3 scripts/check_markup.py tutorial/*.po
13+
python3 scripts/check_markup.py . # recurse a whole directory
14+
"""
15+
import re
16+
import sys
17+
from pathlib import Path
18+
19+
PATTERNS = [
20+
("sphinx role", re.compile(r":(?:\w+:)?[\w.-]+:`.*?`")),
21+
("literal/code span", re.compile(r"``.*?``")),
22+
("substitution ref", re.compile(r"\|[\w.-]+\|")),
23+
("percent placeholder", re.compile(r"%\(\w+\)[a-zA-Z]|%[a-zA-Z]")),
24+
("brace placeholder", re.compile(r"\{[^{}\s]*\}")),
25+
]
26+
27+
28+
def unescape(raw: str) -> str:
29+
"""Undo PO string escaping (raw includes the surrounding quotes)."""
30+
inner = raw[1:-1]
31+
out = []
32+
i = 0
33+
while i < len(inner):
34+
c = inner[i]
35+
if c == "\\" and i + 1 < len(inner):
36+
nxt = inner[i + 1]
37+
out.append({"n": "\n", "t": "\t", '"': '"', "\\": "\\"}.get(nxt, nxt))
38+
i += 2
39+
else:
40+
out.append(c)
41+
i += 1
42+
return "".join(out)
43+
44+
45+
def parse_po(path: Path):
46+
"""Yield (location, flags, msgid, msgstr) for each entry in a .po file."""
47+
lines = path.read_text(encoding="utf-8").splitlines()
48+
i, n = 0, len(lines)
49+
50+
def read_block(keyword_line):
51+
nonlocal i
52+
parts = [unescape(keyword_line.split(" ", 1)[1].strip())]
53+
i += 1
54+
while i < n and lines[i].strip().startswith('"'):
55+
parts.append(unescape(lines[i].strip()))
56+
i += 1
57+
return "".join(parts)
58+
59+
while i < n:
60+
location, flags = "", []
61+
while i < n and lines[i].startswith("#"):
62+
if lines[i].startswith("#:"):
63+
location = lines[i][2:].strip()
64+
elif lines[i].startswith("#,"):
65+
flags = [f.strip() for f in lines[i][2:].split(",")]
66+
i += 1
67+
if i >= n or not lines[i].startswith("msgid"):
68+
i += 1
69+
continue
70+
71+
msgid = read_block(lines[i])
72+
msgid_plural = read_block(lines[i]) if i < n and lines[i].startswith("msgid_plural") else None
73+
74+
if i < n and lines[i].startswith("msgstr["):
75+
msgstrs = {}
76+
while i < n and lines[i].startswith("msgstr["):
77+
idx = int(lines[i][7:lines[i].index("]")])
78+
msgstrs[idx] = read_block(lines[i])
79+
yield location, flags, msgid, msgstrs.get(0, "")
80+
if msgid_plural is not None and 1 in msgstrs:
81+
yield location, flags, msgid_plural, msgstrs[1]
82+
continue
83+
84+
msgstr = read_block(lines[i]) if i < n and lines[i].startswith("msgstr") else ""
85+
yield location, flags, msgid, msgstr
86+
87+
88+
def check_file(path: Path) -> int:
89+
problems = 0
90+
for location, flags, msgid, msgstr in parse_po(path):
91+
if not msgid or not msgstr:
92+
continue # header entry or still untranslated
93+
for label, pattern in PATTERNS:
94+
expected = pattern.findall(msgid)
95+
if not expected:
96+
continue
97+
missing = [tok for tok in expected if tok not in msgstr]
98+
if missing:
99+
problems += 1
100+
loc = f" ({location})" if location else ""
101+
tag = " [fuzzy]" if "fuzzy" in flags else ""
102+
print(f"{path}{loc}{tag}: missing {label}: {missing}")
103+
print(f" msgid : {msgid[:100]}")
104+
print(f" msgstr: {msgstr[:100]}")
105+
return problems
106+
107+
108+
def main():
109+
if len(sys.argv) < 2:
110+
print(__doc__)
111+
sys.exit(1)
112+
113+
files = []
114+
for arg in sys.argv[1:]:
115+
p = Path(arg)
116+
files.extend(sorted(p.rglob("*.po"))) if p.is_dir() else files.append(p)
117+
118+
total = sum(check_file(f) for f in files)
119+
120+
if total:
121+
print(f"\n{total} markup mismatch(es) found.")
122+
sys.exit(1)
123+
print("No markup mismatches found.")
124+
125+
126+
if __name__ == "__main__":
127+
main()

scripts/translation_status.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
#!/usr/bin/env python3
2+
"""
3+
scripts/translation_status.py
4+
5+
Report translation progress across .po files: how many strings are
6+
translated, fuzzy, or untranslated per file, plus overall totals. Meant to
7+
help a contributor quickly find files that need work.
8+
9+
Usage:
10+
python3 scripts/translation_status.py # whole repo, least-translated first
11+
python3 scripts/translation_status.py tutorial/ # just one directory
12+
python3 scripts/translation_status.py --only-incomplete # hide fully-done files
13+
python3 scripts/translation_status.py --sort name
14+
python3 scripts/translation_status.py --format markdown > STATUS.md
15+
"""
16+
import argparse
17+
import sys
18+
from pathlib import Path
19+
20+
21+
def unescape(raw: str) -> str:
22+
"""Undo PO string escaping (raw includes the surrounding quotes)."""
23+
inner = raw[1:-1]
24+
out = []
25+
i = 0
26+
while i < len(inner):
27+
c = inner[i]
28+
if c == "\\" and i + 1 < len(inner):
29+
nxt = inner[i + 1]
30+
out.append({"n": "\n", "t": "\t", '"': '"', "\\": "\\"}.get(nxt, nxt))
31+
i += 2
32+
else:
33+
out.append(c)
34+
i += 1
35+
return "".join(out)
36+
37+
38+
def parse_po_entries(path: Path):
39+
"""Yield (flags, msgid, msgstrs) for each entry in a .po file."""
40+
lines = path.read_text(encoding="utf-8").splitlines()
41+
i, n = 0, len(lines)
42+
43+
def read_block(keyword_line):
44+
nonlocal i
45+
parts = [unescape(keyword_line.split(" ", 1)[1].strip())]
46+
i += 1
47+
while i < n and lines[i].strip().startswith('"'):
48+
parts.append(unescape(lines[i].strip()))
49+
i += 1
50+
return "".join(parts)
51+
52+
while i < n:
53+
flags = []
54+
while i < n and lines[i].startswith("#"):
55+
if lines[i].startswith("#,"):
56+
flags = [f.strip() for f in lines[i][2:].split(",")]
57+
i += 1
58+
if i >= n or not lines[i].startswith("msgid"):
59+
i += 1
60+
continue
61+
62+
msgid = read_block(lines[i])
63+
if i < n and lines[i].startswith("msgid_plural"):
64+
read_block(lines[i]) # plural source not needed for counting
65+
66+
msgstrs = []
67+
if i < n and lines[i].startswith("msgstr["):
68+
while i < n and lines[i].startswith("msgstr["):
69+
msgstrs.append(read_block(lines[i]))
70+
elif i < n and lines[i].startswith("msgstr"):
71+
msgstrs.append(read_block(lines[i]))
72+
73+
yield flags, msgid, msgstrs
74+
75+
76+
def file_stats(path: Path):
77+
translated = fuzzy = untranslated = 0
78+
for flags, msgid, msgstrs in parse_po_entries(path):
79+
if not msgid:
80+
continue # header entry
81+
if "fuzzy" in flags:
82+
fuzzy += 1
83+
elif any(m.strip() for m in msgstrs):
84+
translated += 1
85+
else:
86+
untranslated += 1
87+
return translated, fuzzy, untranslated
88+
89+
90+
def collect_files(paths):
91+
files = []
92+
for arg in paths:
93+
p = Path(arg)
94+
if p.is_dir():
95+
files.extend(sorted(p.rglob("*.po")))
96+
elif p.suffix == ".po":
97+
files.append(p)
98+
return files
99+
100+
101+
def main():
102+
parser = argparse.ArgumentParser(
103+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
104+
)
105+
parser.add_argument("paths", nargs="*", default=["."],
106+
help="Files or directories to scan (default: whole repo)")
107+
parser.add_argument("--sort", choices=["percent", "untranslated", "name"], default="percent",
108+
help="Sort order (default: percent, least-translated first)")
109+
parser.add_argument("--only-incomplete", action="store_true",
110+
help="Hide files that are already fully translated")
111+
parser.add_argument("--format", choices=["text", "markdown", "csv"], default="text")
112+
args = parser.parse_args()
113+
114+
files = collect_files(args.paths)
115+
if not files:
116+
print("No .po files found.")
117+
sys.exit(1)
118+
119+
rows = []
120+
for f in files:
121+
t, fz, u = file_stats(f)
122+
total = t + fz + u
123+
percent = (t / total * 100) if total else 100.0
124+
rows.append((str(f), t, fz, u, total, percent))
125+
126+
if args.only_incomplete:
127+
rows = [r for r in rows if r[2] > 0 or r[3] > 0]
128+
129+
if args.sort == "percent":
130+
rows.sort(key=lambda r: r[5])
131+
elif args.sort == "untranslated":
132+
rows.sort(key=lambda r: -r[3])
133+
else:
134+
rows.sort(key=lambda r: r[0])
135+
136+
total_t = sum(r[1] for r in rows)
137+
total_fz = sum(r[2] for r in rows)
138+
total_u = sum(r[3] for r in rows)
139+
total_all = total_t + total_fz + total_u
140+
total_percent = (total_t / total_all * 100) if total_all else 100.0
141+
142+
if args.format == "csv":
143+
print("file,translated,fuzzy,untranslated,total,percent")
144+
for path, t, fz, u, total, percent in rows:
145+
print(f"{path},{t},{fz},{u},{total},{percent:.1f}")
146+
print(f"TOTAL,{total_t},{total_fz},{total_u},{total_all},{total_percent:.1f}")
147+
return
148+
149+
if args.format == "markdown":
150+
print("| File | Translated | Fuzzy | Untranslated | % done |")
151+
print("|---|---:|---:|---:|---:|")
152+
for path, t, fz, u, total, percent in rows:
153+
print(f"| `{path}` | {t} | {fz} | {u} | {percent:.1f}% |")
154+
print(f"| **TOTAL** | **{total_t}** | **{total_fz}** | **{total_u}** | **{total_percent:.1f}%** |")
155+
return
156+
157+
name_width = max((len(r[0]) for r in rows), default=4)
158+
header = f"{'File':<{name_width}} {'Translated':>10} {'Fuzzy':>6} {'Untranslated':>12} {'% done':>7}"
159+
print(header)
160+
print("-" * len(header))
161+
for path, t, fz, u, total, percent in rows:
162+
print(f"{path:<{name_width}} {t:>10} {fz:>6} {u:>12} {percent:>6.1f}%")
163+
print("-" * len(header))
164+
print(f"{'TOTAL':<{name_width}} {total_t:>10} {total_fz:>6} {total_u:>12} {total_percent:>6.1f}%")
165+
print(f"\n{len(rows)} file(s) shown. "
166+
f"{sum(1 for r in rows if r[5] < 100)} file(s) not fully translated.")
167+
168+
169+
if __name__ == "__main__":
170+
main()

0 commit comments

Comments
 (0)