Proteus-Zweig: dataset.yaml-Pipeline und 5.0/8.0-Modellbaumvergleich
- create_dataset.py, compare_snapshots.py, rst_to_dataset.py: Proteus- Testdatensatz anlegen/vergleichen bzw. aus *.rst-Szenarien extrahieren - create_dataset.py: Feld-Alias-Fallback (FIELD_RENAMES) für bekannte Umbenennungen wie stock.move.uom -> unit, bidirektional - merge_dedup_rsts.py: Extrahierte YAMLs pro Version zusammenführen, versionsinterne Duplikate entfernen, abhängigkeitskorrekt sortieren - extract_model_tree.py: Modellbaum (Modelle/Felder) statisch aus dem Tryton-Quellcode ziehen, für 5.0 und 8.0 - model_diff.py, model_coverage.py: 5.0<->8.0-Modelländerungen finden und gegen die aktuelle Testdatenabdeckung abgleichen, priorisiert nach tatsächlicher Änderung - reports/*_merged.yaml: Extraktionsergebnisse für die aktiven Module (rohe *.rst-Quellen bewusst nicht mitversioniert) - Ordner doc ergänzt
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user