Files
tryton-db-migration/create_dataset.py
T
uha4 c0dd24b6c5 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
2026-08-22 10:52:13 +02:00

282 lines
11 KiB
Python

#!/usr/bin/env python3
"""
create_dataset.py
Legt über Proteus einen deklarativ definierten, reproduzierbaren
Testdatensatz an und liest ihn danach FRISCH VOM SERVER zurück (nicht aus
dem lokalen Proteus-Cache) - damit automatische Reformatierung durch die
Zielversion sichtbar wird. Ergebnis wird als JSON-Snapshot gespeichert.
Gedacht, um dieselbe dataset.yaml gegen zwei Tryton-Versionen laufen zu
lassen (zwei getrennte, frisch installierte Datenbanken) und die beiden
Snapshots danach mit compare_snapshots.py zu vergleichen.
Nutzung:
python3 create_dataset.py --config dataset.yaml \\
--database mydb_68 --output snapshot_68.json
python3 create_dataset.py --config dataset.yaml \\
--database mydb_70 --output snapshot_70.json
python3 compare_snapshots.py snapshot_68.json snapshot_70.json \\
--label-a 6.8 --label-b 7.0
WICHTIG: Dieses Skript wurde nicht gegen einen echten Tryton-Server
getestet (dafür fehlt hier die Umgebung) - nur auf Syntax geprüft. Bitte
bei dir gegenlesen, insbesondere proteus_config.set_trytond(...) an eure
tatsächliche Verbindungsart anpassen (lokal via Unix-Socket/Config-Datei
oder per XML-RPC/JSON-RPC über Netzwerk - die Proteus-API unterscheidet
sich da je nach Version leicht).
Feld-Umbenennungen (FIELD_RENAMES):
Da dieselbe dataset.yaml gegen mehrere Versionen laufen soll, aber Felder
zwischen Versionen umbenannt werden können (bestätigt: stock.move.uom ->
stock.move.unit zwischen 5.0 und 8.0), wird pro Modell zuerst geprüft, ob
der im YAML angegebene Feldname auf dem jeweiligen Server tatsächlich
existiert (via fields_get()). Falls nicht, aber ein bekannter Alias aus
FIELD_RENAMES existiert UND der Alias-Name dort existiert, wird der Alias
verwendet - der Datensatz landet dann inhaltlich identisch in beiden
Datenbanken, nur eben unter dem jeweils richtigen Feldnamen. Im
JSON-Snapshot ("stored") wird trotzdem immer der ORIGINALE YAML-Feldname
als Key verwendet, damit compare_snapshots.py die Werte über beide
Versionen hinweg unter demselben Key vergleicht.
Ein wirklich unbekanntes Feld (kein Treffer in FIELD_RENAMES, oder Alias
existiert dort ebenfalls nicht) wird NICHT stillschweigend übergangen,
sondern lässt den Record ganz normal mit AttributeError fehlschlagen -
das ist ja gerade das gesuchte Signal für eine echte Schema-Änderung.
"""
import argparse
import decimal
import datetime
import json
import sys
import traceback
from pathlib import Path
try:
import yaml
except ImportError:
yaml = None
from proteus import Model, config as proteus_config
# Bekannte Feld-Umbenennungen zwischen Tryton-Versionen: Modell -> {einer
# der beiden Feldnamen: der jeweils andere}. Nur eine Richtung eintragen
# (z. B. die 5.0->8.0-Richtung) - _bidirectional_field_renames() ergänzt
# die Rückrichtung automatisch, damit die Auflösung unabhängig davon
# funktioniert, mit welcher Version die dataset.yaml ursprünglich gebaut
# wurde. Bei Bedarf ergänzen, sobald ein compare_snapshots.py-Lauf einen
# weiteren Fall aufdeckt.
FIELD_RENAMES = {
"stock.move": {"uom": "unit"},
}
def _bidirectional_field_renames(field_renames):
result = {}
for model_name, renames in field_renames.items():
both = dict(renames)
both.update({new: old for old, new in renames.items()})
result[model_name] = both
return result
BIDIRECTIONAL_FIELD_RENAMES = _bidirectional_field_renames(FIELD_RENAMES)
def resolve_field_name(RecordModel, model_name, field_name, fields_cache):
"""Liefert den auf DIESEM Server tatsächlich existierenden Feldnamen.
Existiert field_name direkt (fields_get()), wird er unverändert
zurückgegeben. Sonst wird in FIELD_RENAMES nachgeschaut (in beide
Richtungen), ob ein bekannter Alias existiert UND auf diesem Server
tatsächlich vorhanden ist. Andernfalls bleibt field_name unverändert,
damit ein wirklich unbekanntes Feld weiterhin als Fehler auffällt.
"""
if model_name not in fields_cache:
try:
fields_cache[model_name] = set(RecordModel.fields_get().keys())
except Exception:
fields_cache[model_name] = None # unbekannt - keine Alias-Auflösung möglich
available = fields_cache[model_name]
if available is None or field_name in available:
return field_name
alias = BIDIRECTIONAL_FIELD_RENAMES.get(model_name, {}).get(field_name)
if alias and alias in available:
return alias
return field_name
def load_spec(path):
text = Path(path).read_text(encoding="utf-8")
if path.endswith((".yaml", ".yml")):
if yaml is None:
raise RuntimeError(
"PyYAML nicht installiert (pip install pyyaml) - "
"oder --config auf eine .json-Datei zeigen lassen."
)
return yaml.safe_load(text)
return json.loads(text)
def resolve_value(value, created):
"""
Löst Referenzen der Form {"$ref": "irgendein_key"} auf bereits in
diesem Lauf erzeugte Datensätze auf (z. B. um eine Adresse mit der
vorher angelegten Party zu verknüpfen). Referenzen können nur auf
Records verweisen, die im YAML VOR der aktuellen Stelle stehen.
Versteht außerdem die von rst_to_dataset.py erzeugten Marker für
Werte, die kein einfacher JSON-Typ sind:
{"$decimal": "12.34"} -> decimal.Decimal("12.34")
{"$date": "2024-01-15"} -> datetime.date(2024, 1, 15)
{"$manual": "<ausdruck>"} -> Fehler mit Klartext, da diese
Stelle noch von Hand ausgefüllt
werden muss.
"""
if isinstance(value, dict) and "$ref" in value:
if value["$ref"] not in created:
raise KeyError(
f"Referenz '{value['$ref']}' unbekannt - Datensatz mit "
f"diesem key wurde noch nicht angelegt oder ist "
f"fehlgeschlagen."
)
return created[value["$ref"]]
if isinstance(value, dict) and "$decimal" in value:
return decimal.Decimal(value["$decimal"])
if isinstance(value, dict) and "$date" in value:
return datetime.date.fromisoformat(value["$date"])
if isinstance(value, dict) and "$manual" in value:
raise ValueError(
f"Nicht ausgefüllter $manual-Wert: {value['$manual']!r} - "
f"bitte im YAML durch einen echten Wert ersetzen, bevor "
f"dieser Datensatz angelegt wird."
)
if isinstance(value, list):
return [resolve_value(v, created) for v in value]
if isinstance(value, dict):
return {k: resolve_value(v, created) for k, v in value.items()}
return value
def serialize(value):
"""Proteus-/Tryton-Werte in eine JSON-taugliche, vergleichbare Form bringen."""
if value is None:
return None
if hasattr(value, "id") and hasattr(value, "__class__"):
# Verknüpfter Record (Many2One o. ä.) - Modellname + id vergleichen,
# nicht das Objekt selbst.
return {
"__record__": getattr(value, "__class__").__name__,
"id": value.id,
}
if isinstance(value, (list, tuple)):
return [serialize(v) for v in value]
try:
json.dumps(value)
return value
except TypeError:
# Decimal, date, datetime, etc. - als String vergleichen
return str(value)
def create_records(spec, created, results):
fields_cache = {} # model_name -> Set der auf DIESEM Server bekannten Felder
for entry in spec["records"]:
key = entry["key"]
model_name = entry["model"]
fields = entry.get("fields", {})
record_result = {
"key": key,
"model": model_name,
"status": "ok",
"error": None,
"stored": {},
}
try:
RecordModel = Model.get(model_name)
record = RecordModel()
# actual_name je YAML-Feldname merken: fürs Zurücklesen unten
# brauchen wir denselben (ggf. per Alias aufgelösten) Namen,
# im Snapshot aber weiterhin den originalen YAML-Namen als Key.
actual_names = {}
for field_name, raw_value in fields.items():
actual_name = resolve_field_name(RecordModel, model_name, field_name, fields_cache)
actual_names[field_name] = actual_name
setattr(record, actual_name, resolve_value(raw_value, created))
record.save()
created[key] = record.id
# Bewusst NEU vom Server laden statt das lokale Proteus-Objekt
# weiterzuverwenden - das lokale Objekt kann noch den Wert
# zeigen, den WIR gesetzt haben, nicht den, den der Server
# tatsächlich abgespeichert (und ggf. reformatiert) hat.
reloaded = RecordModel(record.id)
for field_name in fields:
try:
record_result["stored"][field_name] = serialize(
getattr(reloaded, actual_names[field_name])
)
except Exception as e:
record_result["stored"][field_name] = f"<Lesefehler: {e}>"
except Exception as e:
record_result["status"] = "error"
record_result["error"] = f"{type(e).__name__}: {e}"
record_result["traceback"] = traceback.format_exc()
results.append(record_result)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", required=True, help="Pfad zur dataset.yaml/json")
parser.add_argument("--database", required=True, help="Name der Tryton-Datenbank")
parser.add_argument(
"--config-file", default=None,
help="Pfad zu trytond.conf, falls nicht der Standardpfad verwendet wird",
)
parser.add_argument("--output", required=True, help="Wohin der JSON-Snapshot geschrieben wird")
args = parser.parse_args()
# ACHTUNG: set_trytond() ist die lokale Variante (Proteus + trytond im
# selben Prozess/Server, kein separater Client-Server-Aufbau). Läuft
# euer trytond als eigener Prozess, braucht ihr stattdessen
# proteus_config.set_xmlrpc(url) mit einer URL wie
# "http://admin:PASSWORT@localhost:8000/mydb_68/".
proteus_config.set_trytond(database=args.database, config_file=args.config_file)
spec = load_spec(args.config)
created = {}
results = []
create_records(spec, created, results)
snapshot = {"database": args.database, "results": results}
Path(args.output).write_text(
json.dumps(snapshot, indent=2, ensure_ascii=False), encoding="utf-8"
)
ok = sum(1 for r in results if r["status"] == "ok")
err = sum(1 for r in results if r["status"] == "error")
print(f"{ok} Datensätze ok, {err} Fehler -> {args.output}")
if err:
print("Fehlgeschlagene Datensätze:")
for r in results:
if r["status"] == "error":
print(f" - {r['key']} ({r['model']}): {r['error']}")
if __name__ == "__main__":
sys.exit(main())