Files
tryton-db-migration/extract_model_tree.py
T

245 lines
9.1 KiB
Python
Raw Normal View History

#!/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.<Typ>(...)-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 = "<dynamisch>"
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()