sdata 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. sdata/__init__.py +140 -0
  2. sdata/base.py +684 -0
  3. sdata/contrib/__init__.py +0 -0
  4. sdata/contrib/piexif/__init__.py +12 -0
  5. sdata/contrib/piexif/_common.py +94 -0
  6. sdata/contrib/piexif/_dump.py +346 -0
  7. sdata/contrib/piexif/_exceptions.py +2 -0
  8. sdata/contrib/piexif/_exif.py +638 -0
  9. sdata/contrib/piexif/_insert.py +60 -0
  10. sdata/contrib/piexif/_load.py +270 -0
  11. sdata/contrib/piexif/_remove.py +56 -0
  12. sdata/contrib/piexif/_transplant.py +45 -0
  13. sdata/contrib/piexif/_webp.py +240 -0
  14. sdata/contrib/piexif/helper.py +66 -0
  15. sdata/contrib/ranger/__init__.py +8 -0
  16. sdata/contrib/ranger/collections/__init__.py +0 -0
  17. sdata/contrib/ranger/collections/rangebucketmap.py +346 -0
  18. sdata/contrib/ranger/collections/rangemap.py +396 -0
  19. sdata/contrib/ranger/collections/rangeset.py +483 -0
  20. sdata/contrib/ranger/range/__init__.py +0 -0
  21. sdata/contrib/ranger/range/cut.py +285 -0
  22. sdata/contrib/ranger/range/range.py +644 -0
  23. sdata/contrib/sobol_seq.py +394 -0
  24. sdata/contrib/sortedcontainers/LICENSE +13 -0
  25. sdata/contrib/sortedcontainers/__init__.py +74 -0
  26. sdata/contrib/sortedcontainers/sorteddict.py +789 -0
  27. sdata/contrib/sortedcontainers/sortedlist.py +2646 -0
  28. sdata/contrib/sortedcontainers/sortedset.py +733 -0
  29. sdata/contrib/sqlitedict.py +598 -0
  30. sdata/contrib/timeflake/__init__.py +40 -0
  31. sdata/contrib/timeflake/flake.py +69 -0
  32. sdata/contrib/timeflake/utils.py +47 -0
  33. sdata/deprecated/__init__.py +10 -0
  34. sdata/deprecated/data.py +1503 -0
  35. sdata/did/__init__.py +129 -0
  36. sdata/did/_http.py +67 -0
  37. sdata/did/base58btc.py +49 -0
  38. sdata/did/did_github.py +152 -0
  39. sdata/did/did_peer4.py +155 -0
  40. sdata/did/did_web.py +222 -0
  41. sdata/did/diddoc.py +75 -0
  42. sdata/did/ed25519.py +225 -0
  43. sdata/did/eddsa.py +107 -0
  44. sdata/did/errors.py +40 -0
  45. sdata/did/issue_vc.py +193 -0
  46. sdata/did/jose.py +126 -0
  47. sdata/did/keys.py +104 -0
  48. sdata/did/utils_didkey.py +161 -0
  49. sdata/did/verify_vp.py +158 -0
  50. sdata/doe.py +101 -0
  51. sdata/dtypes.py +510 -0
  52. sdata/experiments/__init__.py +331 -0
  53. sdata/experiments/blob0.py +131 -0
  54. sdata/experiments/file.py +42 -0
  55. sdata/experiments/filegroup.py +144 -0
  56. sdata/experiments/image_old.py +313 -0
  57. sdata/experiments/ks2.py +15 -0
  58. sdata/experiments/lapshear.py +8 -0
  59. sdata/experiments/material.py +7 -0
  60. sdata/experiments/parameters_deprecated.py +394 -0
  61. sdata/experiments/workbook.py +226 -0
  62. sdata/format.py +125 -0
  63. sdata/imagemeta.py +533 -0
  64. sdata/interactive.py +118 -0
  65. sdata/iolib/__init__.py +51 -0
  66. sdata/iolib/hashtree.py +264 -0
  67. sdata/iolib/json1sqlitestore.py +554 -0
  68. sdata/iolib/jsonsqlitestore.py +704 -0
  69. sdata/iolib/pud.py +242 -0
  70. sdata/iolib/reader.py +262 -0
  71. sdata/iolib/writer.py +419 -0
  72. sdata/metadata.py +882 -0
  73. sdata/parameter.py +414 -0
  74. sdata/schema.py +273 -0
  75. sdata/sclass/__init__.py +111 -0
  76. sdata/sclass/bfo_classes.py +362 -0
  77. sdata/sclass/blob.py +362 -0
  78. sdata/sclass/content.py +87 -0
  79. sdata/sclass/dataframe.py +1251 -0
  80. sdata/sclass/dataframegroup.py +187 -0
  81. sdata/sclass/dependency_graph.py +85 -0
  82. sdata/sclass/filereference.py +124 -0
  83. sdata/sclass/image.py +218 -0
  84. sdata/sclass/lazy_namespace.py +155 -0
  85. sdata/sclass/process.py +383 -0
  86. sdata/sclass/process_graph.py +221 -0
  87. sdata/sclass/zipfile.py +135 -0
  88. sdata/semantic.py +346 -0
  89. sdata/suuid.py +136 -0
  90. sdata/timestamp.py +336 -0
  91. sdata/tools.py +14 -0
  92. sdata/units.py +602 -0
  93. sdata/vocab.py +149 -0
  94. sdata-1.5.0.dist-info/METADATA +264 -0
  95. sdata-1.5.0.dist-info/RECORD +98 -0
  96. sdata-1.5.0.dist-info/WHEEL +5 -0
  97. sdata-1.5.0.dist-info/licenses/LICENSE-MIT +21 -0
  98. sdata-1.5.0.dist-info/top_level.txt +1 -0
sdata/parameter.py ADDED
@@ -0,0 +1,414 @@
1
+ import json
2
+ import logging
3
+ from typing import Dict, Tuple, List, Union, Optional
4
+ import random
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class Distribution:
10
+ def __init__(self, name: str, **params):
11
+ self.name = name
12
+ self.params = params
13
+ self.dtype = float
14
+
15
+ def to_dict(self) -> Dict:
16
+ return {
17
+ "name": self.name,
18
+ "params": self.params
19
+ }
20
+
21
+ @classmethod
22
+ def from_dict(cls, data: Dict):
23
+ return cls(name=data["name"], **data.get("params", {}))
24
+
25
+ def generate_value(self, bounds: Union[Tuple[float, float], List[Union[int, str]]]) -> Union[float, int, str]:
26
+ min_val, max_val = bounds
27
+ if self.name == "uniform":
28
+ return random.uniform(min_val, max_val)
29
+ raise ValueError(f"Unsupported distribution: {self.name}")
30
+
31
+
32
+ class Parameter:
33
+ def __init__(self, name: str, dtype: type, description: Optional[str] = None, fix: bool = False):
34
+ self.name = name
35
+ self.dtype = dtype
36
+ self.description = description or ""
37
+ self.fix = fix
38
+
39
+ def _validate_and_cast_value(self, value):
40
+ if not isinstance(value, self.dtype):
41
+ try:
42
+ return self.dtype(value)
43
+ except (ValueError, TypeError):
44
+ import warnings
45
+ warnings.warn(f"Value {value} could not be cast to {self.dtype}. Keeping the original value.")
46
+ return value
47
+ return value
48
+
49
+ def to_dict(self) -> Dict:
50
+ return {
51
+ "class_name": self.__class__.__name__,
52
+ "name": self.name,
53
+ # "dtype": self.dtype.__name__,
54
+ "description": self.description,
55
+ "fix": self.fix
56
+ }
57
+
58
+ @classmethod
59
+ def get_subclasses(cls):
60
+ class_name = "Parameter"
61
+ all_subclasses = {}
62
+ subclasses = {subclass.__name__: subclass for subclass in cls.__subclasses__()}
63
+ all_subclasses.update(subclasses)
64
+ for subclassname, subcls in subclasses.items():
65
+ subsubclasses = {subclass.__name__: subclass for subclass in subcls.__subclasses__()}
66
+ # all_subclasses = all_subclasses | subsubclasses
67
+ all_subclasses.update(subsubclasses)
68
+
69
+ return all_subclasses
70
+
71
+ @classmethod
72
+ def from_dict(cls, data: Dict):
73
+ class_name = data.get("class_name", "Parameter")
74
+ subclasses = cls.get_subclasses()
75
+ if class_name in subclasses:
76
+ return subclasses[class_name].from_dict(data)
77
+ raise ValueError(f"Unknown class_name '{class_name}' in data.")
78
+
79
+
80
+ class Variable(Parameter):
81
+ def __init__(self, name: str, value: Union[bool, float, int, str], dtype: type,
82
+ bounds: Optional[Tuple[float, float]] = None,
83
+ distribution: Optional[Distribution] = None,
84
+ description: Optional[str] = None, fix: bool = False):
85
+ description = description or ""
86
+ super().__init__(name, dtype, description, fix)
87
+ self.bounds = bounds
88
+ self.distribution = distribution or Distribution("uniform")
89
+ self.value = self._validate_and_cast_value(value)
90
+
91
+ def sample_value(self) -> Union[bool, float, int, str]:
92
+ if self.fix:
93
+ return self.value
94
+ sampled = self.distribution.generate_value(self.bounds)
95
+ self.value = self._validate_and_cast_value(sampled)
96
+ return self.value
97
+
98
+ def to_dict(self) -> Dict:
99
+ data = super().to_dict()
100
+ data.update({
101
+ "value": self.value,
102
+ "bounds": self.bounds,
103
+ "distribution": self.distribution.to_dict() if self.distribution else None
104
+ })
105
+ return data
106
+
107
+ @classmethod
108
+ def from_dict(cls, data: Dict):
109
+ distribution = Distribution.from_dict(data["distribution"]) if data.get("distribution") else None
110
+ return cls(
111
+ name=data["name"],
112
+ value=data["value"],
113
+ bounds=data.get("bounds"),
114
+ distribution=distribution,
115
+ description=data.get("description"),
116
+ fix=data.get("fix", False)
117
+ )
118
+
119
+ def sample_values(self, n: int = 1) -> List[Union[bool, float, int, str]]:
120
+ """
121
+ Generate n sampled values based on the defined distribution.
122
+ """
123
+ if self.fix is True:
124
+ return [self.value] * n
125
+ else:
126
+ return [self.dtype(self.distribution.generate_value(self.bounds)) for _ in range(n)]
127
+
128
+ def __repr__(self):
129
+ try:
130
+ value_bounds = f"{self.min:.2g},{self.max:.2g}"
131
+ except:
132
+ value_bounds = str(self.bounds)
133
+ return f"({self.name}|{self.value}|[{value_bounds}]|{self.dtype.__name__})"
134
+
135
+ def __str__(self):
136
+ return self.__repr__()
137
+
138
+
139
+ class DiscreteVariable(Parameter):
140
+ def __init__(self, name: str, value: Union[bool, float, int, str], dtype: type,
141
+ discrete_values: List[Union[bool, int, str]],
142
+ description: Optional[str] = None, fix: bool = False):
143
+ description = description or ""
144
+ super().__init__(name, dtype, description, fix)
145
+ self.discrete_values = [self._validate_and_cast_value(v) for v in discrete_values]
146
+ self.value = self._validate_and_cast_value(value)
147
+
148
+ def sample_value(self) -> Union[bool, float, int, str]:
149
+ if self.fix:
150
+ return self.value
151
+ self.value = random.choice(self.discrete_values)
152
+ return self.value
153
+
154
+ def to_dict(self) -> Dict:
155
+ data = super().to_dict()
156
+ data.update({
157
+ "value": self.value,
158
+ "discrete_values": self.discrete_values
159
+ })
160
+ return data
161
+
162
+ def __repr__(self):
163
+ try:
164
+ if self.discrete_values and len(self.discrete_values) > 4:
165
+ value_bounds = self.discrete_values[:4] + ["..."]
166
+ elif self.discrete_values:
167
+ value_bounds = self.discrete_values
168
+ else:
169
+ value_bounds = "None"
170
+ except:
171
+ value_bounds = "!"
172
+ return f"({self.name}|{self.value}|[{value_bounds}]|{self.dtype.__name__})"
173
+
174
+ def __str__(self):
175
+ return self.__repr__()
176
+
177
+ def sample_values(self, n: int = 1) -> List[Union[bool, float, int, str]]:
178
+ """
179
+ Generate n sampled values based on the defined distribution.
180
+ """
181
+ if self.fix is True:
182
+ return [self.value] * n
183
+ else:
184
+ return [random.choice(self.discrete_values) for _ in range(n)]
185
+
186
+
187
+ # Specialized Variable Classes
188
+ class VariableInt(Variable):
189
+ dtype = int
190
+
191
+ def __init__(self, name: str, value: int, bounds: Optional[Tuple[int, int]] = None,
192
+ distribution: Optional[Distribution] = None, description: Optional[str] = None, fix: bool = False):
193
+ super().__init__(name, value, int, bounds, distribution, description, fix)
194
+
195
+ @property
196
+ def min(self):
197
+ if isinstance(self.bounds, (list, tuple)):
198
+ return self.dtype(self.bounds[0])
199
+ raise ValueError("min is only available for continuous boundss")
200
+
201
+ @property
202
+ def max(self):
203
+ if isinstance(self.bounds, (list, tuple)):
204
+ return self.dtype(self.bounds[1])
205
+ raise ValueError("max is only available for continuous boundss")
206
+
207
+
208
+ class VariableFloat(Variable):
209
+ dtype = float
210
+
211
+ def __init__(self, name: str, value: float, bounds: Optional[Tuple[float, float]] = None,
212
+ distribution: Optional[Distribution] = None, description: Optional[str] = None, fix: bool = False):
213
+ if bounds is None:
214
+ bounds = [self.dtype(value), self.dtype(value)]
215
+ super().__init__(name, value, float, bounds, distribution, description, fix)
216
+
217
+ @property
218
+ def min(self):
219
+ if isinstance(self.bounds, (list, tuple)):
220
+ return self.bounds[0]
221
+ raise ValueError("min is only available for continuous boundss")
222
+
223
+ @property
224
+ def max(self):
225
+ if isinstance(self.bounds, (list, tuple)):
226
+ return self.bounds[1]
227
+ raise ValueError("ma"
228
+ "x is only available for continuous boundss")
229
+
230
+
231
+ class VariableStr(Variable):
232
+ dtype = str
233
+
234
+ def __init__(self, name: str, value: str, bounds: Optional[Tuple[str, str]] = None,
235
+ distribution: Optional[Distribution] = None, description: Optional[str] = None, fix: bool = False):
236
+ super().__init__(name, value, str, bounds, distribution, description, fix)
237
+
238
+ def sample_values(self, n: int = 1) -> List[Union[bool, float, int, str]]:
239
+ """
240
+ Generate n sampled values based on the defined distribution.
241
+ """
242
+ return [self.value] * n
243
+
244
+
245
+ # Specialized DiscreteVariable Classes
246
+ class DiscreteVariableInt(DiscreteVariable):
247
+ dtype = int
248
+
249
+ def __init__(self, name: str, value: int, discrete_values: List[int],
250
+ description: Optional[str] = None, fix: bool = False):
251
+ super().__init__(name, value, int, discrete_values, description, fix)
252
+
253
+
254
+ class DiscreteVariableFloat(DiscreteVariable):
255
+ dtype = float
256
+
257
+ def __init__(self, name: str, value: float, discrete_values: List[float],
258
+ description: Optional[str] = None, fix: bool = False):
259
+ super().__init__(name, value, float, discrete_values, description, fix)
260
+
261
+
262
+ class DiscreteVariableStr(DiscreteVariable):
263
+ dtype = str
264
+
265
+ def __init__(self, name: str, value: str, discrete_values: List[str],
266
+ description: Optional[str] = None, fix: bool = False):
267
+ super().__init__(name, value, str, discrete_values, description, fix)
268
+
269
+
270
+ class DiscreteVariableBool(DiscreteVariable):
271
+ dtype = bool
272
+
273
+ def __init__(self, name: str, value: bool, discrete_values: Optional[List[bool]] = None,
274
+ description: Optional[str] = None, fix: bool = False):
275
+ discrete_values = [True, False]
276
+ super().__init__(name, value, bool, discrete_values, description, fix)
277
+
278
+
279
+ class ParameterSet:
280
+ def __init__(self, name=None):
281
+ """
282
+ ParameterSet class to hold multiple _parameter in a dictionary.
283
+ """
284
+ self.name = name or "N.N."
285
+ self._parameter = {}
286
+
287
+ @property
288
+ def parameters(self):
289
+ return self._parameter
290
+
291
+ def update(self, parameter_list):
292
+ """
293
+
294
+ """
295
+ for parameter in parameter_list:
296
+ self._parameter[parameter.name] = parameter
297
+
298
+ def __getitem__(self, name):
299
+ return self.get(name)
300
+
301
+ def __contains__(self, item):
302
+ return item in self._parameter.keys()
303
+
304
+ def pop(self, pname):
305
+ try:
306
+ return self._parameter.pop(pname)
307
+ except KeyError as exp:
308
+ return None
309
+
310
+ def __iter__(self):
311
+ for x in self._parameter.values():
312
+ yield x
313
+
314
+ def items(self):
315
+ """
316
+ Provide a dict-like items view for parameters.
317
+
318
+ :return: Items view of parameters.
319
+ """
320
+ return self._parameter.items()
321
+
322
+ def keys(self):
323
+ """
324
+ Provide a dict-like keys view for parameters.
325
+
326
+ :return: Keys view of parameters.
327
+ """
328
+ return self._parameter.keys()
329
+
330
+ def values(self):
331
+ """
332
+ Provide a dict-like values view for parameters.
333
+
334
+ :return: Values view of parameters.
335
+ """
336
+ return self._parameter.values()
337
+
338
+ def get(self, name, default=None):
339
+ if self._parameter.get(name) is not None:
340
+ return self._parameter.get(name)
341
+ else:
342
+ return default
343
+
344
+ def set(self, parameter: Parameter):
345
+ """
346
+ Add a parameter to the set.
347
+
348
+ :param parameter: A Parameter object to add.
349
+ """
350
+ self._parameter[parameter.name] = parameter
351
+
352
+ def add_parameters(self, parameter_list: List):
353
+ """
354
+ Add a parameter to the set.
355
+
356
+ :param parameter: A Parameter object to add.
357
+ """
358
+ for parameter in parameter_list:
359
+ self._parameter[parameter.name] = parameter
360
+
361
+ def to_dict(self) -> Dict[str, Dict]:
362
+ """
363
+ Convert the entire parameter set to a dictionary.
364
+
365
+ :return: A dictionary of parameter dictionaries.
366
+ """
367
+ d = {"name": self.name}
368
+ d["parameter"] = {pname: param.to_dict() for pname, param in self._parameter.items()}
369
+ return d
370
+
371
+ @classmethod
372
+ def from_dict(cls, data: Dict):
373
+ """
374
+ Create a ParameterSet instance from a dictionary.
375
+
376
+ :param data: A dictionary containing parameter set data.
377
+ :return: A ParameterSet instance.
378
+ """
379
+ instance = cls(name=data.get("name", "N.N."))
380
+ parameters = data.get("parameter", {})
381
+ for pname, param_data in parameters.items():
382
+ parameter = Parameter.from_dict(param_data) # Assuming Parameter has a from_dict method
383
+ instance.set(parameter)
384
+ return instance
385
+
386
+ def to_json(self) -> str:
387
+ """
388
+ Convert the ParameterSet to a JSON string.
389
+
390
+ :return: JSON string representation of the ParameterSet.
391
+ """
392
+ import json
393
+ return json.dumps(self.to_dict())
394
+
395
+ @classmethod
396
+ def from_json(cls, json_str: str):
397
+ """
398
+ Create a ParameterSet instance from a JSON string.
399
+
400
+ :param json_str: JSON string containing parameter set data.
401
+ :return: A ParameterSet instance.
402
+ """
403
+ data = json.loads(json_str)
404
+ return cls.from_dict(data)
405
+
406
+ def print(self):
407
+ for pname in sorted(self._parameter.keys()):
408
+ logger.info("%s", self._parameter[pname])
409
+
410
+ def __repr__(self):
411
+ return f"ParameterSet({self.name}|{list(self._parameter.keys())})"
412
+
413
+ def __str__(self):
414
+ return self.__repr__()
sdata/schema.py ADDED
@@ -0,0 +1,273 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Metadaten-Schemata / Templates zum *Vollqualifizieren* von Metadaten.
3
+
4
+ Ein :class:`MetadataSchema` deklariert die erwarteten Attribute einer Datenklasse
5
+ (Name, dtype, Einheit, required, ontology, …) und kann Metadaten dagegen
6
+ *validieren* sowie *vervollständigen* (Defaults/Einheiten/Ontologie auffüllen).
7
+
8
+ Reine-Python-Validierung ist immer verfügbar; mit dem optionalen Extra
9
+ ``[schema]`` (jsonschema) lässt sich zusätzlich gegen ein generiertes JSON Schema
10
+ prüfen.
11
+ """
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, List, Optional
14
+
15
+ import numpy as np
16
+
17
+ import sdata.dtypes as dtypes
18
+ from sdata import units
19
+
20
+ try: # optionales Backend ([schema]=jsonschema)
21
+ import jsonschema as _jsonschema
22
+ except ImportError: # pragma: no cover - abhängig vom Environment
23
+ _jsonschema = None
24
+
25
+ __all__ = ["AttrSpec", "MetadataSchema", "TableSchema", "ValidationReport"]
26
+
27
+ # sdata-dtype -> JSON-Schema-Typ
28
+ _JSON_TYPE = {
29
+ "str": "string", "uri": "string", "bytes": "string", "timestamp": "string",
30
+ "int": "integer", "float": "number", "bool": "boolean",
31
+ "list": "array", "json": "object",
32
+ }
33
+
34
+
35
+ def _is_empty(value):
36
+ if value is None or value == "":
37
+ return True
38
+ try:
39
+ return bool(np.isnan(value))
40
+ except (TypeError, ValueError):
41
+ return False
42
+
43
+
44
+ @dataclass
45
+ class AttrSpec:
46
+ """Spezifikation eines erwarteten Attributs."""
47
+ name: str
48
+ dtype: str = "str"
49
+ unit: str = "-"
50
+ required: bool = False
51
+ ontology: str = ""
52
+ description: str = ""
53
+ default: Any = None
54
+ allowed: Optional[List[Any]] = None
55
+
56
+ def to_dict(self):
57
+ return {"name": self.name, "dtype": self.dtype, "unit": self.unit,
58
+ "required": self.required, "ontology": self.ontology,
59
+ "description": self.description, "default": self.default,
60
+ "allowed": self.allowed}
61
+
62
+ @classmethod
63
+ def from_dict(cls, d):
64
+ return cls(**{k: d[k] for k in d if k in cls.__dataclass_fields__})
65
+
66
+
67
+ @dataclass
68
+ class ValidationReport:
69
+ """Ergebnis einer Schema-Validierung (truthy, wenn ``ok``)."""
70
+ ok: bool
71
+ missing: List[str] = field(default_factory=list)
72
+ type_errors: List[tuple] = field(default_factory=list)
73
+ unit_errors: List[str] = field(default_factory=list)
74
+ enum_errors: List[str] = field(default_factory=list)
75
+ extra: List[str] = field(default_factory=list)
76
+ messages: List[str] = field(default_factory=list)
77
+
78
+ def __bool__(self):
79
+ return self.ok
80
+
81
+ def _repr_html_(self):
82
+ color = "#2e7d32" if self.ok else "#c62828"
83
+ status = "OK" if self.ok else "INVALID"
84
+ rows = ""
85
+ for label, items in [("missing", self.missing), ("type", self.type_errors),
86
+ ("unit", self.unit_errors), ("enum", self.enum_errors),
87
+ ("extra", self.extra), ("messages", self.messages)]:
88
+ if items:
89
+ rows += "<tr><td><b>{}</b></td><td>{}</td></tr>".format(label, items)
90
+ return ("<div><b style='color:{}'>ValidationReport: {}</b>"
91
+ "<table>{}</table></div>").format(color, status, rows)
92
+
93
+
94
+ class MetadataSchema:
95
+ """Sammlung von :class:`AttrSpec` für eine Datenklasse."""
96
+
97
+ def __init__(self, name, specs, topology_class=None, sdata_class=None):
98
+ self.name = name
99
+ self.specs = list(specs)
100
+ self.topology_class = topology_class
101
+ self.sdata_class = sdata_class
102
+
103
+ @property
104
+ def _by_name(self):
105
+ return {s.name: s for s in self.specs}
106
+
107
+ def to_dict(self):
108
+ return {"name": self.name,
109
+ "topology_class": self.topology_class,
110
+ "sdata_class": self.sdata_class,
111
+ "specs": [s.to_dict() for s in self.specs]}
112
+
113
+ @classmethod
114
+ def from_dict(cls, d):
115
+ return cls(name=d.get("name", "N.N."),
116
+ specs=[AttrSpec.from_dict(s) for s in d.get("specs", [])],
117
+ topology_class=d.get("topology_class"),
118
+ sdata_class=d.get("sdata_class"))
119
+
120
+ def validate(self, metadata):
121
+ """Prüfe ``metadata`` gegen das Schema (wirft nie; liefert ValidationReport)."""
122
+ report = ValidationReport(ok=True)
123
+ specs = self._by_name
124
+ for name, spec in specs.items():
125
+ attr = metadata.get(name)
126
+ if spec.required and (attr is None or _is_empty(attr.value)):
127
+ report.missing.append(name)
128
+ if attr is None:
129
+ continue
130
+ if dtypes.resolve(attr.dtype) != dtypes.resolve(spec.dtype):
131
+ report.type_errors.append((name, spec.dtype))
132
+ if spec.unit not in ("-", "") and \
133
+ units.normalize_symbol(attr.unit) != units.normalize_symbol(spec.unit):
134
+ report.unit_errors.append(name)
135
+ if spec.allowed is not None and attr.value not in spec.allowed:
136
+ report.enum_errors.append(name)
137
+ for name in metadata.user_attributes.keys():
138
+ if name not in specs:
139
+ report.extra.append(name)
140
+ report.ok = not (report.missing or report.type_errors
141
+ or report.unit_errors or report.enum_errors)
142
+ return report
143
+
144
+ def apply(self, metadata):
145
+ """Vervollständige ``metadata`` in-place: fehlende Attribute aus Defaults
146
+ anlegen, vorhandene um Einheit/Ontologie/Beschreibung ergänzen. Gibt
147
+ ``metadata`` zurück (für nicht-destruktiv: ``schema.apply(md.copy())``)."""
148
+ for spec in self.specs:
149
+ attr = metadata.get(spec.name)
150
+ if attr is None:
151
+ metadata.set_attr(spec.name, spec.default, dtype=spec.dtype,
152
+ unit=spec.unit, ontology=spec.ontology,
153
+ description=spec.description)
154
+ continue
155
+ if attr.unit in ("-", "", None) and spec.unit not in ("-", ""):
156
+ attr.unit = spec.unit
157
+ if not attr.ontology and spec.ontology:
158
+ attr.ontology = spec.ontology
159
+ if not attr.description and spec.description:
160
+ attr.description = spec.description
161
+ return metadata
162
+
163
+ def to_json_schema(self):
164
+ """Generiere ein JSON Schema (Draft 2020-12) für ``metadata.get_udict()``."""
165
+ properties = {}
166
+ required = []
167
+ for spec in self.specs:
168
+ prop = {"type": _JSON_TYPE.get(dtypes.resolve(spec.dtype), "string")}
169
+ if spec.description:
170
+ prop["description"] = spec.description
171
+ if spec.allowed is not None:
172
+ prop["enum"] = spec.allowed
173
+ properties[spec.name] = prop
174
+ if spec.required:
175
+ required.append(spec.name)
176
+ schema = {"$schema": "https://json-schema.org/draft/2020-12/schema",
177
+ "type": "object", "properties": properties}
178
+ if required:
179
+ schema["required"] = required
180
+ return schema
181
+
182
+ def validate_jsonschema(self, metadata):
183
+ """Validiere via ``jsonschema`` (falls installiert), sonst native ``validate``."""
184
+ if _jsonschema is None:
185
+ return self.validate(metadata)
186
+ try:
187
+ _jsonschema.validate(metadata.get_udict(), self.to_json_schema())
188
+ return ValidationReport(ok=True)
189
+ except _jsonschema.ValidationError as exp:
190
+ return ValidationReport(ok=False, messages=[str(exp).splitlines()[0]])
191
+
192
+
193
+ class TableSchema:
194
+ """Spalten-Schema für eine :class:`~sdata.sclass.dataframe.DataFrame`.
195
+
196
+ Deklariert je Spalte den erwarteten ``name``/``dtype``/``unit``/``required``
197
+ (wiederverwendet :class:`AttrSpec`) und kann ein DataFrame dagegen *validieren*
198
+ sowie dessen ``column_metadata`` aus dem Schema *vervollständigen*.
199
+
200
+ Der dtype wird gegen die tatsächlichen ``df.dtypes`` geprüft, die Einheit gegen
201
+ die ``column_metadata``-Annotation der Spalte.
202
+ """
203
+
204
+ def __init__(self, name, columns):
205
+ self.name = name
206
+ self.columns = list(columns)
207
+
208
+ @property
209
+ def _by_name(self):
210
+ return {s.name: s for s in self.columns}
211
+
212
+ def to_dict(self):
213
+ return {"name": self.name, "columns": [s.to_dict() for s in self.columns]}
214
+
215
+ @classmethod
216
+ def from_dict(cls, d):
217
+ return cls(name=d.get("name", "N.N."),
218
+ columns=[AttrSpec.from_dict(s) for s in d.get("columns", [])])
219
+
220
+ def validate(self, dataframe):
221
+ """Prüfe ein :class:`DataFrame` gegen das Spalten-Schema.
222
+
223
+ Wirft nie; liefert einen :class:`ValidationReport` mit fehlenden Spalten,
224
+ dtype-Abweichungen (gegen ``df.dtypes``), Einheiten-Abweichungen (gegen
225
+ ``column_metadata``) und zusätzlichen (nicht spezifizierten) Spalten.
226
+ """
227
+ report = ValidationReport(ok=True)
228
+ df = dataframe.df
229
+ cm = dataframe.column_metadata
230
+ present = {str(c) for c in df.columns}
231
+ specs = self._by_name
232
+ for name, spec in specs.items():
233
+ if name not in present:
234
+ report.missing.append(name)
235
+ continue
236
+ actual = dtypes.resolve(str(df[name].dtype))
237
+ if actual is not None and dtypes.resolve(spec.dtype) != actual:
238
+ report.type_errors.append((name, spec.dtype))
239
+ if spec.unit not in ("-", ""):
240
+ attr = cm.get(name)
241
+ attr_unit = attr.unit if attr is not None else "-"
242
+ if units.normalize_symbol(attr_unit) != units.normalize_symbol(spec.unit):
243
+ report.unit_errors.append(name)
244
+ for name in present:
245
+ if name not in specs:
246
+ report.extra.append(name)
247
+ report.ok = not (report.missing or report.type_errors or report.unit_errors)
248
+ return report
249
+
250
+ def apply(self, dataframe):
251
+ """Vervollständige ``column_metadata`` in-place aus dem Schema.
252
+
253
+ Für jede im df vorhandene Schema-Spalte: fehlende Annotation anlegen bzw.
254
+ leere ``unit``/``ontology``/``description`` auffüllen. Gibt ``dataframe`` zurück.
255
+ """
256
+ cm = dataframe.column_metadata
257
+ present = {str(c) for c in dataframe.df.columns}
258
+ for spec in self.columns:
259
+ if spec.name not in present:
260
+ continue
261
+ attr = cm.get(spec.name)
262
+ if attr is None:
263
+ cm.set_attr(spec.name, spec.default, dtype=spec.dtype,
264
+ unit=spec.unit, ontology=spec.ontology,
265
+ description=spec.description)
266
+ continue
267
+ if attr.unit in ("-", "", None) and spec.unit not in ("-", ""):
268
+ attr.unit = spec.unit
269
+ if not attr.ontology and spec.ontology:
270
+ attr.ontology = spec.ontology
271
+ if not attr.description and spec.description:
272
+ attr.description = spec.description
273
+ return dataframe