RFC 0007 — Writer-Interface für sclass.DataFrame (einheitliche Sink-Abstraktion)¶
| Feld | Wert |
|---|---|
| Status | Accepted — implementiert (v2, nach adversariellem Review überarbeitet) |
| Datum | 2026-07-01 |
| Autor | lepy lepy@tuta.io |
| Komponente | neu: sdata/iolib/writer.py; nutzt sdata/sclass/dataframe.py, sdata/semantic.py, sdata/iolib/json1sqlitestore.py |
| Betrifft | DataFrameWriter (Protocol), BaseDataFrameWriter (ABC), WriteReceipt, ParquetWriter, StoreWriter, SqlWriter, GraphWriter, ensure_sdata, write_with_provenance |
| Vorgeschichte | Folge von RFC 0002 (HDF5), RFC 0003 (Blob), RFC 0004 (DataFrame/Blob), RFC 0006 (Units) |
| Validierung | sdata/iolib/writer.py 100 %; 19 Tests (tests/iolib/test_dataframe_writer.py); Gesamtsuite grün |
Umsetzungsstand. Implementiert und getestet. Ein adversarieller Review des v1-Entwurfs deckte zwei belastbare Fehler und mehrere Designschwächen auf; v2 adressiert sie:
# v1-Problem (Entwurf) v2-Antwort (implementiert) F1 „SQL-Dokumentmodus reused JSON1SQLiteStoreverlustfrei, Metadaten reisen im Payload" war falsch:Base.to_dict()nestet_sdata_*unter$.metadata.*.value, der Store extrahiert aber$._sdata_*(top-level) → alle generierten Spalten NULL, Objekt un-auffindbar.Eigene Klasse StoreWritermit Flatten-Adapterstore_payload:_sdata_*top-level + vollesto_dictunter"sdata". Test beweist Auffindbarkeit persuuid/sname.F2 GraphWriter("run42.ttl")gab die Turtle nur zurück, schrieb aber nie die Datei.Einzeldatei-Modus schreibt jetzt tatsächlich nach target(Turtle oder JSON-LD).F3 Relationales SQL: to_sql+ separater Meta-Insert ohne Transaktionsgrenze (pandas committetto_sqlintern → verwaiste Datenzeilen möglich).SqlWriteratomar: Meta-Insert zuerst (pending), dannto_sql(dessen finaler commit beide festschreibt);try/except → rollbackverwirft die pending Metazeile bei Fehler.F4 sdf.suuidist einSUUID-Objekt, als SQL-Bind-Parameter/IRI ungültig.Überall str(sdf.suuid).F6 write() -> Any(Pfad/ID/IRI/Str) untergrub die Polymorphie.Einheitliches WriteReceipt(backend/sname/suuid/target/detail) aus jedemwrite.F7 „SqlWriter mit Dokumentmodus" vermischte Objekt-Persistenz und relationale Tabellen. Sauber getrennt: StoreWriter(Objekt) vs.SqlWriter(relational).F8 for k,v in contract.items(): setattr(self,k,v)schluckte Tippfehler still.Explizite require_metadata/require_columns/require_units-Parameter am ABC-Konstruktor.C1 Statische Analyse (Codacy/Bandit) meldete den interpolierten Metatabellennamen im SqlWriter-SQL als SQL-Injection (critical).Feste Metatabelle sdata_dataframe_metaüber statisches SQL-Literal; Datentabellenname per Allowlist (_safe_identifier) validiert;_check_contractunter die Komplexitätsgrenze refaktoriert.Bestätigt und übernommen:
as_blob("parquet")bettet_sdataein (nutztto_parquet()); die Wahrheitsquelle bleibtmetadata/column_metadata, nichtdf.attrs.
1. Zusammenfassung¶
sclass.DataFrame besitzt heute viele Ein-Format-Serialisierer (to_parquet,
to_csv, to_arrow, to_feather, to_hdf, to_datapackage, to_jsonld/to_turtle,
as_blob, to_dict). Was fehlt, ist eine einheitliche Sink-Abstraktion: ein
gemeinsamer write()-Vertrag, unter dem Backends (Parquet-Datei/Objektspeicher, SQL,
Knowledge Graph) austauschbar sind, der Ressourcen-Lebenszyklus (offene
DB-Verbindung, RDF-Store, Dateihandle) hält und einen Metadaten-Vertrag erzwingt.
Dieses RFC schlägt ein DataFrameWriter-Protocol plus eine BaseDataFrameWriter-ABC
(Template-Method mit Metadaten-Prüfung) und drei konkrete Writer vor.
Kernbefund (Neurahmung). Die naive Writer-Diskussion dreht sich um pandas'
df.attrs als (fragile) Metadatenquelle — attrs wird über groupby/concat/merge
meist nicht propagiert, ist also schon vor dem Writer oft verloren. sdata hat dieses
Problem architektonisch bereits gelöst: die Wahrheitsquelle ist nicht df.attrs,
sondern die erstklassigen Felder DataFrame.metadata (Metadata) und
DataFrame.column_metadata (Per-Spalten-Metadata, inkl. unit/label/ontology).
df.attrs["_sdata"] dient in sdata nur als Transport der letzten Meile beim
Serialisieren (to_dataframe/to_parquet, dataframe.py:495/542). Der Writer bekommt
folglich kein dünnes attrs-Dict, sondern das sdata-DataFrame, das seine Semantik
robust mitträgt. Für alle drei Backends muss der Writer die Metadaten nicht retten,
sondern nur korrekt platzieren.
2. Motivation / Kontext¶
- Polymorphe Ziele. Ein Aufrufer (Pipeline,
DataFrameGroup, CLI) sollwriter.write(sdf)aufrufen, ohne zu wissen, wohin geschrieben wird. Heute ist jeder Serialisierer eine eigene Methode mit eigener Signatur — ein Wechsel Parquet↔SQL↔Graph bedeutet Umschreiben des Aufrufers. - Lebenszyklus. Die
to_*-Methoden sind Ein-Schuss-Serialisierer ohne Zustand. Eine SQL-Verbindung, ein rdflib-Dataset(Named-Graph-Akkumulation über mehrere Tabellen) oder ein partitioniertes Ausgabeverzeichnis brauchen Öffnen → mehrfach schreiben → flush/commit → schließen. Das ist genau diewrite/flush/close/ Context-Manager-Form, die dento_*-Methoden fehlt. - Metadaten-Vertrag an einer Stelle. „Diese Senke akzeptiert nur Tabellen mit
licenseund Einheiten aufforce/stroke" gehört in einen Prüf-Hook, nicht verstreut in jeden Aufrufer. - Backends behandeln Metadaten grundverschieden — das ist der eigentliche Knackpunkt:
| Backend | natürlicher Ort für Dataset-Metadaten |
|---|---|
| Parquet | nativer Key-Value-Schema-Slot → sdata legt sie schon als _sdata ab |
| SQL | kein Ort für tabellenglobale Metadaten → Dokument-JSON oder Sidecar-Tabelle |
| Graph | der natürliche Ort → Metadaten sind die Provenienz (QUDT/PROV-O-Tripel) |
- Vorhandenes bleibt. RFC 0001 lieferte
JSON1SQLiteStore(nimmtto_dict()-JSON mit_sdata_*-Spalten); RFC 0003/0004 denBlob/as_blob-Pfad (fsspec-write(uri)); die Semantik-Schicht (sdata/semantic.py) erzeugt bereits QUDT + PROV-O + CSVW + DCAT ausmetadataund Spalten. Der Writer verdrahtet dieses Vorhandene, er ersetzt es nicht.
3. Ziele / Nicht-Ziele¶
Ziele
DataFrameWriter-Protocol (write/flush/close, Context-Manager) als struktureller Vertrag jeder Senke.BaseDataFrameWriter-ABC: ziehtmetadata/column_metadata/descriptiongetrennt vom Datenrahmen heraus, prüft einen konfigurierbaren Vertrag, ruft_write_impl(sdf, meta).- Drei Referenz-Writer:
ParquetWriter(fsspec-URI),SqlWriter(Dokument- oder relationaler Modus),GraphWriter(RDF/JSON-LD, Named-Graph-Akkumulation). ensure_sdata(df)-Bridge: nimmt eine sdata-DataFrameoder eine reine pandas-df(restauriertdf.attrs["_sdata"]über den vorhandenen_restore_from_attrs-Pfad).- Strikt additiv; Optional-Abhängigkeiten (
sdata[sql],sdata[rdf],sdata[parquet],sdata[blob]) über die etablierten Guards.
Nicht-Ziele (dieses RFC)
- Kein Reader-Gegenstück (die
from_*-Methoden bleiben; einDataFrameReader-Protocol ist ein Folge-RFC). - Kein Query-/Selektions-API und kein ORM; der Writer schreibt nur.
- Keine Ablösung der
to_*-Methoden — der Writer komponiert sie. DataFrameGroup-Batch-Schreiben ist nur vorbereitet (diewrite-Schleife passt), nicht ausspezifiziert.
4. Der Metadaten-Vertrag¶
Die entscheidende Designentscheidung (wie im naiven Entwurf, aber in sdata korrekt
verankert): _write_impl bekommt sdf und meta getrennt. So platziert jedes
Backend die Metadaten dort, wo sie hingehören, statt sie an den pandas-Rahmen gekoppelt zu
lassen, wo sie beim Serialisieren verschwinden.
meta wird nicht aus df.attrs gezogen, sondern aus der Wahrheitsquelle:
meta = {
"metadata": sdf.metadata.to_dict(), # Dataset-Ebene
"column_metadata": sdf.column_metadata.to_dict(), # Per-Spalte: unit/label/ontology
"description": sdf.description,
}
Das ist reicher als ein dict(df.attrs): es trägt Per-Spalten-Semantik (Einheiten aus
RFC 0006, Ontologie-Terme), die ein flaches attrs-Dict gar nicht ausdrücken kann.
5. Entwurf — Protocol + ABC¶
# sdata/iolib/writer.py
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable, TYPE_CHECKING
import logging
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from sdata.sclass.dataframe import DataFrame
@dataclass
class WriteReceipt: # F6: einheitlicher Rückgabetyp aller Senken
backend: str; sname: str; suuid: str; target: Any = None
detail: dict = field(default_factory=dict)
@runtime_checkable
class DataFrameWriter(Protocol):
"""Struktureller Vertrag jeder Senke."""
def write(self, sdf: "DataFrame") -> WriteReceipt: ...
def flush(self) -> None: ...
def close(self) -> None: ...
class BaseDataFrameWriter(ABC):
"""Template-Method: Vertrag prüfen, Metadaten trennen, an das Backend delegieren."""
def __init__(self, require_metadata=(), require_columns=(), require_units=()):
self.require_metadata = tuple(require_metadata) # F8: explizite Parameter,
self.require_columns = tuple(require_columns) # kein setattr-Schlucken von Tippfehlern
self.require_units = tuple(require_units)
self._count = 0
def write(self, sdf: "DataFrame") -> WriteReceipt:
sdf = ensure_sdata(sdf) # Bridge: plain pandas -> sdata
self._check_contract(sdf)
meta = {
"metadata": sdf.metadata.to_dict(),
"column_metadata": sdf.column_metadata.to_dict(),
"description": sdf.description,
}
receipt = self._write_impl(sdf, meta) # sdf UND meta getrennt
self._count += 1
logger.info("%s wrote %r -> %s", type(self).__name__, sdf.sname, receipt.target)
return receipt
def _check_contract(self, sdf: "DataFrame") -> None:
miss_m = [k for k in self.require_metadata if sdf.metadata.get(k) is None]
miss_c = [c for c in self.require_columns if c not in sdf.df.columns]
units = sdf.column_units # {col: unit} (RFC 0006)
miss_u = [c for c in self.require_units if not units.get(c)]
if miss_m or miss_c or miss_u:
raise ValueError(
f"writer contract violated: metadata={miss_m}, columns={miss_c}, units={miss_u}")
@abstractmethod
def _write_impl(self, sdf: "DataFrame", meta: dict[str, Any]) -> WriteReceipt: ...
def flush(self) -> None: pass
def close(self) -> None: self.flush()
def __enter__(self): return self
def __exit__(self, *exc): self.close()
_check_contract nutzt den bestätigten Metadata.get(k) is None-Zugriff (robust
gegenüber der Nesting-Struktur von to_dict()); die Attributnamen ("license", …) sind
gültige Pflichtschlüssel.
6. Backends¶
6.1 Parquet — Metadaten reisen im Format mit¶
Parquet ist der saubere Fall: das Format hat einen Metadaten-Slot, und sdata füllt ihn
bereits (to_parquet bettet _sdata über df.attrs ein, Arrow über den Schema-Key
b"_sdata", dataframe.py:542/694). Der Writer muss also keinen Sidecar bauen — er
schreibt die metadatentragenden Bytes an eine beliebige fsspec-URI (lokal, s3://, gcs://)
über den Blob-Pfad aus RFC 0003/0004:
class ParquetWriter(BaseDataFrameWriter):
def __init__(self, uri, **contract): # contract = require_metadata/columns/units
super().__init__(**contract)
self.uri = uri
def _write_impl(self, sdf, meta):
blob = sdf.as_blob("parquet") # to_parquet()-Bytes: _sdata eingebettet
target = blob.write(self.uri) # fsspec (lokal, s3://, …)
return WriteReceipt("parquet", sdf.sname, str(sdf.suuid), target,
{"bytes": blob.size})
Beim Zurücklesen (DataFrame.from_parquet_bytes) landet _sdata via
_restore_from_attrs wieder in metadata/column_metadata — die Provenienz überlebt den
Roundtrip, was reines pandas.to_parquet() nicht leistet.
6.2 SQL — zwei getrennte Klassen (F7): StoreWriter und SqlWriter¶
SQL hat keinen tabellenglobalen Metadaten-Ort. Der v1-Entwurf wollte das mit einer
SqlWriter-Klasse mit mode="document"/"relational" lösen — der Review verwarf das:
der Dokumentpfad war (a) falsch (F1) und (b) semantisch eine andere Sache
(Objekt-Persistenz statt relationaler Tabellen). v2 trennt beides sauber.
StoreWriter — Objekt-Persistenz in einen JSON1SQLiteStore (RFC 0001). Der
Knackpunkt (F1): Base.to_dict() liefert {"metadata": {…}, "data": {…}, …} — die
_sdata_*-Schlüssel liegen genestet unter $.metadata.<key>.value, der Store
extrahiert aber json_extract(payload, '$._sdata_*') (top-level). Ein naives
store.insert(sdf.to_dict()) ergäbe also durchweg NULL-Spalten und ein
un-auffindbares Objekt. Lösung: ein Flatten-Adapter, der die _sdata_* nach oben
zieht und das verlustfreie to_dict unter "sdata" mitführt:
class StoreWriter(BaseDataFrameWriter):
def __init__(self, target, **contract):
super().__init__(**contract)
from sdata.iolib.json1sqlitestore import JSON1SQLiteStore
if hasattr(target, "insert"):
self._store, self._owns = target, False # übergebener Store
else:
self._store, self._owns = JSON1SQLiteStore(target), True
@staticmethod
def store_payload(sdf):
from sdata.iolib.json1sqlitestore import JSON1SQLiteStore
md = sdf.metadata
payload = {c: (md.get(c).value if md.get(c) else None)
for c in JSON1SQLiteStore.GENERATED_COLUMNS} # _sdata_* TOP-LEVEL
payload["_sdata_name"] = sdf.name
payload["_sdata_sname"] = sdf.sname
payload["_sdata_suuid"] = str(sdf.suuid)
payload["sdata"] = sdf.to_dict() # verlustfrei unter "sdata"
return payload
def _write_impl(self, sdf, meta):
rid = self._store.insert(self.store_payload(sdf))
return WriteReceipt("store", sdf.sname, str(sdf.suuid), rid, {"record_id": rid})
def close(self):
self.flush()
if self._owns: self._store.conn.close()
Damit ist das Objekt per store.get_id_by_key("_sdata_suuid", str(sdf.suuid)) bzw.
"_sdata_sname" wieder auffindbar (Test beweist es) und DataFrame.from_dict(row["sdata"])
stellt es verlustfrei her.
SqlWriter — flache Daten in eine relationale Tabelle + gemeinsame Metadatentabelle,
atomar (F3/F4). pandas to_sql committet intern — der v1-Entwurf (Daten zuerst,
Metadaten danach, ohne Transaktionsgrenze) konnte also verwaiste Datenzeilen ohne
Metadaten hinterlassen. v2 kehrt die Reihenfolge um und macht es atomar. Die Metatabelle
ist fest (sdata_dataframe_meta) und wird über statisches SQL-Literal angelegt/befüllt
(keine Identifier-Interpolation → keine SQL-Injection-Fläche, C1); der Datentabellenname
wird gegen eine Allowlist validiert:
_SQL_META_DDL = ("CREATE TABLE IF NOT EXISTS sdata_dataframe_meta "
"(suuid TEXT, sname TEXT, target_table TEXT, sdata TEXT)")
_SQL_META_INSERT = ("INSERT INTO sdata_dataframe_meta(suuid, sname, target_table, sdata) "
"VALUES (?, ?, ?, ?)")
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
class SqlWriter(BaseDataFrameWriter):
def __init__(self, conn, table="dataframe", *, if_exists="append", **contract):
super().__init__(**contract)
self._conn = conn
self.table = _safe_identifier(table) # Allowlist statt Interpolation von Fremd-IDs
self.meta_table = _SQL_META_TABLE
self.if_exists = if_exists
self._conn.execute(_SQL_META_DDL) # Literal
def _write_impl(self, sdf, meta):
suuid = str(sdf.suuid) # F4: SUUID -> str für Bind-Parameter
try:
self._conn.execute( # Metazeile ZUERST (pending), Literal + Params
_SQL_META_INSERT,
(suuid, sdf.sname, self.table, json.dumps(sdf.to_dict(), default=str)))
sdf.df.to_sql(self.table, self._conn, # dessen finaler commit schreibt BEIDE fest
if_exists=self.if_exists, index=False)
except Exception:
self._conn.rollback() # Fehler -> pending Metazeile verwerfen
raise
return WriteReceipt("sql", sdf.sname, suuid, self.table,
{"meta_table": self.meta_table})
def flush(self):
self._conn.commit()
Die zugehörige Datentabelle einer Metazeile steht in der Spalte target_table
(… WHERE target_table = ?); die feste Metatabelle ersetzt den v1-<table>__sdata-Namen.
Provenienz-Schlüssel str(sdf.suuid) statt eines SHA-256-Hashs der Metadaten: der
suuid ist per Konstruktion eindeutig (der Hash kollidiert bei identischen Metadaten) und
ohnehin vorhanden (RFC 0001, _sdata_suuid). Wer Provenienz pro Zeile braucht, schreibt
suuid zusätzlich als Datenspalte.
6.3 Knowledge Graph — Metadaten sind die Provenienz¶
Hier ist attrs kein Fremdkörper, sondern das Modell. sdata erzeugt aus metadata und den
Spalten bereits QUDT-Quantities, PROV-O-Provenienz, CSVW-Spalten und DCAT/schema.org
(sdata/semantic.py, vocab.py-PREFIXES) — die im naiven Entwurf mühsam von Hand
gebaute rdflib-PROV-Konstruktion ist damit überflüssig. Der Writer akkumuliert die
Tabellen als Named Graphs in einem rdflib-Dataset (Graph-IRI aus suuid) oder
schreibt eine einzelne Datei:
class GraphWriter(BaseDataFrameWriter):
def __init__(self, target=None, *, fmt="turtle", named_graphs=False, **contract):
self.target, self.fmt = target, fmt
self._dataset = None
if named_graphs:
from sdata.semantic import _rdflib # Optional-Guard (rdf-Extra)
if _rdflib is None:
raise ImportError("named_graphs verlangt: pip install sdata[rdf]")
self._dataset = _rdflib.Dataset()
for k, v in contract.items(): setattr(self, k, v)
def _write_impl(self, sdf, meta):
import json
from sdata.semantic import _rdflib
doc = sdf.to_jsonld() # QUDT+PROV-O+CSVW, Spalten inklusive
if self._dataset is not None:
iri = _rdflib.URIRef(f"urn:sdata:{sdf.suuid}")
self._dataset.graph(iri).parse(data=json.dumps(doc), format="json-ld")
return iri
return sdf.to_turtle() # bzw. in self.target-Datei schreiben
def close(self):
if self._dataset is not None and self.target:
self._dataset.serialize(destination=self.target, format="nquads")
Bei Parquet/SQL platziert man die Metadaten; beim Graph sind sie der Inhalt. Die
Per-Spalten-Einheiten (RFC 0006) erscheinen automatisch als qudt:unit an den
csvw:column-Knoten.
6.4 df.attrs als Brücke der letzten Meile¶
Für eine reine pandas-df, die (noch) kein sdata-Objekt ist, restauriert ensure_sdata
die Metadaten aus df.attrs["_sdata"] — genau dem Slot, den sdatas to_dataframe/
to_parquet beschreiben:
def ensure_sdata(obj):
from sdata.sclass.dataframe import DataFrame
import pandas as pd
if isinstance(obj, DataFrame):
return obj
if isinstance(obj, pd.DataFrame):
sdf = DataFrame(df=obj)
sdf._restore_from_attrs(obj.attrs.get("_sdata")) # vorhandener Restore-Pfad
return sdf
raise TypeError(f"erwartet DataFrame oder pandas.DataFrame, nicht {type(obj)!r}")
Und Provenienz kann beim Schreiben explizit injiziert werden, statt auf
attrs-Propagierung zu hoffen — für zertifizierbare Workflows (RecyPredict-Kontext) der
robuste Weg:
def write_with_provenance(writer, obj, provenance: dict) -> WriteReceipt:
sdf = ensure_sdata(obj)
for key, value in provenance.items():
sdf.metadata.add(key, value) # in die Wahrheitsquelle, nicht in df.attrs
return writer.write(sdf)
df.attrs bleibt damit Transportmechanismus der letzten Meile, nicht Wahrheitsquelle
über die Pipeline — dieselbe Schlussfolgerung wie im naiven Entwurf, in sdata aber schon
strukturell eingelöst.
7. Nutzung¶
import sqlite3
from sdata.iolib.writer import ParquetWriter, StoreWriter, SqlWriter, GraphWriter
# austauschbare Senken — der Aufrufer bleibt gleich, jeder write() liefert ein WriteReceipt
for writer in [ParquetWriter("s3://bucket/run42.spq"),
StoreWriter("runs.db"), # Objekt-Persistenz (JSON1-Store)
SqlWriter(sqlite3.connect("runs.db")), # relationale Tabelle + Sidecar
GraphWriter("run42.ttl")]: # RDF/Turtle-Datei
with writer:
rcpt = writer.write(sdf) # rcpt.suuid / rcpt.target / rcpt.backend
# Vertrag erzwingen: nur Tabellen mit Lizenz UND Einheit auf 'force'
ParquetWriter("out.spq", require_metadata=("license",),
require_units=("force",)).write(sdf) # -> ValueError, falls verletzt
# mehrere Tabellen in EINEN Store (auffindbar per suuid/sname)
with StoreWriter("runs.db") as w:
for sdf in group:
w.write(sdf) # close() schließt den Store
8. Designentscheidungen / Optionen¶
- Protocol und ABC. Das
Protocol(runtime_checkable) ist der strukturelle Vertrag (auch Fremdklassen können ihn erfüllen); die ABC liefert die Template-Method mit Vertrags-Prüfung, damit jeder Writer die Metadaten identisch trennt. Verworfen: nur ABC (schließt Duck-Typing aus) bzw. nur Protocol (dupliziert die Prüf-Logik in jedem Backend). write(sdf)stattwrite(df, meta). Das sdata-DataFrameträgt seine Semantik bereits robust; ein separatesmeta-Argument würde die Wahrheitsquelle spalten. Die Trennung passiert im Writer (§4), nicht in der Signatur.StoreWritervs.SqlWritergetrennt (F7). Objekt-Persistenz (JSON1SQLiteStore, RFC 0001, persuuid/snameauffindbar via Flatten-Adapter) und relationale Tabellen (flache Daten + Sidecar) sind verschiedene Dinge — eine Klasse mitmode=würde sie vermischen und war zudem im Dokumentpfad schlicht falsch (F1).- Einheitliches
WriteReceipt(F6). Jederwritegibt denselben Typ zurück (backend/sname/suuid/target/detail), sonst wäre „Senke austauschen ohne den Aufrufer zu ändern" nur halb wahr. str(suuid)als Provenienz-Schlüssel statt Metadaten-Hash (§6.2) — eindeutig statt kollisionsanfällig, und ohnehin vorhanden (F4:SUUIDmuss für Bind-Parameter/IRIs stringifiziert werden).- Graph nutzt die Semantik-Schicht, statt PROV-O von Hand zu bauen — konsistent zu
to_jsonld/to_turtle, QUDT-Einheiten inklusive. - Modul in
iolib/. Writer sind I/O; die Stores liegen dort.DataFramewird nur unterTYPE_CHECKING/lazy importiert, um Zyklen zu vermeiden. - Warum nicht einfach die
to_*-Methoden lassen? Sie bleiben — der Writer ergänzt, was sie strukturell nicht können: einen polymorphen Vertrag, Lebenszyklus (flush/commit/close über mehrere Schreibvorgänge) und einen Ort für den Metadaten-Vertrag.
9. Tests / Coverage (umgesetzt)¶
sdata/iolib/writer.py 100 % Line-Coverage; 19 Tests in
tests/iolib/test_dataframe_writer.py; die Gesamtsuite bleibt grün. Abgedeckt:
ensure_sdata: sdata-DataFrame→ identisch; pandas-dfmit/ohnedf.attrs["_sdata"]→ Metadaten restauriert bzw. leer; Fremdtyp →TypeError.- Vertrag:
require_metadata/require_columns/require_unitsje erfüllt/verletzt (ValueErrormit den fehlenden Schlüsseln);BaseDataFrameWriter()abstrakt →TypeError; Protocol-isinstance(strukturell). - ParquetWriter: Roundtrip über lokale URI (
from_parquet_bytes) erhältmetadata/column_metadata/descriptionund Spalten-Einheiten (RFC 0006); nimmt auch eine reine pandas-df. - StoreWriter (F1-Beweis):
store.get(rid)["_sdata_sname"]/["_sdata_suuid"]top-level gesetzt,from_dict(row["sdata"])≡sdf, undget_id_by_key("_sdata_suuid"/"_sdata_sname", …)findet den Record; Pfad-Ziel wird beiclosegeschlossen und über eine neue Verbindung wieder gelesen (Persistenz). - SqlWriter: relationaler Roundtrip (
read_sql≡df), Metatabellesdata_dataframe_meta(target_table='runs') unterstr(suuid)/sname; Atomarität (F3): beito_sql-Fehler mit bereits pending Metazeile wird diese durchrollbackverworfen (keine Waise); Interop überengine.raw_connection(); unsicherer Tabellenname →ValueError(Allowlist). - GraphWriter (F2):
fmt="json-ld"schreibt die Datei tatsächlich (parsebar);fmt="turtle"ohne Ziel liefert die Turtle imWriteReceipt.detail;named_graphs=Trueohnerdflibwirft die geführteImportError. write_with_provenance: injiziert insdf.metadata(nichtdf.attrs), überlebt den Parquet-Roundtrip.
Die rdflib-only-Zweige (Named-Graph-Akkumulation/serialize) sind # pragma: no cover
(analog zu den optionalen Backends in RFC 0002); writer.py bleibt gemessen (nicht in
omit) und in der kanonischen CI (.[did,parquet,blob]) bei 100 %.
10. Kompatibilität / Migration¶
- Strikt additiv: neues Modul
sdata/iolib/writer.py, keine Signatur-/Verhaltens änderung anDataFrameoder den Stores.to_dict/from_dict, alleto_*/from_*,as_blob,JSON1SQLiteStorebleiben unverändert. StoreWriterundSqlWriterlaufen auf einer DBAPI/PEP-249-Verbindung (stdlibsqlite3, kein neuer Dependency).SqlWriternutzt qmark-Parameter (?) undconn.execute/commit/rollback— eine SQLAlchemy-Engine direkt funktioniert damit nicht; wohl aber deren DBAPI-Verbindung viaengine.raw_connection()(sdata[sql], durch einen gegateten Interop-Test belegt).ParquetWriter/GraphWriternutzen die vorhandenensdata[parquet]/sdata[blob]-Pfade; Named-Graph-Akkumulation brauchtsdata[rdf].- Keine neue Laufzeit-Kernabhängigkeit; Guard-Idiome (
_require_*bzw.try/except ImportError → None) unverändert übernommen.
11. Risiken / offene Punkte¶
Im Review geklärt (nicht mehr offen): Metadata.add/get existieren; as_blob("parquet")
bettet _sdata ein (nutzt to_parquet()); die to_dict()↔Store-Shape-Diskrepanz ist über
StoreWriter.store_payload behoben; SQL-Atomarität über die Meta-first-Ordnung + rollback.
Verbleibend:
- Relationaler
SqlWriter& Typabbildung.df.to_sqlverliert Einheiten/Ontologie (nur die Sidecar-Tabelle hält sie);if_exists="append"deckt Schema-Drift zwischen Tabellen nicht ab. Für semantiktreue Persistenz istStoreWriterverlustfrei. ParquetWriter= eine Datei prowrite(F5). Mehrerewriteauf dieselbe URI überschreiben.flush/closesind für Parquet No-ops — der Lebenszyklus trägt nur beiStore/Sql/Graph. Nachtrag (2026-07-02): der Verzeichnis-ModusParquetWriter(uri, directory=True)ist umgesetzt (RFC 0011 §5.3) — je Mitglied eine<sname>.spq;ParquetReader(uri, directory=True)+keys()+read_groupsind die symmetrische Gegenseite.- Named-Graph-Persistenz. N-Quads-Ausgabe an
closehängt anrdflib; ohne Backend nur Einzeldatei-Turtle/JSON-LD (# pragma: no cover). - Reader-Symmetrie. Ein
DataFrameReader-Protocol (Round-Trip pro Backend) ist der natürliche Folge-RFC. DataFrameGroup-Batch. Diewith writer: for sdf in group: writer.write(sdf)-Schleife ist vorbereitet; eine dediziertewrite_group-Semantik (ein Parquet-Verzeichnis, eine DB-Transaktion, ein Dataset) separat spezifizieren.