sdata 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. sdata/__init__.py +140 -0
  2. sdata/base.py +684 -0
  3. sdata/contrib/__init__.py +0 -0
  4. sdata/contrib/piexif/__init__.py +12 -0
  5. sdata/contrib/piexif/_common.py +94 -0
  6. sdata/contrib/piexif/_dump.py +346 -0
  7. sdata/contrib/piexif/_exceptions.py +2 -0
  8. sdata/contrib/piexif/_exif.py +638 -0
  9. sdata/contrib/piexif/_insert.py +60 -0
  10. sdata/contrib/piexif/_load.py +270 -0
  11. sdata/contrib/piexif/_remove.py +56 -0
  12. sdata/contrib/piexif/_transplant.py +45 -0
  13. sdata/contrib/piexif/_webp.py +240 -0
  14. sdata/contrib/piexif/helper.py +66 -0
  15. sdata/contrib/ranger/__init__.py +8 -0
  16. sdata/contrib/ranger/collections/__init__.py +0 -0
  17. sdata/contrib/ranger/collections/rangebucketmap.py +346 -0
  18. sdata/contrib/ranger/collections/rangemap.py +396 -0
  19. sdata/contrib/ranger/collections/rangeset.py +483 -0
  20. sdata/contrib/ranger/range/__init__.py +0 -0
  21. sdata/contrib/ranger/range/cut.py +285 -0
  22. sdata/contrib/ranger/range/range.py +644 -0
  23. sdata/contrib/sobol_seq.py +394 -0
  24. sdata/contrib/sortedcontainers/LICENSE +13 -0
  25. sdata/contrib/sortedcontainers/__init__.py +74 -0
  26. sdata/contrib/sortedcontainers/sorteddict.py +789 -0
  27. sdata/contrib/sortedcontainers/sortedlist.py +2646 -0
  28. sdata/contrib/sortedcontainers/sortedset.py +733 -0
  29. sdata/contrib/sqlitedict.py +598 -0
  30. sdata/contrib/timeflake/__init__.py +40 -0
  31. sdata/contrib/timeflake/flake.py +69 -0
  32. sdata/contrib/timeflake/utils.py +47 -0
  33. sdata/deprecated/__init__.py +10 -0
  34. sdata/deprecated/data.py +1503 -0
  35. sdata/did/__init__.py +129 -0
  36. sdata/did/_http.py +67 -0
  37. sdata/did/base58btc.py +49 -0
  38. sdata/did/did_github.py +152 -0
  39. sdata/did/did_peer4.py +155 -0
  40. sdata/did/did_web.py +222 -0
  41. sdata/did/diddoc.py +75 -0
  42. sdata/did/ed25519.py +225 -0
  43. sdata/did/eddsa.py +107 -0
  44. sdata/did/errors.py +40 -0
  45. sdata/did/issue_vc.py +193 -0
  46. sdata/did/jose.py +126 -0
  47. sdata/did/keys.py +104 -0
  48. sdata/did/utils_didkey.py +161 -0
  49. sdata/did/verify_vp.py +158 -0
  50. sdata/doe.py +101 -0
  51. sdata/dtypes.py +510 -0
  52. sdata/experiments/__init__.py +331 -0
  53. sdata/experiments/blob0.py +131 -0
  54. sdata/experiments/file.py +42 -0
  55. sdata/experiments/filegroup.py +144 -0
  56. sdata/experiments/image_old.py +313 -0
  57. sdata/experiments/ks2.py +15 -0
  58. sdata/experiments/lapshear.py +8 -0
  59. sdata/experiments/material.py +7 -0
  60. sdata/experiments/parameters_deprecated.py +394 -0
  61. sdata/experiments/workbook.py +226 -0
  62. sdata/format.py +125 -0
  63. sdata/imagemeta.py +533 -0
  64. sdata/interactive.py +118 -0
  65. sdata/iolib/__init__.py +51 -0
  66. sdata/iolib/hashtree.py +264 -0
  67. sdata/iolib/json1sqlitestore.py +554 -0
  68. sdata/iolib/jsonsqlitestore.py +704 -0
  69. sdata/iolib/pud.py +242 -0
  70. sdata/iolib/reader.py +262 -0
  71. sdata/iolib/writer.py +419 -0
  72. sdata/metadata.py +882 -0
  73. sdata/parameter.py +414 -0
  74. sdata/schema.py +273 -0
  75. sdata/sclass/__init__.py +111 -0
  76. sdata/sclass/bfo_classes.py +362 -0
  77. sdata/sclass/blob.py +362 -0
  78. sdata/sclass/content.py +87 -0
  79. sdata/sclass/dataframe.py +1251 -0
  80. sdata/sclass/dataframegroup.py +187 -0
  81. sdata/sclass/dependency_graph.py +85 -0
  82. sdata/sclass/filereference.py +124 -0
  83. sdata/sclass/image.py +218 -0
  84. sdata/sclass/lazy_namespace.py +155 -0
  85. sdata/sclass/process.py +383 -0
  86. sdata/sclass/process_graph.py +221 -0
  87. sdata/sclass/zipfile.py +135 -0
  88. sdata/semantic.py +346 -0
  89. sdata/suuid.py +136 -0
  90. sdata/timestamp.py +336 -0
  91. sdata/tools.py +14 -0
  92. sdata/units.py +602 -0
  93. sdata/vocab.py +149 -0
  94. sdata-1.5.0.dist-info/METADATA +264 -0
  95. sdata-1.5.0.dist-info/RECORD +98 -0
  96. sdata-1.5.0.dist-info/WHEEL +5 -0
  97. sdata-1.5.0.dist-info/licenses/LICENSE-MIT +21 -0
  98. sdata-1.5.0.dist-info/top_level.txt +1 -0
sdata/iolib/writer.py ADDED
@@ -0,0 +1,419 @@
1
+ """Einheitliche Writer-/Sink-Abstraktion für :class:`sdata.sclass.dataframe.DataFrame`.
2
+
3
+ RFC 0007. Ein gemeinsamer ``write()``-Vertrag, unter dem Backends (Parquet-Datei/
4
+ Objektspeicher, JSON1-SQLite-Store, relationales SQL, Knowledge Graph) austauschbar
5
+ sind. Die Wahrheitsquelle der Metadaten ist **nicht** ``df.attrs`` (das pandas über
6
+ ``groupby``/``concat``/``merge`` meist verwirft), sondern die erstklassigen Felder
7
+ ``DataFrame.metadata`` und ``DataFrame.column_metadata``; der Writer *platziert* die
8
+ Metadaten nur backend-gerecht.
9
+
10
+ Konkrete Writer:
11
+
12
+ * :class:`ParquetWriter` — Parquet-Bytes (mit eingebettetem ``_sdata``) an eine fsspec-URI.
13
+ * :class:`StoreWriter` — sdata-Objekt in einen :class:`~sdata.iolib.json1sqlitestore.JSON1SQLiteStore`
14
+ (Dokument-/Objekt-Persistenz; über die ``_sdata_*``-Spalten indiziert/auffindbar).
15
+ * :class:`SqlWriter` — flache Daten in eine relationale Tabelle + Sidecar-Metadatentabelle,
16
+ atomar (eine Transaktion, per ``flush``/``close`` committet).
17
+ * :class:`GraphWriter` — RDF/Turtle bzw. JSON-LD (QUDT + PROV-O + CSVW); optional
18
+ Named-Graph-Akkumulation über ``rdflib``.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import re
25
+ from abc import ABC, abstractmethod
26
+ from dataclasses import dataclass, field
27
+ from typing import Any, Dict, Optional, Protocol, Tuple, runtime_checkable, TYPE_CHECKING
28
+
29
+ import pandas as pd
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ if TYPE_CHECKING: # pragma: no cover
34
+ from sdata.sclass.dataframe import DataFrame
35
+
36
+ __all__ = [
37
+ "WriteReceipt",
38
+ "DataFrameWriter",
39
+ "BaseDataFrameWriter",
40
+ "ParquetWriter",
41
+ "StoreWriter",
42
+ "SqlWriter",
43
+ "GraphWriter",
44
+ "ensure_sdata",
45
+ "write_with_provenance",
46
+ "check_contract",
47
+ "write_group",
48
+ ]
49
+
50
+ #: Feste Metadatentabelle des :class:`SqlWriter` — als **Literal** in statischem SQL
51
+ #: verwendet (keine Identifier-Interpolation → keine SQL-Injection-Fläche). Welche
52
+ #: Datentabelle eine Zeile beschreibt, steht in der Spalte ``target_table``.
53
+ _SQL_META_TABLE = "sdata_dataframe_meta"
54
+ _SQL_META_DDL = (
55
+ "CREATE TABLE IF NOT EXISTS sdata_dataframe_meta "
56
+ "(suuid TEXT, sname TEXT, target_table TEXT, sdata TEXT)"
57
+ )
58
+ _SQL_META_INSERT = (
59
+ "INSERT INTO sdata_dataframe_meta(suuid, sname, target_table, sdata) "
60
+ "VALUES (?, ?, ?, ?)"
61
+ )
62
+ _IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
63
+
64
+
65
+ def _safe_identifier(name: str) -> str:
66
+ """Validate a SQL table identifier (allowlist) and return it unchanged.
67
+
68
+ :raises ValueError: if ``name`` is not a plain ``[A-Za-z_][A-Za-z0-9_]*`` token.
69
+ """
70
+ if not isinstance(name, str) or not _IDENTIFIER_RE.match(name):
71
+ raise ValueError(f"invalid SQL table identifier: {name!r}")
72
+ return name
73
+
74
+
75
+ @dataclass
76
+ class WriteReceipt:
77
+ """Einheitliches Ergebnis eines ``write()`` — macht Senken polymorph austauschbar.
78
+
79
+ :param backend: Kurzname der Senke (``"parquet"``/``"store"``/``"sql"``/``"graph"``).
80
+ :param sname: S3-sicherer Name der geschriebenen Tabelle.
81
+ :param suuid: stabile ID der geschriebenen Tabelle (als ``str``).
82
+ :param target: das Ziel (URI/Pfad/Tabellenname/Record-ID/Graph-IRI).
83
+ :param detail: backend-spezifische Zusatzangaben.
84
+ """
85
+
86
+ backend: str
87
+ sname: str
88
+ suuid: str
89
+ target: Any = None
90
+ detail: Dict[str, Any] = field(default_factory=dict)
91
+
92
+
93
+ @runtime_checkable
94
+ class DataFrameWriter(Protocol):
95
+ """Struktureller Vertrag jeder Senke."""
96
+
97
+ def write(self, sdf: "DataFrame") -> WriteReceipt:
98
+ ...
99
+
100
+ def flush(self) -> None:
101
+ ...
102
+
103
+ def close(self) -> None:
104
+ ...
105
+
106
+
107
+ def check_contract(sdf: "DataFrame",
108
+ require_metadata: Tuple[str, ...] = (),
109
+ require_columns: Tuple[str, ...] = (),
110
+ require_units: Tuple[str, ...] = (),
111
+ role: str = "writer") -> None:
112
+ """Prüfe den ``require_*``-Metadaten-Vertrag (gemeinsam für Writer **und** Reader).
113
+
114
+ RFC 0009: dieselbe Vertragssemantik dient beim Writer als Ausgangs- und beim
115
+ Reader als **Eingangs**-Validierung einer Pipeline.
116
+
117
+ :param role: erscheint in der Fehlermeldung (``"writer"``/``"reader"``).
118
+ :raises ValueError: nennt die fehlenden Schlüssel/Spalten/Einheiten.
119
+ """
120
+ units = sdf.column_units
121
+ missing = {
122
+ "metadata": [k for k in require_metadata if sdf.metadata.get(k) is None],
123
+ "columns": [c for c in require_columns if c not in sdf.df.columns],
124
+ "units": [c for c in require_units if not units.get(c)],
125
+ }
126
+ if any(missing.values()):
127
+ detail = ", ".join(f"{k}={v}" for k, v in missing.items())
128
+ raise ValueError(f"{role} contract violated: {detail}")
129
+
130
+
131
+ def ensure_sdata(obj: Any) -> "DataFrame":
132
+ """Bridge: akzeptiert eine sdata-:class:`DataFrame` **oder** eine reine pandas-``df``.
133
+
134
+ Bei einer pandas-``df`` wird ``df.attrs['_sdata']`` (der von
135
+ :meth:`DataFrame.to_parquet`/``to_dataframe`` beschriebene „letzte-Meile"-Slot)
136
+ über den vorhandenen ``_restore_from_attrs``-Pfad restauriert.
137
+
138
+ :raises TypeError: bei einem anderen Typ.
139
+ """
140
+ from sdata.sclass.dataframe import DataFrame
141
+
142
+ if isinstance(obj, DataFrame):
143
+ return obj
144
+ if isinstance(obj, pd.DataFrame):
145
+ sdf = DataFrame(df=obj)
146
+ sdf._restore_from_attrs(obj.attrs.get("_sdata"))
147
+ return sdf
148
+ raise TypeError(f"expected DataFrame or pandas.DataFrame, not {type(obj)!r}")
149
+
150
+
151
+ def write_group(writer: "DataFrameWriter", group: Any) -> list:
152
+ """Alle Mitglieder einer :class:`~sdata.sclass.dataframegroup.DataFrameGroup`
153
+ in **eine** Senke schreiben (RFC 0011).
154
+
155
+ Der Writer wird als Context-Manager geöffnet; für transaktionale Senken
156
+ (``StoreWriter``/``SqlWriter``) laufen alle Mitglieder in **einer** Transaktion
157
+ (Commit erst beim ``close``), also alles-oder-nichts je Gruppe.
158
+
159
+ :param writer: eine beliebige :class:`DataFrameWriter`-Senke.
160
+ :param group: eine ``DataFrameGroup`` (oder etwas mit ``items() -> (key, DataFrame)``).
161
+ :return: Liste der :class:`WriteReceipt` in Mitglied-Reihenfolge.
162
+ """
163
+ receipts = []
164
+ with writer:
165
+ for _key, sdf in group.items():
166
+ receipts.append(writer.write(sdf))
167
+ return receipts
168
+
169
+
170
+ def write_with_provenance(writer: "DataFrameWriter", obj: Any,
171
+ provenance: Dict[str, Any]) -> WriteReceipt:
172
+ """Provenienz **explizit** zum Schreibzeitpunkt in die Wahrheitsquelle injizieren.
173
+
174
+ Robuster als auf ``df.attrs``-Propagierung zu hoffen: die Werte landen in
175
+ ``sdf.metadata`` (nicht in ``df.attrs``) und damit dort, wo jeder Writer sie liest.
176
+ """
177
+ sdf = ensure_sdata(obj)
178
+ for key, value in provenance.items():
179
+ sdf.metadata.add(key, value)
180
+ return writer.write(sdf)
181
+
182
+
183
+ class BaseDataFrameWriter(ABC):
184
+ """Template-Method: Vertrag prüfen, Metadaten trennen, an das Backend delegieren."""
185
+
186
+ def __init__(self,
187
+ require_metadata: Tuple[str, ...] = (),
188
+ require_columns: Tuple[str, ...] = (),
189
+ require_units: Tuple[str, ...] = ()):
190
+ """Store the require_* contract (Pflichtschlüssel/-spalten/-einheiten)."""
191
+ self.require_metadata = tuple(require_metadata)
192
+ self.require_columns = tuple(require_columns)
193
+ self.require_units = tuple(require_units)
194
+ self._count = 0
195
+
196
+ def write(self, sdf: "DataFrame") -> WriteReceipt:
197
+ sdf = ensure_sdata(sdf)
198
+ self._check_contract(sdf)
199
+ meta = {
200
+ "metadata": sdf.metadata.to_dict(),
201
+ "column_metadata": sdf.column_metadata.to_dict(),
202
+ "description": sdf.description,
203
+ }
204
+ receipt = self._write_impl(sdf, meta)
205
+ self._count += 1
206
+ logger.info("%s wrote %r -> %s", type(self).__name__, sdf.sname, receipt.target)
207
+ return receipt
208
+
209
+ def _check_contract(self, sdf: "DataFrame") -> None:
210
+ """Raise ``ValueError`` if the require_* contract is not met (delegiert)."""
211
+ check_contract(sdf, self.require_metadata, self.require_columns,
212
+ self.require_units, role="writer")
213
+
214
+ @abstractmethod
215
+ def _write_impl(self, sdf: "DataFrame", meta: Dict[str, Any]) -> WriteReceipt:
216
+ ...
217
+
218
+ def flush(self) -> None:
219
+ """No-op by default; stateful sinks override to commit."""
220
+
221
+ def close(self) -> None:
222
+ """Flush and release resources (default: flush only)."""
223
+ self.flush()
224
+
225
+ def __enter__(self) -> "BaseDataFrameWriter":
226
+ """Enter the context manager (returns self)."""
227
+ return self
228
+
229
+ def __exit__(self, *exc: Any) -> None:
230
+ """Exit the context manager, closing the writer."""
231
+ self.close()
232
+
233
+
234
+ class ParquetWriter(BaseDataFrameWriter):
235
+ """Schreibt Parquet-Bytes (mit eingebettetem ``_sdata``) an eine fsspec-URI.
236
+
237
+ Die Metadaten reisen **im** Format mit — kein Sidecar nötig (RFC 0007 §6.1).
238
+
239
+ Zwei Modi (RFC 0007 F5 / RFC 0011 §5.3):
240
+
241
+ * **Einzeldatei** (Default): jeder ``write`` schreibt an dieselbe ``uri`` —
242
+ mehrere ``write`` überschreiben sich.
243
+ * **Verzeichnis** (``directory=True``): ``uri`` ist ein Verzeichnis; jeder
244
+ ``write`` legt eine eigene ``<sname>.spq`` an. Damit schreibt ``write_group``
245
+ jede Tabelle in eine eigene Datei (statt Überschreiben); die Gegenseite ist
246
+ :class:`~sdata.iolib.reader.ParquetReader` im Verzeichnis-Modus.
247
+ """
248
+
249
+ def __init__(self, uri: str, *, directory: bool = False, **contract: Any):
250
+ """Bind the destination fsspec ``uri`` and the require_* contract.
251
+
252
+ :param uri: Ziel-URI (Einzeldatei) bzw. Ziel-Verzeichnis (``directory=True``).
253
+ :param directory: Verzeichnis-Modus — eine ``<sname>.spq`` je ``write``.
254
+ """
255
+ super().__init__(**contract)
256
+ self.uri = uri
257
+ self.directory = directory
258
+
259
+ def _write_impl(self, sdf: "DataFrame", meta: Dict[str, Any]) -> WriteReceipt:
260
+ blob = sdf.as_blob("parquet") # to_parquet()-Bytes: _sdata eingebettet
261
+ target = self.uri
262
+ if self.directory:
263
+ from sdata.sclass.blob import fsspec
264
+ if fsspec is None: # pragma: no cover - optionale Abhängigkeit
265
+ raise ImportError("directory ParquetWriter requires: pip install sdata[blob]")
266
+ base = self.uri.rstrip("/")
267
+ fs, _, _ = fsspec.get_fs_token_paths(base)
268
+ fs.makedirs(base, exist_ok=True)
269
+ target = "{}/{}.spq".format(base, sdf.sname)
270
+ written = blob.write(target) # fsspec (lokal, s3://, …)
271
+ return WriteReceipt("parquet", sdf.sname, str(sdf.suuid), written,
272
+ {"bytes": blob.size})
273
+
274
+
275
+ class StoreWriter(BaseDataFrameWriter):
276
+ """Persistiert das ganze sdata-Objekt in einen :class:`JSON1SQLiteStore`.
277
+
278
+ Das Payload wird so **abgeflacht**, dass die ``_sdata_*``-Felder auf der obersten
279
+ Ebene liegen (die der Store als generierte Spalten indiziert) und das verlustfreie
280
+ :meth:`DataFrame.to_dict` unter dem Schlüssel ``"sdata"`` mitreist. Dadurch ist das
281
+ Objekt per ``sname``/``suuid`` auffindbar (RFC 0007 §6.2, behebt den
282
+ ``to_dict()``↔Store-Shape-Mismatch).
283
+ """
284
+
285
+ def __init__(self, target: Any, **contract: Any):
286
+ """Bind a store: an existing ``JSON1SQLiteStore`` (not owned) or a DB path (owned)."""
287
+ super().__init__(**contract)
288
+ from sdata.iolib.json1sqlitestore import JSON1SQLiteStore
289
+
290
+ if hasattr(target, "insert"):
291
+ self._store = target
292
+ self._owns = False
293
+ else:
294
+ self._store = JSON1SQLiteStore(target)
295
+ self._owns = True
296
+
297
+ @staticmethod
298
+ def store_payload(sdf: "DataFrame") -> Dict[str, Any]:
299
+ """Flaches Store-Payload: ``_sdata_*`` top-level + volles ``to_dict`` unter ``sdata``."""
300
+ from sdata.iolib.json1sqlitestore import JSON1SQLiteStore
301
+
302
+ md = sdf.metadata
303
+ payload: Dict[str, Any] = {}
304
+ for col in JSON1SQLiteStore.GENERATED_COLUMNS:
305
+ attr = md.get(col)
306
+ payload[col] = None if attr is None else attr.value
307
+ # verbindliche Overrides aus den Objekt-Properties (immer gesetzt)
308
+ payload["_sdata_name"] = sdf.name
309
+ payload["_sdata_sname"] = sdf.sname
310
+ payload["_sdata_suuid"] = str(sdf.suuid)
311
+ payload["sdata"] = sdf.to_dict()
312
+ return payload
313
+
314
+ def _write_impl(self, sdf: "DataFrame", meta: Dict[str, Any]) -> WriteReceipt:
315
+ rid = self._store.insert(self.store_payload(sdf))
316
+ return WriteReceipt("store", sdf.sname, str(sdf.suuid), rid,
317
+ {"record_id": rid})
318
+
319
+ def close(self) -> None:
320
+ """Flush and, if this writer owns the store, close its connection."""
321
+ self.flush()
322
+ if self._owns:
323
+ self._store.conn.close()
324
+
325
+
326
+ class SqlWriter(BaseDataFrameWriter):
327
+ """Flache Daten in eine relationale Tabelle + gemeinsame Metadatentabelle, atomar.
328
+
329
+ Die Metadaten gehen in die **feste** Tabelle ``sdata_dataframe_meta``
330
+ (:data:`_SQL_META_TABLE`), deren Spalte ``target_table`` die zugehörige Datentabelle
331
+ benennt. Das SQL der Metatabelle ist ein **Literal** (keine Identifier-Interpolation
332
+ → keine SQL-Injection-Fläche); der Datentabellenname wird gegen eine Allowlist
333
+ validiert und über ``pandas.to_sql`` (parametrisiert/gequotet) geschrieben. Meta-Insert
334
+ und ``to_sql`` liegen in **einer** Transaktion; bei Fehler verwirft ``rollback`` die
335
+ pending Metazeile (RFC 0007 §6.2, F3/F4).
336
+ """
337
+
338
+ def __init__(self, conn: Any, table: str = "dataframe", *,
339
+ if_exists: str = "append", **contract: Any):
340
+ """Bind a DBAPI/PEP-249 connection and ensure the shared meta table exists.
341
+
342
+ :param conn: sqlite3-/DBAPI-Verbindung (oder ``engine.raw_connection()``).
343
+ :param table: Zieltabelle für die flachen Daten (Allowlist-validiert).
344
+ :param if_exists: an ``pandas.to_sql`` durchgereicht (``append``/``replace``/``fail``).
345
+ """
346
+ super().__init__(**contract)
347
+ self._conn = conn
348
+ self.table = _safe_identifier(table)
349
+ self.meta_table = _SQL_META_TABLE
350
+ self.if_exists = if_exists
351
+ self._conn.execute(_SQL_META_DDL)
352
+
353
+ def _write_impl(self, sdf: "DataFrame", meta: Dict[str, Any]) -> WriteReceipt:
354
+ suuid = str(sdf.suuid)
355
+ # Atomar: erst die Metadaten (pending), dann ``to_sql`` — dessen abschließender
356
+ # commit schreibt BEIDE fest. Bei einem Fehler in einem der Schritte verwirft
357
+ # das ``rollback`` die noch nicht committete Metadatenzeile (kein Waise, F3).
358
+ try:
359
+ self._conn.execute(
360
+ _SQL_META_INSERT,
361
+ (suuid, sdf.sname, self.table, json.dumps(sdf.to_dict(), default=str)))
362
+ sdf.df.to_sql(self.table, self._conn, if_exists=self.if_exists, index=False)
363
+ except Exception:
364
+ self._conn.rollback()
365
+ raise
366
+ return WriteReceipt("sql", sdf.sname, suuid, self.table,
367
+ {"meta_table": self.meta_table})
368
+
369
+ def flush(self) -> None:
370
+ """Commit the connection (transaction boundary for data + meta)."""
371
+ self._conn.commit()
372
+
373
+
374
+ class GraphWriter(BaseDataFrameWriter):
375
+ """RDF/Turtle bzw. JSON-LD der Metadaten (QUDT + PROV-O + CSVW).
376
+
377
+ Einzeldatei-Modus schreibt **tatsächlich** nach ``target`` (RFC 0007 §6.3, F2).
378
+ ``named_graphs=True`` akkumuliert mehrere Tabellen als Named Graphs in einem
379
+ ``rdflib.Dataset`` (Graph-IRI aus ``suuid``) und serialisiert bei :meth:`close`.
380
+ """
381
+
382
+ def __init__(self, target: Optional[str] = None, *, fmt: str = "turtle",
383
+ named_graphs: bool = False, **contract: Any):
384
+ """Configure the RDF target file, serialization ``fmt`` and named-graph mode."""
385
+ super().__init__(**contract)
386
+ self.target = target
387
+ self.fmt = fmt
388
+ self._dataset = None
389
+ if named_graphs:
390
+ from sdata.semantic import _rdflib
391
+
392
+ if _rdflib is None:
393
+ raise ImportError("named_graphs requires: pip install sdata[rdf]")
394
+ self._dataset = _rdflib.Dataset() # pragma: no cover
395
+
396
+ def _write_impl(self, sdf: "DataFrame", meta: Dict[str, Any]) -> WriteReceipt:
397
+ suuid = str(sdf.suuid)
398
+ if self._dataset is not None: # pragma: no cover (rdflib nicht in kanonischer CI)
399
+ from sdata.semantic import _rdflib
400
+
401
+ iri = _rdflib.URIRef(f"urn:sdata:{suuid}")
402
+ self._dataset.graph(iri).parse(
403
+ data=json.dumps(sdf.to_jsonld()), format="json-ld")
404
+ return WriteReceipt("graph", sdf.sname, suuid, str(iri),
405
+ {"named_graph": True})
406
+ if self.fmt == "json-ld":
407
+ text = json.dumps(sdf.to_jsonld(), indent=2)
408
+ else:
409
+ text = sdf.to_turtle()
410
+ if self.target is not None:
411
+ with open(self.target, "w", encoding="utf-8") as fh:
412
+ fh.write(text)
413
+ return WriteReceipt("graph", sdf.sname, suuid, self.target,
414
+ {"text": text})
415
+
416
+ def close(self) -> None:
417
+ """Serialize the accumulated named-graph dataset to ``target`` (if any)."""
418
+ if self._dataset is not None and self.target: # pragma: no cover
419
+ self._dataset.serialize(destination=self.target, format="nquads")