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
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import io
|
|
3
|
+
import os
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Dict, Optional, Union
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from sdata import dtypes
|
|
10
|
+
from sdata.metadata import Metadata, Attribute
|
|
11
|
+
from sdata.base import Base
|
|
12
|
+
from sdata.interactive import ColumnAccessor
|
|
13
|
+
from sdata.sclass.content import ContentIntegrityMixin
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
#: Spalten-Annotationen, die zusätzlich nativ als Arrow-Field-Metadata mitreisen
|
|
18
|
+
#: (tool-agnostisch lesbar, z. B. von DuckDB/Polars), neben dem ``_sdata``-Blob.
|
|
19
|
+
_COL_FIELD_KEYS = ("unit", "label", "description", "ontology", "symbol")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Die drei ``_h5_*``-Helfer sind ``# pragma: no cover``: sie laufen nur mit
|
|
23
|
+
# installiertem h5py (Extra ``sdata[hdf]``), das die kanonische CI nicht mitbringt
|
|
24
|
+
# -> die HDF-Tests skippen dort. Verifiziert in Umgebungen mit ``sdata[hdf]``.
|
|
25
|
+
def _h5_string_dtype(): # pragma: no cover
|
|
26
|
+
"""Variable-length UTF-8 dtype for object/string columns (h5py)."""
|
|
27
|
+
import h5py
|
|
28
|
+
return h5py.string_dtype(encoding="utf-8")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _h5_encode(values): # pragma: no cover
|
|
32
|
+
"""Encode a 1-D array for h5py, returning ``(data, kind)``.
|
|
33
|
+
|
|
34
|
+
Numeric/boolean arrays are stored natively; ``datetime64``/``timedelta64`` are
|
|
35
|
+
stored as int64 nanoseconds (with a ``kind`` tag for lossless restore); anything
|
|
36
|
+
else (object/string/category) is stored as variable-length UTF-8 strings.
|
|
37
|
+
|
|
38
|
+
:param values: a numpy array (e.g. ``series.to_numpy()``).
|
|
39
|
+
:return: ``(data, kind)`` where ``kind`` is one of
|
|
40
|
+
``"num" | "datetime" | "timedelta" | "str"``.
|
|
41
|
+
"""
|
|
42
|
+
import numpy as np
|
|
43
|
+
arr = np.asarray(values)
|
|
44
|
+
kind = arr.dtype.kind
|
|
45
|
+
if kind in ("f", "i", "u", "b"):
|
|
46
|
+
return arr, "num"
|
|
47
|
+
if kind == "M":
|
|
48
|
+
return arr.astype("datetime64[ns]").view("i8"), "datetime"
|
|
49
|
+
if kind == "m":
|
|
50
|
+
return arr.astype("timedelta64[ns]").view("i8"), "timedelta"
|
|
51
|
+
out = np.array(
|
|
52
|
+
["" if (v is None or (isinstance(v, float) and np.isnan(v))) else str(v)
|
|
53
|
+
for v in arr.tolist()],
|
|
54
|
+
dtype=object,
|
|
55
|
+
)
|
|
56
|
+
return out, "str"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _h5_decode(dataset): # pragma: no cover
|
|
60
|
+
"""Inverse of :func:`_h5_encode` for one h5py dataset (uses its ``kind`` attr)."""
|
|
61
|
+
import numpy as np
|
|
62
|
+
raw = dataset[()]
|
|
63
|
+
kind = dataset.attrs.get("kind", "num")
|
|
64
|
+
if kind == "datetime":
|
|
65
|
+
return pd.to_datetime(np.asarray(raw, dtype="i8"))
|
|
66
|
+
if kind == "timedelta":
|
|
67
|
+
return pd.to_timedelta(np.asarray(raw, dtype="i8"))
|
|
68
|
+
if kind == "str":
|
|
69
|
+
return [v.decode("utf-8") if isinstance(v, (bytes, bytearray)) else v
|
|
70
|
+
for v in raw]
|
|
71
|
+
return raw
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _require_parquet(engine: str = "pyarrow") -> None:
|
|
75
|
+
"""Stelle sicher, dass die Parquet-Engine importierbar ist.
|
|
76
|
+
|
|
77
|
+
pandas meldet ein fehlendes Backend erst tief im Aufruf; diese Vorabprüfung
|
|
78
|
+
liefert stattdessen eine klare, handlungsorientierte Meldung.
|
|
79
|
+
|
|
80
|
+
:param engine: Name des Parquet-Backends (z. B. ``"pyarrow"``).
|
|
81
|
+
:raises ImportError: wenn das Backend nicht installiert ist.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
__import__(engine)
|
|
85
|
+
except ImportError as exp:
|
|
86
|
+
raise ImportError(
|
|
87
|
+
f"Parquet engine {engine!r} is not available. "
|
|
88
|
+
f"Install it, e.g. `pip install sdata[parquet]`."
|
|
89
|
+
) from exp
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class DataFrame(ContentIntegrityMixin, Base):
|
|
93
|
+
SDATA_CLS = "sdata.sclass.dataframe.DataFrame"
|
|
94
|
+
|
|
95
|
+
#: optionales :class:`~sdata.schema.TableSchema`; beim Init angewandt (Default None)
|
|
96
|
+
TABLE_SCHEMA = None
|
|
97
|
+
|
|
98
|
+
#: Reserviertes Metadaten-Feld, das das Ziel-Einheitensystem der Tabelle
|
|
99
|
+
#: persistiert (RFC 0014): die Basis-Einheiten-Liste eines ``UnitSystem``.
|
|
100
|
+
#: Da es in ``metadata`` liegt, reist es durch alle Serialisierungspfade
|
|
101
|
+
#: (dict/parquet/hdf/JSON-LD) automatisch mit.
|
|
102
|
+
SDATA_UNIT_SYSTEM = "_sdata_unit_system"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
df: Optional[pd.DataFrame] = None,
|
|
108
|
+
column_metadata: Optional[
|
|
109
|
+
Union[Dict[str, Dict[str, str]], Metadata]
|
|
110
|
+
] = None,
|
|
111
|
+
unit_system=None,
|
|
112
|
+
**kwargs: Any
|
|
113
|
+
) -> None:
|
|
114
|
+
"""
|
|
115
|
+
Initialize a DataFrame with an optional pandas DataFrame and column metadata.
|
|
116
|
+
|
|
117
|
+
The pandas DataFrame is kept in memory; serialization to Parquet/dict/JSON-LD
|
|
118
|
+
happens on demand. Per-column annotations (unit/label/description/ontology)
|
|
119
|
+
live in ``column_metadata`` (a :class:`~sdata.metadata.Metadata`).
|
|
120
|
+
|
|
121
|
+
:param df: Optional pandas DataFrame to store (default: an empty DataFrame).
|
|
122
|
+
:param column_metadata: Optional per-column metadata, either a dict
|
|
123
|
+
``{colname: {'label': ..., 'unit': ...}}`` or a :class:`Metadata`. Keys
|
|
124
|
+
that do not match a df column are kept but a warning is logged.
|
|
125
|
+
:param unit_system: optional target :class:`~sdata.units.UnitSystem` (or a
|
|
126
|
+
unit list like ``["kN", "mm", "ms"]``) recorded on the table; see
|
|
127
|
+
:attr:`unit_system` and :meth:`convert`.
|
|
128
|
+
:param kwargs: forwarded to :class:`~sdata.base.Base` (e.g. ``name``,
|
|
129
|
+
``description``, ``project``).
|
|
130
|
+
"""
|
|
131
|
+
super().__init__(**kwargs)
|
|
132
|
+
|
|
133
|
+
if isinstance(column_metadata, dict):
|
|
134
|
+
# Spalten-Attribute tragen den pandas-dtype-Namen als Wert und sind
|
|
135
|
+
# daher selbst vom dtype str (ältere Dateien: dtype="float" o. ä.)
|
|
136
|
+
column_metadata = {
|
|
137
|
+
key: dict(entry, dtype="str") if isinstance(entry, dict) else entry
|
|
138
|
+
for key, entry in column_metadata.items()}
|
|
139
|
+
self._column_metadata = Metadata.from_dict(column_metadata)
|
|
140
|
+
elif isinstance(column_metadata, Metadata):
|
|
141
|
+
self._column_metadata = column_metadata.copy()
|
|
142
|
+
else:
|
|
143
|
+
if column_metadata is not None:
|
|
144
|
+
logger.warning("column_metadata type not supported: %s",
|
|
145
|
+
type(column_metadata).__name__)
|
|
146
|
+
self._column_metadata = Metadata(name="column_metadata")
|
|
147
|
+
|
|
148
|
+
self._df = pd.DataFrame()
|
|
149
|
+
if unit_system is not None:
|
|
150
|
+
self.unit_system = unit_system
|
|
151
|
+
if df is not None:
|
|
152
|
+
# Bei der Konstruktion vom Nutzer gelieferte column_metadata bewahren
|
|
153
|
+
# (kein Prune); Waisen werden nur gemeldet.
|
|
154
|
+
self._assign_df(df, prune=False)
|
|
155
|
+
self._warn_orphan_columns()
|
|
156
|
+
|
|
157
|
+
# Optionales Tabellen-Schema anwenden (column_metadata vollqualifizieren)
|
|
158
|
+
if self.TABLE_SCHEMA is not None:
|
|
159
|
+
self.TABLE_SCHEMA.apply(self)
|
|
160
|
+
|
|
161
|
+
def _warn_orphan_columns(self):
|
|
162
|
+
"""Warne, wenn column_metadata-Schlüssel keiner df-Spalte entsprechen."""
|
|
163
|
+
cols = {str(c) for c in self._df.columns}
|
|
164
|
+
orphans = [k for k in self._column_metadata.keys() if k not in cols]
|
|
165
|
+
if orphans:
|
|
166
|
+
logger.warning("column_metadata keys not in df columns: %s", orphans)
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def cmd(self):
|
|
170
|
+
return self._column_metadata
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def cmdf(self):
|
|
174
|
+
return self._column_metadata.to_dataframe()
|
|
175
|
+
|
|
176
|
+
def _get_df(self):
|
|
177
|
+
return self._df
|
|
178
|
+
|
|
179
|
+
def _set_df(self, df):
|
|
180
|
+
# Zuweisung über die ``df``-Property synchronisiert die column_metadata und
|
|
181
|
+
# entfernt dabei Attribute zu nicht mehr vorhandenen Spalten (prune).
|
|
182
|
+
self._assign_df(df, prune=True)
|
|
183
|
+
|
|
184
|
+
def _assign_df(self, df, prune=False):
|
|
185
|
+
if isinstance(df, pd.DataFrame):
|
|
186
|
+
self._df = df
|
|
187
|
+
if self._df.index.name is None:
|
|
188
|
+
self._df.index.name = "index"
|
|
189
|
+
self._sync_column_metadata(prune=prune)
|
|
190
|
+
|
|
191
|
+
def _sync_column_metadata(self, prune=False):
|
|
192
|
+
"""Halte column_metadata mit den df-Spalten konsistent.
|
|
193
|
+
|
|
194
|
+
Ergänzt fehlende Spalten-Attribute (Wert = pandas-dtype-Name) und bewahrt
|
|
195
|
+
dabei vorhandene Annotationen (unit/label/description/ontology), da
|
|
196
|
+
:meth:`~sdata.metadata.Metadata.set_attr` vorhandene Attribute wiederverwendet.
|
|
197
|
+
|
|
198
|
+
:param prune: wenn ``True``, werden Attribute zu Spalten entfernt, die nicht
|
|
199
|
+
(mehr) im df vorhanden sind (z. B. nach einer df-Neuzuweisung).
|
|
200
|
+
"""
|
|
201
|
+
colnames = [str(c) for c in self._df.columns]
|
|
202
|
+
for col in self._df.columns:
|
|
203
|
+
attr = self._column_metadata.get(str(col))
|
|
204
|
+
if attr is not None and attr.dtype != "str":
|
|
205
|
+
attr.dtype = "str"
|
|
206
|
+
self._column_metadata.add(name=str(col), value=self._df[col].dtype.name)
|
|
207
|
+
if prune:
|
|
208
|
+
for key in list(self._column_metadata.keys()):
|
|
209
|
+
if key not in colnames:
|
|
210
|
+
self._column_metadata.pop(key)
|
|
211
|
+
|
|
212
|
+
df = property(fget=_get_df, fset=_set_df, doc="df object(pandas.DataFrame)")
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def content_bytes(self) -> bytes:
|
|
216
|
+
"""Canonical byte serialization of the **data** for content hashing (RFC 0004).
|
|
217
|
+
|
|
218
|
+
The hook the inherited :class:`~sdata.sclass.content.ContentIntegrityMixin`
|
|
219
|
+
hashes over — enables ``sha256``/``md5``/``sha1``, ``size`` and
|
|
220
|
+
``verify()``/``update_checksum()`` directly on a :class:`DataFrame`.
|
|
221
|
+
|
|
222
|
+
Deliberately **CSV**, not Parquet: Parquet is not byte-stable (pyarrow
|
|
223
|
+
version, embedded ``created_by``, compression codec, platform), so a
|
|
224
|
+
Parquet-based checksum is not portable — the same data can hash differently
|
|
225
|
+
across environments. The canonical CSV (header + values in row order, UTF-8,
|
|
226
|
+
``\\n`` line breaks, index excluded) depends only on the logical data, so a
|
|
227
|
+
checksum written with :meth:`update_checksum` on one machine
|
|
228
|
+
:meth:`verify`\\ s on another, and across pyarrow/pandas upgrades. It also
|
|
229
|
+
needs no pyarrow. Hashing the data (not the metadata) keeps the checksum
|
|
230
|
+
stable when only metadata changes.
|
|
231
|
+
|
|
232
|
+
Note: content identity is over the **column values in row order** (with the
|
|
233
|
+
header); the DataFrame index is excluded (consistent with :meth:`to_csv` and
|
|
234
|
+
:meth:`to_datapackage`).
|
|
235
|
+
"""
|
|
236
|
+
return self.df.to_csv(index=False).encode("utf-8")
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def column_metadata(self) -> Metadata:
|
|
240
|
+
"""
|
|
241
|
+
Retrieve the per-column metadata.
|
|
242
|
+
|
|
243
|
+
:return: a :class:`~sdata.metadata.Metadata` holding one attribute per
|
|
244
|
+
column (e.g. ``label``/``unit`` annotations).
|
|
245
|
+
"""
|
|
246
|
+
return self._column_metadata
|
|
247
|
+
|
|
248
|
+
def get_column(self, name) -> Optional[Attribute]:
|
|
249
|
+
"""Return the column :class:`~sdata.metadata.Attribute` for ``name``.
|
|
250
|
+
|
|
251
|
+
:param name: column name.
|
|
252
|
+
:return: the column's :class:`Attribute`, or ``None`` if unknown.
|
|
253
|
+
"""
|
|
254
|
+
return self._column_metadata.get(str(name))
|
|
255
|
+
|
|
256
|
+
def set_column(self, name, *, unit=None, label=None, description=None,
|
|
257
|
+
ontology=None, required=None, dtype=None, symbol=None) -> Attribute:
|
|
258
|
+
"""Annotate a column; writes through to :attr:`column_metadata`.
|
|
259
|
+
|
|
260
|
+
Only the provided fields are changed; existing annotations are preserved
|
|
261
|
+
(delegates to :meth:`~sdata.metadata.Metadata.set_attr`). A warning is
|
|
262
|
+
logged if ``name`` is not a column of the current df.
|
|
263
|
+
|
|
264
|
+
:param name: column name.
|
|
265
|
+
:param unit: physical unit (e.g. ``"kg"``).
|
|
266
|
+
:param label: human-readable label.
|
|
267
|
+
:param description: free-text description.
|
|
268
|
+
:param ontology: CURIE/IRI of the column's class.
|
|
269
|
+
:param required: whether the column is required.
|
|
270
|
+
:param symbol: quantity symbol (e.g. ``"R_p0,2"``, RFC 0016).
|
|
271
|
+
:param dtype: expected dtype (e.g. ``"float"``), checked against the
|
|
272
|
+
actual pandas dtype; a mismatch is logged. Not stored: the column
|
|
273
|
+
attribute's value already is the pandas dtype name, so the attribute
|
|
274
|
+
itself stays of dtype ``str``.
|
|
275
|
+
:return: the (created or updated) column :class:`Attribute`.
|
|
276
|
+
"""
|
|
277
|
+
name = str(name)
|
|
278
|
+
fields = {"unit": unit, "label": label, "description": description,
|
|
279
|
+
"ontology": ontology, "required": required, "symbol": symbol}
|
|
280
|
+
self._column_metadata.set_attr(
|
|
281
|
+
name, **{k: v for k, v in fields.items() if v is not None})
|
|
282
|
+
if name not in {str(c) for c in self._df.columns}:
|
|
283
|
+
logger.warning("set_column: %r is not a df column", name)
|
|
284
|
+
elif dtype is not None:
|
|
285
|
+
actual = dtypes.resolve(str(self._df[name].dtype))
|
|
286
|
+
if dtypes.resolve(dtype) != actual:
|
|
287
|
+
logger.warning("set_column: %r has dtype %s, not %s",
|
|
288
|
+
name, actual, dtype)
|
|
289
|
+
return self._column_metadata.get(name)
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def col(self) -> ColumnAccessor:
|
|
293
|
+
"""Column-annotation accessor: ``df.col.weight`` / ``df.col['weight']``.
|
|
294
|
+
|
|
295
|
+
Returns the column :class:`Attribute`; mutate its fields in place
|
|
296
|
+
(``df.col.weight.unit = 'kg'``) or use :meth:`set_column`.
|
|
297
|
+
"""
|
|
298
|
+
return ColumnAccessor(self)
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def column_units(self) -> Dict[str, str]:
|
|
302
|
+
"""Mapping ``{column: unit}`` (in df-column order) from column_metadata."""
|
|
303
|
+
units = {}
|
|
304
|
+
for col in self._df.columns:
|
|
305
|
+
attr = self._column_metadata.get(str(col))
|
|
306
|
+
if attr is not None:
|
|
307
|
+
units[str(col)] = attr.unit
|
|
308
|
+
return units
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def unit_system(self):
|
|
312
|
+
"""The table's target :class:`~sdata.units.UnitSystem` (or ``None``).
|
|
313
|
+
|
|
314
|
+
Assign a :class:`~sdata.units.UnitSystem` **or** a unit list (e.g.
|
|
315
|
+
``["kN", "mm", "ms"]``, wrapped automatically) to record the unit system
|
|
316
|
+
this table should be expressed in; :meth:`convert` with no argument then
|
|
317
|
+
rescales the data into it. Setting only records the target — it does **not**
|
|
318
|
+
mutate the data (call :meth:`convert` to apply), which keeps it robust when
|
|
319
|
+
the system is set before the column units. Assign ``None`` to clear.
|
|
320
|
+
|
|
321
|
+
:return: the recorded :class:`~sdata.units.UnitSystem`, or ``None``.
|
|
322
|
+
"""
|
|
323
|
+
from sdata import units as U
|
|
324
|
+
attr = self.metadata.get(self.SDATA_UNIT_SYSTEM)
|
|
325
|
+
if attr is None or not attr.value:
|
|
326
|
+
return None
|
|
327
|
+
return U.UnitSystem(list(attr.value))
|
|
328
|
+
|
|
329
|
+
@unit_system.setter
|
|
330
|
+
def unit_system(self, value):
|
|
331
|
+
from sdata import units as U
|
|
332
|
+
if value is None:
|
|
333
|
+
if self.SDATA_UNIT_SYSTEM in self.metadata:
|
|
334
|
+
self.metadata.pop(self.SDATA_UNIT_SYSTEM)
|
|
335
|
+
return
|
|
336
|
+
system = value if isinstance(value, U.UnitSystem) else U.UnitSystem(value)
|
|
337
|
+
self.metadata.set_attr(
|
|
338
|
+
self.SDATA_UNIT_SYSTEM, list(system.units), dtype="list",
|
|
339
|
+
label="unit system", description="target unit system (base units, RFC 0014)")
|
|
340
|
+
|
|
341
|
+
def _converted_copy(self):
|
|
342
|
+
"""Tiefe Kopie dieses DataFrame (Daten + Metadaten) für nicht-mutierende Ops."""
|
|
343
|
+
new = self.__class__(df=self._df.copy(deep=True))
|
|
344
|
+
new.metadata = self.metadata.copy()
|
|
345
|
+
new._column_metadata = self._column_metadata.copy()
|
|
346
|
+
new.description = self.description
|
|
347
|
+
# unit_system reist über die metadata-Kopie mit (_sdata_unit_system, RFC 0014)
|
|
348
|
+
return new
|
|
349
|
+
|
|
350
|
+
def convert(self, units=None, inplace=False):
|
|
351
|
+
"""Convert numeric columns into a target unit system (or explicit units).
|
|
352
|
+
|
|
353
|
+
``units`` may be
|
|
354
|
+
|
|
355
|
+
* ``None`` (the default) — use this table's :attr:`unit_system`,
|
|
356
|
+
* a :class:`~sdata.units.UnitSystem`,
|
|
357
|
+
* a list/tuple of base-unit symbols, e.g. ``["kN", "mm", "ms"]`` — wrapped into
|
|
358
|
+
a :class:`~sdata.units.UnitSystem` (a *consistent* system: **derived** units
|
|
359
|
+
such as stress→``GPa``, energy→``J``, velocity→``m/s`` follow from the base
|
|
360
|
+
units via dimensional algebra), or
|
|
361
|
+
* a dict ``{column: target_unit}`` for explicit per-column targets.
|
|
362
|
+
|
|
363
|
+
Every column that carries a unit in :attr:`column_metadata` and whose
|
|
364
|
+
**dimension** the system spans has its values rescaled and its ``unit``
|
|
365
|
+
annotation updated to the system's (derived) unit. Columns without a unit,
|
|
366
|
+
dimensionless columns, and dimensions the system does not span are left
|
|
367
|
+
untouched. By default a converted **copy** is returned (data + metadata);
|
|
368
|
+
pass ``inplace=True`` to mutate this DataFrame. When a full unit system (not
|
|
369
|
+
a per-column dict) is applied, the result's :attr:`unit_system` is set to it.
|
|
370
|
+
|
|
371
|
+
:param units: target unit system, unit list, or ``{column: unit}`` mapping;
|
|
372
|
+
``None`` uses :attr:`unit_system`.
|
|
373
|
+
:param inplace: mutate this DataFrame instead of returning a converted copy.
|
|
374
|
+
:return: the converted :class:`DataFrame` (``self`` if ``inplace``).
|
|
375
|
+
:raises ValueError: if ``units`` is ``None`` and no :attr:`unit_system` is set.
|
|
376
|
+
:raises sdata.units.UnitConversionError: on incompatible units in a
|
|
377
|
+
``{column: unit}`` mapping (e.g. a length column to a force unit).
|
|
378
|
+
"""
|
|
379
|
+
from sdata import units as U
|
|
380
|
+
if units is None:
|
|
381
|
+
units = self.unit_system
|
|
382
|
+
if units is None:
|
|
383
|
+
raise ValueError(
|
|
384
|
+
"no unit system: pass units to convert() or set .unit_system")
|
|
385
|
+
sdf = self if inplace else self._converted_copy()
|
|
386
|
+
if isinstance(units, dict):
|
|
387
|
+
sdf._convert_by_mapping(units, U)
|
|
388
|
+
else:
|
|
389
|
+
system = units if isinstance(units, U.UnitSystem) else U.UnitSystem(units)
|
|
390
|
+
sdf._convert_by_system(system)
|
|
391
|
+
sdf.unit_system = system
|
|
392
|
+
return sdf
|
|
393
|
+
|
|
394
|
+
def _convert_by_system(self, system):
|
|
395
|
+
"""Rechne jede abgedeckte Spalte in die (hergeleitete) System-Einheit um."""
|
|
396
|
+
for col in self._df.columns:
|
|
397
|
+
attr = self._column_metadata.get(str(col))
|
|
398
|
+
current = attr.unit if attr is not None else None
|
|
399
|
+
if not current or current in ("-", ""):
|
|
400
|
+
continue
|
|
401
|
+
result = system.convert_value(self._df[col], current)
|
|
402
|
+
if result is None: # Dimension nicht aufgespannt
|
|
403
|
+
continue
|
|
404
|
+
converted, label = result
|
|
405
|
+
if str(label) == str(current): # bereits in System-Einheit
|
|
406
|
+
continue
|
|
407
|
+
self._df[col] = converted
|
|
408
|
+
self.set_column(str(col), unit=str(label))
|
|
409
|
+
logger.info("convert: %s %s -> %s", col, current, label)
|
|
410
|
+
|
|
411
|
+
def _convert_by_mapping(self, mapping, U):
|
|
412
|
+
"""Rechne genau die genannten Spalten in die jeweils angegebene Einheit um."""
|
|
413
|
+
for col, target in mapping.items():
|
|
414
|
+
col = str(col)
|
|
415
|
+
attr = self._column_metadata.get(col)
|
|
416
|
+
current = attr.unit if attr is not None else None
|
|
417
|
+
if not current or current in ("-", ""):
|
|
418
|
+
logger.warning("convert: column %r has no unit; skipped", col)
|
|
419
|
+
continue
|
|
420
|
+
if str(target) == str(current):
|
|
421
|
+
continue
|
|
422
|
+
self._df[col] = U.convert(self._df[col], current, target)
|
|
423
|
+
self.set_column(col, unit=str(target))
|
|
424
|
+
logger.info("convert: %s %s -> %s", col, current, target)
|
|
425
|
+
|
|
426
|
+
def relabel_units(self, mapping, *, force=False):
|
|
427
|
+
"""Set column units **without** rescaling the values (fix mislabeled units).
|
|
428
|
+
|
|
429
|
+
Use this when a column's numbers are already in the intended unit but its
|
|
430
|
+
``unit`` annotation is wrong. Unlike :meth:`convert`, the data is left
|
|
431
|
+
untouched; only the ``unit`` field is overwritten. Mutates **in place** and
|
|
432
|
+
returns a report.
|
|
433
|
+
|
|
434
|
+
Relabeling within the same dimension (e.g. ``"N"`` → ``"kN"`` on data already
|
|
435
|
+
in kN) is allowed and logged. A **dimension change** (e.g. force → length) is
|
|
436
|
+
refused unless ``force=True`` — overwriting then logs a warning and is flagged
|
|
437
|
+
in the report.
|
|
438
|
+
|
|
439
|
+
:param mapping: ``{column: new_unit}``; columns may have a missing/unknown
|
|
440
|
+
current unit (the pure mislabel-fix case).
|
|
441
|
+
:param force: allow overwriting with a unit of a different dimension.
|
|
442
|
+
:return: a list of records ``{"column", "old", "new", "dimension_changed"}``.
|
|
443
|
+
:raises sdata.units.UnitConversionError: on a dimension change without
|
|
444
|
+
``force=True``.
|
|
445
|
+
"""
|
|
446
|
+
from sdata import units as U
|
|
447
|
+
report = []
|
|
448
|
+
for col, new_unit in mapping.items():
|
|
449
|
+
col = str(col)
|
|
450
|
+
new_unit = str(new_unit)
|
|
451
|
+
attr = self._column_metadata.get(col)
|
|
452
|
+
old = attr.unit if attr is not None else None
|
|
453
|
+
# "-"/"" zählen wie "keine Einheit": ein Label-Set, kein Dimensionswechsel
|
|
454
|
+
old_real = old if old and old not in ("-", "") else None
|
|
455
|
+
old_dim = U.dimension_of(old_real) if old_real else None
|
|
456
|
+
new_dim = U.dimension_of(new_unit)
|
|
457
|
+
dim_changed = (old_dim is not None and new_dim is not None
|
|
458
|
+
and old_dim != new_dim)
|
|
459
|
+
if dim_changed and not force:
|
|
460
|
+
raise U.UnitConversionError(
|
|
461
|
+
f"relabel_units: {col!r} {old!r} -> {new_unit!r} changes dimension; "
|
|
462
|
+
f"pass force=True to override")
|
|
463
|
+
self.set_column(col, unit=new_unit)
|
|
464
|
+
logger.warning("relabel_units: %s %s -> %s%s", col, old, new_unit,
|
|
465
|
+
" (dimension changed)" if dim_changed else "")
|
|
466
|
+
report.append({"column": col, "old": old, "new": new_unit,
|
|
467
|
+
"dimension_changed": dim_changed})
|
|
468
|
+
return report
|
|
469
|
+
|
|
470
|
+
def validate_table(self, schema=None):
|
|
471
|
+
"""Validate the df/column_metadata against a :class:`~sdata.schema.TableSchema`.
|
|
472
|
+
|
|
473
|
+
:param schema: a ``TableSchema``; defaults to the class-level
|
|
474
|
+
:attr:`TABLE_SCHEMA`. Without any schema the result is trivially valid.
|
|
475
|
+
:return: a :class:`~sdata.schema.ValidationReport` (truthy if valid).
|
|
476
|
+
"""
|
|
477
|
+
from sdata.schema import ValidationReport
|
|
478
|
+
schema = schema or self.TABLE_SCHEMA
|
|
479
|
+
if schema is None:
|
|
480
|
+
return ValidationReport(ok=True)
|
|
481
|
+
return schema.validate(self)
|
|
482
|
+
|
|
483
|
+
def __len__(self) -> int:
|
|
484
|
+
"""Number of rows of the underlying df."""
|
|
485
|
+
return len(self.df)
|
|
486
|
+
|
|
487
|
+
@property
|
|
488
|
+
def shape(self):
|
|
489
|
+
"""``(nrows, ncols)`` of the underlying df."""
|
|
490
|
+
return self.df.shape
|
|
491
|
+
|
|
492
|
+
@property
|
|
493
|
+
def columns(self):
|
|
494
|
+
"""Column index of the underlying df."""
|
|
495
|
+
return self.df.columns
|
|
496
|
+
|
|
497
|
+
@property
|
|
498
|
+
def dtypes(self):
|
|
499
|
+
"""Per-column dtypes of the underlying df."""
|
|
500
|
+
return self.df.dtypes
|
|
501
|
+
|
|
502
|
+
def head(self, n: int = 5) -> pd.DataFrame:
|
|
503
|
+
"""First ``n`` rows of the underlying df (delegates to ``pandas.DataFrame.head``)."""
|
|
504
|
+
return self.df.head(n)
|
|
505
|
+
|
|
506
|
+
def describe(self, *args, **kwargs) -> pd.DataFrame:
|
|
507
|
+
"""Descriptive statistics of the df (delegates to ``pandas.DataFrame.describe``)."""
|
|
508
|
+
return self.df.describe(*args, **kwargs)
|
|
509
|
+
|
|
510
|
+
def __repr__(self) -> str:
|
|
511
|
+
return f"({self.__class__.__name__} <{self.sname}> shape={self.df.shape})"
|
|
512
|
+
|
|
513
|
+
def to_dict(self, engine: str = "pyarrow") -> Dict[str, Any]:
|
|
514
|
+
"""
|
|
515
|
+
Serialize to a dict, embedding the df as base64 Parquet plus column_metadata.
|
|
516
|
+
|
|
517
|
+
:param engine: Parquet engine for pandas (default ``"pyarrow"``).
|
|
518
|
+
:return: dict with the :class:`~sdata.base.Base` payload plus
|
|
519
|
+
``data['parquet_bytes']`` and ``data['column_metadata']``.
|
|
520
|
+
"""
|
|
521
|
+
_require_parquet(engine)
|
|
522
|
+
result = super().to_dict()
|
|
523
|
+
bytes_io = io.BytesIO()
|
|
524
|
+
self.df.to_parquet(bytes_io, engine=engine)
|
|
525
|
+
parquet_bytes = bytes_io.getvalue()
|
|
526
|
+
result['data']['parquet_bytes'] = base64.b64encode(parquet_bytes).decode("ascii")
|
|
527
|
+
result['data']['column_metadata'] = self.column_metadata.to_dict()
|
|
528
|
+
return result
|
|
529
|
+
|
|
530
|
+
def _ordered_columns(self):
|
|
531
|
+
"""Spalten-Attribute in echter df-Spaltenreihenfolge (nicht alphabetisch)."""
|
|
532
|
+
cols = [self.column_metadata.get(str(c)) for c in self.df.columns]
|
|
533
|
+
return [c for c in cols if c is not None]
|
|
534
|
+
|
|
535
|
+
def to_jsonld(self, context_mode="inline"):
|
|
536
|
+
"""JSON-LD der Metadaten inkl. Spalten-Metadaten (csvw:column)."""
|
|
537
|
+
from sdata import semantic
|
|
538
|
+
return semantic.to_jsonld(self.metadata, context_mode=context_mode,
|
|
539
|
+
columns=self._ordered_columns())
|
|
540
|
+
|
|
541
|
+
def to_turtle(self):
|
|
542
|
+
"""RDF/Turtle inkl. Spalten-Metadaten."""
|
|
543
|
+
from sdata import semantic
|
|
544
|
+
return semantic.rdf_from_doc(self.to_jsonld(), fmt="turtle")
|
|
545
|
+
|
|
546
|
+
def write_sidecar(self, path=None, indent=2):
|
|
547
|
+
"""Sidecar ``<sname>.meta.jsonld`` inkl. Spalten-Metadaten; gibt den Pfad zurück."""
|
|
548
|
+
from sdata import semantic
|
|
549
|
+
return semantic.write_sidecar_doc(self.to_jsonld(), path, self.sname, indent=indent)
|
|
550
|
+
|
|
551
|
+
@classmethod
|
|
552
|
+
def from_dict(cls, d: Dict[str, Any], engine: str = "pyarrow") -> 'DataFrame':
|
|
553
|
+
"""
|
|
554
|
+
Reconstruct a DataFrame from a dict produced by :meth:`to_dict`.
|
|
555
|
+
|
|
556
|
+
Restores metadata and column_metadata and decodes the df from the
|
|
557
|
+
embedded base64 Parquet payload.
|
|
558
|
+
|
|
559
|
+
:param d: dict with ``metadata`` and ``data.{parquet_bytes,column_metadata}``.
|
|
560
|
+
:param engine: Parquet engine for pandas (default ``"pyarrow"``).
|
|
561
|
+
:return: a :class:`DataFrame` instance.
|
|
562
|
+
"""
|
|
563
|
+
_require_parquet(engine)
|
|
564
|
+
from sdata.format import ensure_compatible
|
|
565
|
+
d = ensure_compatible(d) # RFC 0010: Version prüfen/migrieren
|
|
566
|
+
metadata = Metadata.from_dict(d.get("metadata", {}))
|
|
567
|
+
column_metadata_dict = d['data'].get('column_metadata', {})
|
|
568
|
+
column_metadata = Metadata.from_dict(column_metadata_dict)
|
|
569
|
+
|
|
570
|
+
instance = cls()
|
|
571
|
+
instance.metadata = metadata
|
|
572
|
+
instance._column_metadata = column_metadata
|
|
573
|
+
instance.description = d.get("description", "")
|
|
574
|
+
|
|
575
|
+
parquet_str = d['data'].get('parquet_bytes', '')
|
|
576
|
+
parquet_bytes = base64.b64decode(parquet_str.encode("ascii"))
|
|
577
|
+
instance.df = pd.read_parquet(io.BytesIO(parquet_bytes), engine=engine)
|
|
578
|
+
return instance
|
|
579
|
+
|
|
580
|
+
def to_dataframe(self):
|
|
581
|
+
"""Return a copy of the pandas DataFrame with sdata metadata in ``df.attrs``.
|
|
582
|
+
|
|
583
|
+
Metadata, column_metadata and description are embedded under the
|
|
584
|
+
``"_sdata"`` key (same layout as :meth:`to_parquet`), so that a round-trip
|
|
585
|
+
through pandas keeps the annotations discoverable.
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
pandas.DataFrame: A copy of the DataFrame with ``attrs['_sdata']`` set.
|
|
589
|
+
"""
|
|
590
|
+
df = self.df.copy()
|
|
591
|
+
df.attrs["_sdata"] = {
|
|
592
|
+
"metadata": self.metadata.to_dict(),
|
|
593
|
+
"column_metadata": self.column_metadata.to_dict(),
|
|
594
|
+
"description": self.description,
|
|
595
|
+
}
|
|
596
|
+
return df
|
|
597
|
+
|
|
598
|
+
def to_parquet(self, path=None, filename=None, **kwargs):
|
|
599
|
+
"""
|
|
600
|
+
Serialize this sdata.DataFrame to Parquet format, embedding metadata.
|
|
601
|
+
|
|
602
|
+
This method will copy the internal pandas DataFrame, attach SData metadata
|
|
603
|
+
(dataset‐level metadata, per‐column metadata, and description) to `df.attrs`,
|
|
604
|
+
and then write the result as a Parquet file. If no output path is given,
|
|
605
|
+
it will return the Parquet bytes buffer.
|
|
606
|
+
|
|
607
|
+
Args:
|
|
608
|
+
path (str, optional): Directory where the Parquet file will be saved.
|
|
609
|
+
If provided (and `filename` is None), a file named
|
|
610
|
+
`<sname>.spq` is created under this directory.
|
|
611
|
+
filename (str, optional): Exact output filename; joined with `path`
|
|
612
|
+
if given, otherwise used as is (may contain a directory).
|
|
613
|
+
Defaults to `<sname>.spq`.
|
|
614
|
+
**kwargs: Additional keyword arguments passed to `pandas.DataFrame.to_parquet`,
|
|
615
|
+
e.g.:
|
|
616
|
+
- engine (str): Parquet engine, defaults to "pyarrow".
|
|
617
|
+
- compression (str): Compression codec, defaults to "zstd".
|
|
618
|
+
|
|
619
|
+
Returns:
|
|
620
|
+
str or bytes:
|
|
621
|
+
- If `path` (or `filename`) is provided, returns the full file path (str)
|
|
622
|
+
where the Parquet file was written.
|
|
623
|
+
- Otherwise, returns the in‐memory Parquet representation as bytes.
|
|
624
|
+
|
|
625
|
+
Example:
|
|
626
|
+
# Save to disk under /data/output/<sname>.spq with default naming:
|
|
627
|
+
out_fp = sdf.to_parquet(path="/data/output")
|
|
628
|
+
print("Saved to:", out_fp)
|
|
629
|
+
|
|
630
|
+
# Get in‐memory Parquet bytes (no file on disk):
|
|
631
|
+
parquet_bytes = sdf.to_parquet()
|
|
632
|
+
"""
|
|
633
|
+
engine = kwargs.get("engine", "pyarrow")
|
|
634
|
+
compression = kwargs.get("compression", "zstd")
|
|
635
|
+
sidecar = kwargs.get("sidecar", False)
|
|
636
|
+
_require_parquet(engine)
|
|
637
|
+
|
|
638
|
+
df = self.df.copy()
|
|
639
|
+
df.attrs["_sdata"] = {"metadata": self.metadata.to_dict(),
|
|
640
|
+
"column_metadata": self.column_metadata.to_dict(),
|
|
641
|
+
"description": self.description}
|
|
642
|
+
|
|
643
|
+
if filename is None and path is not None:
|
|
644
|
+
filename = self.sname + ".spq"
|
|
645
|
+
if filename is not None:
|
|
646
|
+
filepath = os.path.join(path, filename) if path else filename
|
|
647
|
+
df.to_parquet(filepath, engine=engine, compression=compression)
|
|
648
|
+
logger.info(f"DataFrame Parquet saved to {filepath}")
|
|
649
|
+
if sidecar:
|
|
650
|
+
self.write_sidecar(os.path.dirname(filepath) or ".")
|
|
651
|
+
return filepath
|
|
652
|
+
return df.to_parquet(engine=engine, compression=compression)
|
|
653
|
+
|
|
654
|
+
def _restore_from_attrs(self, attrs):
|
|
655
|
+
"""Restore metadata/column_metadata/description from a ``_sdata`` attrs dict.
|
|
656
|
+
|
|
657
|
+
:param attrs: the dict stored under ``df.attrs['_sdata']`` (may be ``None``
|
|
658
|
+
or empty, in which case nothing is restored).
|
|
659
|
+
"""
|
|
660
|
+
if not attrs:
|
|
661
|
+
return
|
|
662
|
+
from sdata.format import ensure_compatible
|
|
663
|
+
attrs = ensure_compatible(attrs) # RFC 0010: deckt Parquet/Arrow/Feather ab
|
|
664
|
+
if "metadata" in attrs:
|
|
665
|
+
self.metadata = Metadata.from_dict(attrs["metadata"])
|
|
666
|
+
if "column_metadata" in attrs:
|
|
667
|
+
self._column_metadata = Metadata.from_dict(attrs["column_metadata"])
|
|
668
|
+
if "description" in attrs:
|
|
669
|
+
self.description = attrs.get("description")
|
|
670
|
+
|
|
671
|
+
@classmethod
|
|
672
|
+
def from_parquet_bytes(cls, parquet_bytes, engine: str = "pyarrow"):
|
|
673
|
+
"""Load a DataFrame from in-memory Parquet bytes.
|
|
674
|
+
|
|
675
|
+
:param parquet_bytes: Parquet file content as bytes.
|
|
676
|
+
:param engine: Parquet engine for pandas (default ``"pyarrow"``).
|
|
677
|
+
:return: a :class:`DataFrame` instance.
|
|
678
|
+
"""
|
|
679
|
+
_require_parquet(engine)
|
|
680
|
+
buffer = io.BytesIO(parquet_bytes)
|
|
681
|
+
df = pd.read_parquet(buffer, engine=engine)
|
|
682
|
+
tt = cls()
|
|
683
|
+
tt.df = df
|
|
684
|
+
tt._restore_from_attrs(df.attrs.get("_sdata"))
|
|
685
|
+
return tt
|
|
686
|
+
|
|
687
|
+
@classmethod
|
|
688
|
+
def from_parquet(cls, filepath, engine: str = "pyarrow"):
|
|
689
|
+
"""Load a DataFrame from a Parquet file on disk.
|
|
690
|
+
|
|
691
|
+
:param filepath: path to the ``.spq``/Parquet file.
|
|
692
|
+
:param engine: Parquet engine for pandas (default ``"pyarrow"``).
|
|
693
|
+
:return: a :class:`DataFrame` instance.
|
|
694
|
+
:raises FileNotFoundError: if ``filepath`` does not exist.
|
|
695
|
+
"""
|
|
696
|
+
if not os.path.exists(filepath):
|
|
697
|
+
raise FileNotFoundError(f"no DataFrame parquet file {filepath}")
|
|
698
|
+
_require_parquet(engine)
|
|
699
|
+
df = pd.read_parquet(filepath, engine=engine)
|
|
700
|
+
tt = cls(name=filepath)
|
|
701
|
+
tt.df = df
|
|
702
|
+
logger.info(f"{tt}")
|
|
703
|
+
attrs = df.attrs.get("_sdata")
|
|
704
|
+
tt._restore_from_attrs(attrs)
|
|
705
|
+
if attrs is not None:
|
|
706
|
+
tt.df.attrs.pop("_sdata")
|
|
707
|
+
return tt
|
|
708
|
+
|
|
709
|
+
# ------------------------------------------------------------------ CSV
|
|
710
|
+
def to_csv(self, path=None, filename=None, sidecar=False, **kwargs):
|
|
711
|
+
"""Serialize the df to CSV (pure pandas, no extra dependency).
|
|
712
|
+
|
|
713
|
+
CSV carries data only; the qualifying metadata travels in the optional
|
|
714
|
+
``<sname>.meta.jsonld`` sidecar. The index is dropped by default
|
|
715
|
+
(override via ``index=True``).
|
|
716
|
+
|
|
717
|
+
:param path: directory to write ``<sname>.csv`` into (if given).
|
|
718
|
+
:param filename: exact output filename (defaults to ``<sname>.csv``).
|
|
719
|
+
:param sidecar: also write a JSON-LD metadata sidecar next to the file.
|
|
720
|
+
:param kwargs: forwarded to :meth:`pandas.DataFrame.to_csv`.
|
|
721
|
+
:return: the file path (if written to disk) or the CSV string.
|
|
722
|
+
"""
|
|
723
|
+
kwargs.setdefault("index", False)
|
|
724
|
+
if filename is None and path is not None:
|
|
725
|
+
filename = self.sname + ".csv"
|
|
726
|
+
if filename is not None:
|
|
727
|
+
filepath = os.path.join(path, filename) if path else filename
|
|
728
|
+
self.df.to_csv(filepath, **kwargs)
|
|
729
|
+
logger.info(f"DataFrame CSV saved to {filepath}")
|
|
730
|
+
if sidecar:
|
|
731
|
+
# neben die CSV (<stem>.meta.jsonld), damit from_csv ihn findet;
|
|
732
|
+
# beim Default-Dateinamen identisch zu <sname>.meta.jsonld
|
|
733
|
+
self.write_sidecar(self._csv_sidecar_path(filepath))
|
|
734
|
+
return filepath
|
|
735
|
+
return self.df.to_csv(**kwargs)
|
|
736
|
+
|
|
737
|
+
@staticmethod
|
|
738
|
+
def _csv_sidecar_path(filepath):
|
|
739
|
+
"""Sidecar-Pfad zu einer CSV-Datei: ``<stem>.meta.jsonld`` daneben."""
|
|
740
|
+
return os.path.splitext(filepath)[0] + ".meta.jsonld"
|
|
741
|
+
|
|
742
|
+
@classmethod
|
|
743
|
+
def from_csv(cls, filepath, sidecar=True, **kwargs):
|
|
744
|
+
"""Load a DataFrame from a CSV file (pure pandas).
|
|
745
|
+
|
|
746
|
+
If a ``<stem>.meta.jsonld`` sidecar (written by ``to_csv(sidecar=True)``)
|
|
747
|
+
exists next to the file, its dataset metadata and column annotations
|
|
748
|
+
(unit/label/description/ontology) are merged back automatically — pass
|
|
749
|
+
``sidecar=False`` to load the data only.
|
|
750
|
+
|
|
751
|
+
:param filepath: path to the CSV file.
|
|
752
|
+
:param sidecar: consume an adjacent metadata sidecar if present (default True).
|
|
753
|
+
:param kwargs: forwarded to :func:`pandas.read_csv`.
|
|
754
|
+
:return: a :class:`DataFrame` instance.
|
|
755
|
+
:raises FileNotFoundError: if ``filepath`` does not exist.
|
|
756
|
+
"""
|
|
757
|
+
if not os.path.exists(filepath):
|
|
758
|
+
raise FileNotFoundError(f"no CSV file {filepath}")
|
|
759
|
+
df = pd.read_csv(filepath, **kwargs)
|
|
760
|
+
tt = cls(name=filepath)
|
|
761
|
+
tt.df = df
|
|
762
|
+
if sidecar:
|
|
763
|
+
tt._load_csv_sidecar(cls._csv_sidecar_path(filepath))
|
|
764
|
+
return tt
|
|
765
|
+
|
|
766
|
+
def _load_csv_sidecar(self, sidecar_path):
|
|
767
|
+
"""Merge metadata from an adjacent ``<stem>.meta.jsonld`` sidecar, if present.
|
|
768
|
+
|
|
769
|
+
Dataset-Metadaten kommen über :func:`sdata.semantic.from_jsonld`;
|
|
770
|
+
die ``csvw:column``-Knoten (von :func:`sdata.semantic.column_node`)
|
|
771
|
+
werden hier invertiert (unit aus ``symbol``, label, description,
|
|
772
|
+
ontology aus ``rdfType``).
|
|
773
|
+
``_sdata_class`` bleibt unangetastet (JSON-LD trägt nur den lokalen
|
|
774
|
+
Klassennamen, nicht die importierbare Spec).
|
|
775
|
+
"""
|
|
776
|
+
if not os.path.exists(sidecar_path):
|
|
777
|
+
return
|
|
778
|
+
from sdata import semantic
|
|
779
|
+
try:
|
|
780
|
+
with open(sidecar_path, "r") as fh:
|
|
781
|
+
doc = json.load(fh)
|
|
782
|
+
meta = semantic.from_jsonld(doc)
|
|
783
|
+
except Exception as exp:
|
|
784
|
+
logger.warning(f"CSV sidecar not loadable: {sidecar_path}: {exp}")
|
|
785
|
+
return
|
|
786
|
+
for attr in meta.attributes.values():
|
|
787
|
+
if attr.name == self.SDATA_CLASS:
|
|
788
|
+
continue
|
|
789
|
+
self.metadata.set_attr(
|
|
790
|
+
attr.name, attr.value, dtype=attr.dtype, unit=attr.unit,
|
|
791
|
+
description=attr.description, label=attr.label,
|
|
792
|
+
ontology=attr.ontology, required=attr.required)
|
|
793
|
+
for node in doc.get("columns", []):
|
|
794
|
+
name = node.get("name")
|
|
795
|
+
if not name:
|
|
796
|
+
continue
|
|
797
|
+
self.set_column(
|
|
798
|
+
name,
|
|
799
|
+
unit=node.get("symbol"),
|
|
800
|
+
label=node.get("label"),
|
|
801
|
+
description=node.get("description"),
|
|
802
|
+
ontology=node.get("rdfType"),
|
|
803
|
+
symbol=node.get("quantitySymbol"))
|
|
804
|
+
logger.debug(f"merged CSV sidecar metadata from {sidecar_path}")
|
|
805
|
+
|
|
806
|
+
# ---------------------------------------------------------------- Arrow
|
|
807
|
+
def _field_metadata_for(self, colname):
|
|
808
|
+
"""Per-column annotations as an Arrow field-metadata ``bytes->bytes`` dict.
|
|
809
|
+
|
|
810
|
+
:param colname: column name.
|
|
811
|
+
:return: ``{b"unit": b"kg", ...}`` for the annotated keys, or ``None`` if the
|
|
812
|
+
column has no (non-default) annotation.
|
|
813
|
+
"""
|
|
814
|
+
attr = self._column_metadata.get(str(colname))
|
|
815
|
+
if attr is None:
|
|
816
|
+
return None
|
|
817
|
+
md = {}
|
|
818
|
+
for key in _COL_FIELD_KEYS:
|
|
819
|
+
val = getattr(attr, key, "")
|
|
820
|
+
if val not in (None, "", "-"):
|
|
821
|
+
md[key.encode("utf-8")] = str(val).encode("utf-8")
|
|
822
|
+
return md or None
|
|
823
|
+
|
|
824
|
+
def to_arrow(self):
|
|
825
|
+
"""Return a :class:`pyarrow.Table` with sdata metadata in the schema.
|
|
826
|
+
|
|
827
|
+
The dataset metadata, column_metadata and description are embedded as JSON
|
|
828
|
+
under the ``b"_sdata"`` schema-metadata key. In addition, each column's
|
|
829
|
+
``unit``/``label``/``description``/``ontology`` are attached **natively** to
|
|
830
|
+
that column's Arrow field metadata, so Arrow-aware tools (DuckDB, Polars,
|
|
831
|
+
pyarrow) can read the per-column annotations without sdata.
|
|
832
|
+
|
|
833
|
+
:return: a ``pyarrow.Table``.
|
|
834
|
+
:raises ImportError: if pyarrow is not installed (``pip install sdata[parquet]``).
|
|
835
|
+
"""
|
|
836
|
+
_require_parquet("pyarrow")
|
|
837
|
+
import pyarrow as pa
|
|
838
|
+
table = pa.Table.from_pandas(self.df)
|
|
839
|
+
fields = []
|
|
840
|
+
for field in table.schema:
|
|
841
|
+
fmd = self._field_metadata_for(field.name)
|
|
842
|
+
if fmd:
|
|
843
|
+
merged = dict(field.metadata or {})
|
|
844
|
+
merged.update(fmd)
|
|
845
|
+
field = field.with_metadata(merged)
|
|
846
|
+
fields.append(field)
|
|
847
|
+
schema_meta = dict(table.schema.metadata or {})
|
|
848
|
+
schema_meta[b"_sdata"] = json.dumps({
|
|
849
|
+
"metadata": self.metadata.to_dict(),
|
|
850
|
+
"column_metadata": self.column_metadata.to_dict(),
|
|
851
|
+
"description": self.description,
|
|
852
|
+
}).encode("utf-8")
|
|
853
|
+
new_schema = pa.schema(fields, metadata=schema_meta)
|
|
854
|
+
return pa.Table.from_arrays(list(table.columns), schema=new_schema)
|
|
855
|
+
|
|
856
|
+
@classmethod
|
|
857
|
+
def from_arrow(cls, table):
|
|
858
|
+
"""Build a DataFrame from a :class:`pyarrow.Table` written by :meth:`to_arrow`.
|
|
859
|
+
|
|
860
|
+
The ``b"_sdata"`` schema blob is restored if present; per-column Arrow field
|
|
861
|
+
metadata (``unit``/``label``/...) is also merged into ``column_metadata``, so
|
|
862
|
+
tables produced by other Arrow-native tools keep their column annotations.
|
|
863
|
+
|
|
864
|
+
:param table: a ``pyarrow.Table`` (sdata metadata restored if present).
|
|
865
|
+
:return: a :class:`DataFrame` instance.
|
|
866
|
+
:raises ImportError: if pyarrow is not installed.
|
|
867
|
+
"""
|
|
868
|
+
_require_parquet("pyarrow")
|
|
869
|
+
tt = cls()
|
|
870
|
+
tt.df = table.to_pandas()
|
|
871
|
+
raw = (table.schema.metadata or {}).get(b"_sdata")
|
|
872
|
+
if raw is not None:
|
|
873
|
+
tt._restore_from_attrs(json.loads(raw.decode("utf-8")))
|
|
874
|
+
tt._merge_field_metadata(table.schema)
|
|
875
|
+
return tt
|
|
876
|
+
|
|
877
|
+
def _merge_field_metadata(self, schema):
|
|
878
|
+
"""Merge per-column Arrow field metadata into ``column_metadata`` (in-place).
|
|
879
|
+
|
|
880
|
+
:param schema: a ``pyarrow.Schema``; fields carrying ``unit``/``label``/...
|
|
881
|
+
metadata update the matching column's annotation.
|
|
882
|
+
"""
|
|
883
|
+
cols = {str(c) for c in self._df.columns}
|
|
884
|
+
for field in schema:
|
|
885
|
+
if not field.metadata or field.name not in cols:
|
|
886
|
+
continue
|
|
887
|
+
kwargs = {key: field.metadata[key.encode("utf-8")].decode("utf-8")
|
|
888
|
+
for key in _COL_FIELD_KEYS
|
|
889
|
+
if key.encode("utf-8") in field.metadata}
|
|
890
|
+
if kwargs:
|
|
891
|
+
self.set_column(field.name, **kwargs)
|
|
892
|
+
|
|
893
|
+
# -------------------------------------------------------------- Feather
|
|
894
|
+
def to_feather(self, path=None, filename=None, sidecar=False, **kwargs):
|
|
895
|
+
"""Serialize to the Feather (Arrow IPC) format, embedding sdata metadata.
|
|
896
|
+
|
|
897
|
+
:param path: directory to write ``<sname>.feather`` into (if given).
|
|
898
|
+
:param filename: exact output filename (defaults to ``<sname>.feather``).
|
|
899
|
+
:param sidecar: also write a JSON-LD metadata sidecar next to the file.
|
|
900
|
+
:param kwargs: forwarded to :func:`pyarrow.feather.write_feather`.
|
|
901
|
+
:return: the file path (if written to disk) or the Feather bytes.
|
|
902
|
+
:raises ImportError: if pyarrow is not installed.
|
|
903
|
+
"""
|
|
904
|
+
_require_parquet("pyarrow")
|
|
905
|
+
import pyarrow.feather as feather
|
|
906
|
+
table = self.to_arrow()
|
|
907
|
+
if filename is None and path is not None:
|
|
908
|
+
filename = self.sname + ".feather"
|
|
909
|
+
if filename is not None:
|
|
910
|
+
filepath = os.path.join(path, filename) if path else filename
|
|
911
|
+
feather.write_feather(table, filepath, **kwargs)
|
|
912
|
+
logger.info(f"DataFrame Feather saved to {filepath}")
|
|
913
|
+
if sidecar:
|
|
914
|
+
self.write_sidecar(path)
|
|
915
|
+
return filepath
|
|
916
|
+
sink = io.BytesIO()
|
|
917
|
+
feather.write_feather(table, sink, **kwargs)
|
|
918
|
+
return sink.getvalue()
|
|
919
|
+
|
|
920
|
+
@classmethod
|
|
921
|
+
def from_feather(cls, filepath):
|
|
922
|
+
"""Load a DataFrame from a Feather file written by :meth:`to_feather`.
|
|
923
|
+
|
|
924
|
+
:param filepath: path to the ``.feather`` file.
|
|
925
|
+
:return: a :class:`DataFrame` instance.
|
|
926
|
+
:raises FileNotFoundError: if ``filepath`` does not exist.
|
|
927
|
+
:raises ImportError: if pyarrow is not installed.
|
|
928
|
+
"""
|
|
929
|
+
if not os.path.exists(filepath):
|
|
930
|
+
raise FileNotFoundError(f"no Feather file {filepath}")
|
|
931
|
+
_require_parquet("pyarrow")
|
|
932
|
+
import pyarrow.feather as feather
|
|
933
|
+
return cls.from_arrow(feather.read_table(filepath))
|
|
934
|
+
|
|
935
|
+
# ------------------------------------------- Blob composition (RFC 0004 C)
|
|
936
|
+
#: Serialisierer je ``as_blob``-Format: ``fmt -> (builder, filetype, mime_type)``.
|
|
937
|
+
#: Der Builder bekommt ``self`` und liefert ``bytes``. So wird eine Tabelle in
|
|
938
|
+
#: **einem gewählten Format** zu einem binären Asset (hash-/signier-/übertragbar),
|
|
939
|
+
#: ohne dass ``DataFrame`` von ``Blob`` erben muss (Komposition statt Vererbung).
|
|
940
|
+
_BLOB_FORMATS = {
|
|
941
|
+
"parquet": (lambda self: self.to_parquet(), "parquet",
|
|
942
|
+
"application/vnd.apache.parquet"),
|
|
943
|
+
"csv": (lambda self: self.to_csv().encode("utf-8"), "csv", "text/csv"),
|
|
944
|
+
"arrow": (lambda self: self.to_feather(), "arrow",
|
|
945
|
+
"application/vnd.apache.arrow.file"),
|
|
946
|
+
"feather": (lambda self: self.to_feather(), "feather",
|
|
947
|
+
"application/vnd.apache.arrow.file"),
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
def as_blob(self, fmt: str = "parquet", **kwargs):
|
|
951
|
+
"""Render the table as a standalone :class:`~sdata.sclass.blob.Blob`.
|
|
952
|
+
|
|
953
|
+
The df is serialized once (in the chosen ``fmt``) into a fixed
|
|
954
|
+
``bytes``-content Blob — a binary snapshot that can be hashed, signed,
|
|
955
|
+
stored or transferred like any other asset. This is **composition**, not
|
|
956
|
+
inheritance: a living, multi-format table is rendered into *one* chosen
|
|
957
|
+
format on demand (RFC 0004, Option C). The Blob's ``checksum`` is filled
|
|
958
|
+
(``update_checksum``), so ``blob.verify()`` works out of the box.
|
|
959
|
+
|
|
960
|
+
:param fmt: serialization format — ``"parquet"`` (default), ``"csv"``,
|
|
961
|
+
``"arrow"`` or ``"feather"``.
|
|
962
|
+
:param kwargs: forwarded to :class:`~sdata.sclass.blob.Blob` (``name`` and
|
|
963
|
+
``description`` default to this DataFrame's).
|
|
964
|
+
:return: a :class:`~sdata.sclass.blob.Blob` with the serialized bytes.
|
|
965
|
+
:raises ValueError: if ``fmt`` is not a supported format.
|
|
966
|
+
:raises ImportError: if the format needs pyarrow and it is not installed.
|
|
967
|
+
"""
|
|
968
|
+
from sdata.sclass.blob import Blob
|
|
969
|
+
key = fmt.lower()
|
|
970
|
+
if key not in self._BLOB_FORMATS:
|
|
971
|
+
raise ValueError(f"unsupported blob format: {fmt!r} "
|
|
972
|
+
f"({'|'.join(self._BLOB_FORMATS)})")
|
|
973
|
+
build, filetype, mime = self._BLOB_FORMATS[key]
|
|
974
|
+
payload = build(self)
|
|
975
|
+
kwargs.setdefault("name", self.name)
|
|
976
|
+
kwargs.setdefault("description", self.description)
|
|
977
|
+
blob = Blob(content_type="bytes", value=payload, filetype=filetype, **kwargs)
|
|
978
|
+
blob.metadata.set_attr("mime_type", mime)
|
|
979
|
+
blob.update_checksum()
|
|
980
|
+
logger.info(f"DataFrame rendered as {key} Blob <{blob.sname}>")
|
|
981
|
+
return blob
|
|
982
|
+
|
|
983
|
+
# --------------------------------------------------------- Data Package
|
|
984
|
+
def _frictionless_type(self, series):
|
|
985
|
+
"""Map a pandas dtype to a Frictionless Table Schema type."""
|
|
986
|
+
return {"i": "integer", "u": "integer", "f": "number",
|
|
987
|
+
"b": "boolean", "M": "datetime"}.get(series.dtype.kind, "string")
|
|
988
|
+
|
|
989
|
+
def _datapackage_descriptor(self, data_path, fmt):
|
|
990
|
+
"""Build a Frictionless ``tabular-data-package`` descriptor.
|
|
991
|
+
|
|
992
|
+
Emits standard Frictionless fields (so generic tooling can read it) plus a
|
|
993
|
+
lossless ``"sdata"`` block (full metadata/column_metadata) for a perfect
|
|
994
|
+
round-trip.
|
|
995
|
+
"""
|
|
996
|
+
fields = []
|
|
997
|
+
for col in self.df.columns:
|
|
998
|
+
field = {"name": str(col), "type": self._frictionless_type(self.df[col])}
|
|
999
|
+
attr = self.column_metadata.get(str(col))
|
|
1000
|
+
if attr is not None:
|
|
1001
|
+
if attr.label:
|
|
1002
|
+
field["title"] = attr.label
|
|
1003
|
+
if attr.description:
|
|
1004
|
+
field["description"] = attr.description
|
|
1005
|
+
if attr.unit not in ("-", "", None):
|
|
1006
|
+
field["unit"] = attr.unit
|
|
1007
|
+
if attr.ontology:
|
|
1008
|
+
field["rdfType"] = attr.ontology
|
|
1009
|
+
fields.append(field)
|
|
1010
|
+
resource = {"name": self.sname.lower(), "path": data_path, "format": fmt,
|
|
1011
|
+
"profile": "tabular-data-resource", "schema": {"fields": fields}}
|
|
1012
|
+
return {"name": self.sname.lower(), "title": self.name,
|
|
1013
|
+
"description": self.description, "profile": "tabular-data-package",
|
|
1014
|
+
"resources": [resource],
|
|
1015
|
+
"sdata": {"metadata": self.metadata.to_dict(),
|
|
1016
|
+
"column_metadata": self.column_metadata.to_dict(),
|
|
1017
|
+
"description": self.description}}
|
|
1018
|
+
|
|
1019
|
+
def to_datapackage(self, path=None, filename=None, fmt="csv", sidecar=True):
|
|
1020
|
+
"""Write a Frictionless **Data Package** (``.zip``) — a portable bundle.
|
|
1021
|
+
|
|
1022
|
+
The zip holds a standard ``datapackage.json`` descriptor (so generic
|
|
1023
|
+
Frictionless tooling can read it), the data as CSV or Parquet, and — for a
|
|
1024
|
+
lossless sdata round-trip — the full sdata metadata under the descriptor's
|
|
1025
|
+
``"sdata"`` key. Optionally the ``<sname>.meta.jsonld`` JSON-LD sidecar.
|
|
1026
|
+
|
|
1027
|
+
:param path: directory to write ``<sname>.zip`` into (if given).
|
|
1028
|
+
:param filename: exact output filename (defaults to ``<sname>.zip``).
|
|
1029
|
+
:param fmt: data format inside the package, ``"csv"`` (default) or ``"parquet"``.
|
|
1030
|
+
:param sidecar: also embed the JSON-LD sidecar in the zip (default ``True``).
|
|
1031
|
+
:return: the file path (if written to disk) or the zip bytes.
|
|
1032
|
+
:raises ValueError: if ``fmt`` is not ``"csv"``/``"parquet"``.
|
|
1033
|
+
"""
|
|
1034
|
+
import zipfile
|
|
1035
|
+
if fmt == "csv":
|
|
1036
|
+
data_path = f"data/{self.sname}.csv"
|
|
1037
|
+
data_bytes = self.df.to_csv(index=False).encode("utf-8")
|
|
1038
|
+
elif fmt == "parquet":
|
|
1039
|
+
data_path = f"data/{self.sname}.spq"
|
|
1040
|
+
data_bytes = self.to_parquet()
|
|
1041
|
+
else:
|
|
1042
|
+
raise ValueError(f"unsupported data package format: {fmt!r} (csv|parquet)")
|
|
1043
|
+
|
|
1044
|
+
descriptor = self._datapackage_descriptor(data_path, fmt)
|
|
1045
|
+
buffer = io.BytesIO()
|
|
1046
|
+
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
1047
|
+
zf.writestr(data_path, data_bytes)
|
|
1048
|
+
zf.writestr("datapackage.json", json.dumps(descriptor, indent=2))
|
|
1049
|
+
if sidecar:
|
|
1050
|
+
zf.writestr(f"{self.sname}.meta.jsonld",
|
|
1051
|
+
json.dumps(self.to_jsonld(), indent=2))
|
|
1052
|
+
payload = buffer.getvalue()
|
|
1053
|
+
|
|
1054
|
+
if filename is None and path is not None:
|
|
1055
|
+
filename = self.sname + ".zip"
|
|
1056
|
+
if filename is not None:
|
|
1057
|
+
filepath = os.path.join(path, filename) if path else filename
|
|
1058
|
+
with open(filepath, "wb") as fh:
|
|
1059
|
+
fh.write(payload)
|
|
1060
|
+
logger.info(f"DataFrame Data Package saved to {filepath}")
|
|
1061
|
+
return filepath
|
|
1062
|
+
return payload
|
|
1063
|
+
|
|
1064
|
+
@classmethod
|
|
1065
|
+
def from_datapackage(cls, filepath):
|
|
1066
|
+
"""Load a DataFrame from a Data Package ``.zip`` written by :meth:`to_datapackage`.
|
|
1067
|
+
|
|
1068
|
+
Restores the data and — losslessly — metadata/column_metadata/description
|
|
1069
|
+
from the descriptor's ``"sdata"`` block.
|
|
1070
|
+
|
|
1071
|
+
:param filepath: path to the ``.zip`` data package.
|
|
1072
|
+
:return: a :class:`DataFrame` instance.
|
|
1073
|
+
:raises FileNotFoundError: if ``filepath`` does not exist.
|
|
1074
|
+
"""
|
|
1075
|
+
import zipfile
|
|
1076
|
+
if not os.path.exists(filepath):
|
|
1077
|
+
raise FileNotFoundError(f"no Data Package {filepath}")
|
|
1078
|
+
with zipfile.ZipFile(filepath) as zf:
|
|
1079
|
+
descriptor = json.loads(zf.read("datapackage.json"))
|
|
1080
|
+
resource = descriptor["resources"][0]
|
|
1081
|
+
data_path = resource["path"]
|
|
1082
|
+
fmt = resource.get("format", "csv")
|
|
1083
|
+
data_bytes = zf.read(data_path)
|
|
1084
|
+
|
|
1085
|
+
if fmt == "parquet":
|
|
1086
|
+
_require_parquet("pyarrow")
|
|
1087
|
+
df = pd.read_parquet(io.BytesIO(data_bytes))
|
|
1088
|
+
else:
|
|
1089
|
+
df = pd.read_csv(io.BytesIO(data_bytes))
|
|
1090
|
+
|
|
1091
|
+
tt = cls()
|
|
1092
|
+
tt.df = df
|
|
1093
|
+
tt._restore_from_attrs(descriptor.get("sdata"))
|
|
1094
|
+
return tt
|
|
1095
|
+
|
|
1096
|
+
# ------------------------------------------------------------------ HDF5
|
|
1097
|
+
# Optionales HDF5-Backend (h5py, Extra ``sdata[hdf]``), siehe RFC 0002.
|
|
1098
|
+
# Bewusst ``# pragma: no cover``: h5py ist nicht in der kanonischen CI
|
|
1099
|
+
# (installiert nur ``[did,parquet,blob,sql]``), also skippt die frische CI
|
|
1100
|
+
# den Pfad. Verifiziert über die ``importorskip("h5py")``-Tests in
|
|
1101
|
+
# ``tests/test_sclass_dataframe_hdf.py`` (laufen, sobald ``sdata[hdf]`` da ist).
|
|
1102
|
+
def to_hdf(self, path=None, filename=None, key=None, sidecar=False,
|
|
1103
|
+
compression=None, **kwargs): # pragma: no cover
|
|
1104
|
+
"""Serialize the df to HDF5 (h5py), embedding sdata metadata as group attrs.
|
|
1105
|
+
|
|
1106
|
+
Each column is stored as its own native HDF5 dataset under a group named
|
|
1107
|
+
``key`` (so other HDF5 tools can read the values); the sdata metadata
|
|
1108
|
+
(metadata/column_metadata/description) rides along as the group's ``_sdata``
|
|
1109
|
+
attribute. HDF5 has no in-memory bytes form, so a ``path``/``filename`` is
|
|
1110
|
+
required; several DataFrames can share one file via distinct ``key``.
|
|
1111
|
+
|
|
1112
|
+
:param path: directory to write ``<sname>.h5`` into.
|
|
1113
|
+
:param filename: exact output filename (defaults to ``<sname>.h5``).
|
|
1114
|
+
:param key: HDF5 group name (default: ``self.sname``); rewritten if present.
|
|
1115
|
+
:param sidecar: also write a JSON-LD metadata sidecar next to the file.
|
|
1116
|
+
:param compression: h5py dataset compression (e.g. ``"gzip"``); ``None`` = off.
|
|
1117
|
+
:param kwargs: legacy PyTables kwargs (``format``/``complevel``/``complib``)
|
|
1118
|
+
are accepted for backward compatibility and ignored by the h5py backend.
|
|
1119
|
+
:return: the file path.
|
|
1120
|
+
:raises ImportError: if h5py is not installed (``pip install sdata[hdf]``).
|
|
1121
|
+
:raises ValueError: if neither ``path`` nor ``filename`` is given.
|
|
1122
|
+
"""
|
|
1123
|
+
try:
|
|
1124
|
+
import h5py
|
|
1125
|
+
except ImportError as exp:
|
|
1126
|
+
raise ImportError("HDF5 support requires h5py. Install it, e.g. "
|
|
1127
|
+
"`pip install sdata[hdf]`.") from exp
|
|
1128
|
+
for legacy in ("format", "complevel", "complib"):
|
|
1129
|
+
if kwargs.pop(legacy, None) is not None:
|
|
1130
|
+
logger.debug("to_hdf: PyTables kwarg %r is ignored by the h5py backend",
|
|
1131
|
+
legacy)
|
|
1132
|
+
if filename is None and path is not None:
|
|
1133
|
+
filename = self.sname + ".h5"
|
|
1134
|
+
if filename is None:
|
|
1135
|
+
raise ValueError("to_hdf requires a path or filename (HDF5 has no bytes form)")
|
|
1136
|
+
filepath = os.path.join(path, filename) if path else filename
|
|
1137
|
+
key = key or self.sname
|
|
1138
|
+
columns = [str(c) for c in self.df.columns]
|
|
1139
|
+
with h5py.File(filepath, "a") as f:
|
|
1140
|
+
if key in f:
|
|
1141
|
+
del f[key] # mode="a" + rewrite same key
|
|
1142
|
+
grp = f.create_group(key)
|
|
1143
|
+
grp.attrs["_sdata"] = json.dumps({
|
|
1144
|
+
"metadata": self.metadata.to_dict(),
|
|
1145
|
+
"column_metadata": self.column_metadata.to_dict(),
|
|
1146
|
+
"description": self.description,
|
|
1147
|
+
})
|
|
1148
|
+
grp.attrs["_columns"] = json.dumps(columns)
|
|
1149
|
+
idx = self.df.index
|
|
1150
|
+
default_index = (isinstance(idx, pd.RangeIndex)
|
|
1151
|
+
and idx.start == 0 and idx.step == 1)
|
|
1152
|
+
grp.attrs["_has_index"] = not default_index
|
|
1153
|
+
if not default_index:
|
|
1154
|
+
data, kind = _h5_encode(idx.to_numpy())
|
|
1155
|
+
ds = grp.create_dataset(
|
|
1156
|
+
"_index", data=data,
|
|
1157
|
+
dtype=_h5_string_dtype() if kind == "str" else None)
|
|
1158
|
+
ds.attrs["kind"] = kind
|
|
1159
|
+
grp.attrs["_index_name"] = "" if idx.name is None else str(idx.name)
|
|
1160
|
+
for i, col in enumerate(self.df.columns):
|
|
1161
|
+
data, kind = _h5_encode(self.df[col].to_numpy())
|
|
1162
|
+
ds = grp.create_dataset(
|
|
1163
|
+
"col_{}".format(i), data=data,
|
|
1164
|
+
dtype=_h5_string_dtype() if kind == "str" else None,
|
|
1165
|
+
compression=compression)
|
|
1166
|
+
ds.attrs["kind"] = kind
|
|
1167
|
+
# Per-Spalten-Annotationen zusätzlich nativ als Dataset-Attribute
|
|
1168
|
+
# (tool-agnostisch lesbar via h5py/HDFView/h5ls), neben dem _sdata-Blob.
|
|
1169
|
+
col_attr = self._column_metadata.get(str(col))
|
|
1170
|
+
if col_attr is not None:
|
|
1171
|
+
for fkey in _COL_FIELD_KEYS:
|
|
1172
|
+
val = getattr(col_attr, fkey, "")
|
|
1173
|
+
if val not in (None, "", "-"):
|
|
1174
|
+
ds.attrs[fkey] = str(val)
|
|
1175
|
+
logger.info(f"DataFrame HDF5 saved to {filepath}")
|
|
1176
|
+
if sidecar:
|
|
1177
|
+
self.write_sidecar(path)
|
|
1178
|
+
return filepath
|
|
1179
|
+
|
|
1180
|
+
@classmethod
|
|
1181
|
+
def from_hdf(cls, filepath, key=None): # pragma: no cover
|
|
1182
|
+
"""Load a DataFrame from an HDF5 file written by :meth:`to_hdf`.
|
|
1183
|
+
|
|
1184
|
+
:param filepath: path to the ``.h5`` file.
|
|
1185
|
+
:param key: HDF5 group to read (default: the first group in the file).
|
|
1186
|
+
:return: a :class:`DataFrame` instance.
|
|
1187
|
+
:raises FileNotFoundError: if ``filepath`` does not exist.
|
|
1188
|
+
:raises ImportError: if h5py is not installed.
|
|
1189
|
+
:raises ValueError: if the file holds no group.
|
|
1190
|
+
"""
|
|
1191
|
+
if not os.path.exists(filepath):
|
|
1192
|
+
raise FileNotFoundError(f"no HDF5 file {filepath}")
|
|
1193
|
+
try:
|
|
1194
|
+
import h5py
|
|
1195
|
+
except ImportError as exp:
|
|
1196
|
+
raise ImportError("HDF5 support requires h5py. Install it, e.g. "
|
|
1197
|
+
"`pip install sdata[hdf]`.") from exp
|
|
1198
|
+
with h5py.File(filepath, "r") as f:
|
|
1199
|
+
if key is None:
|
|
1200
|
+
groups = list(f.keys())
|
|
1201
|
+
if not groups:
|
|
1202
|
+
raise ValueError(f"no DataFrame group in {filepath}")
|
|
1203
|
+
key = groups[0]
|
|
1204
|
+
grp = f[key]
|
|
1205
|
+
native = {}
|
|
1206
|
+
if "_columns" in grp.attrs:
|
|
1207
|
+
columns = json.loads(grp.attrs["_columns"])
|
|
1208
|
+
data = {}
|
|
1209
|
+
for i, col in enumerate(columns):
|
|
1210
|
+
ds = grp["col_{}".format(i)]
|
|
1211
|
+
data[col] = _h5_decode(ds)
|
|
1212
|
+
ann = {}
|
|
1213
|
+
for fkey in _COL_FIELD_KEYS:
|
|
1214
|
+
if fkey in ds.attrs:
|
|
1215
|
+
raw_val = ds.attrs[fkey]
|
|
1216
|
+
ann[fkey] = (raw_val.decode("utf-8")
|
|
1217
|
+
if isinstance(raw_val, bytes) else str(raw_val))
|
|
1218
|
+
if ann:
|
|
1219
|
+
native[col] = ann
|
|
1220
|
+
df = pd.DataFrame(data, columns=columns)
|
|
1221
|
+
if grp.attrs.get("_has_index"):
|
|
1222
|
+
name = grp.attrs.get("_index_name") or None
|
|
1223
|
+
df.index = pd.Index(_h5_decode(grp["_index"]), name=name)
|
|
1224
|
+
else:
|
|
1225
|
+
df = pd.DataFrame()
|
|
1226
|
+
raw = grp.attrs["_sdata"] if "_sdata" in grp.attrs else None
|
|
1227
|
+
tt = cls()
|
|
1228
|
+
tt.df = df
|
|
1229
|
+
tt._restore_from_attrs(json.loads(raw) if raw else None)
|
|
1230
|
+
# native Per-Spalten-Attribute mergen (auch für fremd geschriebene Dateien
|
|
1231
|
+
# ohne _sdata-Blob), symmetrisch zu from_arrow._merge_field_metadata
|
|
1232
|
+
for col, ann in native.items():
|
|
1233
|
+
tt.set_column(col, **ann)
|
|
1234
|
+
return tt
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
if __name__ == '__main__':
|
|
1238
|
+
# Erstelle einen Pandas DataFrame
|
|
1239
|
+
import pandas as pd
|
|
1240
|
+
|
|
1241
|
+
df = pd.DataFrame({
|
|
1242
|
+
'weight': [10, 20, 30],
|
|
1243
|
+
'height': [1.5, 1.6, 1.7]
|
|
1244
|
+
})
|
|
1245
|
+
|
|
1246
|
+
# Erstelle eine DataFrame-Instanz mit Metadaten
|
|
1247
|
+
column_meta = {
|
|
1248
|
+
'weight': {'label': 'Gewicht', 'unit': 'kg'},
|
|
1249
|
+
'height': {'label': 'Höhe', 'unit': 'm'}
|
|
1250
|
+
}
|
|
1251
|
+
sdf = DataFrame(df=df, column_metadata=column_meta, name="MeinDataFrame", description="Beispieldaten")
|