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:
2026-08-22 10:52:13 +02:00
parent b549063ff6
commit c0dd24b6c5
38 changed files with 26136 additions and 20 deletions
+317
View File
@@ -0,0 +1,317 @@
#!/usr/bin/env python3
"""
rst_to_dataset.py
Extrahiert aus Tryton-Modul-Testdateien (tests/scenario_*.rst - das sind
ausführbare Doctests mit Proteus-Code in >>>-Zeilen) automatisch Record-
Definitionen im dataset.yaml-Format von create_dataset.py.
Erkennt:
>>> Party = Model.get('party.party') # Modell-Zuordnung
>>> party = Party(name='Testfirma GmbH') # Instanziierung + Felder
>>> party.vat_number = '123' # weitere Feld-Zuweisung
>>> party.save() # Abschluss -> Record wird übernommen
sowie Referenzen auf andere in derselben Datei bereits angelegte Records
(z. B. `address.party = party`) -> werden zu {"$ref": "..."}, und die
gängigsten nicht-JSON-tauglichen Werte:
Decimal('12.34') -> {"$decimal": "12.34"}
datetime.date(2024, 1, 1) -> {"$date": "2024-01-01"}
Alles andere (Wizards, .click(...), Reports, One2Many .new()/.append(),
Aufrufe von Hilfsfunktionen wie create_company()) wird NICHT automatisch
aufgelöst, sondern als {"$manual": "<Original-Ausdruck>"} markiert bzw.
komplett übersprungen und in der Zusammenfassung aufgelistet - das ist
bewusst so: lieber sichtbar unvollständig als still falsch.
Nutzung:
python3 rst_to_dataset.py tests/scenario_party.rst tests/scenario_address.rst \\
--output-dir extracted/
Erzeugt extracted/scenario_party.yaml, extracted/scenario_address.yaml.
Record-Keys werden mit dem Dateinamen präfixiert (party__party_1 statt
nur party_1), damit beim manuellen Zusammenführen mehrerer Module keine
Kollisionen entstehen.
WICHTIG: Ungetestet gegen echte .rst-Dateien (hier ohne Netzwerkzugriff auf
das Tryton-Repo) - nur gegen eine selbstgebaute Beispieldatei verifiziert
(siehe Testlauf in der Antwort). Bitte an echten Dateien gegenprüfen, bevor
du dich auf die Ausgabe verlässt - der "manual"-Anteil wird bei komplexeren
Szenarien vermutlich größer sein als im Testfall.
"""
import argparse
import ast
import re
import sys
from pathlib import Path
try:
import yaml
except ImportError:
yaml = None
PROMPT_RE = re.compile(r'^\s*>>>\s?(.*)$')
CONTINUATION_RE = re.compile(r'^\s*\.\.\.\s?(.*)$')
def extract_doctest_source(text):
"""Zieht alle >>>/...-Zeilen aus einer .rst-Datei und reiht sie zu einem
zusammenhängenden Python-Quelltext aneinander. Alles andere (Prosa,
erwartete Ausgabe) wird ignoriert."""
lines = []
for raw_line in text.splitlines():
m = PROMPT_RE.match(raw_line)
if m:
lines.append(m.group(1))
continue
m = CONTINUATION_RE.match(raw_line)
if m:
lines.append(m.group(1))
continue
return "\n".join(lines)
def is_call_to(node, *dotted_names):
"""True, wenn node ein Call ist, dessen func-Ausdruck (als unparse-Text)
zu einem der übergebenen Namen passt, z. B. 'Model.get' oder 'Decimal'."""
if not isinstance(node, ast.Call):
return False
try:
text = ast.unparse(node.func)
except Exception:
return False
return text in dotted_names
def resolve_model_get(node):
"""Liefert den Modellnamen aus einem Model.get('x.y')-Aufruf, sonst None."""
if not is_call_to(node, "Model.get"):
return None
if len(node.args) != 1 or not isinstance(node.args[0], ast.Constant):
return None
if not isinstance(node.args[0].value, str):
return None
return node.args[0].value
class ScenarioParser:
def __init__(self):
self.model_vars = {} # Variable -> Modellname (aus Model.get(...))
self.record_vars = {} # Variable -> Record-Dict (key/model/fields)
self.finalized = set() # Variablen, deren erster .save()-Aufruf schon verarbeitet wurde
self.output = [] # fertige Record-Dicts in Reihenfolge
self.manual_notes = [] # Zeilen, die nicht automatisch verstanden wurden
def convert_value(self, node):
if isinstance(node, ast.Constant):
return node.value
if isinstance(node, ast.Name):
if node.id in self.record_vars:
return {"$ref": self.record_vars[node.id]["key"]}
return self._manual(node)
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
inner = self.convert_value(node.operand)
if isinstance(inner, (int, float)):
return -inner
return self._manual(node)
if isinstance(node, ast.Call):
if is_call_to(node, "Decimal") and node.args and isinstance(node.args[0], ast.Constant):
return {"$decimal": str(node.args[0].value)}
if is_call_to(node, "datetime.date", "date") and len(node.args) >= 3:
try:
y, m, d = (a.value for a in node.args[:3])
return {"$date": f"{int(y):04d}-{int(m):02d}-{int(d):02d}"}
except Exception:
pass
return self._manual(node)
if isinstance(node, (ast.List, ast.Tuple)):
return [self.convert_value(el) for el in node.elts]
return self._manual(node)
def _manual(self, node):
try:
text = ast.unparse(node)
except Exception:
text = "<nicht darstellbar>"
self.manual_notes.append(f"Zeile {getattr(node, 'lineno', '?')}: {text}")
return {"$manual": text}
def make_key(self, var_name):
base = var_name
key = base
i = 2
existing_keys = {r["key"] for r in self.output} | {
r["key"] for r in self.record_vars.values()
}
while key in existing_keys:
key = f"{base}_{i}"
i += 1
return key
def handle_assign(self, node):
if len(node.targets) != 1:
self._manual(node)
return
target = node.targets[0]
# X = Model.get('some.model')
if isinstance(target, ast.Name):
model_name = resolve_model_get(node.value)
if model_name:
self.model_vars[target.id] = model_name
return
# x = SomeModelVar(...) ODER x = Model.get('x.y')(...)
model_name = None
kwargs = {}
if isinstance(node.value, ast.Call):
func = node.value.func
if isinstance(func, ast.Name) and func.id in self.model_vars:
model_name = self.model_vars[func.id]
else:
model_name = resolve_model_get(func)
if model_name:
for kw in node.value.keywords:
if kw.arg:
kwargs[kw.arg] = self.convert_value(kw.value)
if model_name:
key = self.make_key(target.id)
self.record_vars[target.id] = {
"key": key,
"model": model_name,
"fields": kwargs,
}
return
# x = <irgendwas anderes>, z. B. Ergebnis einer Helper-Funktion
# (create_company(), Party.find([...])[0], ...) - können wir
# nicht auflösen.
self.manual_notes.append(
f"Zeile {node.lineno}: '{target.id} = {ast.unparse(node.value)}' "
f"nicht automatisch aufgelöst (kein erkanntes Modell/Konstruktor)."
)
return
# x.feld = value
if isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name):
var = target.value.id
if var in self.record_vars:
self.record_vars[var]["fields"][target.attr] = self.convert_value(node.value)
return
self._manual(node)
def handle_call_statement(self, node):
call = node.value
if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Attribute):
return
if call.func.attr != "save":
return
if not isinstance(call.func.value, ast.Name):
return
var = call.func.value.id
if var in self.record_vars and var not in self.finalized:
self.output.append(dict(self.record_vars[var]))
self.finalized.add(var)
def parse(self, source):
try:
tree = ast.parse(source)
except SyntaxError as e:
raise ValueError(f"Konnte extrahierten Doctest-Code nicht parsen: {e}") from e
for stmt in tree.body:
if isinstance(stmt, ast.Assign):
self.handle_assign(stmt)
elif isinstance(stmt, ast.Expr):
self.handle_call_statement(stmt)
# alles andere (if/for/import/...) wird bewusst ignoriert
def namespace_keys(records, prefix):
"""Schreibt allen Record-Keys (und $ref-Verweisen darauf) ein Präfix
voran, damit beim manuellen Zusammenführen mehrerer Module-YAMLs keine
Kollisionen entstehen."""
rename = {r["key"]: f"{prefix}__{r['key']}" for r in records}
def rewrite(value):
if isinstance(value, dict):
if "$ref" in value and value["$ref"] in rename:
return {"$ref": rename[value["$ref"]]}
return {k: rewrite(v) for k, v in value.items()}
if isinstance(value, list):
return [rewrite(v) for v in value]
return value
result = []
for r in records:
result.append({
"key": rename[r["key"]],
"model": r["model"],
"fields": {k: rewrite(v) for k, v in r["fields"].items()},
})
return result
def process_file(path, output_dir):
text = Path(path).read_text(encoding="utf-8")
source = extract_doctest_source(text)
parser = ScenarioParser()
parser.parse(source)
prefix = Path(path).stem
records = namespace_keys(parser.output, prefix)
out_path = Path(output_dir) / f"{prefix}.yaml"
out_path.parent.mkdir(parents=True, exist_ok=True)
doc = {"records": records}
if yaml is not None:
out_path.write_text(
yaml.dump(doc, allow_unicode=True, sort_keys=False, default_flow_style=False),
encoding="utf-8",
)
else:
import json
out_path.write_text(json.dumps(doc, indent=2, ensure_ascii=False), encoding="utf-8")
return out_path, len(records), parser.manual_notes
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("rst_files", nargs="+", help="Ein oder mehrere scenario_*.rst-Dateien")
parser.add_argument("--output-dir", default="extracted", help="Zielordner für die YAMLs")
args = parser.parse_args()
for rst_file in args.rst_files:
try:
out_path, count, notes = process_file(rst_file, args.output_dir)
except ValueError as e:
print(f"{rst_file}: FEHLER - {e}", file=sys.stderr)
continue
print(f"{rst_file} -> {out_path} ({count} Record(s) erkannt)")
if notes:
print(f" {len(notes)} Ausdruck/Ausdrücke nicht automatisch aufgelöst (siehe $manual im YAML):")
for note in notes[:10]:
print(f" - {note}")
if len(notes) > 10:
print(f" ... und {len(notes) - 10} weitere")
if __name__ == "__main__":
main()