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,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()
|
||||
Reference in New Issue
Block a user