#!/usr/bin/env python3 """ model_coverage.py Vergleicht den per extract_model_tree.py ermittelten Modellbaum (was gibt es in der 5.0-Codebasis wirklich an Modellen/Feldern) mit dem aktuellen Stand einer aus *.rst-Szenarien gemergten dataset.yaml (was wird davon schon durch Testdaten abgedeckt). Zeigt: - Modelle, die es in 5.0 gibt, aber für die noch KEIN Record in der dataset.yaml existiert (komplett unabgedeckt). - Pro abgedecktem Modell: setzbare Felder (keine reinen Function-Felder ohne setter), die von keinem Record verwendet werden - required zuerst, da dort eher etwas fehlt, das für's Anlegen nötig wäre. Für die Liste der komplett unabgedeckten Modelle werden per Default drei Kategorien ausgeblendet, die man ohnehin nie händisch mit Testdaten befüllt (jeweils per Flag reaktivierbar): - ir.*/res.*-Kernmodelle (--include-framework) - Wizard-/Transient-Formulare, die nur ModelView statt ModelSQL erben, laut extract_model_tree.py also "persisted": false (--include-transient) - automatisch verwaltete Many2Many-Verknüpfungstabellen, erkennbar am Bindestrich im Modellnamen wie "party.party-party.category" (--include-relations) Mit --diff (Ausgabe von model_diff.py, z. B. model_diff.json aus einem 5.0->8.0-Vergleich) wird zusätzlich pro Modell vermerkt, ob es sich zwischen den Versionen überhaupt geändert hat, und danach sortiert: "geändert"/"entfernt" zuerst (echte Kandidaten für einen Vergleichslauf), "nur ergänzt"/"unverändert" zuletzt (dort ist über den Codevergleich hinaus erstmal nichts Auffälliges zu erwarten). Nutzung: python3 model_coverage.py --model-tree reports/model_tree_5.0.json \\ --dataset reports/5_testdata_merged.yaml --diff reports/model_diff.json \\ --report reports/coverage_5.0.md """ import argparse import json from pathlib import Path try: import yaml except ImportError: yaml = None def load_records(path): text = Path(path).read_text(encoding="utf-8") if yaml is not None: return yaml.safe_load(text)["records"] return json.loads(text)["records"] def used_fields_by_model(records): used = {} for r in records: used.setdefault(r["model"], set()).update(r["fields"].keys()) return used def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--model-tree", required=True, help="JSON von extract_model_tree.py") parser.add_argument("--dataset", required=True, help="Gemergte dataset.yaml/json") parser.add_argument("--report", required=True, help="Ziel-Markdown-Datei") parser.add_argument( "--include-framework", action="store_true", help="ir.*/res.*-Kernmodelle in der Liste unabgedeckter Modelle NICHT herausfiltern", ) parser.add_argument( "--include-transient", action="store_true", help="Wizard-/Transient-Formulare (persisted=false) NICHT herausfiltern", ) parser.add_argument( "--include-relations", action="store_true", help="Many2Many-Verknüpfungstabellen (Bindestrich im Namen) NICHT herausfiltern", ) parser.add_argument( "--diff", default=None, help="Optional: model_diff.json - blendet die Diff-Kategorie ein und " "sortiert 'geändert'/'entfernt' nach oben", ) args = parser.parse_args() tree = json.loads(Path(args.model_tree).read_text(encoding="utf-8")) records = load_records(args.dataset) used = used_fields_by_model(records) diff = json.loads(Path(args.diff).read_text(encoding="utf-8")) if args.diff else {} PRIORITY = {"geändert": 0, "entfernt": 1, "neu": 2, "nur ergänzt": 3, "unverändert": 4} def priority(model_name): if not diff: return 0 # ohne --diff: keine Umsortierung, alphabetische Reihenfolge bleibt category = diff.get(model_name, {}).get("category") return PRIORITY.get(category, 5) def diff_note(model_name): if not diff: return "" category = diff.get(model_name, {}).get("category", "nicht in beiden Bäumen") return f" _[{category}]_" uncovered_models = [] partially_covered = [] skipped = {"framework": 0, "transient": 0, "relation": 0} for model_name in sorted(tree): entry = tree[model_name] fields = entry["fields"] settable = {name for name, info in fields.items() if info["settable"]} if not settable: continue # z.B. reine Log-/History-Modelle ohne setzbare Felder if model_name not in used: if not args.include_framework and ( model_name.startswith("ir.") or model_name.startswith("res.") ): skipped["framework"] += 1 continue if not args.include_transient and not entry["persisted"]: skipped["transient"] += 1 continue if not args.include_relations and "-" in model_name: skipped["relation"] += 1 continue uncovered_models.append(model_name) continue missing = settable - used[model_name] if missing: missing_required = sorted(f for f in missing if fields[f]["required"] is True) missing_optional = sorted(f for f in missing if fields[f]["required"] is not True) partially_covered.append((model_name, missing_required, missing_optional)) uncovered_models.sort(key=priority) partially_covered.sort(key=lambda item: priority(item[0])) lines = ["# Testdaten-Abdeckung gegen den 5.0-Modellbaum\n"] lines.append(f"\n## Komplett unabgedeckte Modelle ({len(uncovered_models)})\n") lines.append("Für diese Modelle gibt es noch gar keinen Record in der dataset.yaml:\n") for m in uncovered_models: lines.append(f"- `{m}`{diff_note(m)}") lines.append(f"\n\n## Teilweise abgedeckte Modelle ({len(partially_covered)})\n") lines.append("Modell hat mind. einen Record, aber nicht alle setzbaren Felder werden benutzt:\n") for model_name, missing_required, missing_optional in partially_covered: lines.append(f"\n### `{model_name}`{diff_note(model_name)}") if missing_required: lines.append(f"- **required, aber ungenutzt:** {', '.join(missing_required)}") if missing_optional: lines.append(f"- optional, ungenutzt: {', '.join(missing_optional)}") Path(args.report).write_text("\n".join(lines) + "\n", encoding="utf-8") skip_parts = [] if skipped["framework"]: skip_parts.append(f"{skipped['framework']} ir.*/res.*") if skipped["transient"]: skip_parts.append(f"{skipped['transient']} Wizard/Transient") if skipped["relation"]: skip_parts.append(f"{skipped['relation']} Verknüpfungstabellen") note = f" ({', '.join(skip_parts)} ausgeblendet)" if skip_parts else "" print( f"{len(uncovered_models)} Modelle komplett unabgedeckt{note}, " f"{len(partially_covered)} teilweise abgedeckt -> {args.report}" ) if __name__ == "__main__": main()