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.
- sdata/__init__.py +140 -0
- sdata/base.py +684 -0
- sdata/contrib/__init__.py +0 -0
- sdata/contrib/piexif/__init__.py +12 -0
- sdata/contrib/piexif/_common.py +94 -0
- sdata/contrib/piexif/_dump.py +346 -0
- sdata/contrib/piexif/_exceptions.py +2 -0
- sdata/contrib/piexif/_exif.py +638 -0
- sdata/contrib/piexif/_insert.py +60 -0
- sdata/contrib/piexif/_load.py +270 -0
- sdata/contrib/piexif/_remove.py +56 -0
- sdata/contrib/piexif/_transplant.py +45 -0
- sdata/contrib/piexif/_webp.py +240 -0
- sdata/contrib/piexif/helper.py +66 -0
- sdata/contrib/ranger/__init__.py +8 -0
- sdata/contrib/ranger/collections/__init__.py +0 -0
- sdata/contrib/ranger/collections/rangebucketmap.py +346 -0
- sdata/contrib/ranger/collections/rangemap.py +396 -0
- sdata/contrib/ranger/collections/rangeset.py +483 -0
- sdata/contrib/ranger/range/__init__.py +0 -0
- sdata/contrib/ranger/range/cut.py +285 -0
- sdata/contrib/ranger/range/range.py +644 -0
- sdata/contrib/sobol_seq.py +394 -0
- sdata/contrib/sortedcontainers/LICENSE +13 -0
- sdata/contrib/sortedcontainers/__init__.py +74 -0
- sdata/contrib/sortedcontainers/sorteddict.py +789 -0
- sdata/contrib/sortedcontainers/sortedlist.py +2646 -0
- sdata/contrib/sortedcontainers/sortedset.py +733 -0
- sdata/contrib/sqlitedict.py +598 -0
- sdata/contrib/timeflake/__init__.py +40 -0
- sdata/contrib/timeflake/flake.py +69 -0
- sdata/contrib/timeflake/utils.py +47 -0
- sdata/deprecated/__init__.py +10 -0
- sdata/deprecated/data.py +1503 -0
- sdata/did/__init__.py +129 -0
- sdata/did/_http.py +67 -0
- sdata/did/base58btc.py +49 -0
- sdata/did/did_github.py +152 -0
- sdata/did/did_peer4.py +155 -0
- sdata/did/did_web.py +222 -0
- sdata/did/diddoc.py +75 -0
- sdata/did/ed25519.py +225 -0
- sdata/did/eddsa.py +107 -0
- sdata/did/errors.py +40 -0
- sdata/did/issue_vc.py +193 -0
- sdata/did/jose.py +126 -0
- sdata/did/keys.py +104 -0
- sdata/did/utils_didkey.py +161 -0
- sdata/did/verify_vp.py +158 -0
- sdata/doe.py +101 -0
- sdata/dtypes.py +510 -0
- sdata/experiments/__init__.py +331 -0
- sdata/experiments/blob0.py +131 -0
- sdata/experiments/file.py +42 -0
- sdata/experiments/filegroup.py +144 -0
- sdata/experiments/image_old.py +313 -0
- sdata/experiments/ks2.py +15 -0
- sdata/experiments/lapshear.py +8 -0
- sdata/experiments/material.py +7 -0
- sdata/experiments/parameters_deprecated.py +394 -0
- sdata/experiments/workbook.py +226 -0
- sdata/format.py +125 -0
- sdata/imagemeta.py +533 -0
- sdata/interactive.py +118 -0
- sdata/iolib/__init__.py +51 -0
- sdata/iolib/hashtree.py +264 -0
- sdata/iolib/json1sqlitestore.py +554 -0
- sdata/iolib/jsonsqlitestore.py +704 -0
- sdata/iolib/pud.py +242 -0
- sdata/iolib/reader.py +262 -0
- sdata/iolib/writer.py +419 -0
- sdata/metadata.py +882 -0
- sdata/parameter.py +414 -0
- sdata/schema.py +273 -0
- sdata/sclass/__init__.py +111 -0
- sdata/sclass/bfo_classes.py +362 -0
- sdata/sclass/blob.py +362 -0
- sdata/sclass/content.py +87 -0
- sdata/sclass/dataframe.py +1251 -0
- sdata/sclass/dataframegroup.py +187 -0
- sdata/sclass/dependency_graph.py +85 -0
- sdata/sclass/filereference.py +124 -0
- sdata/sclass/image.py +218 -0
- sdata/sclass/lazy_namespace.py +155 -0
- sdata/sclass/process.py +383 -0
- sdata/sclass/process_graph.py +221 -0
- sdata/sclass/zipfile.py +135 -0
- sdata/semantic.py +346 -0
- sdata/suuid.py +136 -0
- sdata/timestamp.py +336 -0
- sdata/tools.py +14 -0
- sdata/units.py +602 -0
- sdata/vocab.py +149 -0
- sdata-1.5.0.dist-info/METADATA +264 -0
- sdata-1.5.0.dist-info/RECORD +98 -0
- sdata-1.5.0.dist-info/WHEEL +5 -0
- sdata-1.5.0.dist-info/licenses/LICENSE-MIT +21 -0
- 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")
|