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
@@ -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")