Compare commits

...
2 Commits
Author SHA1 Message Date
uha4 a55774eff2 neues skript sql_dump_diff.py zum Vergleich von 2 sql-dumps.
- leere, frisch initialisierte Datenbanken der Versionen verglichen
- datasetprogramme erkennen jetzt auch suche nach standardtabellen wie uom
2026-08-22 17:45:46 +02:00
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
43 changed files with 104450 additions and 20 deletions
+6
View File
@@ -179,3 +179,9 @@ cython_debug/
# PyPI configuration file
.pypirc
# Ignore all dotfiles/dotfolders in the home dir (credentials, tool state, etc.)
.*
# rsts sind nur ein zwischenschritt für Testdaten. 1. reproduzierbar, 2. nur kurzfristig nötig
rsts/
+20 -3
View File
@@ -1,7 +1,24 @@
# Datenbankmigration von 5.0 auf 8.0
aktuell zwei Ansätze:
- Codeanalyse des Updatemechanismus
- Unterschiede im Zustand erkennen nachdem in beiden Versionen die gleichen Testdaten angelegt wurden.
Ziel: verstehen, wie sich das Datenbankschema der aktiven Tryton-Module
über die Versionen 5.0 bis 8.0 verändert, als Vorbereitung für die
eigentliche Migration. Zwei sich ergänzende Ansätze:
- **[doc/UPDATEANALYSE.md](doc/UPDATEANALYSE.md)** – Codeanalyse des
Updatemechanismus: statischer Scan der `__register__`-Methoden aller
Module über alle Versions-Branches hinweg. _(fertig)_
- **[doc/TESTDATEN.md](doc/TESTDATEN.md)** – Unterschiede im Zustand
erkennen, nachdem in zwei frisch installierten Versionen dieselben
Testdaten per Proteus angelegt wurden (Snapshot- und später
SQL-Dump-Vergleich). _(in Arbeit - siehe Stand im Dokument)_
- dazu als Vorarbeit: **[doc/MODELLVERGLEICH.md](doc/MODELLVERGLEICH.md)**
– welche Modelle/Felder gibt es je Version überhaupt, und wo hat sich
zwischen 5.0 und 8.0 tatsächlich etwas geändert (steuert, wo sich
Testdaten-Aufwand lohnt).
Eigene, von den Standardmodulen abweichende Module (`production`,
`stock_product_location`, `stock_split`, `stock_lot_sequence`) sind
bewusst noch nicht Teil dieser Analyse - erst wenn die Standardmodule
migrierbar sind, werden diese auf 8.0 portiert und die Testdaten-Pipeline
darauf erweitert.
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
compare_snapshots.py
Vergleicht zwei mit create_dataset.py erzeugte Snapshots (z. B. aus
Version 6.8 und 7.0, jeweils mit derselben dataset.yaml erzeugt) und
markiert:
- status_changed: Datensatz ist in einer Version fehlgeschlagen, in der
anderen nicht -> Kandidat für Format-/
Constraint-/Pflichtfeld-Änderung
- value_changed: gespeicherter Wert unterscheidet sich zwischen den
Versionen, obwohl derselbe Eingabewert verwendet
wurde -> Kandidat für automatische Reformatierung
- missing_record: Datensatz-Key nur in einem der beiden Snapshots
vorhanden (z. B. weil er in dataset.yaml erst später
ergänzt wurde)
Nutzung:
python3 compare_snapshots.py snapshot_68.json snapshot_70.json \\
--label-a 6.8 --label-b 7.0
Weiter geht's dann typischerweise so: Modellname aus einem Fund hier
nehmen, den zugehörigen Tabellennamen bilden (Punkte durch Unterstriche
ersetzen, wie es extract_updates.py auch tut) und in den AST-Analyse-Ergebnissen
danach filtern:
jq '.files[].operations[] | select(.tables[]? == "party_identifier")' \\
data/7.0/party.json
"""
import argparse
import json
def load(path):
return json.load(open(path, encoding="utf-8"))
def index_by_key(snapshot):
return {r["key"]: r for r in snapshot["results"]}
def compare(snap_a, snap_b, label_a, label_b):
a = index_by_key(snap_a)
b = index_by_key(snap_b)
findings = []
for key in sorted(set(a) | set(b)):
ra, rb = a.get(key), b.get(key)
if ra is None or rb is None:
findings.append({
"key": key,
"kind": "missing_record",
"detail": f"nur in {label_b if ra is None else label_a} vorhanden",
})
continue
if ra["status"] != rb["status"]:
findings.append({
"key": key,
"model": ra["model"],
"kind": "status_changed",
"detail": (
f"{label_a}: {ra['status']} ({ra.get('error')}) | "
f"{label_b}: {rb['status']} ({rb.get('error')})"
),
})
continue
if ra["status"] == "error":
continue # beide fehlgeschlagen - nichts auf Feldebene zu vergleichen
fields_a, fields_b = ra["stored"], rb["stored"]
for field in sorted(set(fields_a) | set(fields_b)):
va = fields_a.get(field, "<fehlt>")
vb = fields_b.get(field, "<fehlt>")
if va != vb:
findings.append({
"key": key,
"model": ra["model"],
"field": field,
"kind": "value_changed",
"detail": f"{label_a}={va!r} | {label_b}={vb!r}",
})
return findings
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("snapshot_a")
parser.add_argument("snapshot_b")
parser.add_argument("--label-a", default="A")
parser.add_argument("--label-b", default="B")
args = parser.parse_args()
findings = compare(
load(args.snapshot_a), load(args.snapshot_b), args.label_a, args.label_b
)
if not findings:
print("Keine Unterschiede gefunden.")
return
by_model = {}
for f in findings:
by_model.setdefault(f.get("model", "(kein Modell)"), []).append(f)
for model, items in sorted(by_model.items()):
if model == "(kein Modell)":
print(f"\n=== {model} - {len(items)} Fund(e) ===")
else:
table = model.replace(".", "_")
print(f"\n=== {model} (Tabelle vermutlich: {table}) - {len(items)} Fund(e) ===")
for item in items:
field_suffix = f".{item['field']}" if "field" in item else ""
print(f" [{item['kind']}] {item['key']}{field_suffix}: {item['detail']}")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -6,7 +6,7 @@ from collections import OrderedDict
DATA = Path("data")
REPORTS = Path("reports")
REPORTS = Path("reports/updateChanges")
VERSION_DIRS = sorted(
[d for d in DATA.iterdir() if d.is_dir()],
+310
View File
@@ -0,0 +1,310 @@
#!/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, search_cache):
"""
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)
{"$search": {"model": "product.uom",
"domain": [["name", "=", "Unit"]]}}
-> sucht den Record live auf dem
Zielserver (Model.find(domain)) -
für Stammdaten, die nicht Teil des
Testdatensatzes sind, sondern beim
Modul-Aktivieren per XML-Fixture
angelegt werden (Länder, Währungen,
Einheiten, Standard-Lagerorte, ...).
Wirft einen Fehler, wenn nichts
gefunden wird, statt einen neuen
Record anzulegen - ein fehlender
Treffer bedeutet meist, dass sich
die Fixture-Daten zwischen den
Versionen geändert haben.
{"$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 "$search" in value:
spec = value["$search"]
cache_key = (spec["model"], json.dumps(spec["domain"], sort_keys=True))
if cache_key not in search_cache:
matches = Model.get(spec["model"]).find(spec["domain"])
if not matches:
raise ValueError(
f"$search fand keinen Treffer: model={spec['model']!r} "
f"domain={spec['domain']!r} - Stammdaten auf diesem "
f"Server geändert/entfernt?"
)
search_cache[cache_key] = matches[0].id
return search_cache[cache_key]
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, search_cache) for v in value]
if isinstance(value, dict):
return {k: resolve_value(v, created, search_cache) 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
search_cache = {} # (model, domain-json) -> id, damit dieselbe Stammdaten-Suche nicht mehrfach läuft
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, search_cache))
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())
+35005
View File
File diff suppressed because it is too large Load Diff
+38412
View File
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
# Modellbaum-Vergleich (5.0 ↔ 8.0)
Werkzeug für die [Testdaten-Pipeline](TESTDATEN.md): Bevor Testdaten von
Hand ergänzt werden, erstmal statisch (kein laufendes Tryton nötig)
Schwarz auf Weiß sehen, welche Modelle/Felder es in einer Version
überhaupt gibt - damit weder Felder aus 8.0 übernommen werden, die es in
5.0 noch gar nicht gibt (würde beim Anlegen nur Fehler werfen), noch
Aufwand in Modelle gesteckt wird, die sich zwischen den Versionen gar
nicht verändert haben.
## Workflow
### 1. `extract_model_tree.py` – Modellbaum aus dem Quellcode ziehen
Scannt den Python-Code ausgewählter Module per AST (kein Import, keine
laufende Instanz) und baut pro Modell (`__name__`) die Liste aller per
`fields.X(...)` definierten Felder, mit Typ, Relationsziel
(Many2One/One2Many/Many2Many/One2One/Reference) und - falls literal im
Code erkennbar - `required`. 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.
Pro Modell wird zusätzlich `persisted` erkannt (erbt die Klasse `ModelSQL`
o. ä., oder nur `ModelView` - dann ist es ein reines Wizard-/
Transient-Formular ohne eigene Tabelle).
Verfolgt zusätzlich ganz normale **Python-Vererbung innerhalb derselben
Datei**: 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 (eigene Felder überschreiben geerbte bei
Namensgleichheit). Ohne das sähe ein rein über Vererbung wiederverwendetes
Modell wie eines OHNE Felder aus, und Änderungen daran (z. B. eine
Feldumbenennung im Elternteil) blieben komplett unsichtbar - genau das hat
`sql_dump_diff.py` (Schritt 4) einmal live aufgedeckt.
Die Basisklassen-Auflösung ist bewusst auf "gleiche Datei" beschränkt
(nicht "gleiches Modul"): derselbe lokale Klassenname wie `Cron` taucht
innerhalb eines Moduls oft mehrfach für völlig unterschiedliche Klassen
auf (Tryton-Konvention: eine PoolMeta-Erweiterung heißt oft genauso wie
das Original, z. B. `currency/currency.py` definiert `class Cron(ModelSQL,
ModelView): __name__ = 'currency.cron'`, während `currency/ir.py`
unabhängig davon eine andere Klasse `class Cron(metaclass=PoolMeta):` zur
Erweiterung von `ir.cron` definiert) - ein modulweites Registry würde sich
da gegenseitig überschreiben.
```bash
python3 extract_model_tree.py --repo /mnt/tryton \
--modules company country currency party product ... \
--output reports/model_tree_5.0.json --report reports/model_tree_5.0.md
```
Für eine zweite Version denselben Befehl gegen einen zweiten Checkout
laufen lassen (z. B. per `git worktree add /tmp/tryton-8.0 8.0`, ohne den
Haupt-Checkout umzuschalten).
### 2. `model_diff.py` – zwei Modellbäume vergleichen
Sortiert jedes Modell in eine von fünf Kategorien:
- **entfernt** – Modell gibt es in Version A, nicht mehr in B
- **geändert** – Feld(er) entfernt oder Typ/Relation/required geändert →
echter Kandidat für einen Vergleichslauf
- **neu** – Modell gibt es erst in B
- **nur ergänzt** – nur neue Felder dazugekommen, Rest unverändert
- **unverändert** – komplett identisch geblieben
```bash
python3 model_diff.py --tree-a reports/model_tree_5.0.json --label-a 5.0 \
--tree-b reports/model_tree_8.0.json --label-b 8.0 \
--output reports/model_diff.json --report reports/model_diff.md
```
### 3. `model_coverage.py` – gegen die aktuelle Testdaten-Abdeckung abgleichen
Vergleicht einen Modellbaum mit einer gemergten `dataset.yaml`
(`reports/<version>_testdata_merged.yaml`, siehe
[TESTDATEN.md](TESTDATEN.md)) und zeigt komplett unabgedeckte Modelle sowie
pro teilweise abgedecktem Modell die ungenutzten Felder (required zuerst).
Drei Kategorien werden aus der Liste unabgedeckter Modelle per Default
herausgefiltert (nie von Hand testdatenrelevant, jeweils per Flag wieder
einschaltbar):
- `ir.*`/`res.*`-Kernmodelle (`--include-framework`)
- Wizard-/Transient-Formulare, `persisted: false` (`--include-transient`)
- automatisch verwaltete Many2Many-Verknüpfungstabellen, erkennbar am
Bindestrich im Namen wie `party.party-party.category`
(`--include-relations`)
Mit `--diff reports/model_diff.json` wird zusätzlich pro Modell die
Diff-Kategorie aus Schritt 2 eingeblendet und die Liste danach sortiert:
`geändert`/`entfernt` zuerst (lohnt sich, dort Testdaten zu ergänzen),
`nur ergänzt`/`unverändert` zuletzt (dort ist über den reinen
Codevergleich hinaus erstmal nichts Auffälliges zu erwarten).
```bash
python3 model_coverage.py --model-tree reports/model_tree_5.0.json \
--dataset reports/5_testdata_merged.yaml --diff reports/model_diff.json \
--report reports/coverage_5.0.md
```
### 4. `sql_dump_diff.py` – gegen den echten DB-Zustand validieren
Alles bisherige ist reine Code-Analyse. Zum Cross-Check: zwei mit
`pg_dump` (Klartext-Format) erzeugte Dumps frisch initialisierter (leerer)
Datenbanken beider Versionen strukturell vergleichen - Tabellen
entfernt/neu, Spalten hinzugefügt/entfernt/Typ geändert, Zeilenzahl
(z. B. mehr XML-Fixture-Daten in der neueren Version). Zeilen werden
bereits vollständig geparst (für eine spätere inhaltliche
Vergleichs-Erweiterung, sobald es Snapshots mit echten Testdaten gibt),
aktuell nutzt der Report davon aber nur die Zeilenzahl.
Mit `--model-diff reports/model_diff.json` wird pro betroffener Tabelle
zusätzlich geprüft, ob die rein statische Code-Analyse (Schritt 2)
dieselbe Änderung schon vorhergesagt hatte (Modellname -> Tabellenname:
Punkte durch Unterstriche ersetzen, wie `extract_updates.py`/
`compare_snapshots.py` es auch tun) - Übereinstimmung bzw. Abweichung wird
markiert.
```bash
python3 sql_dump_diff.py --dump-a data/leer5.sql --label-a 5.0 \
--dump-b data/leer8.sql --label-b 8.0 \
--model-diff reports/model_diff.json \
--output reports/dump_diff_5.0_8.0.json --report reports/dump_diff_5.0_8.0.md
```
**Stand:** 78 von 92 vergleichbaren Fällen stimmen überein (~85%). Die
verbleibenden Abweichungen gehen meist in eine erwartbare Richtung: die
Code-Analyse findet MEHR Veränderung, als im leeren DB-Schema sichtbar
ist (z. B. ein entferntes Function-Feld ohne eigene Spalte, oder
Selection-Wertänderungen ohne Typänderung) - kein Fehler, sondern zwei
Werkzeuge, die unterschiedliche Ebenen messen (Python-Feld-Definition vs.
physisches SQL-Schema). Der `production.bom.output`-Fund oben (Schritt 1)
kam genau aus einer vorherigen Abweichung dieser Art zustande.
## Bekannte Grenzen
- Rein statische Analyse: Felder, die nicht als einfaches
`name = fields.X(...)` auf Klassenebene stehen (z. B. per Schleife/Helper
dynamisch erzeugt), werden nicht erkannt.
- Python-Vererbung wird nur INNERHALB derselben Datei aufgelöst (s. o.).
Erbt eine Klasse von einer Basisklasse aus einer anderen Datei desselben
oder eines anderen Moduls (Import statt lokaler Definition), werden
deren Felder nicht gefunden - unauffällig, aber ein bekannter blinder
Fleck.
- `persisted`-Erkennung ist eine Textsuche über die Basisklassen-Ausdrücke
(`ModelSQL`/`Workflow`/`DeactivableMixin`) - deckt den Normalfall ab, ist
aber kein echtes Type-Checking.
- Erkennt nur Unterschiede auf Ebene der Feld-**Definition** im Code
(Typ/Relation/required). Laufzeitverhalten wie Constraints, Defaults oder
DB-Spaltenpräzision kann trotzdem abweichen, ohne hier aufzufallen - dafür
bleibt der eigentliche Testlauf über `create_dataset.py`/
`compare_snapshots.py` nötig.
+132
View File
@@ -0,0 +1,132 @@
# Proteus-Testdaten-Pipeline
Ergänzender Ansatz zur [Codeanalyse](UPDATEANALYSE.md): Statt den
`__register__`-Code zu lesen, wird ein deklarativ definierter Testdatensatz
über Proteus in zwei frisch installierten Tryton-Instanzen (5.0 und 8.0)
tatsächlich angelegt. Ziel ist, dieselbe Datei gegen beide Versionen laufen
zu lassen, damit sowohl die Skript-Ausgabe (`compare_snapshots.py`) als
auch anschließend die SQL-Dumps beider Datenbanken vergleichbar sind -
dafür müssen inhaltlich identische Daten in derselben Reihenfolge
eingespielt werden.
## Workflow
```
tests/scenario_*.rst (Tryton-Repo, pro Modul)
│ rst_to_dataset.py
▼
rsts/<version>e/*.yaml (ein YAML-Record pro angelegtem Proteus-Objekt)
│ rsts_merge_dedup.py
▼
reports/<version>_testdata_merged.yaml (dedupliziert, abhängigkeitskorrekt sortiert)
│ create_dataset.py --database ...
▼
snapshot_<version>.json
│ compare_snapshots.py snapshot_a.json snapshot_b.json
▼
Fund-Liste: status_changed / value_changed / missing_record
```
### 1. `rst_to_dataset.py` – Testdaten aus den Modul-eigenen Tests extrahieren
Tryton-Module bringen eigene `tests/scenario_*.rst`-Doctests mit (ausführbarer
Proteus-Code in `>>>`-Zeilen). Das Skript parst diese Dateien per AST und
erkennt:
```
>>> Party = Model.get('party.party')
>>> party = Party(name='Testfirma GmbH')
>>> party.vat_number = '123'
>>> party.save()
```
→ wird zu einem YAML-Record mit `key`, `model`, `fields`. Referenzen auf
zuvor im selben File angelegte Records werden zu `{"$ref": "..."}`,
`Decimal(...)`/`date(...)` zu `{"$decimal": ...}`/`{"$date": ...}`.
Ein weiteres, sehr häufiges Muster wird ebenfalls erkannt: Zugriff auf
bereits vorhandene Stammdaten (Länder, Währungen, Einheiten,
Standard-Lagerorte, ... - i. d. R. per XML-Fixture beim Modul-Aktivieren
angelegt, nicht Teil des eigentlichen Testdatensatzes):
```
>>> ProductUom = Model.get('product.uom')
>>> unit, = ProductUom.find([('name', '=', 'Unit')])
```
→ wird zu `{"$search": {"model": "product.uom", "domain": [["name", "=", "Unit"]]}}`
- aufgelöst wird das erst live beim Anlegen durch `create_dataset.py`
(Schritt 3), nicht schon hier, da diese Records ja nicht selbst Teil der
YAML sind. Nur literale Domains werden erkannt (keine Variablen darin);
sonst bleibt es wie bisher `$manual`.
Alles sonst nicht automatisch Auflösbare (Wizards, `.click(...)`,
Helper-Funktionsaufrufe) wird als `{"$manual": "<Ausdruck>"}` markiert oder
ganz übersprungen - bewusst sichtbar unvollständig statt still falsch.
Record-Keys werden mit dem Dateinamen präfixiert, damit beim
Zusammenführen mehrerer Module keine Kollisionen entstehen.
### 2. `rsts_merge_dedup.py` – pro Version zusammenführen
Liest alle `rsts/<version>e/*.yaml` eines Verzeichnisses ein und entfernt
Records mit identischem `model`+`fields` (z. B. dasselbe
Basis-`product.template`, das in vielen Szenarien wiederverwendet wird).
`$ref`-Verweise auf entfernte Duplikate werden auf den behaltenen Record
umgeschrieben. Danach wird per Kahn-Algorithmus so sortiert, dass
`$ref`-Abhängigkeiten immer VOR dem referenzierenden Record stehen, aber
innerhalb der Freiheitsgrade weiterhin nach `model` gruppiert wird.
**Achtung:** Dedupliziert bewusst GLOBAL, auch innerhalb derselben Datei.
Bei Szenarien, die zwei absichtlich gleich aussehende Records anlegen (z. B.
`scenario_party_replace.rst`: zwei Parteien namens "Pam", um eine zu
ersetzen), verändert das den Sinn des Szenarios - für einen groben
Testdatensatz ist das in Ordnung, für szenario-treue Tests nicht.
### 3. `create_dataset.py` – gegen eine echte Instanz anlegen
Legt die Records der Reihe nach per Proteus an, liest jeden Wert danach
FRISCH VOM SERVER zurück (nicht aus dem lokalen Proteus-Cache), damit
automatische Reformatierung sichtbar wird. Ergebnis: ein JSON-Snapshot
(`status`/`error`/`stored` pro Record).
**Feld-Umbenennungen zwischen Versionen** (`FIELD_RENAMES`): Da dieselbe
YAML gegen mehrere Versionen laufen soll, aber Felder umbenannt werden
können (bestätigt: `stock.move.uom` → `stock.move.unit` zwischen 5.0 und
8.0), prüft das Skript pro Modell zuerst per `fields_get()`, ob der
YAML-Feldname auf dem jeweiligen Server existiert, und weicht sonst auf
einen bekannten, bidirektionalen Alias aus. Ein wirklich unbekanntes Feld
bleibt trotzdem ein Fehler - das ist ja gerade das gesuchte Signal für eine
echte Schema-Änderung. Weitere Umbenennungen bei Bedarf in `FIELD_RENAMES`
ergänzen, sobald ein Lauf sie aufdeckt.
**`$search`-Auflösung:** Werte der Form `{"$search": {"model": ...,
"domain": ...}}` (von `rst_to_dataset.py` erzeugt, siehe Schritt 1) werden
live per `Model.get(model).find(domain)` gegen den Zielserver aufgelöst -
für Stammdaten, die nicht selbst angelegt werden, sondern schon per
XML-Fixture da sind. Ergebnis wird pro Lauf gecacht (dieselbe Suche, z. B.
nach der Einheit "Unit", kommt in vielen Records vor). Kein Treffer ->
Fehler mit Klartext statt eines neu angelegten Records - das ist selbst
ein Signal, dass sich die Stammdaten zwischen den Versionen geändert
haben.
### 4. `compare_snapshots.py` – zwei Versionen gegenüberstellen
Vergleicht zwei Snapshots (gleiche YAML, zwei Versionen/Datenbanken) und
markiert `status_changed` (ein Record schlägt nur in einer Version fehl -
Kandidat für Format-/Constraint-/Pflichtfeld-Änderung), `value_changed`
(gespeicherter Wert weicht trotz gleichem Input ab - automatische
Reformatierung) und `missing_record`.
## Stand
- `rst_to_dataset.py` und `rsts_merge_dedup.py` sind gegen echte
`scenario_*.rst`-Dateien aus `/mnt/tryton` (5.0- und 8.0-Branch) getestet,
siehe `reports/5_testdata_merged.yaml` / `reports/8_testdata_merged.yaml`
(jeweils NUR aus den Szenarien der jeweiligen Version extrahiert, noch
NICHT zu einer gemeinsamen Datei zusammengeführt).
- `create_dataset.py` und `compare_snapshots.py` sind noch nicht gegen eine
echte Tryton-Instanz gelaufen (nur syntaktisch geprüft).
- Welche Modelle/Felder überhaupt Testdaten brauchen bzw. wo sich 5.0 und
8.0 tatsächlich unterscheiden, siehe [MODELLVERGLEICH.md](MODELLVERGLEICH.md)
- das steuert, wo als Nächstes von Hand YAML-Records ergänzt werden.
+13 -14
View File
@@ -11,18 +11,18 @@ ausführen – ohne die Module tatsächlich zu installieren/upzudaten.
```
Checkt der Reihe nach jeden Versions-Branch (`5.0` bis `8.0`) im
Tryton-Repo aus, lässt `extract.py` pro Version über alle konfigurierten
Tryton-Repo aus, lässt `extract_updates.py` pro Version über alle konfigurierten
Module laufen (Ergebnis: `data/<version>/<modul>.json`), und ruft danach
`compare.py` auf, das daraus die aggregierten Reports in `reports/*.md`
`compare_updates.py` auf, das daraus die aggregierten Reports in `reports/updateChanges/*.md`
erzeugt (ein Report pro Modul, gegliedert nach Tabelle und Version).
Einzeln aufrufbar:
```bash
python3 extract.py --repo <pfad-zum-tryton-clone> --output data
python3 compare.py
python3 extract_updates.py --repo <pfad-zum-tryton-clone> --output data
python3 compare_updates.py
```
## Was `extract.py` erkennt
## Was `extract_updates.py` erkennt
- Erkannte DB-Operationen (`column_rename`, `add_column`, `update`,
`insert`, `execute`, ... – siehe `INTERESTING_OPS`) inkl. Tabelle(n),
@@ -36,7 +36,7 @@ python3 compare.py
um bekanntes Rauschen (`Pool()`, `Transaction()`, `cursor`, `.append`,
`pool.get`, `logger.*`, `__table__`/`__table_handler__`).
## Was `extract.py` NICHT erkennt (bewusste Grenzen)
## Was `extract_updates.py` NICHT erkennt (bewusste Grenzen)
- **Cross-Datei-Aufrufe**: Ruft `__register__` eine Funktion aus einem
anderen Modul/Utility-Modul auf (z. B. `migrate_property(...)` aus
@@ -48,7 +48,7 @@ python3 compare.py
- **Implizite Schema-Änderungen** durch reine Feldtyp-Änderungen im
Python-Code (ohne expliziten `add_column`/`alter_size`-Aufruf).
## `compare.py` – Aggregation & Dedup
## `compare_updates.py` – Aggregation & Dedup
Fasst pro Tabelle die Operationen aller Versionen zusammen und zeigt eine
Operation nur dann erneut an, wenn sie sich vom **tatsächlichen,
@@ -63,17 +63,16 @@ Zeigt keine "wurde entfernt"-Markierung – nur Neues/Verändertes.
## Konfiguration
- Modul-/Repo-Defaults: oben in `extract.py` (`DEFAULT_REPO`,
- Modul-/Repo-Defaults: oben in `extract_updates.py` (`DEFAULT_REPO`,
`DEFAULT_MODULES`).
- Versions-Range: `START`/`END` in `run.sh`.
- Rauschfilter für `unresolved`: `IGNORED_UNRESOLVED_*` in `extract.py` –
- Rauschfilter für `unresolved`: `IGNORED_UNRESOLVED_*` in `extract_updates.py` –
bei Bedarf erweitern, wenn sich beim Sichten neue, eindeutig irrelevante
Muster zeigen (siehe Git-Historie für die bisherige Herleitung).
## Nächster Schritt
## Verwandter Zweig
Ergänzender, unabhängiger Zweig: Proteus-basierter Vergleich (echte
Ergänzender, unabhängiger Ansatz: Proteus-basierter Vergleich (echte
Testdaten in zwei frisch installierten Versionen anlegen, Fehler/
Reformatierung erkennen) – siehe `create_dataset.py`,
`compare_snapshots.py`, `rst_to_dataset.py` (Stand: ungetestet gegen
echte Tryton-Instanz).
Reformatierung erkennen) – siehe [doc/TESTDATEN.md](TESTDATEN.md) und
[doc/MODELLVERGLEICH.md](MODELLVERGLEICH.md).
+345
View File
@@ -0,0 +1,345 @@
#!/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.
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.
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 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."""
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
file_key = (module_name, str(path))
for node in ast.walk(tree):
if not isinstance(node, ast.ClassDef):
continue
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),
}
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
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):
# Phase 1: alle Klassen roh einsammeln (Basen, eigene Felder, __name__).
registry = {}
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
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"])
return {
model_name: {"persisted": model_name in persisted_models, "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()
View File
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""
model_coverage.py
Vergleicht den per extract_model_tree.py ermittelten Modellbaum (was gibt
es in der 5.0-Codebasis wirklich an Modellen/Feldern) mit dem aktuellen
Stand einer aus *.rst-Szenarien gemergten dataset.yaml (was wird davon
schon durch Testdaten abgedeckt).
Zeigt:
- Modelle, die es in 5.0 gibt, aber für die noch KEIN Record in der
dataset.yaml existiert (komplett unabgedeckt).
- Pro abgedecktem Modell: setzbare Felder (keine reinen Function-Felder
ohne setter), die von keinem Record verwendet werden - required
zuerst, da dort eher etwas fehlt, das für's Anlegen nötig wäre.
Für die Liste der komplett unabgedeckten Modelle werden per Default drei
Kategorien ausgeblendet, die man ohnehin nie händisch mit Testdaten
befüllt (jeweils per Flag reaktivierbar):
- ir.*/res.*-Kernmodelle (--include-framework)
- Wizard-/Transient-Formulare, die nur ModelView statt ModelSQL erben,
laut extract_model_tree.py also "persisted": false (--include-transient)
- automatisch verwaltete Many2Many-Verknüpfungstabellen, erkennbar am
Bindestrich im Modellnamen wie "party.party-party.category"
(--include-relations)
Mit --diff (Ausgabe von model_diff.py, z. B. model_diff.json aus einem
5.0->8.0-Vergleich) wird zusätzlich pro Modell vermerkt, ob es sich
zwischen den Versionen überhaupt geändert hat, und danach sortiert:
"geändert"/"entfernt" zuerst (echte Kandidaten für einen Vergleichslauf),
"nur ergänzt"/"unverändert" zuletzt (dort ist über den Codevergleich
hinaus erstmal nichts Auffälliges zu erwarten).
Nutzung:
python3 model_coverage.py --model-tree reports/model_tree_5.0.json \\
--dataset reports/5_testdata_merged.yaml --diff reports/model_diff.json \\
--report reports/coverage_5.0.md
"""
import argparse
import json
from pathlib import Path
try:
import yaml
except ImportError:
yaml = None
def load_records(path):
text = Path(path).read_text(encoding="utf-8")
if yaml is not None:
return yaml.safe_load(text)["records"]
return json.loads(text)["records"]
def used_fields_by_model(records):
used = {}
for r in records:
used.setdefault(r["model"], set()).update(r["fields"].keys())
return used
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--model-tree", required=True, help="JSON von extract_model_tree.py")
parser.add_argument("--dataset", required=True, help="Gemergte dataset.yaml/json")
parser.add_argument("--report", required=True, help="Ziel-Markdown-Datei")
parser.add_argument(
"--include-framework", action="store_true",
help="ir.*/res.*-Kernmodelle in der Liste unabgedeckter Modelle NICHT herausfiltern",
)
parser.add_argument(
"--include-transient", action="store_true",
help="Wizard-/Transient-Formulare (persisted=false) NICHT herausfiltern",
)
parser.add_argument(
"--include-relations", action="store_true",
help="Many2Many-Verknüpfungstabellen (Bindestrich im Namen) NICHT herausfiltern",
)
parser.add_argument(
"--diff", default=None,
help="Optional: model_diff.json - blendet die Diff-Kategorie ein und "
"sortiert 'geändert'/'entfernt' nach oben",
)
args = parser.parse_args()
tree = json.loads(Path(args.model_tree).read_text(encoding="utf-8"))
records = load_records(args.dataset)
used = used_fields_by_model(records)
diff = json.loads(Path(args.diff).read_text(encoding="utf-8")) if args.diff else {}
PRIORITY = {"geändert": 0, "entfernt": 1, "neu": 2, "nur ergänzt": 3, "unverändert": 4}
def priority(model_name):
if not diff:
return 0 # ohne --diff: keine Umsortierung, alphabetische Reihenfolge bleibt
category = diff.get(model_name, {}).get("category")
return PRIORITY.get(category, 5)
def diff_note(model_name):
if not diff:
return ""
category = diff.get(model_name, {}).get("category", "nicht in beiden Bäumen")
return f" _[{category}]_"
uncovered_models = []
partially_covered = []
skipped = {"framework": 0, "transient": 0, "relation": 0}
for model_name in sorted(tree):
entry = tree[model_name]
fields = entry["fields"]
settable = {name for name, info in fields.items() if info["settable"]}
if not settable:
continue # z.B. reine Log-/History-Modelle ohne setzbare Felder
if model_name not in used:
if not args.include_framework and (
model_name.startswith("ir.") or model_name.startswith("res.")
):
skipped["framework"] += 1
continue
if not args.include_transient and not entry["persisted"]:
skipped["transient"] += 1
continue
if not args.include_relations and "-" in model_name:
skipped["relation"] += 1
continue
uncovered_models.append(model_name)
continue
missing = settable - used[model_name]
if missing:
missing_required = sorted(f for f in missing if fields[f]["required"] is True)
missing_optional = sorted(f for f in missing if fields[f]["required"] is not True)
partially_covered.append((model_name, missing_required, missing_optional))
uncovered_models.sort(key=priority)
partially_covered.sort(key=lambda item: priority(item[0]))
lines = ["# Testdaten-Abdeckung gegen den 5.0-Modellbaum\n"]
lines.append(f"\n## Komplett unabgedeckte Modelle ({len(uncovered_models)})\n")
lines.append("Für diese Modelle gibt es noch gar keinen Record in der dataset.yaml:\n")
for m in uncovered_models:
lines.append(f"- `{m}`{diff_note(m)}")
lines.append(f"\n\n## Teilweise abgedeckte Modelle ({len(partially_covered)})\n")
lines.append("Modell hat mind. einen Record, aber nicht alle setzbaren Felder werden benutzt:\n")
for model_name, missing_required, missing_optional in partially_covered:
lines.append(f"\n### `{model_name}`{diff_note(model_name)}")
if missing_required:
lines.append(f"- **required, aber ungenutzt:** {', '.join(missing_required)}")
if missing_optional:
lines.append(f"- optional, ungenutzt: {', '.join(missing_optional)}")
Path(args.report).write_text("\n".join(lines) + "\n", encoding="utf-8")
skip_parts = []
if skipped["framework"]:
skip_parts.append(f"{skipped['framework']} ir.*/res.*")
if skipped["transient"]:
skip_parts.append(f"{skipped['transient']} Wizard/Transient")
if skipped["relation"]:
skip_parts.append(f"{skipped['relation']} Verknüpfungstabellen")
note = f" ({', '.join(skip_parts)} ausgeblendet)" if skip_parts else ""
print(
f"{len(uncovered_models)} Modelle komplett unabgedeckt{note}, "
f"{len(partially_covered)} teilweise abgedeckt -> {args.report}"
)
if __name__ == "__main__":
main()
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
model_diff.py
Vergleicht zwei mit extract_model_tree.py erzeugte Modellbäume (z. B.
model_tree_5.0.json gegen model_tree_8.0.json) auf Modell- und Feldebene
und sortiert jedes Modell in eine von vier Kategorien:
- entfernt: Modell gibt es in der ersten Version, aber nicht mehr
in der zweiten.
- neu: Modell gibt es erst in der zweiten Version.
- geändert: Modell existiert in beiden, aber mind. ein Feld wurde
entfernt oder hat sich (Typ/Relation/required)
geändert. Das sind die Kandidaten, bei denen sich ein
echter Testlauf gegen beide Versionen lohnt.
- unverändert / nur ergänzt: Modell ist identisch geblieben oder hat nur
zusätzliche Felder bekommen - hier ist über den reinen
Code-Vergleich hinaus erstmal nichts Auffälliges zu
erwarten (Laufzeitverhalten wie Constraints/Defaults
kann natürlich trotzdem abweichen, das sieht man mit
dieser rein statischen Analyse nicht).
Nutzung:
python3 model_diff.py --tree-a reports/model_tree_5.0.json --label-a 5.0 \\
--tree-b reports/model_tree_8.0.json --label-b 8.0 \\
--output reports/model_diff.json --report reports/model_diff.md
"""
import argparse
import json
from pathlib import Path
def diff_field(fa, fb):
changes = []
if fa["type"] != fb["type"]:
changes.append(f"type: {fa['type']} -> {fb['type']}")
if fa["relation"] != fb["relation"]:
changes.append(f"relation: {fa['relation']} -> {fb['relation']}")
if fa["required"] != fb["required"]:
changes.append(f"required: {fa['required']} -> {fb['required']}")
return changes
def diff_trees(tree_a, tree_b):
result = {}
for model_name in sorted(set(tree_a) | set(tree_b)):
if model_name not in tree_b:
result[model_name] = {"category": "entfernt", "added": [], "removed": [], "changed": {}}
continue
if model_name not in tree_a:
result[model_name] = {"category": "neu", "added": [], "removed": [], "changed": {}}
continue
fields_a = tree_a[model_name]["fields"]
fields_b = tree_b[model_name]["fields"]
added = sorted(set(fields_b) - set(fields_a))
removed = sorted(set(fields_a) - set(fields_b))
changed = {}
for name in sorted(set(fields_a) & set(fields_b)):
c = diff_field(fields_a[name], fields_b[name])
if c:
changed[name] = c
if removed or changed:
category = "geändert"
elif added:
category = "nur ergänzt"
else:
category = "unverändert"
result[model_name] = {
"category": category,
"added": added,
"removed": removed,
"changed": changed,
}
return result
def write_report(diff, out_path, label_a, label_b):
by_category = {}
for model_name, info in diff.items():
by_category.setdefault(info["category"], []).append(model_name)
lines = [f"# Modellbaum-Diff: {label_a} -> {label_b}\n"]
for category, count_note in [
("entfernt", "Modell existiert nicht mehr"),
("geändert", "Feld(er) entfernt oder geändert - Kandidat für einen echten Vergleichslauf"),
("neu", f"Modell gibt es erst ab {label_b}"),
("nur ergänzt", "nur neue Felder dazugekommen, Rest unverändert"),
("unverändert", "komplett identisch geblieben"),
]:
models = sorted(by_category.get(category, []))
lines.append(f"\n## {category} ({len(models)}) - {count_note}\n")
for model_name in models:
info = diff[model_name]
if category == "geändert":
lines.append(f"\n### `{model_name}`")
if info["removed"]:
lines.append(f"- entfernte Felder: {', '.join(info['removed'])}")
for field_name, changes in info["changed"].items():
lines.append(f"- **{field_name}**: {'; '.join(changes)}")
if info["added"]:
lines.append(f"- (zusätzlich neue Felder: {', '.join(info['added'])})")
else:
lines.append(f"- `{model_name}`")
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("--tree-a", required=True)
parser.add_argument("--tree-b", required=True)
parser.add_argument("--label-a", default="A")
parser.add_argument("--label-b", default="B")
parser.add_argument("--output", default="reports/model_diff.json")
parser.add_argument("--report", default="reports/model_diff.md")
args = parser.parse_args()
tree_a = json.loads(Path(args.tree_a).read_text(encoding="utf-8"))
tree_b = json.loads(Path(args.tree_b).read_text(encoding="utf-8"))
diff = diff_trees(tree_a, tree_b)
Path(args.output).write_text(json.dumps(diff, indent=2, ensure_ascii=False, sort_keys=True), encoding="utf-8")
write_report(diff, Path(args.report), args.label_a, args.label_b)
counts = {}
for info in diff.values():
counts[info["category"]] = counts.get(info["category"], 0) + 1
summary = ", ".join(f"{v} {k}" for k, v in counts.items())
print(f"{summary} -> {args.output}, {args.report}")
if __name__ == "__main__":
main()
+775
View File
@@ -0,0 +1,775 @@
{
"records": [
{
"key": "scenario_party_erase__party",
"model": "party.party",
"fields": {
"name": "Pam",
"active": false
}
},
{
"key": "scenario_party_erase__attachment",
"model": "ir.attachment",
"fields": {
"resource": {
"$ref": "scenario_party_erase__party"
},
"name": "Attachment"
}
},
{
"key": "scenario_party_replace__party1",
"model": "party.party",
"fields": {
"name": "Pam"
}
},
{
"key": "scenario_stock_lot_shipment_out__customer",
"model": "party.party",
"fields": {
"name": "Customer"
}
},
{
"key": "scenario_stock_product_location__supplier",
"model": "party.party",
"fields": {
"name": "Suppier"
}
},
{
"key": "scenario_stock_reporting__supplier",
"model": "party.party",
"fields": {
"name": "Supplier"
}
},
{
"key": "scenario_stock_shipment_internal__employee_party",
"model": "party.party",
"fields": {
"name": "Employee"
}
},
{
"key": "scenario_stock_shipment_internal__employee",
"model": "company.employee",
"fields": {
"party": {
"$ref": "scenario_stock_shipment_internal__employee_party"
}
}
},
{
"key": "scenario_stock_shipment_out__employee",
"model": "company.employee",
"fields": {
"party": {
"$ref": "scenario_stock_shipment_internal__employee_party"
}
}
},
{
"key": "scenario_production__template",
"model": "product.template",
"fields": {
"name": "product",
"default_uom": {
"$search": {
"model": "product.uom",
"domain": [
[
"name",
"=",
"Unit"
]
]
}
},
"type": "goods",
"producible": true,
"list_price": {
"$decimal": "30"
}
}
},
{
"key": "scenario_production__template1",
"model": "product.template",
"fields": {
"name": "component 1",
"default_uom": {
"$search": {
"model": "product.uom",
"domain": [
[
"name",
"=",
"Unit"
]
]
}
},
"type": "goods",
"list_price": {
"$decimal": "5"
}
}
},
{
"key": "scenario_production__template2",
"model": "product.template",
"fields": {
"name": "component 2",
"default_uom": {
"$search": {
"model": "product.uom",
"domain": [
[
"name",
"=",
"Meter"
]
]
}
},
"type": "goods",
"list_price": {
"$decimal": "7"
}
}
},
{
"key": "scenario_stock_average_cost_price__template",
"model": "product.template",
"fields": {
"name": "Product",
"default_uom": {
"$search": {
"model": "product.uom",
"domain": [
[
"name",
"=",
"Unit"
]
]
}
},
"type": "goods",
"list_price": {
"$decimal": "300"
},
"cost_price_method": "average"
}
},
{
"key": "scenario_stock_inventory__template2",
"model": "product.template",
"fields": {
"name": "Product",
"default_uom": {
"$search": {
"model": "product.uom",
"domain": [
[
"name",
"=",
"Kilogram"
]
]
}
},
"type": "goods",
"list_price": {
"$decimal": "140"
},
"cost_price_method": "average"
}
},
{
"key": "scenario_stock_lot_shipment_out__template",
"model": "product.template",
"fields": {
"name": "Product",
"default_uom": {
"$search": {
"model": "product.uom",
"domain": [
[
"name",
"=",
"Unit"
]
]
}
},
"type": "goods",
"list_price": {
"$decimal": "20"
}
}
},
{
"key": "scenario_production__bom",
"model": "production.bom",
"fields": {
"name": "product"
}
},
{
"key": "scenario_production__production",
"model": "production",
"fields": {
"planned_date": {
"$manual": "today"
},
"product": {
"$manual": "product"
},
"bom": {
"$ref": "scenario_production__bom"
},
"quantity": 2
}
},
{
"key": "scenario_production__production_lead_time",
"model": "production.lead_time",
"fields": {
"product": {
"$manual": "product"
},
"bom": {
"$ref": "scenario_production__bom"
},
"lead_time": {
"$manual": "datetime.timedelta(1)"
}
}
},
{
"key": "scenario_stock_shipment_internal__stock_user",
"model": "res.user",
"fields": {
"name": "Stock",
"login": "stock",
"main_company": {
"$manual": "company"
},
"employee": {
"$ref": "scenario_stock_shipment_internal__employee"
}
}
},
{
"key": "scenario_stock_shipment_out__stock_user",
"model": "res.user",
"fields": {
"name": "Stock",
"login": "stock",
"main_company": {
"$manual": "company"
},
"employee": {
"$ref": "scenario_stock_shipment_out__employee"
}
}
},
{
"key": "scenario_stock_inventory__inventory",
"model": "stock.inventory",
"fields": {
"location": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"STO"
]
]
}
},
"empty_quantity": "keep"
}
},
{
"key": "scenario_stock_inventory_empty_quantity__inventory",
"model": "stock.inventory",
"fields": {
"location": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"STO"
]
]
}
},
"empty_quantity": "empty"
}
},
{
"key": "scenario_stock_average_cost_price__storage_sub_loc",
"model": "stock.location",
"fields": {
"name": "Storage Sub",
"type": "storage",
"parent": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"STO"
]
]
}
}
}
},
{
"key": "scenario_stock_product_location__child_loc",
"model": "stock.location",
"fields": {
"name": "Child Location",
"parent": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"STO"
]
]
}
},
"code": "CHI"
}
},
{
"key": "scenario_stock_shipment_in_same_storage_input__storage1",
"model": "stock.location",
"fields": {
"name": "Storage 1",
"parent": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"STO"
]
]
}
}
}
},
{
"key": "scenario_stock_shipment_internal__internal_loc",
"model": "stock.location",
"fields": {
"name": "Internal",
"type": "storage"
}
},
{
"key": "scenario_stock_shipment_internal__lead_time",
"model": "stock.location.lead_time",
"fields": {
"from_warehouse": {
"$manual": "storage_loc.warehouse"
},
"to_warehouse": {
"$manual": "storage_loc.warehouse"
},
"lead_time": {
"$manual": "datetime.timedelta(1)"
}
}
},
{
"key": "scenario_stock_average_cost_price__incoming_move",
"model": "stock.move",
"fields": {
"product": {
"$manual": "product"
},
"uom": {
"$search": {
"model": "product.uom",
"domain": [
[
"name",
"=",
"Unit"
]
]
}
},
"quantity": 1,
"from_location": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"SUP"
]
]
}
},
"to_location": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"STO"
]
]
}
},
"planned_date": {
"$manual": "today"
},
"effective_date": {
"$manual": "today"
},
"company": {
"$manual": "company"
},
"unit_price": {
"$decimal": "200"
},
"currency": {
"$manual": "company.currency"
}
}
},
{
"key": "scenario_stock_product_location__shipment_in",
"model": "stock.shipment.in",
"fields": {
"planned_date": {
"$manual": "today"
},
"supplier": {
"$ref": "scenario_stock_product_location__supplier"
},
"warehouse": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"WH"
]
]
}
},
"company": {
"$manual": "company"
}
}
},
{
"key": "scenario_stock_reporting__shipment_in",
"model": "stock.shipment.in",
"fields": {
"planned_date": {
"$manual": "today"
},
"supplier": {
"$ref": "scenario_stock_reporting__supplier"
},
"warehouse": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"WH"
]
]
}
},
"company": {
"$manual": "company"
}
}
},
{
"key": "scenario_stock_shipment_in_same_storage_input__shipment_in",
"model": "stock.shipment.in",
"fields": {
"supplier": {
"$ref": "scenario_stock_reporting__supplier"
},
"warehouse": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"WH"
]
]
}
},
"company": {
"$manual": "company"
}
}
},
{
"key": "scenario_stock_reporting__shipment_internal",
"model": "stock.shipment.internal",
"fields": {
"planned_date": {
"$manual": "today"
},
"company": {
"$manual": "company"
},
"from_location": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"STO"
]
]
}
},
"to_location": {
"$search": {
"model": "stock.location",
"domain": [
[
"type",
"=",
"lost_found"
]
]
}
}
}
},
{
"key": "scenario_stock_shipment_internal__shipment",
"model": "stock.shipment.internal",
"fields": {
"planned_date": {
"$manual": "today"
},
"from_location": {
"$ref": "scenario_stock_shipment_internal__internal_loc"
},
"to_location": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"STO"
]
]
}
}
}
},
{
"key": "scenario_stock_lot_shipment_out__shipment_out",
"model": "stock.shipment.out",
"fields": {
"planned_date": {
"$manual": "today"
},
"customer": {
"$ref": "scenario_stock_lot_shipment_out__customer"
},
"warehouse": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"WH"
]
]
}
},
"company": {
"$manual": "company"
}
}
},
{
"key": "scenario_stock_reporting__shipment_out",
"model": "stock.shipment.out",
"fields": {
"planned_date": {
"$manual": "today"
},
"customer": {
"$ref": "scenario_stock_lot_shipment_out__customer"
},
"warehouse": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"WH"
]
]
}
},
"company": {
"$manual": "company"
}
}
},
{
"key": "scenario_stock_shipment_out__shipment_out",
"model": "stock.shipment.out",
"fields": {
"planned_date": {
"$manual": "today"
},
"customer": {
"$ref": "scenario_stock_lot_shipment_out__customer"
},
"warehouse": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"WH"
]
]
}
},
"company": {
"$manual": "company"
}
}
},
{
"key": "scenario_stock_shipment_out_same_storage_output__shipment_out",
"model": "stock.shipment.out",
"fields": {
"customer": {
"$ref": "scenario_stock_lot_shipment_out__customer"
},
"warehouse": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"WH"
]
]
}
},
"company": {
"$manual": "company"
}
}
},
{
"key": "scenario_stock_split_shipment__shipment1",
"model": "stock.shipment.out",
"fields": {
"customer": {
"$ref": "scenario_stock_lot_shipment_out__customer"
}
}
},
{
"key": "scenario_stock_product_location__shipment_out_return",
"model": "stock.shipment.out.return",
"fields": {
"customer": {
"$ref": "scenario_stock_lot_shipment_out__customer"
},
"supplier": {
"$ref": "scenario_stock_product_location__supplier"
},
"from_location": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"OUT"
]
]
}
},
"to_location": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"SUP"
]
]
}
}
}
},
{
"key": "scenario_stock_shipment_out_return_same_storage_input__shipment",
"model": "stock.shipment.out.return",
"fields": {
"customer": {
"$ref": "scenario_stock_lot_shipment_out__customer"
},
"warehouse": {
"$search": {
"model": "stock.location",
"domain": [
[
"code",
"=",
"WH"
]
]
}
},
"company": {
"$manual": "company"
}
}
}
]
}
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
# Testdaten-Abdeckung gegen den 5.0-Modellbaum
## Komplett unabgedeckte Modelle (36)
Für diese Modelle gibt es noch gar keinen Record in der dataset.yaml:
- `company.company` _[geändert]_
- `country.subdivision` _[geändert]_
- `currency.currency` _[geändert]_
- `currency.currency.rate` _[geändert]_
- `party.address` _[geändert]_
- `party.address.format` _[geändert]_
- `party.contact_mechanism` _[geändert]_
- `product.cost_price` _[geändert]_
- `product.list_price` _[geändert]_
- `product.product` _[geändert]_
- `production.bom.input` _[geändert]_
- `production.bom.output` _[geändert]_
- `stock.inventory.line` _[geändert]_
- `stock.product.location` _[geändert]_
- `country.zip` _[entfernt]_
- `stock.lot.type` _[entfernt]_
- `country.country` _[nur ergänzt]_
- `party.configuration` _[nur ergänzt]_
- `party.identifier` _[nur ergänzt]_
- `product.category` _[nur ergänzt]_
- `product.configuration` _[nur ergänzt]_
- `production.configuration` _[nur ergänzt]_
- `stock.configuration` _[nur ergänzt]_
- `stock.lot` _[nur ergänzt]_
- `stock.shipment.in.return` _[nur ergänzt]_
- `party.category` _[unverändert]_
- `party.party.lang` _[unverändert]_
- `party.party.location` _[unverändert]_
- `product.cost_price_method` _[unverändert]_
- `product.uom` _[unverändert]_
- `product.uom.category` _[unverändert]_
- `production.configuration.production_sequence` _[unverändert]_
- `stock.configuration.sequence` _[unverändert]_
- `stock.period` _[unverändert]_
- `stock.period.cache` _[unverändert]_
- `stock.period.cache.lot` _[unverändert]_
## Teilweise abgedeckte Modelle (15)
Modell hat mind. einen Record, aber nicht alle setzbaren Felder werden benutzt:
### `product.template` _[geändert]_
- optional, ungenutzt: categories, categories_all, consumable, cost_price_methods, expiration_state, expiration_time, height, height_uom, length, length_uom, list_prices, lot_required, products, shelf_life_state, shelf_life_time, volume, volume_uom, weight, weight_uom, width, width_uom
### `production` _[geändert]_
- **required, aber ungenutzt:** company, location, warehouse
- optional, ungenutzt: effective_date, effective_start_date, inputs, number, outputs, planned_start_date, reference, state, uom
### `res.user` _[geändert]_
- **required, aber ungenutzt:** menu
- optional, ungenutzt: actions, applications, company, email, employees, groups, language, password, password_hash, password_reset, password_reset_expire, signature, warnings
### `stock.inventory` _[geändert]_
- **required, aber ungenutzt:** company, date, lost_found
- optional, ungenutzt: lines, number, state
### `stock.move` _[geändert]_
- **required, aber ungenutzt:** internal_quantity
- optional, ungenutzt: cost_price, internal_volume, internal_weight, lot, origin, production_input, production_output, shipment, state
### `stock.shipment.in` _[geändert]_
- optional, ungenutzt: contact_address, effective_date, incoming_moves, inventory_moves, moves, number, reference, state
### `stock.shipment.out` _[geändert]_
- **required, aber ungenutzt:** delivery_address
- optional, ungenutzt: effective_date, inventory_moves, moves, number, outgoing_moves, reference, state
### `stock.shipment.out.return` _[geändert]_
- **required, aber ungenutzt:** delivery_address
- optional, ungenutzt: effective_date, incoming_moves, inventory_moves, moves, number, planned_date, reference, state
### `company.employee` _[nur ergänzt]_
- **required, aber ungenutzt:** company
- optional, ungenutzt: end_date, start_date
### `party.party` _[nur ergänzt]_
- **required, aber ungenutzt:** code
- optional, ungenutzt: addresses, categories, contact_mechanisms, customer_location, identifiers, lang, langs, locations, replaced_by, supplier_location
### `production.bom` _[nur ergänzt]_
- optional, ungenutzt: inputs, output_products, outputs
### `stock.location` _[nur ergänzt]_
- **required, aber ungenutzt:** left, right
- optional, ungenutzt: address, childs, flat_childs, input_location, output_location, picking_location, production_location, storage_location
### `stock.shipment.internal` _[nur ergänzt]_
- optional, ungenutzt: effective_date, effective_start_date, incoming_moves, moves, number, outgoing_moves, planned_start_date, reference, state
### `ir.attachment` _[unverändert]_
- **required, aber ungenutzt:** type
- optional, ungenutzt: data, description, file_id, link
### `stock.location.lead_time` _[unverändert]_
- optional, ungenutzt: warehouse_from, warehouse_to
File diff suppressed because it is too large Load Diff
+294
View File
@@ -0,0 +1,294 @@
# SQL-Dump-Diff: 5.0 -> 8.0
## Tabellen entfernt (4)
- `country_zip` _(`country.zip`=entfernt [übereinstimmung])_
- `cron_company_rel` _(kein passendes Modell in model_diff gefunden)_
- `party_category_rel` _(kein passendes Modell in model_diff gefunden)_
- `stock_lot_type` _(`stock.lot.type`=entfernt [übereinstimmung])_
## Tabellen neu (31)
- `company_company_logo_cache` _(`company.company.logo.cache`=neu [übereinstimmung])_
- `company_company_tax_identifier` _(`company.company.tax_identifier`=neu [übereinstimmung])_
- `country_organization` _(`country.organization`=neu [übereinstimmung])_
- `country_organization_member` _(`country.organization.member`=neu [übereinstimmung])_
- `country_postal_code` _(`country.postal_code`=neu [übereinstimmung])_
- `country_region` _(`country.region`=neu [übereinstimmung])_
- `currency_cron` _(`currency.cron`=neu [übereinstimmung])_
- `ir_avatar` _(`ir.avatar`=neu [übereinstimmung])_
- `ir_avatar_cache` _(`ir.avatar.cache`=neu [übereinstimmung])_
- `ir_calendar_day` _(`ir.calendar.day`=neu [übereinstimmung])_
- `ir_calendar_month` _(`ir.calendar.month`=neu [übereinstimmung])_
- `ir_chat_channel` _(`ir.chat.channel`=neu [übereinstimmung])_
- `ir_chat_follower` _(`ir.chat.follower`=neu [übereinstimmung])_
- `ir_chat_message` _(`ir.chat.message`=neu [übereinstimmung])_
- `ir_cron_log` _(`ir.cron.log`=neu [übereinstimmung])_
- `ir_email` _(`ir.email`=neu [übereinstimmung])_
- `ir_email_address` _(`ir.email.address`=neu [übereinstimmung])_
- `ir_email_template` _(`ir.email.template`=neu [übereinstimmung])_
- `ir_error` _(`ir.error`=neu [übereinstimmung])_
- `ir_filestore_queue` _(`ir.filestore.queue`=neu [übereinstimmung])_
- `ir_message` _(`ir.message`=neu [übereinstimmung])_
- `ir_model_log` _(`ir.model.log`=neu [übereinstimmung])_
- `ir_ui_view_tree_optional` _(`ir.ui.view_tree_optional`=neu [übereinstimmung])_
- `party_address_subdivision_type` _(`party.address.subdivision_type`=neu [übereinstimmung])_
- `party_contact_mechanism_language` _(`party.contact_mechanism.language`=neu [übereinstimmung])_
- `product_configuration_default_lot_sequence` _(`product.configuration.default_lot_sequence`=neu [übereinstimmung])_
- `product_cost_price_revision` _(`product.cost_price.revision`=neu [übereinstimmung])_
- `product_identifier` _(`product.identifier`=neu [übereinstimmung])_
- `res_notification` _(`res.notification`=neu [übereinstimmung])_
- `res_user_device` _(`res.user.device`=neu [übereinstimmung])_
- `stock_location_waste` _(`stock.location.waste`=neu [übereinstimmung])_
## Tabellen mit Schema-Änderung (59)
Spalten wurden hinzugefügt/entfernt oder haben einen anderen Typ - vergleichbar mit einer model_diff.py-Kategorie.
### `company_company` _(`company.company`=geändert [übereinstimmung])_
- neue Spalten: logo
- entfernte Spalten: parent
### `company_employee` _(`company.employee`=nur ergänzt [übereinstimmung])_
- neue Spalten: supervisor
### `country_country` _(`country.country`=nur ergänzt [übereinstimmung])_
- neue Spalten: active, region
### `country_subdivision` _(`country.subdivision`=geändert [ABWEICHUNG])_
- neue Spalten: active
- Zeilenzahl: 4835 -> 5046
### `ir_action` _(`ir.action`=geändert [ABWEICHUNG])_
- neue Spalten: records
- Zeilenzahl: 122 -> 167
### `ir_action_report` _(`ir.action.report`=geändert [übereinstimmung])_
- neue Spalten: record_name
- entfernte Spalten: email
### `ir_action_wizard` _(`ir.action.wizard`=geändert [übereinstimmung])_
- entfernte Spalten: email
- Zeilenzahl: 25 -> 32
### `ir_attachment` _(`ir.attachment`=unverändert [ABWEICHUNG])_
- neue Spalten: copy_to_resources
### `ir_cache` _(`ir.cache`=unverändert [ABWEICHUNG])_
- **create_date**: timestamp(6) without time zone -> timestamp without time zone
- **write_date**: timestamp(6) without time zone -> timestamp without time zone
- Zeilenzahl: 21 -> 43
### `ir_configuration` _(`ir.configuration`=nur ergänzt [übereinstimmung])_
- neue Spalten: product_price_decimal, series
### `ir_cron` _(`ir.cron`=geändert [übereinstimmung])_
- neue Spalten: day, hour, method, minute, weekday
- entfernte Spalten: args, function, model, name, number_calls, repeat_missed, request_user, user
- Zeilenzahl: 1 -> 10
### `ir_export` _(`ir.export`=nur ergänzt [übereinstimmung])_
- neue Spalten: header, ignore_search_limit, records, user
### `ir_lang` _(`ir.lang`=nur ergänzt [übereinstimmung])_
- neue Spalten: am, nplurals, pg_text_search, plural, pm
- Zeilenzahl: 19 -> 25
### `ir_model` _(`ir.model`=geändert [übereinstimmung])_
- neue Spalten: string
- entfernte Spalten: model
- Zeilenzahl: 159 -> 215
### `ir_model_access` _(`ir.model.access`=geändert [übereinstimmung])_
- neue Spalten: active
- **model**: integer -> character varying
- Zeilenzahl: 138 -> 163
### `ir_model_button` _(`ir.model.button`=geändert [übereinstimmung])_
- neue Spalten: active
- **model**: integer -> character varying
- Zeilenzahl: 67 -> 80
### `ir_model_data` _(`ir.model.data`=geändert [übereinstimmung])_
- neue Spalten: field_names
- entfernte Spalten: fs_values, values
- Zeilenzahl: 6583 -> 2080
### `ir_model_field` _(`ir.model.field`=geändert [übereinstimmung])_
- neue Spalten: access, string
- entfernte Spalten: field_description
- **model**: integer -> character varying
- Zeilenzahl: 1709 -> 2858
### `ir_model_field_access` _(`ir.model.field.access`=geändert [übereinstimmung])_
- neue Spalten: active, model
- **field**: integer -> character varying
### `ir_note` _(`ir.note`=unverändert [ABWEICHUNG])_
- neue Spalten: copy_to_resources
### `ir_queue` _(`ir.queue`=unverändert [ABWEICHUNG])_
- **data**: text -> jsonb
### `ir_rule_group` _(`ir.rule.group`=geändert [übereinstimmung])_
- **model**: integer -> character varying
- Zeilenzahl: 26 -> 34
### `ir_sequence` _(`ir.sequence`=geändert [übereinstimmung])_
- neue Spalten: sequence_type
- entfernte Spalten: code
### `ir_sequence_strict` _(`ir.sequence.strict`=geändert [übereinstimmung])_
- neue Spalten: sequence_type
- entfernte Spalten: code
### `ir_sequence_type` _(`ir.sequence.type`=geändert [übereinstimmung])_
- entfernte Spalten: code
- Zeilenzahl: 8 -> 13
### `ir_session` _(`ir.session`=nur ergänzt [übereinstimmung])_
- neue Spalten: ip_address
### `ir_translation` _(`ir.translation`=geändert [übereinstimmung])_
- neue Spalten: src_plural, value_1, value_2, value_3
- entfernte Spalten: src_md5
- Zeilenzahl: 8173 -> 8940
### `ir_trigger` _(`ir.trigger`=geändert [übereinstimmung])_
- neue Spalten: action, on_create_, on_delete_, on_time_, on_write_
- entfernte Spalten: action_function, action_model, on_create, on_delete, on_time, on_write
### `ir_ui_menu_favorite` _(`ir.ui.menu.favorite`=unverändert [ABWEICHUNG])_
- entfernte Spalten: sequence
### `ir_ui_view_tree_width` _(`ir.ui.view_tree_width`=geändert [ABWEICHUNG])_
- neue Spalten: occurrence, screen_width
### `party_address` _(`party.address`=geändert [übereinstimmung])_
- neue Spalten: attn, building_name, building_number, floor_number, post_box, post_office, postal_code, private_bag, room_number, street_name, street_unstructured, unit_number
- entfernte Spalten: name, street, zip
### `party_address_format` _(`party.address.format`=geändert [übereinstimmung])_
- neue Spalten: building_number_format, country_code, floor_number_format, language_code, post_box_format, post_office_format, private_bag_format, room_number_format, street_format, unit_number_format
- entfernte Spalten: country, language
- Zeilenzahl: 66 -> 222
### `party_configuration` _(`party.configuration`=nur ergänzt [übereinstimmung])_
- neue Spalten: identifier_types
### `party_contact_mechanism` _(`party.contact_mechanism`=geändert [ABWEICHUNG])_
- neue Spalten: address, delivery
### `party_identifier` _(`party.identifier`=nur ergänzt [übereinstimmung])_
- neue Spalten: active, address, code_compact
### `party_party` _(`party.party`=nur ergänzt [übereinstimmung])_
- neue Spalten: code_alnum, code_digit
### `product_category` _(`product.category`=nur ergänzt [übereinstimmung])_
- neue Spalten: code
### `product_configuration` _(`product.configuration`=nur ergänzt [übereinstimmung])_
- neue Spalten: category_sequence, product_sequence, template_sequence
### `product_list_price` _(`product.list_price`=geändert [ABWEICHUNG])_
- neue Spalten: product
### `product_product` _(`product.product`=geändert [ABWEICHUNG])_
- neue Spalten: position, replaced_by, suffix_code
### `product_template` _(`product.template`=geändert [ABWEICHUNG])_
- neue Spalten: code, lot_required, lot_sequence
### `production` _(`production`=geändert [übereinstimmung])_
- neue Spalten: assigned_by, done_by, origin, origin_planned_date, run_by, type, unit
- entfernte Spalten: uom
### `production_bom` _(`production.bom`=nur ergänzt [übereinstimmung])_
- neue Spalten: code, phantom, phantom_quantity, phantom_unit
### `production_bom_input` _(`production.bom.input`=geändert [übereinstimmung])_
- neue Spalten: phantom_bom, unit
- entfernte Spalten: uom
### `production_bom_output` _(`production.bom.output`=geändert [übereinstimmung])_
- neue Spalten: phantom_bom, unit
- entfernte Spalten: uom
### `production_configuration` _(`production.configuration`=nur ergänzt [übereinstimmung])_
- neue Spalten: bom_sequence
### `res_group` _(`res.group`=nur ergänzt [übereinstimmung])_
- neue Spalten: active, parent
- Zeilenzahl: 10 -> 13
### `res_user` _(`res.user`=geändert [übereinstimmung])_
- neue Spalten: company_filter, notifications, warehouse
- entfernte Spalten: main_company
- Zeilenzahl: 3 -> 2
### `res_user_login_attempt` _(`res.user.login.attempt`=nur ergänzt [übereinstimmung])_
- neue Spalten: device_cookie
### `stock_inventory` _(`stock.inventory`=geändert [übereinstimmung])_
- entfernte Spalten: lost_found
### `stock_location` _(`stock.location`=nur ergänzt [übereinstimmung])_
- neue Spalten: allow_pickup, lost_found_location, production_output_location, production_picking_location
### `stock_lot` _(`stock.lot`=nur ergänzt [übereinstimmung])_
- neue Spalten: active
### `stock_move` _(`stock.move`=geändert [übereinstimmung])_
- neue Spalten: origin_planned_date, product_cost_price, production_cost_price_updated, unit, unit_price_updated
- entfernte Spalten: internal_volume, internal_weight, uom
### `stock_product_location` _(`stock.product.location`=geändert [ABWEICHUNG])_
- neue Spalten: template
### `stock_shipment_in` _(`stock.shipment.in`=geändert [ABWEICHUNG])_
- neue Spalten: origin_planned_date, warehouse_input, warehouse_storage
### `stock_shipment_in_return` _(`stock.shipment.in.return`=nur ergänzt [übereinstimmung])_
- neue Spalten: origin_planned_date
### `stock_shipment_internal` _(`stock.shipment.internal`=nur ergänzt [übereinstimmung])_
- neue Spalten: internal_transit_location, origin_planned_date, packed_by
### `stock_shipment_out` _(`stock.shipment.out`=geändert [übereinstimmung])_
- neue Spalten: origin_planned_date, picked_by, shipped_by, warehouse_output, warehouse_storage
- entfernte Spalten: assigned_by
### `stock_shipment_out_return` _(`stock.shipment.out.return`=geändert [übereinstimmung])_
- neue Spalten: contact_address, origin_planned_date, warehouse_input, warehouse_storage
- entfernte Spalten: delivery_address
## Nur Zeilenzahl unterschiedlich, Schema identisch (15)
Keine Code-Definitionsänderung zu erwarten (z. B. mehr XML-Fixture-Daten in der neueren Version) - daher kein model_diff-Abgleich.
- `currency_currency`: 170 -> 178
- `currency_currency_rate`: 1 -> 0
- `ir_action_act_window`: 87 -> 125
- `ir_action_act_window_domain`: 39 -> 47
- `ir_action_act_window_view`: 146 -> 225
- `ir_action_keyword`: 117 -> 165
- `ir_module`: 138 -> 219
- `ir_module_config_wizard_item`: 3 -> 4
- `ir_module_dependency`: 587 -> 951
- `ir_rule`: 34 -> 38
- `ir_ui_icon`: 16 -> 20
- `ir_ui_menu`: 93 -> 108
- `ir_ui_view`: 261 -> 367
- `product_uom`: 32 -> 35
- `product_uom_category`: 6 -> 7
## Unverändert (30)
`ir_action_url`, `ir_export_line`, `ir_model_button_click`, `ir_model_button_rule`, `ir_note_read`, `ir_session_wizard`, `ir_trigger_log`, `ir_ui_view_search`, `ir_ui_view_tree_state`, `party_category`, `party_configuration_party_lang`, `party_configuration_party_sequence`, `party_party_lang`, `party_party_location`, `product_configuration_default_cost_price_method`, `product_cost_price`, `product_cost_price_method`, `production_configuration_production_sequence`, `production_lead_time`, `res_user_application`, `res_user_warning`, `stock_configuration`, `stock_configuration_location`, `stock_configuration_sequence`, `stock_inventory_line`, `stock_location_lead_time`, `stock_location_lot_shelf_life`, `stock_period`, `stock_period_cache`, `stock_period_cache_lot`
File diff suppressed because it is too large Load Diff
+458
View File
@@ -0,0 +1,458 @@
# Modellbaum-Diff: 5.0 -> 8.0
## entfernt (12) - Modell existiert nicht mehr
- `country.zip`
- `ir.model.field-res.group`
- `product.template-stock.lot.type`
- `production.assign`
- `production.assign.failed`
- `stock.lot.type`
- `stock.shipment.in.return.assign`
- `stock.shipment.in.return.assign.failed`
- `stock.shipment.internal.assign`
- `stock.shipment.internal.assign.failed`
- `stock.shipment.out.assign`
- `stock.shipment.out.assign.failed`
## geändert (55) - Feld(er) entfernt oder geändert - Kandidat für einen echten Vergleichslauf
### `company.company`
- entfernte Felder: childs, parent
- (zusätzlich neue Felder: logo, logo_cache, tax_identifiers)
### `country.subdivision`
- **code**: required: True -> None
- **type**: required: True -> None
- (zusätzlich neue Felder: flag)
### `currency.currency`
- **symbol**: required: True -> None
### `currency.currency.rate`
- **currency**: required: None -> True
- **rate**: required: None -> True
### `ir.action`
- **type**: type: Char -> Selection
- (zusätzlich neue Felder: action, records)
### `ir.action.act_window`
- **domains**: type: Function[Binary] -> Function[Field]
- **views**: type: Function[Binary] -> Function[Field]
### `ir.action.act_window.domain`
- entfernte Felder: sequence
### `ir.action.act_window.view`
- entfernte Felder: sequence
- **act_window**: required: None -> True
- (zusätzlich neue Felder: model)
### `ir.action.keyword`
- **action**: required: None -> True
- **groups**: type: Function[One2Many] -> Function[Many2Many]
### `ir.action.report`
- entfernte Felder: email, pyson_email
- (zusätzlich neue Felder: record_name, report_content_html)
### `ir.action.wizard`
- entfernte Felder: email
### `ir.cron`
- entfernte Felder: args, function, model, name, number_calls, repeat_missed, request_user, user
- **interval_type**: required: None -> True
- **next_call**: required: True -> None
- (zusätzlich neue Felder: day, hour, logs, method, minute, running, timezone, weekday)
### `ir.model`
- entfernte Felder: info, model
- **fields**: required: True -> None
- **name**: required: None -> True
- (zusätzlich neue Felder: string)
### `ir.model.access`
- **model**: type: Many2One -> Char; relation: ir.model -> None
### `ir.model.button`
- **model**: type: Many2One -> Char; relation: ir.model -> None
### `ir.model.data`
- entfernte Felder: fs_values, out_of_sync, values
- **db_id**: required: True -> None
- (zusätzlich neue Felder: field_names)
### `ir.model.field`
- entfernte Felder: field_description, groups
- **model**: type: Many2One -> Char; relation: ir.model -> None
- (zusätzlich neue Felder: access, string)
### `ir.model.field.access`
- **field**: type: Many2One -> Char; relation: ir.model.field -> None
- (zusätzlich neue Felder: model)
### `ir.module`
- **childs**: type: Function[One2Many] -> Function[Many2Many]
- **parents**: type: Function[One2Many] -> Function[Many2Many]
### `ir.module.config_wizard.item`
- entfernte Felder: sequence
### `ir.rule.group`
- **model**: type: Many2One -> Char; relation: ir.model -> None
- **name**: required: None -> True
### `ir.sequence`
- entfernte Felder: code, groups
- (zusätzlich neue Felder: preview, sequence_type)
### `ir.sequence.strict`
- entfernte Felder: code, groups
- (zusätzlich neue Felder: preview, sequence_type)
### `ir.sequence.type`
- entfernte Felder: code
### `ir.translation`
- entfernte Felder: src_md5
- (zusätzlich neue Felder: field, src_plural, value_1, value_2, value_3)
### `ir.trigger`
- entfernte Felder: action_function, action_model, on_create, on_delete, on_time, on_write
- (zusätzlich neue Felder: action, on_create_, on_delete_, on_time_, on_write_)
### `ir.ui.icon`
- entfernte Felder: sequence
- **icon**: type: Function[Char] -> Function[Text]
### `ir.ui.view`
- entfernte Felder: priority
- (zusätzlich neue Felder: basis, extensions)
### `ir.ui.view_search`
- **user**: required: True -> None
### `ir.ui.view_tree_width`
- **width**: required: True -> None
- (zusätzlich neue Felder: occurrence, screen_width)
### `party.address`
- entfernte Felder: name, zip
- **street**: type: Text -> Function[Text]
- (zusätzlich neue Felder: attn, building_name, building_number, contact_mechanisms, floor_number, identifiers, post_box, post_office, postal_code, private_bag, room_number, street_name, street_single_line, street_unstructured, subdivision_types, unit_number, warehouses)
### `party.address.format`
- entfernte Felder: country, language
- (zusätzlich neue Felder: building_number_format, country_code, floor_number_format, language_code, post_box_format, post_office_format, private_bag_format, room_number_format, street_format, unit_number_format)
### `party.contact_mechanism`
- **value**: required: None -> True
- (zusätzlich neue Felder: address, delivery, language, languages)
### `product.cost_price`
- **cost_price**: required: None -> True
### `product.list_price`
- **template**: required: None -> True
- (zusätzlich neue Felder: product)
### `product.product`
- entfernte Felder: lead_times
- (zusätzlich neue Felder: code_readonly, identifiers, list_price, list_price_used, list_prices, position, prefix_code, production_lead_times, replaced_by, suffix_code)
### `product.template`
- entfernte Felder: height_digits, length_digits, volume_digits, weight_digits, width_digits
- **lot_required**: type: Many2Many -> MultiSelection; relation: product.template-stock.lot.type -> None
- (zusätzlich neue Felder: code, code_readonly, locations, lot_sequence)
### `production`
- entfernte Felder: effective_date, planned_date, reference, unit_digits, uom
- (zusätzlich neue Felder: origin, type, unit)
### `production.bom.input`
- entfernte Felder: unit_digits, uom
- **product**: required: True -> None
- (zusätzlich neue Felder: phantom_bom, unit)
### `production.bom.output`
- entfernte Felder: unit_digits, uom
- **product**: required: True -> None
- (zusätzlich neue Felder: phantom_bom, unit)
### `production.bom.tree`
- entfernte Felder: unit_digits, uom
- (zusätzlich neue Felder: unit)
### `production.bom.tree.open.start`
- entfernte Felder: unit_digits, uom
- (zusätzlich neue Felder: unit)
### `production.split.start`
- entfernte Felder: unit_digits, uom
- (zusätzlich neue Felder: unit)
### `res.user`
- entfernte Felder: main_company
- **companies**: type: Function[One2Many] -> Many2Many; relation: None -> res.user-company.company
- **menu**: required: True -> None
- (zusätzlich neue Felder: avatar_badge_url, company_filter, notifications, warehouse)
### `res.user.application`
- **application**: required: None -> True
### `stock.inventory`
- entfernte Felder: lost_found
### `stock.inventory.count.quantity`
- entfernte Felder: quantity_added, quantity_resulting, unit_digits, uom
- (zusätzlich neue Felder: lot, quantity, total_quantity, unit)
### `stock.inventory.count.search`
- entfernte Felder: search_model
### `stock.inventory.line`
- entfernte Felder: unit_digits, uom
- (zusätzlich neue Felder: inventory_date, inventory_location, unit)
### `stock.move`
- entfernte Felder: unit_digits, uom
- (zusätzlich neue Felder: cost_price_required, delay, from_location_name, origin_planned_date, outcome_moves, product_cost_price, production, production_cost_price_updated, to_location_name, unit, unit_price_company, unit_price_updated)
### `stock.move.split.start`
- entfernte Felder: unit_digits, uom
- (zusätzlich neue Felder: unit)
### `stock.product.location`
- **product**: required: True -> None
- (zusätzlich neue Felder: template)
### `stock.shipment.in`
- **warehouse_input**: type: Function[Many2One] -> Many2One; relation: None -> stock.location; required: None -> True
- **warehouse_storage**: type: Function[Many2One] -> Many2One; relation: None -> stock.location; required: None -> True
- (zusätzlich neue Felder: delay, internal_volume, internal_weight, origin_planned_date, volume_uom, weight_uom)
### `stock.shipment.out`
- **warehouse_output**: type: Function[Many2One] -> Many2One; relation: None -> stock.location; required: None -> True
- **warehouse_storage**: type: Function[Many2One] -> Many2One; relation: None -> stock.location; required: None -> True
- (zusätzlich neue Felder: delay, internal_volume, internal_weight, origin_planned_date, packages_volume, packages_weight, partially_assigned, volume_uom, weight_uom)
### `stock.shipment.out.return`
- entfernte Felder: delivery_address
- **warehouse_input**: type: Function[Many2One] -> Many2One; relation: None -> stock.location; required: None -> True
- **warehouse_storage**: type: Function[Many2One] -> Many2One; relation: None -> stock.location; required: None -> True
- (zusätzlich neue Felder: contact_address, delay, internal_volume, internal_weight, origin_planned_date, volume_uom, weight_uom)
## neu (76) - Modell gibt es erst ab 8.0
- `company.company.logo.cache`
- `company.company.tax_identifier`
- `country.organization`
- `country.organization.member`
- `country.postal_code`
- `country.region`
- `currency.cron`
- `currency.cron-currency.currency`
- `ir.avatar`
- `ir.avatar.cache`
- `ir.calendar.day`
- `ir.calendar.month`
- `ir.chat.channel`
- `ir.chat.follower`
- `ir.chat.message`
- `ir.cron.log`
- `ir.email`
- `ir.email.address`
- `ir.email.template`
- `ir.email.template-ir.action.report`
- `ir.error`
- `ir.export-res.group`
- `ir.export-write-res.group`
- `ir.filestore.queue`
- `ir.lang.config`
- `ir.lang.config.start`
- `ir.message`
- `ir.model.log`
- `ir.model.refresh_materialized`
- `ir.module.config.start`
- `ir.ui.view_tree_optional`
- `party.address.subdivision_type`
- `party.contact_mechanism.language`
- `party.party-delivered_to-stock.location`
- `product.configuration.default_lot_sequence`
- `product.cost_price.revision`
- `product.identifier`
- `product.modify_cost_price`
- `product.modify_cost_price.start`
- `product.price_list`
- `product.price_list.line`
- `product.product.replace`
- `product.product.replace.ask`
- `product.recompute_cost_price.start`
- `res.notification`
- `res.user-company.company`
- `res.user.device`
- `stock.configuration.measurement`
- `stock.location.waste`
- `stock.lot.trace`
- `stock.lots_by_locations`
- `stock.move.add.lots`
- `stock.move.add.lots.start`
- `stock.move.add.lots.start.lot`
- `stock.product_quantities_warehouse.move`
- `stock.product_quantities_warehouse.move.open`
- `stock.product_quantities_warehouse.open`
- `stock.products_by_locations`
- `stock.reporting.inventory`
- `stock.reporting.inventory.context`
- `stock.reporting.inventory.daily`
- `stock.reporting.inventory.move`
- `stock.reporting.inventory.range.context`
- `stock.reporting.inventory.turnover`
- `stock.reporting.inventory.turnover.context`
- `stock.reporting.margin.category`
- `stock.reporting.margin.category.time_series`
- `stock.reporting.margin.category.tree`
- `stock.reporting.margin.context`
- `stock.reporting.margin.main`
- `stock.reporting.margin.main.time_series`
- `stock.reporting.margin.product`
- `stock.reporting.margin.product.time_series`
- `stock.shipment.assign`
- `stock.shipment.assign.partial`
- `stock.shipment.drop`
## nur ergänzt (27) - nur neue Felder dazugekommen, Rest unverändert
- `company.employee`
- `country.country`
- `ir.configuration`
- `ir.export`
- `ir.lang`
- `ir.session`
- `ir.translation.export.result`
- `ir.ui.menu`
- `party.configuration`
- `party.identifier`
- `party.party`
- `product.by_location.context`
- `product.category`
- `product.configuration`
- `production.bom`
- `production.configuration`
- `res.group`
- `res.user.login.attempt`
- `stock.configuration`
- `stock.location`
- `stock.lot`
- `stock.package`
- `stock.product_quantities_warehouse`
- `stock.product_quantities_warehouse.context`
- `stock.products_by_locations.context`
- `stock.shipment.in.return`
- `stock.shipment.internal`
## unverändert (100) - komplett identisch geblieben
- `company.company.config`
- `company.company.config.start`
- `ir.action-res.group`
- `ir.action.url`
- `ir.attachment`
- `ir.cache`
- `ir.cron-company.company`
- `ir.date`
- `ir.export.line`
- `ir.model.button-button.reset`
- `ir.model.button-res.group`
- `ir.model.button.click`
- `ir.model.button.rule`
- `ir.model.graph`
- `ir.model.print_model_graph`
- `ir.model.print_model_graph.start`
- `ir.model.workflow_graph`
- `ir.module.activate_upgrade`
- `ir.module.activate_upgrade.done`
- `ir.module.activate_upgrade.start`
- `ir.module.config`
- `ir.module.config_wizard`
- `ir.module.config_wizard.done`
- `ir.module.config_wizard.first`
- `ir.module.config_wizard.other`
- `ir.module.dependency`
- `ir.note`
- `ir.note.read`
- `ir.queue`
- `ir.rule`
- `ir.rule.group-res.group`
- `ir.sequence.type-res.group`
- `ir.session.wizard`
- `ir.translation.clean`
- `ir.translation.clean.start`
- `ir.translation.clean.succeed`
- `ir.translation.export`
- `ir.translation.export.start`
- `ir.translation.report`
- `ir.translation.set`
- `ir.translation.set.start`
- `ir.translation.set.succeed`
- `ir.translation.update`
- `ir.translation.update.start`
- `ir.trigger.log`
- `ir.ui.menu-res.group`
- `ir.ui.menu.favorite`
- `ir.ui.view.show`
- `ir.ui.view.show.start`
- `ir.ui.view_tree_state`
- `party.category`
- `party.check_vies`
- `party.check_vies.result`
- `party.configuration.party_lang`
- `party.configuration.party_sequence`
- `party.erase`
- `party.erase.ask`
- `party.letter`
- `party.party-party.category`
- `party.party.lang`
- `party.party.location`
- `party.replace`
- `party.replace.ask`
- `product.configuration.default_cost_price_method`
- `product.cost_price_method`
- `product.product-production.bom`
- `product.recompute_cost_price`
- `product.template-product.category`
- `product.template-product.category.all`
- `product.uom`
- `product.uom.category`
- `production.bom.tree.open`
- `production.bom.tree.open.tree`
- `production.configuration.production_sequence`
- `production.lead_time`
- `production.split`
- `res.user-company.employee`
- `res.user-ir.action`
- `res.user-res.group`
- `res.user.config`
- `res.user.config.start`
- `res.user.email_reset_password`
- `res.user.warning`
- `stock.configuration.location`
- `stock.configuration.sequence`
- `stock.inventory.count`
- `stock.location.lead_time`
- `stock.location.lot.shelf_life`
- `stock.lots_by_location.context`
- `stock.move.split`
- `stock.period`
- `stock.period.cache`
- `stock.period.cache.lot`
- `stock.shipment.in.restocking_list`
- `stock.shipment.internal.report`
- `stock.shipment.out.delivery_note`
- `stock.shipment.out.picking_list`
- `stock.shipment.out.return.restocking_list`
- `stock.shipment.split`
- `stock.shipment.split.start`
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+359
View File
@@ -0,0 +1,359 @@
#!/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
def resolve_find_call(node, model_vars):
"""Erkennt `SomeModelVar.find([('feld', '=', 'wert'), ...])` bzw.
`.search(...)` - das Standardmuster, mit dem Tryton-Tests auf bereits
vorhandene Stammdaten zugreifen (Länder, Währungen, Einheiten,
Lagerorte, ... - i. d. R. per XML-Fixture beim Modul-Aktivieren
angelegt, nicht Teil des Testdatensatzes selbst). Liefert
(model_name, domain) oder None, wenn das Domain-Argument nicht rein
literal ist (z. B. eine Variable referenziert) oder das Muster nicht
passt."""
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
return None
if node.func.attr not in ("find", "search"):
return None
if not isinstance(node.func.value, ast.Name) or node.func.value.id not in model_vars:
return None
if not node.args:
return None
try:
domain = ast.literal_eval(node.args[0])
except Exception:
return None
return model_vars[node.func.value.id], domain
class ScenarioParser:
def __init__(self):
self.model_vars = {} # Variable -> Modellname (aus Model.get(...))
self.record_vars = {} # Variable -> Record-Dict (key/model/fields)
self.search_vars = {} # Variable -> {"model": ..., "domain": ...} (aus X.find(...)/.search(...))
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"]}
if node.id in self.search_vars:
return {"$search": self.search_vars[node.id]}
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, = SomeModelVar.find([('feld', '=', 'wert')]) (Zugriff auf
# vorhandene Stammdaten statt Anlegen eines neuen Records)
if (
isinstance(target, ast.Tuple)
and len(target.elts) == 1
and isinstance(target.elts[0], ast.Name)
):
found = resolve_find_call(node.value, self.model_vars)
if found:
model_name, domain = found
self.search_vars[target.elts[0].id] = {"model": model_name, "domain": domain}
return
self._manual(node)
return
# 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()
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""
rsts_merge_dedup.py
Liest alle von rst_to_dataset.py erzeugten *.yaml-Dateien aus einem
Verzeichnis ein, entfernt Records mit identischem model+fields (die Keys
unterscheiden sich zwar je Datei durch das Präfix, der Inhalt ist aber oft
gleich, z. B. dasselbe Basis-product.template in vielen Szenarien) und
schreibt eine einzige zusammengeführte, nach model sortierte Datei.
$ref-Verweise auf entfernte Duplikate werden automatisch auf den jeweils
behaltenen Record umgeschrieben.
ACHTUNG: Es wird bewusst GLOBAL dedupliziert, auch innerhalb derselben
Datei. Das kann bei Szenarien, die zwei absichtlich gleich aussehende
Records anlegen (z. B. scenario_party_replace.yaml: zwei Parteien namens
"Pam", um eine davon zu ersetzen), einen der beiden Records entfernen und
damit den Sinn des Szenarios verändern. Für den Zweck hier (grober
Testdatensatz) ist das laut Absprache in Ordnung - falls die Datei später
1:1 für szenario-treue Tests verwendet werden soll, nochmal prüfen.
Nutzung:
python3 rsts_merge_dedup.py rsts/8e --output reports/8_testdata_merged.yaml
"""
import argparse
import heapq
import json
import sys
from pathlib import Path
try:
import yaml
except ImportError:
yaml = None
def load_records(path):
text = path.read_text(encoding="utf-8")
if yaml is not None:
data = yaml.safe_load(text)
else:
data = json.loads(text)
return data.get("records", [])
def rewrite_refs(value, rename):
if isinstance(value, dict):
if "$ref" in value:
return {"$ref": rename.get(value["$ref"], value["$ref"])}
return {k: rewrite_refs(v, rename) for k, v in value.items()}
if isinstance(value, list):
return [rewrite_refs(v, rename) for v in value]
return value
def merge_and_dedupe(input_dir):
all_records = []
for path in sorted(Path(input_dir).glob("*.yaml")):
for record in load_records(path):
all_records.append((path.name, record))
# Signatur = model + fields (Key bewusst ignoriert, da der sich je
# Datei nur durchs Präfix unterscheidet).
canonical_key = {} # signatur -> key des ersten Fundes
rename = {} # jeder key -> key des ersten Fundes derselben Signatur
kept = []
removed = 0
for source, record in all_records:
signature = (record["model"], json.dumps(record["fields"], sort_keys=True))
if signature in canonical_key:
rename[record["key"]] = canonical_key[signature]
removed += 1
continue
canonical_key[signature] = record["key"]
rename[record["key"]] = record["key"]
kept.append(record)
for record in kept:
record["fields"] = rewrite_refs(record["fields"], rename)
kept = topo_sort_by_model(kept)
return kept, removed
def find_refs(value):
if isinstance(value, dict):
if "$ref" in value:
yield value["$ref"]
else:
for v in value.values():
yield from find_refs(v)
elif isinstance(value, list):
for v in value:
yield from find_refs(v)
def topo_sort_by_model(records):
"""Sortiert primär nach model, respektiert dabei aber zwingend die
$ref-Abhängigkeiten (ein Record muss nach allem stehen, worauf er per
$ref verweist). Kahn-Algorithmus: unter allen gerade freien Records
(alle Abhängigkeiten schon platziert) wird immer der mit dem
alphabetisch kleinsten model+key gewählt - das gruppiert gleiche
models so weit, wie es die Abhängigkeiten zulassen."""
by_key = {r["key"]: r for r in records}
deps = {r["key"]: set() for r in records}
dependents = {r["key"]: set() for r in records}
for r in records:
for ref in find_refs(r["fields"]):
if ref in by_key and ref != r["key"]:
deps[r["key"]].add(ref)
dependents[ref].add(r["key"])
indegree = {k: len(v) for k, v in deps.items()}
heap = [(by_key[k]["model"], k) for k, deg in indegree.items() if deg == 0]
heapq.heapify(heap)
result = []
while heap:
_, key = heapq.heappop(heap)
result.append(by_key[key])
for dependent in dependents[key]:
indegree[dependent] -= 1
if indegree[dependent] == 0:
heapq.heappush(heap, (by_key[dependent]["model"], dependent))
if len(result) != len(records):
stuck = set(by_key) - {r["key"] for r in result}
raise ValueError(
f"Zyklische oder unauflösbare $ref-Abhängigkeiten bei: {sorted(stuck)}"
)
return result
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("input_dir", help="Verzeichnis mit den *.yaml-Dateien (z. B. rsts/8e)")
parser.add_argument("--output", required=True, help="Zieldatei für den zusammengeführten Datensatz")
args = parser.parse_args()
kept, removed = merge_and_dedupe(args.input_dir)
doc = {"records": kept}
out_path = Path(args.output)
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:
out_path.write_text(json.dumps(doc, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"{len(kept)} Records behalten, {removed} Duplikate entfernt -> {out_path}")
if __name__ == "__main__":
sys.exit(main())
+2 -2
View File
@@ -4,7 +4,7 @@ set -e
TRYTON_REPO="$HOME/tmp/tryton"
WORKDIR="$HOME/code/tryton-db-migration"
EXTRACTOR="$WORKDIR/extract.py"
EXTRACTOR="$WORKDIR/extract_updates.py"
START='5.0'
@@ -47,7 +47,7 @@ echo
echo "starte Auswertung"
echo "======================================================"
python3 compare.py
python3 compare_updates.py
echo
echo "Fertig."
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""
sql_dump_diff.py
Vergleicht zwei mit `pg_dump` (Klartext-Format, --format=plain) erzeugte
SQL-Dumps strukturell: welche Tabellen/Spalten gibt es in Version A, die
es in B nicht (mehr) gibt (und umgekehrt), welche Spaltentypen haben sich
geändert, und wie viele Zeilen stehen jeweils in einer gemeinsamen
Tabelle. Gedacht für zwei frisch initialisierte (oder später: mit
denselben Proteus-Testdaten befüllte) Datenbanken zweier Tryton-Versionen
- als dritte, von der Code-Analyse (extract_updates.py/model_diff.py)
unabhängige Quelle für "was hat sich am Schema wirklich geändert".
Mit --model-diff (Ausgabe von model_diff.py) wird zusätzlich pro
betroffener Tabelle geprüft, ob die rein statische Code-Analyse dieselbe
Änderung schon vorhergesagt hatte (Modellname -> Tabellenname: Punkte
durch Unterstriche ersetzen, wie es extract_updates.py/
compare_snapshots.py auch tun) - zeigt, wie gut beide Ansätze
übereinstimmen, und macht Abweichungen sichtbar (z. B. Python-seitige
Änderungen ohne SQL-Schemaauswirkung, oder Tabellenänderungen durch ein
Modul, das nicht im --modules-Set von extract_model_tree.py steckte).
Zeilen werden bereits vollständig geparst (als dict pro Zeile, Spalte ->
Wert) und im Rückgabewert von parse_dump() mitgeführt - für eine spätere
inhaltliche Vergleichs-Erweiterung (z. B. per --key-column concrete Werte
statt nur Zeilenzahlen gegenüberstellen). Der aktuelle Report nutzt davon
bisher nur die Zeilenzahl.
Bekannte Grenzen: erkennt keine Indizes/Constraints/Sequences/
Spaltenreihenfolge, nur Tabellen + Spalten(typen) + Zeilenzahl.
Nutzung:
python3 sql_dump_diff.py --dump-a data/leer5.sql --label-a 5.0 \\
--dump-b data/leer8.sql --label-b 8.0 \\
--model-diff reports/model_diff.json \\
--output reports/dump_diff_5.0_8.0.json \\
--report reports/dump_diff_5.0_8.0.md
"""
import argparse
import json
import re
from pathlib import Path
CREATE_TABLE_RE = re.compile(r'^CREATE TABLE public\.(\w+) \($')
COPY_RE = re.compile(r'^COPY public\.(\w+) \(([^)]*)\) FROM stdin;$')
def unescape_copy_value(raw):
"""pg_dump-COPY-Escaping rückgängig machen (\\N -> NULL, \\t/\\n/\\\\ -> Zeichen)."""
if raw == r'\N':
return None
out = []
i = 0
while i < len(raw):
c = raw[i]
if c == '\\' and i + 1 < len(raw):
out.append({'t': '\t', 'n': '\n', 'r': '\r', '\\': '\\'}.get(raw[i + 1], raw[i + 1]))
i += 2
else:
out.append(c)
i += 1
return ''.join(out)
def parse_column_line(line):
"""Eine Zeile aus dem CREATE-TABLE-Block -> (spaltenname, typ) oder (None, None)."""
text = line.strip()
if not text or text.startswith(('CONSTRAINT', 'PRIMARY KEY', 'UNIQUE')):
return None, None
text = text.rstrip(',')
for stopper in (' NOT NULL', ' DEFAULT ', ' COLLATE '):
idx = text.find(stopper)
if idx != -1:
text = text[:idx]
parts = text.split(None, 1)
if len(parts) != 2:
return None, None
name, coltype = parts
return name.strip('"'), coltype.strip()
def parse_dump(path):
"""Liefert {tabelle: {"columns": {name: typ}, "row_count": int, "rows": [dict, ...]}}."""
tables = {}
with open(path, encoding='utf-8', errors='replace') as f:
lines = iter(f)
for line in lines:
stripped = line.rstrip('\n')
m = CREATE_TABLE_RE.match(stripped)
if m:
table = m.group(1)
columns = {}
for col_line in lines:
col_line = col_line.rstrip('\n')
if col_line.startswith(');'):
break
name, coltype = parse_column_line(col_line)
if name:
columns[name] = coltype
entry = tables.setdefault(table, {"columns": {}, "row_count": 0, "rows": []})
entry["columns"] = columns
continue
m = COPY_RE.match(stripped)
if m:
table, col_csv = m.group(1), m.group(2)
col_names = [c.strip() for c in col_csv.split(',')]
rows = []
for data_line in lines:
data_line = data_line.rstrip('\n')
if data_line == '\\.':
break
values = [unescape_copy_value(v) for v in data_line.split('\t')]
rows.append(dict(zip(col_names, values)))
entry = tables.setdefault(table, {"columns": {}, "row_count": 0, "rows": []})
entry["row_count"] = len(rows)
entry["rows"] = rows
return tables
def diff_dumps(tables_a, tables_b):
tables_removed = sorted(set(tables_a) - set(tables_b))
tables_added = sorted(set(tables_b) - set(tables_a))
common = sorted(set(tables_a) & set(tables_b))
changed = {}
unchanged = []
for table in common:
cols_a, cols_b = tables_a[table]["columns"], tables_b[table]["columns"]
added_cols = sorted(set(cols_b) - set(cols_a))
removed_cols = sorted(set(cols_a) - set(cols_b))
type_changes = {
c: [cols_a[c], cols_b[c]]
for c in sorted(set(cols_a) & set(cols_b))
if cols_a[c] != cols_b[c]
}
row_count_a, row_count_b = tables_a[table]["row_count"], tables_b[table]["row_count"]
schema_changed = bool(added_cols or removed_cols or type_changes)
if schema_changed or row_count_a != row_count_b:
if removed_cols or type_changes:
expected_category = "geändert"
elif added_cols:
expected_category = "nur ergänzt"
else:
expected_category = None # nur Zeilenzahl anders - keine Code-Definitionsänderung
changed[table] = {
"added_columns": added_cols,
"removed_columns": removed_cols,
"type_changes": type_changes,
"row_count_a": row_count_a,
"row_count_b": row_count_b,
"schema_changed": schema_changed,
"expected_category": expected_category,
}
else:
unchanged.append(table)
return {
"tables_removed": tables_removed,
"tables_added": tables_added,
"tables_changed": changed,
"tables_unchanged": unchanged,
}
def build_table_to_models(model_diff):
mapping = {}
for model_name in model_diff:
mapping.setdefault(model_name.replace(".", "_"), []).append(model_name)
return mapping
def model_diff_note(table, model_diff, table_to_models, expected_category):
"""Vergleicht einen Tabellen-Fund mit der Vorhersage aus model_diff.py.
Liefert einen kurzen Text: Übereinstimmung, Abweichung, oder kein
passendes Modell gefunden (z. B. reine Relations-/Interntabelle)."""
if not model_diff:
return ""
models = table_to_models.get(table)
if not models:
return " _(kein passendes Modell in model_diff gefunden)_"
parts = []
for model in models:
category = model_diff[model]["category"]
flag = "übereinstimmung" if category == expected_category else "ABWEICHUNG"
parts.append(f"`{model}`={category} [{flag}]")
return " _(" + "; ".join(parts) + ")_"
def write_report(diff, out_path, label_a, label_b, model_diff, table_to_models):
lines = [f"# SQL-Dump-Diff: {label_a} -> {label_b}\n"]
lines.append(f"\n## Tabellen entfernt ({len(diff['tables_removed'])})\n")
for t in diff["tables_removed"]:
lines.append(f"- `{t}`{model_diff_note(t, model_diff, table_to_models, 'entfernt')}")
lines.append(f"\n\n## Tabellen neu ({len(diff['tables_added'])})\n")
for t in diff["tables_added"]:
lines.append(f"- `{t}`{model_diff_note(t, model_diff, table_to_models, 'neu')}")
schema_changed = {t: i for t, i in diff["tables_changed"].items() if i["schema_changed"]}
rows_only = {t: i for t, i in diff["tables_changed"].items() if not i["schema_changed"]}
lines.append(f"\n\n## Tabellen mit Schema-Änderung ({len(schema_changed)})\n")
lines.append(
"Spalten wurden hinzugefügt/entfernt oder haben einen anderen Typ - "
"vergleichbar mit einer model_diff.py-Kategorie.\n"
)
for t, info in sorted(schema_changed.items()):
note = model_diff_note(t, model_diff, table_to_models, info["expected_category"])
lines.append(f"\n### `{t}`{note}")
if info["added_columns"]:
lines.append(f"- neue Spalten: {', '.join(info['added_columns'])}")
if info["removed_columns"]:
lines.append(f"- entfernte Spalten: {', '.join(info['removed_columns'])}")
for col, (ta, tb) in info["type_changes"].items():
lines.append(f"- **{col}**: {ta} -> {tb}")
if info["row_count_a"] != info["row_count_b"]:
lines.append(f"- Zeilenzahl: {info['row_count_a']} -> {info['row_count_b']}")
lines.append(f"\n\n## Nur Zeilenzahl unterschiedlich, Schema identisch ({len(rows_only)})\n")
lines.append(
"Keine Code-Definitionsänderung zu erwarten (z. B. mehr XML-Fixture-Daten in "
"der neueren Version) - daher kein model_diff-Abgleich.\n"
)
for t, info in sorted(rows_only.items()):
lines.append(f"- `{t}`: {info['row_count_a']} -> {info['row_count_b']}")
lines.append(f"\n\n## Unverändert ({len(diff['tables_unchanged'])})\n")
lines.append(", ".join(f"`{t}`" for t in diff["tables_unchanged"]))
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("--dump-a", required=True)
parser.add_argument("--dump-b", required=True)
parser.add_argument("--label-a", default="A")
parser.add_argument("--label-b", default="B")
parser.add_argument("--model-diff", default=None, help="Optional: model_diff.json zum Abgleich")
parser.add_argument("--output", default="reports/dump_diff.json")
parser.add_argument("--report", default="reports/dump_diff.md")
args = parser.parse_args()
tables_a = parse_dump(args.dump_a)
tables_b = parse_dump(args.dump_b)
diff = diff_dumps(tables_a, tables_b)
model_diff = json.loads(Path(args.model_diff).read_text(encoding="utf-8")) if args.model_diff else {}
table_to_models = build_table_to_models(model_diff) if model_diff else {}
Path(args.output).write_text(json.dumps(diff, indent=2, ensure_ascii=False, sort_keys=True), encoding="utf-8")
write_report(diff, Path(args.report), args.label_a, args.label_b, model_diff, table_to_models)
print(
f"{len(diff['tables_removed'])} entfernt, {len(diff['tables_added'])} neu, "
f"{len(diff['tables_changed'])} geändert, {len(diff['tables_unchanged'])} unverändert "
f"-> {args.output}, {args.report}"
)
if __name__ == "__main__":
main()