Skip to content

Commit 7ba6f0b

Browse files
committed
Add shared tooling scripts to 3.14 (po_status.py, check_roles.py, pre_push_check.sh, CLAUDE.md)
1 parent 96c5886 commit 7ba6f0b

4 files changed

Lines changed: 535 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Übersetzungsregeln python-docs-de
2+
3+
## Sprache
4+
- Konsequent "Du"-Form
5+
- msgid (Englisch) NIE verändern, nur msgstr bearbeiten
6+
7+
## Sphinx/reST-Syntax
8+
- `:ref:`Anzeigetext <ziel>`` → nur Anzeigetext übersetzen, Ziel bleibt Englisch
9+
- `:class:`/:func:`/:meth:`/:attr:`/:mod:`/:exc:`/:const:`/:keyword:`/:program:`/:pep:`/:kbd:`X`` ohne <...> → X bleibt unverändert
10+
- `~`-Präfix (z.B. `:meth:`~object.__lt__``) → kompletter Ausdruck inkl. ~ unverändert
11+
- `!`-Präfix (z.B. `:mod:`!string``) → nur Verlinkung unterdrückt, Bezeichner unverändert
12+
- Backticks sauber schließen, kein Leerzeichen davor
13+
- `::` am Satzende (leitet Codeblock ein) bleibt stehen
14+
- Zitierte Originaltitel aus externen Standards (z.B. Unicode Standard "Default Case Folding") bleiben unübersetzt in Anführungszeichen
15+
- Platzhalter/Makros (%s, {0}, <TRANSLATION_REPO_>) unverändert
16+
17+
## Code-/REPL-Beispiele
18+
- Reiner Code, Ausgaben, Tracebacks: unverändert
19+
- NUR `#`-Kommentare darin übersetzen
20+
- msgstr immer identisch zu msgid befüllen, NIE leer lassen (auch bei reinem Code)
21+
22+
## Fuzzy-Flags
23+
- Nach Prüfung/Übersetzung entfernen
24+
- Datei-Header-fuzzy (über leerem msgid "") ist reine Altlast, ohne Prüfung löschbar
25+
26+
## Terminologie (unübersetzt lassen)
27+
Dictionary, Tuple, List Comprehension, Sentinel, Lazy Import, Property,
28+
Slice, Type Hints, f-string/f-String, t-string/T-String, Whitespace (Singular),
29+
Subclassing, API, Repository, Wheel
30+
31+
## Terminologie (feste Übersetzung)
32+
- frozen... → unveränderlich (NICHT "eingefroren")
33+
- picklable → picklebar
34+
- I/O (NICHT E/A)
35+
- locale → Ländereinstellung
36+
- presentation type → Darstellungstyp
37+
- String (als Datentyp) → Zeichenkette
38+
- Debug/Conversion/Format specifier → Debug-/Konvertierungs-/Formatbezeichner
39+
- rich comparisons → erweiterte Vergleichsoperationen
40+
- generic over → "Typparameter" bei mehreren festen Parametern (z.B. dict: zwei),
41+
"hinsichtlich des Typs" bei genau einem Parameter (list/set/frozenset/memoryview),
42+
Plural "Typen" bei variabler Anzahl (tuple)
43+
44+
## PO-Datei-Struktur
45+
- Genau ein leerer msgid ""-Header mit charset=UTF-8 pro Datei
46+
- Zeilenlänge ~80 Zeichen (powrap via CI erledigt das automatisch bei Push)
47+
- Vor Push immer lokal prüfen: find . -name "*.po" -not -path "./c-api/*" -exec msgfmt --check {} -o /dev/null \;
48+
49+
## Core-Dateien (Pflicht für Sprachschalter-Aufnahme)
50+
Nur: bugs.po, library/functions.po, tutorial/*.po

check_roles.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
#!/usr/bin/env python3
2+
"""
3+
check_roles.py — Vergleicht Sphinx-Rollen (:ref:, :term:, :class:, :func:, ...)
4+
zwischen msgid und msgstr in .po-Dateien, um Inkonsistenzen zu finden, OHNE
5+
dass ein kompletter Sphinx-Build nötig ist. Erkennt dieselbe Fehlerklasse wie
6+
Sphinxs 'i18n.inconsistent_references', plus zusätzliche Muster
7+
(fehlende/überzählige Rollen, falsche Ziele).
8+
"""
9+
import re
10+
import sys
11+
from pathlib import Path
12+
13+
ROLE_RE = re.compile(r":([a-zA-Z][\w-]*):`([^`]+)`")
14+
15+
16+
def normalize_target(content: str) -> str:
17+
content = content.strip()
18+
m = re.match(r"^(.*)<([^<>]+)>$", content)
19+
if m:
20+
return m.group(2).strip()
21+
return content
22+
23+
24+
ROLES_WITHOUT_FIXED_TARGET = {"dfn"}
25+
26+
27+
def extract_roles(text: str) -> list[tuple[str, str]]:
28+
roles = []
29+
for role, content in ROLE_RE.findall(text):
30+
if role in ROLES_WITHOUT_FIXED_TARGET:
31+
continue
32+
roles.append((role, normalize_target(content)))
33+
return sorted(roles)
34+
35+
36+
def parse_po_entries(path: Path):
37+
lines = path.read_text(encoding="utf-8", errors="replace").split("\n")
38+
i = 0
39+
entries = []
40+
while i < len(lines):
41+
line = lines[i]
42+
if line.startswith("#~"):
43+
i += 1
44+
continue
45+
is_fuzzy = False
46+
while i < len(lines) and lines[i].startswith("#"):
47+
if ", fuzzy" in lines[i]:
48+
is_fuzzy = True
49+
i += 1
50+
if i >= len(lines) or not lines[i].startswith("msgid"):
51+
i += 1
52+
continue
53+
msgid_lineno = i + 1
54+
msgid_parts = []
55+
m = re.match(r'msgid\s+"(.*)"$', lines[i])
56+
if m:
57+
msgid_parts.append(m.group(1))
58+
i += 1
59+
while i < len(lines) and lines[i].startswith('"'):
60+
msgid_parts.append(lines[i].strip()[1:-1])
61+
i += 1
62+
if i >= len(lines) or not lines[i].startswith("msgstr"):
63+
continue
64+
msgstr_parts = []
65+
m = re.match(r'msgstr\s+"(.*)"$', lines[i])
66+
if m:
67+
msgstr_parts.append(m.group(1))
68+
i += 1
69+
while i < len(lines) and lines[i].startswith('"'):
70+
msgstr_parts.append(lines[i].strip()[1:-1])
71+
i += 1
72+
msgid = "".join(msgid_parts)
73+
msgstr = "".join(msgstr_parts)
74+
entries.append((msgid_lineno, msgid, msgstr, is_fuzzy))
75+
return entries
76+
77+
78+
def check_file(path: Path) -> list[str]:
79+
findings = []
80+
try:
81+
entries = parse_po_entries(path)
82+
except Exception as e:
83+
return [f" [Parse-Fehler] {e}"]
84+
85+
for lineno, msgid, msgstr, is_fuzzy in entries:
86+
if is_fuzzy or not msgstr or not msgid:
87+
continue
88+
orig_roles = extract_roles(msgid)
89+
trans_roles = extract_roles(msgstr)
90+
if orig_roles != trans_roles:
91+
missing = [r for r in orig_roles if r not in trans_roles]
92+
extra = [r for r in trans_roles if r not in orig_roles]
93+
parts = []
94+
if missing:
95+
parts.append(f"fehlt: {missing}")
96+
if extra:
97+
parts.append(f"zusätzlich/falsch: {extra}")
98+
findings.append(f" Zeile ~{lineno}: {', '.join(parts)}")
99+
return findings
100+
101+
102+
def main():
103+
if len(sys.argv) < 2:
104+
print("Nutzung: check_roles.py <verzeichnis-oder-datei> [--exclude dir1,dir2]")
105+
sys.exit(1)
106+
107+
root = Path(sys.argv[1])
108+
excludes = set()
109+
if "--exclude" in sys.argv:
110+
idx = sys.argv.index("--exclude")
111+
excludes = set(sys.argv[idx + 1].split(","))
112+
113+
if root.is_file():
114+
po_files = [root]
115+
else:
116+
po_files = sorted(
117+
p for p in root.rglob("*.po")
118+
if not excludes.intersection(p.relative_to(root).parts)
119+
)
120+
121+
total_findings = 0
122+
files_with_findings = 0
123+
for po_file in po_files:
124+
findings = check_file(po_file)
125+
if findings:
126+
files_with_findings += 1
127+
total_findings += len(findings)
128+
rel = po_file.relative_to(root) if root.is_dir() else po_file
129+
print(f"\n{rel}")
130+
for f in findings:
131+
print(f)
132+
133+
print(f"\n{'=' * 60}")
134+
print(f"{total_findings} mögliche Rollen-Inkonsistenz(en) in {files_with_findings} "
135+
f"von {len(po_files)} Dateien gefunden.")
136+
print("Hinweis: Das ist eine Heuristik (Textvergleich, kein echter Sphinx-Build) "
137+
"-> bitte jeden Fund manuell gegenprüfen, es können auch Fehlalarme dabei sein.")
138+
sys.exit(1 if total_findings else 0)
139+
140+
141+
if __name__ == "__main__":
142+
main()

po_status.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/usr/bin/env python3
2+
"""
3+
po_status.py — Listet alle .po-Dateien in einem Verzeichnisbaum mit
4+
Übersetzungsstand (x/y), Prozent, Dateigröße und Pfad und Status Gesamtübersetzung.
5+
"""
6+
import argparse
7+
import re
8+
import subprocess
9+
import sys
10+
from pathlib import Path
11+
12+
STATS_RE = re.compile(
13+
r"(?:(\d+) translated messages?)?"
14+
r"(?:, (\d+) fuzzy translations?)?"
15+
r"(?:, (\d+) untranslated messages?)?"
16+
)
17+
18+
19+
def get_stats(po_file: Path):
20+
"""Ruft msgfmt --statistics auf und parst das Ergebnis."""
21+
try:
22+
result = subprocess.run(
23+
["msgfmt", "--statistics", "-o", "/dev/null", str(po_file)],
24+
capture_output=True, text=True, timeout=15,
25+
)
26+
except FileNotFoundError:
27+
sys.exit("Fehler: 'msgfmt' wurde nicht gefunden. Ist gettext installiert "
28+
"(z.B. 'brew install gettext' + PATH-Anpassung)?")
29+
30+
output = (result.stderr or "") + (result.stdout or "")
31+
if "error" in output.lower() and "translated" not in output.lower():
32+
return None # Syntaxfehler in der Datei
33+
34+
m = STATS_RE.search(output)
35+
if not m:
36+
return {"translated": 0, "fuzzy": 0, "untranslated": 0}
37+
translated, fuzzy, untranslated = (int(x) if x else 0 for x in m.groups())
38+
return {"translated": translated, "fuzzy": fuzzy, "untranslated": untranslated}
39+
40+
41+
def human_size(num_bytes: int) -> str:
42+
for unit in ("B", "KB", "MB"):
43+
if num_bytes < 1024:
44+
return f"{num_bytes:.0f} {unit}" if unit == "B" else f"{num_bytes:.1f} {unit}"
45+
num_bytes /= 1024
46+
return f"{num_bytes:.1f} GB"
47+
48+
49+
def main():
50+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
51+
parser.add_argument("path", nargs="?", default=".", help="Wurzelverzeichnis (Standard: .)")
52+
parser.add_argument("--exclude", default="c-api,.venv,.git",
53+
help="Kommagetrennte Ordnernamen, die übersprungen werden")
54+
parser.add_argument("--sort", choices=["path", "percent", "size"], default="percent")
55+
parser.add_argument("--max-percent", type=float, default=100.1,
56+
help="Nur Dateien mit weniger als diesem Prozentwert anzeigen")
57+
args = parser.parse_args()
58+
59+
root = Path(args.path).resolve()
60+
excludes = {e.strip() for e in args.exclude.split(",") if e.strip()}
61+
62+
po_files = sorted(
63+
p for p in root.rglob("*.po")
64+
if not excludes.intersection(p.relative_to(root).parts)
65+
)
66+
67+
if not po_files:
68+
print(f"Keine .po-Dateien unter {root} gefunden.")
69+
return
70+
71+
rows = []
72+
for po_file in po_files:
73+
stats = get_stats(po_file)
74+
rel_path = po_file.relative_to(root)
75+
size = po_file.stat().st_size
76+
if stats is None:
77+
rows.append((str(rel_path), "FEHLER (Syntax)", -1.0, size))
78+
continue
79+
total = stats["translated"] + stats["fuzzy"] + stats["untranslated"]
80+
pct = (stats["translated"] / total * 100) if total else 100.0
81+
label = f"{stats['translated']}/{total}"
82+
if stats["fuzzy"]:
83+
label += f" ({stats['fuzzy']} fuzzy)"
84+
rows.append((str(rel_path), label, pct, size))
85+
86+
# Gesamtstatistik wird VOR dem --max-percent-Filter berechnet (über alle Dateien)
87+
complete_count = sum(1 for r in rows if r[2] >= 100.0)
88+
error_count = sum(1 for r in rows if r[2] < 0)
89+
total_translated = sum(int(r[1].split("/")[0]) for r in rows if r[2] >= 0)
90+
total_strings = sum(int(r[1].split("/")[1].split(" ")[0]) for r in rows if r[2] >= 0)
91+
overall_pct = (total_translated / total_strings * 100) if total_strings else 0.0
92+
93+
rows = [r for r in rows if r[2] < args.max_percent or r[2] < 0]
94+
95+
if not rows:
96+
print(f"Alle {len(po_files)} Dateien liegen bei/über --max-percent {args.max_percent} "
97+
f"— nichts anzuzeigen.")
98+
return
99+
100+
if args.sort == "percent":
101+
rows.sort(key=lambda r: r[2], reverse=True) # 100% -> 0%
102+
elif args.sort == "size":
103+
rows.sort(key=lambda r: -r[3])
104+
105+
path_w = max(len(r[0]) for r in rows) + 2
106+
label_w = max(len(r[1]) for r in rows) + 2
107+
108+
print(f"{'Pfad':<{path_w}}{'Übersetzt':<{label_w}}{'%':>7} {'Größe':>8}")
109+
print("-" * (path_w + label_w + 20))
110+
for rel_path, label, pct, size in rows:
111+
pct_str = " n/a" if pct < 0 else f"{pct:6.1f}%"
112+
print(f"{rel_path:<{path_w}}{label:<{label_w}}{pct_str:>7} {human_size(size):>8}")
113+
114+
print("-" * (path_w + label_w + 20))
115+
print(f"{len(rows)} von {len(po_files)} Dateien angezeigt "
116+
f"(Filter: --max-percent {args.max_percent})")
117+
print()
118+
print(f"Vollständig übersetzt (100%): {complete_count} von {len(po_files)} Dateien")
119+
if error_count:
120+
print(f"Dateien mit Syntaxfehler: {error_count}")
121+
print(f"Gesamtstand über alle Dateien: {total_translated}/{total_strings} Strings "
122+
f"({overall_pct:.2f}%)")
123+
124+
125+
if __name__ == "__main__":
126+
main()

0 commit comments

Comments
 (0)