#!/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. Zusätzlich wird ganz normale Python-Vererbung innerhalb eines Moduls verfolgt: erbt eine Klasse mit eigenem __name__ von einer anderen lokalen Klasse (z. B. `class BOMOutput(BOMInput): __name__ = 'production.bom.output'` in production/bom.py, die selbst keine einzige fields.X(...)-Zeile deklariert, sondern alle Felder von BOMInput übernimmt), werden die geerbten Felder mit aufgenommen. Ohne das sähe ein rein über Vererbung wiederverwendetes Modell wie ein Modell OHNE Felder aus - und Änderungen daran (z. B. eine Feldumbenennung im Elternteil) blieben unsichtbar. Löst nur Basisklassen auf, die im selben Modul per einfachem Namen (kein Attribut-Zugriff auf ein anderes Modul) gefunden werden - externe Basen wie ModelSQL/ModelView/Workflow tragen ohnehin keine fields.X(...) bei. 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 own_fields_of(node, module_name, module_dir, path): """Nur die in DIESEM Klassenkörper direkt deklarierten fields.X(...).""" fields = {} for stmt in node.body: if not ( isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name) ): continue info = fields_call_info(stmt.value) if info is None: continue field_type, relation, required, is_function, has_setter = info fields[stmt.targets[0].id] = { "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)}", } return fields def base_class_names(node): """Best-effort Namen der Basisklassen als String, nur einfache `class X(Y):`-Fälle (kein Attribut-Zugriff wie `other.Y`, das wäre ohnehin eine externe/andere Modul-Klasse, die wir hier nicht kennen).""" return [base.id for base in node.bases if isinstance(base, ast.Name)] def collect_classes(path, module_name, module_dir, registry): """Erste Durchsicht: registriert jede Klasse (lokaler Python-Name, Basisklassen, eigene Felder, ggf. Tryton-__name__, ggf. direkt persistent) - unabhängig davon, ob/wie sie später gebraucht wird. Die eigentliche Feld-/Persisted-Auflösung inkl. Vererbung passiert danach in resolve_class_fields()/resolve_class_persisted(). Registry-Key ist (module_name, Dateipfad), NICHT nur module_name: derselbe lokale Klassenname (z. B. "Cron") kommt innerhalb eines Moduls in mehreren Dateien für kompott unterschiedliche Klassen vor (Tryton-Konvention: eine PoolMeta-Erweiterung heißt oft genauso wie das Original) - ein modulweites Registry würde sich da gegenseitig überschreiben. Basisklassen-Auflösung bleibt dadurch bewusst auf "gleiche Datei" beschränkt, was dem tatsächlichen Python-Scoping in der weit überwiegenden Mehrheit der Fälle entspricht.""" 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 file_key = (module_name, str(path)) for node in ast.walk(tree): if not isinstance(node, ast.ClassDef): continue registry.setdefault(file_key, {})[node.name] = { "bases": base_class_names(node), "own_fields": own_fields_of(node, module_name, module_dir, path), "model_name": find_name_assignment(node.body), "persisted_here": is_persisted_class(node), } def resolve_class_fields(scope_key, class_name, registry, cache, visiting): """Felder einer lokalen Klasse inkl. per Basisklasse(n) in derselben Datei (scope_key = (module_name, Dateipfad)) geerbter Felder (eigene Felder überschreiben geerbte bei Namensgleichheit, wie bei echter Python-Attributauflösung).""" key = (scope_key, class_name) if key in cache: return cache[key] if key in visiting: return {} # zyklische Vererbung - sollte nicht vorkommen, Notbremse class_info = registry.get(scope_key, {}).get(class_name) if class_info is None: return {} # externe/unbekannte Basisklasse (ModelSQL, Workflow, ...) visiting.add(key) fields = {} for base_name in class_info["bases"]: fields.update(resolve_class_fields(scope_key, base_name, registry, cache, visiting)) fields.update(class_info["own_fields"]) visiting.discard(key) cache[key] = fields return fields def resolve_class_persisted(scope_key, class_name, registry, cache, visiting): """True, wenn diese Klasse selbst oder eine ihrer lokalen Basisklassen ModelSQL/Workflow/DeactivableMixin als Basis hat.""" key = (scope_key, class_name) if key in cache: return cache[key] if key in visiting: return False class_info = registry.get(scope_key, {}).get(class_name) if class_info is None: return False visiting.add(key) result = class_info["persisted_here"] or any( resolve_class_persisted(scope_key, base_name, registry, cache, visiting) for base_name in class_info["bases"] ) visiting.discard(key) cache[key] = result return result 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): # Phase 1: alle Klassen roh einsammeln (Basen, eigene Felder, __name__). registry = {} 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 collect_classes(path, module_name, module_dir, registry) # Phase 2: pro Klasse mit __name__ die Felder (inkl. lokaler Python- # Vererbung) auflösen und unter dem Tryton-Modellnamen zusammenführen - # mehrere Klassen mit demselben __name__ (PoolMeta-Erweiterungen aus # anderen Modulen) tragen weiterhin gemeinsam zum selben Eintrag bei. fields_cache, persisted_cache = {}, {} models = {} persisted_models = set() for scope_key, classes in registry.items(): for class_name, info in classes.items(): if not info["model_name"]: continue resolved = resolve_class_fields(scope_key, class_name, registry, fields_cache, set()) models.setdefault(info["model_name"], {}).update(resolved) if resolve_class_persisted(scope_key, class_name, registry, persisted_cache, set()): persisted_models.add(info["model_name"]) return { model_name: {"persisted": model_name in persisted_models, "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()