sdata 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sdata/__init__.py +140 -0
- sdata/base.py +684 -0
- sdata/contrib/__init__.py +0 -0
- sdata/contrib/piexif/__init__.py +12 -0
- sdata/contrib/piexif/_common.py +94 -0
- sdata/contrib/piexif/_dump.py +346 -0
- sdata/contrib/piexif/_exceptions.py +2 -0
- sdata/contrib/piexif/_exif.py +638 -0
- sdata/contrib/piexif/_insert.py +60 -0
- sdata/contrib/piexif/_load.py +270 -0
- sdata/contrib/piexif/_remove.py +56 -0
- sdata/contrib/piexif/_transplant.py +45 -0
- sdata/contrib/piexif/_webp.py +240 -0
- sdata/contrib/piexif/helper.py +66 -0
- sdata/contrib/ranger/__init__.py +8 -0
- sdata/contrib/ranger/collections/__init__.py +0 -0
- sdata/contrib/ranger/collections/rangebucketmap.py +346 -0
- sdata/contrib/ranger/collections/rangemap.py +396 -0
- sdata/contrib/ranger/collections/rangeset.py +483 -0
- sdata/contrib/ranger/range/__init__.py +0 -0
- sdata/contrib/ranger/range/cut.py +285 -0
- sdata/contrib/ranger/range/range.py +644 -0
- sdata/contrib/sobol_seq.py +394 -0
- sdata/contrib/sortedcontainers/LICENSE +13 -0
- sdata/contrib/sortedcontainers/__init__.py +74 -0
- sdata/contrib/sortedcontainers/sorteddict.py +789 -0
- sdata/contrib/sortedcontainers/sortedlist.py +2646 -0
- sdata/contrib/sortedcontainers/sortedset.py +733 -0
- sdata/contrib/sqlitedict.py +598 -0
- sdata/contrib/timeflake/__init__.py +40 -0
- sdata/contrib/timeflake/flake.py +69 -0
- sdata/contrib/timeflake/utils.py +47 -0
- sdata/deprecated/__init__.py +10 -0
- sdata/deprecated/data.py +1503 -0
- sdata/did/__init__.py +129 -0
- sdata/did/_http.py +67 -0
- sdata/did/base58btc.py +49 -0
- sdata/did/did_github.py +152 -0
- sdata/did/did_peer4.py +155 -0
- sdata/did/did_web.py +222 -0
- sdata/did/diddoc.py +75 -0
- sdata/did/ed25519.py +225 -0
- sdata/did/eddsa.py +107 -0
- sdata/did/errors.py +40 -0
- sdata/did/issue_vc.py +193 -0
- sdata/did/jose.py +126 -0
- sdata/did/keys.py +104 -0
- sdata/did/utils_didkey.py +161 -0
- sdata/did/verify_vp.py +158 -0
- sdata/doe.py +101 -0
- sdata/dtypes.py +510 -0
- sdata/experiments/__init__.py +331 -0
- sdata/experiments/blob0.py +131 -0
- sdata/experiments/file.py +42 -0
- sdata/experiments/filegroup.py +144 -0
- sdata/experiments/image_old.py +313 -0
- sdata/experiments/ks2.py +15 -0
- sdata/experiments/lapshear.py +8 -0
- sdata/experiments/material.py +7 -0
- sdata/experiments/parameters_deprecated.py +394 -0
- sdata/experiments/workbook.py +226 -0
- sdata/format.py +125 -0
- sdata/imagemeta.py +533 -0
- sdata/interactive.py +118 -0
- sdata/iolib/__init__.py +51 -0
- sdata/iolib/hashtree.py +264 -0
- sdata/iolib/json1sqlitestore.py +554 -0
- sdata/iolib/jsonsqlitestore.py +704 -0
- sdata/iolib/pud.py +242 -0
- sdata/iolib/reader.py +262 -0
- sdata/iolib/writer.py +419 -0
- sdata/metadata.py +882 -0
- sdata/parameter.py +414 -0
- sdata/schema.py +273 -0
- sdata/sclass/__init__.py +111 -0
- sdata/sclass/bfo_classes.py +362 -0
- sdata/sclass/blob.py +362 -0
- sdata/sclass/content.py +87 -0
- sdata/sclass/dataframe.py +1251 -0
- sdata/sclass/dataframegroup.py +187 -0
- sdata/sclass/dependency_graph.py +85 -0
- sdata/sclass/filereference.py +124 -0
- sdata/sclass/image.py +218 -0
- sdata/sclass/lazy_namespace.py +155 -0
- sdata/sclass/process.py +383 -0
- sdata/sclass/process_graph.py +221 -0
- sdata/sclass/zipfile.py +135 -0
- sdata/semantic.py +346 -0
- sdata/suuid.py +136 -0
- sdata/timestamp.py +336 -0
- sdata/tools.py +14 -0
- sdata/units.py +602 -0
- sdata/vocab.py +149 -0
- sdata-1.5.0.dist-info/METADATA +264 -0
- sdata-1.5.0.dist-info/RECORD +98 -0
- sdata-1.5.0.dist-info/WHEEL +5 -0
- sdata-1.5.0.dist-info/licenses/LICENSE-MIT +21 -0
- sdata-1.5.0.dist-info/top_level.txt +1 -0
sdata/sclass/blob.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import logging
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import io
|
|
6
|
+
import mimetypes
|
|
7
|
+
import typing
|
|
8
|
+
from typing import Any, List, Dict, Optional, Literal, Optional, Type
|
|
9
|
+
from sdata.base import Base
|
|
10
|
+
from sdata.sclass.content import ContentIntegrityMixin
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
# Import fsspec for handling various URI schemes (local file, S3, Zip, etc.)
|
|
14
|
+
# Note: fsspec must be installed (pip install fsspec) and for S3, also install s3fs (pip install s3fs)
|
|
15
|
+
try:
|
|
16
|
+
import fsspec
|
|
17
|
+
except ImportError: # pragma: no cover - optionales Backend (sdata[blob])
|
|
18
|
+
logger.warning('fsspec not installed')
|
|
19
|
+
fsspec = None
|
|
20
|
+
|
|
21
|
+
class Blob(ContentIntegrityMixin, Base):
|
|
22
|
+
"""
|
|
23
|
+
A derived class from Base that represents a generic binary large object (Blob).
|
|
24
|
+
Stores the content in self.data['content'] as a dictionary with:
|
|
25
|
+
- 'type': 'bytes' for in-memory bytes (base64-encoded for serialization) or 'uri' for a filesystem URI (local path, S3 object, Zip path, etc., handled via fsspec).
|
|
26
|
+
- 'value': The base64-encoded bytes string (for 'bytes') or the URI string (for 'uri').
|
|
27
|
+
- 'filetype': The file type (e.g., 'pdf', 'png', 'jpg', 'txt', or any custom type). This is always stored and serialized.
|
|
28
|
+
|
|
29
|
+
Additionally, integrates hash calculations (SHA1 and MD5) from the provided class for integrity checks.
|
|
30
|
+
The actual bytes are loaded lazily when accessed via .content_bytes property, ensuring large content is not loaded unless explicitly requested.
|
|
31
|
+
For serialization in to_dict(), bytes are base64-encoded if type is 'bytes'; URIs are kept as-is.
|
|
32
|
+
Supports PDFs, images (png, jpg), or any arbitrary file types.
|
|
33
|
+
Uses fsspec to handle various URI schemes:
|
|
34
|
+
- Local file: 'file:///path/to/file.pdf' or simply '/path/to/file.pdf'
|
|
35
|
+
- S3: 's3://bucket/key.pdf'
|
|
36
|
+
- Zip: 'zip://innerfile.txt::/path/to/outer.zip'
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
ContentType = Literal['bytes', 'uri']
|
|
40
|
+
|
|
41
|
+
DEFAULT_METADATA = {
|
|
42
|
+
# "uuid": {
|
|
43
|
+
# "name": "uuid",
|
|
44
|
+
# "value": "123e4567-e89b-12d3-a456-426614174000",
|
|
45
|
+
# "unit": None,
|
|
46
|
+
# "dtype": "str",
|
|
47
|
+
# "description": "A universally unique identification number (Universally Unique Identifier) that makes the asset globally unique. It is used to avoid duplicates and enable references.",
|
|
48
|
+
# "label": "UUID",
|
|
49
|
+
# "required": True,
|
|
50
|
+
# "ontology": "saf:uuid"
|
|
51
|
+
# },
|
|
52
|
+
"checksum": {
|
|
53
|
+
"name": "checksum",
|
|
54
|
+
"value": None, #"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
55
|
+
"unit": None,
|
|
56
|
+
"dtype": "str",
|
|
57
|
+
"description": "A hash value (based on the SHA-256 algorithm) that verifies the integrity of the asset. It ensures that the resource has not been modified (e.g., during downloads or storage).",
|
|
58
|
+
"label": "Checksum (SHA-256)",
|
|
59
|
+
"required": True,
|
|
60
|
+
"ontology": "schema:sha256"
|
|
61
|
+
},
|
|
62
|
+
"mime_type": {
|
|
63
|
+
"name": "mime_type",
|
|
64
|
+
"value": None, #"application/pdf",
|
|
65
|
+
"unit": None,
|
|
66
|
+
"dtype": "str",
|
|
67
|
+
"description": "The MIME type of the resource (e.g., 'application/pdf' for PDFs or 'text/html' for web pages), which indicates how the asset should be interpreted and processed.",
|
|
68
|
+
"label": "MIME Type",
|
|
69
|
+
"required": True,
|
|
70
|
+
"ontology": "dcat:mediaType"
|
|
71
|
+
},
|
|
72
|
+
"source_uri": {
|
|
73
|
+
"name": "source_uri",
|
|
74
|
+
"value": None, #"https://example.com/original/file.pdf",
|
|
75
|
+
"unit": None,
|
|
76
|
+
"dtype": "str",
|
|
77
|
+
"description": "The original URI (Uniform Resource Identifier) from which the asset originates (e.g., a URL to a website or file).",
|
|
78
|
+
"label": "Source URI",
|
|
79
|
+
"required": False,
|
|
80
|
+
"ontology": "dcterms:source"
|
|
81
|
+
},
|
|
82
|
+
"creation_date": {
|
|
83
|
+
"name": "creation_date",
|
|
84
|
+
"value": None, #"2025-10-16T12:00:00Z",
|
|
85
|
+
"unit": None,
|
|
86
|
+
"dtype": "datetime",
|
|
87
|
+
"description": "The creation date of the asset, to track the history.",
|
|
88
|
+
"label": "Creation Date",
|
|
89
|
+
"required": True,
|
|
90
|
+
"ontology": "dcterms:created"
|
|
91
|
+
},
|
|
92
|
+
"modified_date": {
|
|
93
|
+
"name": "modified_date",
|
|
94
|
+
"value": None, #"2025-10-16T14:30:00Z",
|
|
95
|
+
"unit": None,
|
|
96
|
+
"dtype": "datetime",
|
|
97
|
+
"description": "The date of the last modification, important for version control.",
|
|
98
|
+
"label": "Modified Date",
|
|
99
|
+
"required": False,
|
|
100
|
+
"ontology": "dcterms:modified"
|
|
101
|
+
},
|
|
102
|
+
"publisher": {
|
|
103
|
+
"name": "publisher",
|
|
104
|
+
"value": None, #"ISO",
|
|
105
|
+
"unit": None,
|
|
106
|
+
"dtype": "str",
|
|
107
|
+
"description": "The publisher or owner of the resource (e.g., an organization like ISO or a company).",
|
|
108
|
+
"label": "Publisher",
|
|
109
|
+
"required": False,
|
|
110
|
+
"ontology": "dcterms:publisher"
|
|
111
|
+
},
|
|
112
|
+
"license": {
|
|
113
|
+
"name": "license",
|
|
114
|
+
"value": None, #"Creative Commons BY-SA 4.0",
|
|
115
|
+
"unit": None,
|
|
116
|
+
"dtype": "str",
|
|
117
|
+
"description": "Information about the license (e.g., Creative Commons, proprietary), which regulates the usage rights.",
|
|
118
|
+
"label": "License",
|
|
119
|
+
"required": False,
|
|
120
|
+
"ontology": "dcterms:license"
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
content_type: Optional[ContentType] = 'bytes',
|
|
127
|
+
value: Optional[Any] = None, # bytes for 'bytes', str URI for 'uri'
|
|
128
|
+
filetype: Optional[str] = 'binary', # Default to generic binary, e.g., 'pdf', 'png', 'jpg'
|
|
129
|
+
**kwargs: Any
|
|
130
|
+
) -> None:
|
|
131
|
+
"""
|
|
132
|
+
Initialize Blob with content type, value, and filetype.
|
|
133
|
+
No content loading occurs here; loading is lazy via content_bytes property.
|
|
134
|
+
|
|
135
|
+
:param content_type: 'bytes' for in-memory bytes or 'uri' for filesystem URI (local, S3, Zip, etc.).
|
|
136
|
+
:param value: The content value - bytes object for 'bytes', URI string for 'uri' (e.g., '/local/path', 's3://bucket/key').
|
|
137
|
+
:param filetype: The type of the file (e.g., 'pdf', 'png', 'jpg', 'txt').
|
|
138
|
+
:param kwargs: Keyword arguments passed to Base.__init__ (e.g., name, description).
|
|
139
|
+
:raises ValueError: If invalid content_type or mismatched value type.
|
|
140
|
+
"""
|
|
141
|
+
super().__init__(**kwargs)
|
|
142
|
+
self._content_cache = None # nicht-serialisierter Lazy-Cache (nicht in self.data)
|
|
143
|
+
self.metadata.update_from_dict(self.DEFAULT_METADATA)
|
|
144
|
+
|
|
145
|
+
if content_type not in typing.get_args(self.ContentType):
|
|
146
|
+
raise ValueError(f"Invalid content_type: {content_type}")
|
|
147
|
+
|
|
148
|
+
self.data['content'] = {
|
|
149
|
+
'type': content_type,
|
|
150
|
+
'filetype': filetype,
|
|
151
|
+
}
|
|
152
|
+
self._set_value(value)
|
|
153
|
+
self._autofill_metadata()
|
|
154
|
+
|
|
155
|
+
logger.debug(f"Created Blob '{self.sname}' with content_type '{content_type}' and filetype '{filetype}'")
|
|
156
|
+
|
|
157
|
+
def _autofill_metadata(self) -> None:
|
|
158
|
+
"""Fill derivable metadata: ``mime_type`` from ``filetype`` and
|
|
159
|
+
``creation_date`` from the reserved ``_sdata_ctime`` (no second timestamp).
|
|
160
|
+
Only sets values that can be derived; leaves the rest untouched.
|
|
161
|
+
"""
|
|
162
|
+
mime = mimetypes.guess_type(f"x.{self.filetype}")[0]
|
|
163
|
+
if mime:
|
|
164
|
+
self.metadata.set_attr("mime_type", mime)
|
|
165
|
+
ctime = self.metadata.get(self.SDATA_CTIME)
|
|
166
|
+
if ctime is not None and ctime.value:
|
|
167
|
+
self.metadata.set_attr("creation_date", ctime.value)
|
|
168
|
+
|
|
169
|
+
def _set_value(self, value: Any) -> None:
|
|
170
|
+
"""
|
|
171
|
+
Set the 'value' in self.data['content'] based on content_type.
|
|
172
|
+
For 'bytes', store as base64-encoded string; for 'uri', store as str.
|
|
173
|
+
Handle value=None explicitly to avoid errors during deserialization.
|
|
174
|
+
"""
|
|
175
|
+
content = self.data['content']
|
|
176
|
+
ctype = content['type']
|
|
177
|
+
|
|
178
|
+
if value is None:
|
|
179
|
+
content['value'] = None
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
if ctype == 'bytes':
|
|
183
|
+
if not isinstance(value, bytes):
|
|
184
|
+
raise ValueError("For 'bytes' type, value must be a bytes object.")
|
|
185
|
+
content['value'] = base64.b64encode(value).decode('utf-8')
|
|
186
|
+
elif ctype == 'uri':
|
|
187
|
+
if not isinstance(value, str):
|
|
188
|
+
raise ValueError("For 'uri' type, value must be a string URI.")
|
|
189
|
+
content['value'] = value
|
|
190
|
+
else:
|
|
191
|
+
raise ValueError(f"Unknown content_type: {ctype}")
|
|
192
|
+
|
|
193
|
+
def set_content(
|
|
194
|
+
self,
|
|
195
|
+
content_type: ContentType,
|
|
196
|
+
value: Any,
|
|
197
|
+
filetype: Optional[str] = None
|
|
198
|
+
) -> None:
|
|
199
|
+
"""
|
|
200
|
+
Update the content type, value, and optionally filetype.
|
|
201
|
+
Clears any cached content to maintain lazy loading.
|
|
202
|
+
|
|
203
|
+
:param content_type: New content_type ('bytes' or 'uri').
|
|
204
|
+
:param value: New value (bytes or URI str).
|
|
205
|
+
:param filetype: Optional new filetype (e.g., 'pdf', 'png', 'jpg').
|
|
206
|
+
"""
|
|
207
|
+
self.data['content']['type'] = content_type
|
|
208
|
+
if filetype is not None:
|
|
209
|
+
self.data['content']['filetype'] = filetype
|
|
210
|
+
self._set_value(value)
|
|
211
|
+
self._content_cache = None # Clear cache for lazy reloading
|
|
212
|
+
self._autofill_metadata()
|
|
213
|
+
logger.debug(
|
|
214
|
+
f"Updated Blob '{self.sname}' to content_type '{content_type}' and filetype '{self.data['content']['filetype']}'")
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def content_bytes(self) -> bytes:
|
|
218
|
+
"""
|
|
219
|
+
Lazily load and retrieve the content as bytes (only when this property is accessed).
|
|
220
|
+
If type is 'uri', use fsspec to open and read; if 'bytes', decode from base64.
|
|
221
|
+
Caches the result for subsequent accesses.
|
|
222
|
+
|
|
223
|
+
:return: The content as bytes.
|
|
224
|
+
:raises ValueError: If loading fails or no value set.
|
|
225
|
+
:raises Exception: If fsspec encounters an error (e.g., invalid URI, missing dependencies like s3fs for S3).
|
|
226
|
+
"""
|
|
227
|
+
if getattr(self, '_content_cache', None) is not None:
|
|
228
|
+
return self._content_cache
|
|
229
|
+
|
|
230
|
+
content = self.data.get('content')
|
|
231
|
+
if content is None:
|
|
232
|
+
raise ValueError("No content set in Blob.")
|
|
233
|
+
|
|
234
|
+
ctype = content.get('type')
|
|
235
|
+
val = content.get('value')
|
|
236
|
+
if val is None:
|
|
237
|
+
raise ValueError("No value set in content.")
|
|
238
|
+
|
|
239
|
+
if ctype == 'bytes':
|
|
240
|
+
loaded_bytes = base64.b64decode(val)
|
|
241
|
+
elif ctype == 'uri':
|
|
242
|
+
if fsspec is None:
|
|
243
|
+
raise ImportError("fsspec is required for URI loading.")
|
|
244
|
+
try:
|
|
245
|
+
with fsspec.open(val, 'rb') as f:
|
|
246
|
+
loaded_bytes = f.read()
|
|
247
|
+
except Exception as e:
|
|
248
|
+
raise ValueError(f"Failed to load from URI '{val}': {str(e)}")
|
|
249
|
+
else:
|
|
250
|
+
raise ValueError(f"Unknown content_type: {ctype}")
|
|
251
|
+
|
|
252
|
+
self._content_cache = loaded_bytes # Cache the loaded bytes (instance, not serialized)
|
|
253
|
+
return loaded_bytes
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def filetype(self) -> str:
|
|
257
|
+
"""
|
|
258
|
+
Retrieve the filetype (no content loading required).
|
|
259
|
+
|
|
260
|
+
:return: The filetype string (e.g., 'pdf', 'png', 'jpg').
|
|
261
|
+
"""
|
|
262
|
+
return self.data['content'].get('filetype', 'binary')
|
|
263
|
+
|
|
264
|
+
def exists(self) -> bool:
|
|
265
|
+
"""
|
|
266
|
+
Test whether the blob content exists.
|
|
267
|
+
For 'uri', checks if the path/URI is accessible via fsspec; for 'bytes', always True if value set.
|
|
268
|
+
"""
|
|
269
|
+
content = self.data.get('content')
|
|
270
|
+
if content is None:
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
ctype = content.get('type')
|
|
274
|
+
val = content.get('value')
|
|
275
|
+
if val is None:
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
if ctype == 'bytes':
|
|
279
|
+
return True # In-memory, assumes exists if set
|
|
280
|
+
elif ctype == 'uri':
|
|
281
|
+
if fsspec is None:
|
|
282
|
+
logger.warning('fsspec not installed')
|
|
283
|
+
return False
|
|
284
|
+
try:
|
|
285
|
+
fs, _, paths = fsspec.core.get_fs_token_paths(val)
|
|
286
|
+
return fs.exists(paths[0])
|
|
287
|
+
except Exception as e:
|
|
288
|
+
logger.warning(f"Failed to check existence for URI '{val}': {str(e)}")
|
|
289
|
+
return False
|
|
290
|
+
else:
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
def write(self, uri: str, **kwargs: Any) -> str:
|
|
294
|
+
"""Write the content to a destination ``uri`` via fsspec (local/S3/zip/…).
|
|
295
|
+
|
|
296
|
+
:param uri: destination URI/path (e.g. ``/out/file.pdf``, ``s3://bucket/key``).
|
|
297
|
+
:param kwargs: forwarded to ``fsspec.open``.
|
|
298
|
+
:return: the destination ``uri``.
|
|
299
|
+
:raises ImportError: if fsspec is not installed (``pip install sdata[blob]``).
|
|
300
|
+
"""
|
|
301
|
+
if fsspec is None:
|
|
302
|
+
raise ImportError("fsspec is required for write() (pip install sdata[blob]).")
|
|
303
|
+
data = self.content_bytes
|
|
304
|
+
with fsspec.open(uri, "wb", **kwargs) as f:
|
|
305
|
+
f.write(data)
|
|
306
|
+
logger.info(f"Blob content written to {uri}")
|
|
307
|
+
return uri
|
|
308
|
+
|
|
309
|
+
def open(self, mode: str = "rb"):
|
|
310
|
+
"""Return a file-like handle to the content; use as a context manager.
|
|
311
|
+
|
|
312
|
+
For ``uri`` content a **streaming** fsspec handle is returned (no full
|
|
313
|
+
in-memory load); for ``bytes`` content an :class:`io.BytesIO` over the
|
|
314
|
+
decoded bytes.
|
|
315
|
+
|
|
316
|
+
:param mode: open mode (default ``"rb"``).
|
|
317
|
+
:raises ValueError: if no content/value is set or the content_type is unknown.
|
|
318
|
+
:raises ImportError: if fsspec is required (uri) but not installed.
|
|
319
|
+
"""
|
|
320
|
+
content = self.data.get('content')
|
|
321
|
+
if content is None or content.get('value') is None:
|
|
322
|
+
raise ValueError("No content set in Blob.")
|
|
323
|
+
ctype = content['type']
|
|
324
|
+
if ctype == 'uri':
|
|
325
|
+
if fsspec is None:
|
|
326
|
+
raise ImportError("fsspec is required for uri open() (pip install sdata[blob]).")
|
|
327
|
+
return fsspec.open(content['value'], mode)
|
|
328
|
+
elif ctype == 'bytes':
|
|
329
|
+
return io.BytesIO(self.content_bytes)
|
|
330
|
+
else:
|
|
331
|
+
raise ValueError(f"Unknown content_type: {ctype}")
|
|
332
|
+
|
|
333
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
334
|
+
"""
|
|
335
|
+
Extend Base.to_dict to include the content dict as-is (with base64 for bytes and filetype).
|
|
336
|
+
Does not include or load the actual content bytes.
|
|
337
|
+
"""
|
|
338
|
+
data_copy = self.data.copy()
|
|
339
|
+
result = super().to_dict()
|
|
340
|
+
result['data'] = data_copy
|
|
341
|
+
return result
|
|
342
|
+
|
|
343
|
+
@classmethod
|
|
344
|
+
def from_dict(cls, d: Dict[str, Any]) -> 'Blob':
|
|
345
|
+
"""
|
|
346
|
+
Create a Blob instance from a dictionary.
|
|
347
|
+
Restores content dict including filetype; no content loading occurs here (lazy via content_bytes).
|
|
348
|
+
"""
|
|
349
|
+
instance = super().from_dict(d)
|
|
350
|
+
if 'content' not in instance.data:
|
|
351
|
+
instance.data['content'] = {'type': 'bytes', 'filetype': 'binary'}
|
|
352
|
+
# Validate content structure
|
|
353
|
+
content = instance.data['content']
|
|
354
|
+
if 'type' not in content or 'filetype' not in content:
|
|
355
|
+
raise ValueError("Invalid content structure in dict.")
|
|
356
|
+
if content['type'] == 'bytes' and 'value' in content:
|
|
357
|
+
# Ensure value is base64 string; no need to decode here
|
|
358
|
+
pass
|
|
359
|
+
elif content['type'] == 'uri' and 'value' in content:
|
|
360
|
+
if not isinstance(content['value'], str):
|
|
361
|
+
raise ValueError("URI value must be a string.")
|
|
362
|
+
return instance
|
sdata/sclass/content.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Wiederverwendbarer Integritäts-/Hash-Layer (RFC 0004, Option B).
|
|
3
|
+
|
|
4
|
+
:class:`ContentIntegrityMixin` stellt Prüfsummen (``sha1``/``md5``/``sha256``),
|
|
5
|
+
``size`` sowie ``update_checksum``/``verify`` über einen ``content_bytes``-Hook und
|
|
6
|
+
das ``metadata``-Objekt (aus :class:`~sdata.base.Base`) bereit. Genutzt von
|
|
7
|
+
:class:`~sdata.sclass.blob.Blob` und :class:`~sdata.sclass.dataframe.DataFrame`.
|
|
8
|
+
"""
|
|
9
|
+
import io
|
|
10
|
+
import hashlib
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ContentIntegrityMixin:
|
|
18
|
+
"""Hash/``verify``/``size`` über ``self.content_bytes`` + ``self.metadata``.
|
|
19
|
+
|
|
20
|
+
Subklassen liefern eine ``content_bytes``-Property (``bytes``) und besitzen
|
|
21
|
+
(über :class:`~sdata.base.Base`) ein ``metadata``-Objekt.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def _update_hash(self, hash_obj: Any, buffer_size: int = 65536) -> None:
|
|
25
|
+
"""Speise den Hash-Objekt-Stream aus ``content_bytes`` (chunked)."""
|
|
26
|
+
bytes_io = io.BytesIO(self.content_bytes)
|
|
27
|
+
while True:
|
|
28
|
+
data = bytes_io.read(buffer_size)
|
|
29
|
+
if not data:
|
|
30
|
+
break
|
|
31
|
+
hash_obj.update(data)
|
|
32
|
+
|
|
33
|
+
def _content_hexdigest(self, algo) -> Optional[str]:
|
|
34
|
+
"""Hex-Digest des Inhalts mit ``algo`` (z. B. ``hashlib.sha256``); ``None`` bei Fehler."""
|
|
35
|
+
try:
|
|
36
|
+
hash_obj = algo()
|
|
37
|
+
self._update_hash(hash_obj)
|
|
38
|
+
return hash_obj.hexdigest()
|
|
39
|
+
except Exception as exp:
|
|
40
|
+
logger.error(f"Failed to compute {algo().name}: {exp}")
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def sha1(self) -> Optional[str]:
|
|
45
|
+
"""SHA-1 hex digest of the content (lazy; ``None`` on error)."""
|
|
46
|
+
return self._content_hexdigest(hashlib.sha1)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def md5(self) -> Optional[str]:
|
|
50
|
+
"""MD5 hex digest of the content (lazy; ``None`` on error)."""
|
|
51
|
+
return self._content_hexdigest(hashlib.md5)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def sha256(self) -> Optional[str]:
|
|
55
|
+
"""SHA-256 hex digest of the content (lazy; ``None`` on error)."""
|
|
56
|
+
return self._content_hexdigest(hashlib.sha256)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def size(self) -> Optional[int]:
|
|
60
|
+
"""Size of the content in bytes (lazy; ``None`` on error)."""
|
|
61
|
+
try:
|
|
62
|
+
return len(self.content_bytes)
|
|
63
|
+
except Exception as exp:
|
|
64
|
+
logger.error(f"Failed to determine size: {exp}")
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
def update_checksum(self) -> Optional[str]:
|
|
68
|
+
"""Store the SHA-256 of the content in the ``checksum`` metadata (``schema:sha256``).
|
|
69
|
+
|
|
70
|
+
:return: the stored SHA-256 hex digest (``None`` if the content is unavailable).
|
|
71
|
+
"""
|
|
72
|
+
digest = self.sha256
|
|
73
|
+
self.metadata.set_attr("checksum", digest)
|
|
74
|
+
return digest
|
|
75
|
+
|
|
76
|
+
def verify(self) -> bool:
|
|
77
|
+
"""Verify the content against the stored ``checksum`` (SHA-256) metadata.
|
|
78
|
+
|
|
79
|
+
:return: ``True`` iff a checksum is stored and matches the current content;
|
|
80
|
+
``False`` on mismatch or when no checksum has been stored yet.
|
|
81
|
+
"""
|
|
82
|
+
attr = self.metadata.get("checksum")
|
|
83
|
+
stored = attr.value if attr is not None else None
|
|
84
|
+
if not stored:
|
|
85
|
+
logger.warning("verify: no checksum stored (call update_checksum first)")
|
|
86
|
+
return False
|
|
87
|
+
return stored == self.sha256
|