#!/usr/bin/env python3 """Findet deutschen Text in .py-Dateien, der offenbar ohne Umlaute/ß geschrieben wurde. Hintergrund: Plugin_SN_Basis #42. KI-generierter Text weicht gelegentlich auf eine ASCII-Ersatzschreibweise aus ("gueltig" statt "gültig", "ueber" statt "über") statt echte Umlaute zu verwenden. Das betrifft vor allem Meldungstexte (MessageBox, zeige_hinweis, QLabel) und Docstrings/Kommentare. Technische Bezeichner (Variablennamen, "aktion"-Codes, WFS-Feldnamen, Projektvariablen-Keys) sind davon ausdrücklich nicht betroffen: Sie sind bewusst ASCII und enthalten kein Leerzeichen, während geschriebener deutscher Text so gut wie immer eines enthält. Deshalb prüft dieses Skript nur String-Inhalte mit mindestens einem Leerzeichen sowie Kommentartext - keine Bezeichner. Kein Ersatz fürs Korrekturlesen: Die Liste verdächtiger Wortstämme ist bewusst kurz gehalten (nur eindeutige Fälle), um keine Fehlalarme zu erzeugen. Neue, hier nicht erfasste Wortstämme ergänzen. Aufrufe (aus dem Workspace-Root): python scripts/pruefe_umlaute.py # alle Repos aus workspace.toml mit Paketnamen python scripts/pruefe_umlaute.py Basis # nur ein Repo (Kurzname genügt) Exit-Code: 0 = keine Funde, 1 = mindestens ein Fund. """ from __future__ import annotations import argparse import re import sys import tomllib from pathlib import Path ROOT = Path(__file__).resolve().parents[1] MANIFEST = ROOT / "workspace.toml" # Nur eindeutige Fälle: Wortstämme, die als ASCII-Ersatzschreibweise so gut wie nie # etwas anderes als ein fehlender Umlaut/ß sein können. Absichtlich kein "ss" für # "ß" (zu viele legitime Doppel-S-Wörter) und kein "ae/oe/ue" pauschal (zu viele # Fehlalarme in technischen Bezeichnern und Fremdwörtern). VERDAECHTIGE_STAEMME = [ r"gueltig\w*", r"ungueltig\w*", r"verfuegbar\w*", r"ausgewaehlt\w*", r"gewaehlt\w*", r"waehlen\b", r"auswaehlen\b", r"loeschen\b", r"loescht\w*", r"groesse\w*", r"zuruecksetzen\b", r"erlaeuter\w*", r"bestaetig\w*", r"natuerlich\w*", r"moeglich\w*", r"zusaetzlich\w*", r"abhaengig\w*", r"unzulaessig\w*", r"ueberpruef\w*", r"vollstaendig\w*", r"geaendert\w*", r"aenderung\w*", r"einfuegen\b", r"ausfuehr\w*", r"benoetig\w*", r"gewuenscht\w*", r"ueber\w*", r"fuer\b", r"hoehe\w*", r"waere\b", ] _STAMM_PATTERN = re.compile( r"\b(?:" + "|".join(VERDAECHTIGE_STAEMME) + r")\b", re.IGNORECASE ) # Erlaubt genau diese exakten Fundstellen (Datei, Zeile) - für Ausnahmefälle, die # geprüft und bewusst akzeptiert wurden. Aktuell leer: siehe Docstring, warum # technische Bezeichner die Heuristik ohnehin nicht erreichen. ALLOWLIST: set[tuple[str, int]] = set() _STRING_PATTERN = re.compile( r'"""(?P(?:.|\n)*?)"""' r"|'''(?P(?:.|\n)*?)'''" r'|"(?P(?:[^"\\\n]|\\.)*)"' r"|'(?P(?:[^'\\\n]|\\.)*)'" ) _KOMMENTAR_PATTERN = re.compile(r"#(?P.*)$", re.MULTILINE) def _kandidaten(quelltext: str): """Liefert (start_offset, text) für String-Inhalte und Kommentare mit Leerzeichen.""" for m in _STRING_PATTERN.finditer(quelltext): text = next(g for g in m.groups() if g is not None) if " " in text: yield m.start(), text for m in _KOMMENTAR_PATTERN.finditer(quelltext): text = m.group("text") if " " in text: yield m.start("text"), text def pruefe_datei(pfad: Path) -> list[tuple[int, str, str]]: """Liefert Funde als (Zeilennummer, Wortstamm, Kontext) für eine Datei. Ein Kandidat (String-Inhalt oder Kommentar) kann mehrzeilig sein (Docstrings); deshalb wird für jeden Treffer einzeln die Zeile anhand seiner eigenen Position berechnet, nicht anhand des Kandidaten-Anfangs. """ quelltext = pfad.read_text(encoding="utf-8", errors="replace") funde = [] for offset, text in _kandidaten(quelltext): for treffer in _STAMM_PATTERN.finditer(text): zeile = quelltext.count("\n", 0, offset + treffer.start()) + 1 zeilen_text = text.splitlines()[treffer.string.count("\n", 0, treffer.start())] \ if "\n" in text else text kontext = zeilen_text.strip() if len(kontext) > 90: kontext = kontext[:87] + "..." funde.append((zeile, treffer.group(0), kontext)) return funde def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("repos", nargs="*", help="Kurzname(n) aus workspace.toml (Standard: alle mit Paketnamen)") args = ap.parse_args() with MANIFEST.open("rb") as f: manifest = tomllib.load(f) alle_repos = [r for r in manifest["repos"] if r.get("paket")] if args.repos: gesucht = {r.lower() for r in args.repos} repos = [ r for r in alle_repos if r["name"].lower() in gesucht or r["name"].lower().replace("plugin_sn_", "") in gesucht or r["paket"].lower() in gesucht ] unbekannt = gesucht - { n for r in repos for n in (r["name"].lower(), r["name"].lower().replace("plugin_sn_", ""), r["paket"].lower()) } if unbekannt: print(f"Unbekannte Repo-Angabe: {', '.join(sorted(unbekannt))}", file=sys.stderr) return 2 else: repos = alle_repos gesamt_funde = 0 for repo in repos: basis = ROOT / repo["name"] if not basis.is_dir(): print(f"[{repo['paket']}] Ordner fehlt ({basis}) - übersprungen") continue repo_funde = 0 for py_datei in sorted(basis.rglob("*.py")): if any(teil in (".git", "__pycache__") for teil in py_datei.parts): continue relativ = py_datei.relative_to(ROOT).as_posix() for zeile, stamm, kontext in pruefe_datei(py_datei): if (relativ, zeile) in ALLOWLIST: continue print(f"{relativ}:{zeile}: '{stamm}' in: {kontext}") repo_funde += 1 status = "GRÜN" if repo_funde == 0 else "ROT" print(f"[{repo['paket']}] {status}: {repo_funde} Fund/e") gesamt_funde += repo_funde print(f"\nGesamt: {gesamt_funde} Fund/e in {len(repos)} Repo(s).") return 1 if gesamt_funde else 0 if __name__ == "__main__": raise SystemExit(main())