#!/usr/bin/env python3 """ extract_model_tree.py Scannt den Python-Quellcode ausgewählter Tryton-Module (statisch per AST, keine laufende Instanz nötig) und baut daraus einen Modellbaum: pro Modell (__name__) alle per fields.X(...) definierten Felder, mit Typ, Relationsziel (bei Many2One/One2Many/Many2Many/One2One/Reference) und - falls literal im Code erkennbar - ob das Feld required ist. Modelle werden über PoolMeta-Erweiterungen hinweg zusammengeführt: Fügt z. B. stock_split eine Klasse mit __name__ = 'stock.move' hinzu, landen deren Felder unter demselben Modell-Eintrag, mit Vermerk, aus welchem Modul sie stammen. Zweck hier: BEVOR versucht wird, dieselbe dataset.yaml gegen 5.0 und 8.0 laufen zu lassen, erstmal Schwarz auf Weiß sehen, welche Modelle/Felder in der 5.0-Codebasis (bei den tatsächlich aktiven Modulen) überhaupt existieren - damit weder Modelle/Felder aus 8.0 übernommen werden, die es in 5.0 noch gar nicht gibt (das würde nur Fehler werfen), noch Modelle im Datensatz fehlen, für die aktuell kein *.rst-Testszenario existiert. Nutzung: python3 extract_model_tree.py --repo /mnt/tryton \\ --modules company country currency party product \\ product_measurements production production_split \\ stock stock_lot stock_product_location \\ stock_shipment_measurements stock_split \\ --output reports/model_tree_5.0.json --report reports/model_tree_5.0.md Ohne --modules wird DEFAULT_MODULES (unten) verwendet. WICHTIG: Rein statische Analyse. Felder, die nicht als einfaches "name = fields.X(...)" auf Klassenebene stehen (z. B. per Schleife/ Helper-Funktion dynamisch erzeugt), werden nicht erkannt - kommt in der Praxis selten vor, aber nicht ausgeschlossen. """ import argparse import ast import json import sys from pathlib import Path DEFAULT_REPO = "/mnt/tryton" DEFAULT_MODULES = [ "ir", "res", "company", "country", "currency", "party", "product", "product_measurements", "production", "production_split", "stock", "stock_lot", "stock_lot_sled", "stock_product_location", "stock_shipment_measurements", "stock_split", ] RELATIONAL_TYPES = {"Many2One", "One2Many", "Many2Many", "One2One", "Reference"} def find_name_assignment(class_body): """Sucht `__name__ = '...'` direkt im Klassenkörper.""" for stmt in class_body: if ( isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name) and stmt.targets[0].id == "__name__" and isinstance(stmt.value, ast.Constant) and isinstance(stmt.value.value, str) ): return stmt.value.value return None def fields_call_info(call): """Wenn `call` ein fields.(...)-Aufruf ist, gibt (Typ, Relation, required, is_function, has_setter) zurück, sonst None.""" if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Attribute): return None if not isinstance(call.func.value, ast.Name) or call.func.value.id != "fields": return None field_type = call.func.attr relation = None if field_type in RELATIONAL_TYPES and call.args: first = call.args[0] if isinstance(first, ast.Constant) and isinstance(first.value, str): relation = first.value else: relation = "" is_function = field_type == "Function" has_setter = False if is_function: # fields.Function(fields.Char(...), 'getter', setter='setter_name') if len(call.args) >= 1: inner = fields_call_info(call.args[0]) if inner: field_type = f"Function[{inner[0]}]" has_setter = any(kw.arg == "setter" for kw in call.keywords) required = None for kw in call.keywords: if kw.arg == "required" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, bool): required = kw.value.value return field_type, relation, required, is_function, has_setter def is_persisted_class(node): """True, wenn die Klasse ModelSQL (direkt oder z. B. via Workflow, das selbst von ModelSQL erbt - daher genügt eine Textsuche über alle Basisklassen-Ausdrücke) als Basis hat - Unterscheidungsmerkmal zwischen echten, persistenten Modellen und reinen Wizard-/ Transient-Formularen (die nur ModelView erben).""" for base in node.bases: try: text = ast.unparse(base) except Exception: continue if "ModelSQL" in text or "Workflow" in text or "DeactivableMixin" in text: return True return False def scan_file(path, module_name, module_dir, models, persisted): try: tree = ast.parse(path.read_text(encoding="utf-8")) except (SyntaxError, UnicodeDecodeError) as e: print(f" WARNUNG: {path} übersprungen ({e})", file=sys.stderr) return for node in ast.walk(tree): if not isinstance(node, ast.ClassDef): continue model_name = find_name_assignment(node.body) if not model_name: continue if is_persisted_class(node): persisted.add(model_name) model = models.setdefault(model_name, {}) for stmt in node.body: if not ( isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name) ): continue field_name = stmt.targets[0].id info = fields_call_info(stmt.value) if info is None: continue field_type, relation, required, is_function, has_setter = info model[field_name] = { "type": field_type, "relation": relation, "required": required, "settable": (not is_function) or has_setter, "module": module_name, "file": f"{module_name}/{path.relative_to(module_dir)}", } def module_directory(repo, module_name): """ir/res liegen als Kernbestandteil in trytond/trytond/, nicht unter modules/ wie die regulären (nachinstallierbaren) Module.""" if module_name in ("ir", "res"): return Path(repo) / "trytond" / "trytond" / module_name return Path(repo) / "modules" / module_name def scan_modules(repo, modules): models = {} persisted = set() for module_name in modules: module_dir = module_directory(repo, module_name) if not module_dir.is_dir(): print(f"WARNUNG: Modul '{module_name}' nicht gefunden unter {module_dir}", file=sys.stderr) continue for path in sorted(module_dir.rglob("*.py")): if "tests" in path.relative_to(module_dir).parts: continue scan_file(path, module_name, module_dir, models, persisted) return { model_name: {"persisted": model_name in persisted, "fields": fields} for model_name, fields in models.items() } def write_report(models, out_path, modules): lines = [f"# Modellbaum ({', '.join(modules)})\n"] for model_name in sorted(models): fields = models[model_name]["fields"] if not fields: continue contributing_modules = sorted({f["module"] for f in fields.values()}) transient_note = "" if models[model_name]["persisted"] else " _(Wizard/Transient - nicht persistent)_" lines.append(f"\n## `{model_name}`{transient_note} _(Felder aus: {', '.join(contributing_modules)})_\n") for field_name in sorted(fields): f = fields[field_name] parts = [f["type"]] if f["relation"]: parts.append(f"-> `{f['relation']}`") if f["required"] is True: parts.append("required") if not f["settable"]: parts.append("NICHT setzbar (Function ohne setter)") if f["module"] != model_name.split(".")[0]: parts.append(f"(aus Modul `{f['module']}`)") lines.append(f"- **{field_name}**: {' '.join(parts)}") out_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--repo", default=DEFAULT_REPO, help="Pfad zum Tryton-Repo-Checkout") parser.add_argument("--modules", nargs="+", default=DEFAULT_MODULES, help="Zu scannende Modulnamen") parser.add_argument("--output", default="reports/model_tree.json", help="JSON-Ausgabe (maschinenlesbar)") parser.add_argument("--report", default="reports/model_tree.md", help="Markdown-Report (menschenlesbar)") args = parser.parse_args() models = scan_modules(args.repo, args.modules) Path(args.output).write_text(json.dumps(models, indent=2, ensure_ascii=False, sort_keys=True), encoding="utf-8") write_report(models, Path(args.report), args.modules) total_fields = sum(len(m["fields"]) for m in models.values()) print(f"{len(models)} Modelle, {total_fields} Felder -> {args.output}, {args.report}") if __name__ == "__main__": main()