245 lines
9.1 KiB
Python
245 lines
9.1 KiB
Python
#!/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()
|