2026-08-22 07:58:39 +00:00
|
|
|
#!/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.
|
|
|
|
|
|
2026-08-22 17:45:46 +02:00
|
|
|
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.
|
|
|
|
|
|
2026-08-22 07:58:39 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-22 17:45:46 +02:00
|
|
|
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."""
|
2026-08-22 07:58:39 +00:00
|
|
|
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
|
|
|
|
|
|
2026-08-22 17:45:46 +02:00
|
|
|
file_key = (module_name, str(path))
|
2026-08-22 07:58:39 +00:00
|
|
|
for node in ast.walk(tree):
|
|
|
|
|
if not isinstance(node, ast.ClassDef):
|
|
|
|
|
continue
|
2026-08-22 17:45:46 +02:00
|
|
|
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),
|
|
|
|
|
}
|
2026-08-22 07:58:39 +00:00
|
|
|
|
|
|
|
|
|
2026-08-22 17:45:46 +02:00
|
|
|
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
|
2026-08-22 07:58:39 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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):
|
2026-08-22 17:45:46 +02:00
|
|
|
# Phase 1: alle Klassen roh einsammeln (Basen, eigene Felder, __name__).
|
|
|
|
|
registry = {}
|
2026-08-22 07:58:39 +00:00
|
|
|
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
|
2026-08-22 17:45:46 +02:00
|
|
|
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"])
|
2026-08-22 07:58:39 +00:00
|
|
|
|
|
|
|
|
return {
|
2026-08-22 17:45:46 +02:00
|
|
|
model_name: {"persisted": model_name in persisted_models, "fields": fields}
|
2026-08-22 07:58:39 +00:00
|
|
|
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()
|