sdata 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sdata/__init__.py +140 -0
- sdata/base.py +684 -0
- sdata/contrib/__init__.py +0 -0
- sdata/contrib/piexif/__init__.py +12 -0
- sdata/contrib/piexif/_common.py +94 -0
- sdata/contrib/piexif/_dump.py +346 -0
- sdata/contrib/piexif/_exceptions.py +2 -0
- sdata/contrib/piexif/_exif.py +638 -0
- sdata/contrib/piexif/_insert.py +60 -0
- sdata/contrib/piexif/_load.py +270 -0
- sdata/contrib/piexif/_remove.py +56 -0
- sdata/contrib/piexif/_transplant.py +45 -0
- sdata/contrib/piexif/_webp.py +240 -0
- sdata/contrib/piexif/helper.py +66 -0
- sdata/contrib/ranger/__init__.py +8 -0
- sdata/contrib/ranger/collections/__init__.py +0 -0
- sdata/contrib/ranger/collections/rangebucketmap.py +346 -0
- sdata/contrib/ranger/collections/rangemap.py +396 -0
- sdata/contrib/ranger/collections/rangeset.py +483 -0
- sdata/contrib/ranger/range/__init__.py +0 -0
- sdata/contrib/ranger/range/cut.py +285 -0
- sdata/contrib/ranger/range/range.py +644 -0
- sdata/contrib/sobol_seq.py +394 -0
- sdata/contrib/sortedcontainers/LICENSE +13 -0
- sdata/contrib/sortedcontainers/__init__.py +74 -0
- sdata/contrib/sortedcontainers/sorteddict.py +789 -0
- sdata/contrib/sortedcontainers/sortedlist.py +2646 -0
- sdata/contrib/sortedcontainers/sortedset.py +733 -0
- sdata/contrib/sqlitedict.py +598 -0
- sdata/contrib/timeflake/__init__.py +40 -0
- sdata/contrib/timeflake/flake.py +69 -0
- sdata/contrib/timeflake/utils.py +47 -0
- sdata/deprecated/__init__.py +10 -0
- sdata/deprecated/data.py +1503 -0
- sdata/did/__init__.py +129 -0
- sdata/did/_http.py +67 -0
- sdata/did/base58btc.py +49 -0
- sdata/did/did_github.py +152 -0
- sdata/did/did_peer4.py +155 -0
- sdata/did/did_web.py +222 -0
- sdata/did/diddoc.py +75 -0
- sdata/did/ed25519.py +225 -0
- sdata/did/eddsa.py +107 -0
- sdata/did/errors.py +40 -0
- sdata/did/issue_vc.py +193 -0
- sdata/did/jose.py +126 -0
- sdata/did/keys.py +104 -0
- sdata/did/utils_didkey.py +161 -0
- sdata/did/verify_vp.py +158 -0
- sdata/doe.py +101 -0
- sdata/dtypes.py +510 -0
- sdata/experiments/__init__.py +331 -0
- sdata/experiments/blob0.py +131 -0
- sdata/experiments/file.py +42 -0
- sdata/experiments/filegroup.py +144 -0
- sdata/experiments/image_old.py +313 -0
- sdata/experiments/ks2.py +15 -0
- sdata/experiments/lapshear.py +8 -0
- sdata/experiments/material.py +7 -0
- sdata/experiments/parameters_deprecated.py +394 -0
- sdata/experiments/workbook.py +226 -0
- sdata/format.py +125 -0
- sdata/imagemeta.py +533 -0
- sdata/interactive.py +118 -0
- sdata/iolib/__init__.py +51 -0
- sdata/iolib/hashtree.py +264 -0
- sdata/iolib/json1sqlitestore.py +554 -0
- sdata/iolib/jsonsqlitestore.py +704 -0
- sdata/iolib/pud.py +242 -0
- sdata/iolib/reader.py +262 -0
- sdata/iolib/writer.py +419 -0
- sdata/metadata.py +882 -0
- sdata/parameter.py +414 -0
- sdata/schema.py +273 -0
- sdata/sclass/__init__.py +111 -0
- sdata/sclass/bfo_classes.py +362 -0
- sdata/sclass/blob.py +362 -0
- sdata/sclass/content.py +87 -0
- sdata/sclass/dataframe.py +1251 -0
- sdata/sclass/dataframegroup.py +187 -0
- sdata/sclass/dependency_graph.py +85 -0
- sdata/sclass/filereference.py +124 -0
- sdata/sclass/image.py +218 -0
- sdata/sclass/lazy_namespace.py +155 -0
- sdata/sclass/process.py +383 -0
- sdata/sclass/process_graph.py +221 -0
- sdata/sclass/zipfile.py +135 -0
- sdata/semantic.py +346 -0
- sdata/suuid.py +136 -0
- sdata/timestamp.py +336 -0
- sdata/tools.py +14 -0
- sdata/units.py +602 -0
- sdata/vocab.py +149 -0
- sdata-1.5.0.dist-info/METADATA +264 -0
- sdata-1.5.0.dist-info/RECORD +98 -0
- sdata-1.5.0.dist-info/WHEEL +5 -0
- sdata-1.5.0.dist-info/licenses/LICENSE-MIT +21 -0
- sdata-1.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Dict, Type, List, Union, Iterable, TypeVar, Hashable, Set
|
|
3
|
+
|
|
4
|
+
T = TypeVar('T', bound=Hashable)
|
|
5
|
+
|
|
6
|
+
from sdata.sclass.process import ProcessNode, CompositeProcess
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import graphviz
|
|
11
|
+
except ImportError as e:
|
|
12
|
+
logging.warning("graphviz not available.")
|
|
13
|
+
graphviz = None
|
|
14
|
+
|
|
15
|
+
class CircularDependencyError(ValueError):
|
|
16
|
+
"""Exception raised when circular dependencies are detected during topological sort."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, remaining_graph: Dict[T, Set[T]]):
|
|
19
|
+
message = (
|
|
20
|
+
"Circular dependencies exist among these items: "
|
|
21
|
+
f"{{{', '.join(f'{key}: {sorted(value)}' for key, value in sorted(remaining_graph.items()))}}}"
|
|
22
|
+
)
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.remaining_graph = remaining_graph
|
|
25
|
+
|
|
26
|
+
class ProcessGraph:
|
|
27
|
+
def __init__(self, processes: List[ProcessNode] = None, name: str = "ProcessGraph"):
|
|
28
|
+
self.name = name
|
|
29
|
+
self.data_nodes = set()
|
|
30
|
+
self.process_nodes = set()
|
|
31
|
+
self.edges = []
|
|
32
|
+
self.class_map: Dict[str, Type[ProcessData]] = {}
|
|
33
|
+
self.process_class_map: Dict[str, Union[Type[ProcessNode], Type[CompositeProcess]]] = {}
|
|
34
|
+
if processes is None:
|
|
35
|
+
processes = []
|
|
36
|
+
self.processes: List[ProcessNode] = processes
|
|
37
|
+
for process in self.processes:
|
|
38
|
+
self.add_process(process)
|
|
39
|
+
|
|
40
|
+
def get_dependencies(self):
|
|
41
|
+
dependencies = {}
|
|
42
|
+
for process in self.processes:
|
|
43
|
+
d = process.get_dependencies()
|
|
44
|
+
dependencies.update(d)
|
|
45
|
+
return dependencies
|
|
46
|
+
|
|
47
|
+
def get_subprocess_graph(self):
|
|
48
|
+
sg = self.__class__(name="subprocessgraph")
|
|
49
|
+
for p in self.processes:
|
|
50
|
+
for sp in p.processes:
|
|
51
|
+
sg.add_process(sp)
|
|
52
|
+
return sg
|
|
53
|
+
|
|
54
|
+
def get_dag(self, flatten=False, sort: bool = True):
|
|
55
|
+
dag = self.toposort(self.get_dependencies())
|
|
56
|
+
if flatten:
|
|
57
|
+
dag_flat: List[T] = []
|
|
58
|
+
for level in dag:
|
|
59
|
+
dag_flat.extend(sorted(level) if sort else list(level))
|
|
60
|
+
return dag_flat
|
|
61
|
+
else:
|
|
62
|
+
return dag
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def __str__(self):
|
|
66
|
+
return f"<{self.__class__.__name__}:proc={len(self.process_nodes)}:data={len(self.data_nodes)}>"
|
|
67
|
+
|
|
68
|
+
__repr__ = __str__
|
|
69
|
+
|
|
70
|
+
def add_process(self, process_class: Union[Type[ProcessNode], ]):
|
|
71
|
+
self.proc_name = proc_name = process_class.__name__ if hasattr(process_class, '__name__') else process_class.name
|
|
72
|
+
self.process_nodes.add(proc_name)
|
|
73
|
+
self.process_class_map[proc_name] = process_class
|
|
74
|
+
|
|
75
|
+
self.processes.append(process_class)
|
|
76
|
+
|
|
77
|
+
# Instantiate a dummy to get attributes, as composites set them in __init__
|
|
78
|
+
dummy = process_class(inputs=None) # Use inputs=None for dummy
|
|
79
|
+
self.input_classes = input_classes = dummy.input_classes
|
|
80
|
+
self.output_classes = output_classes = dummy.output_classes
|
|
81
|
+
# computation = dummy.computation # Not needed here, but for consistency
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
for input_name, input_class in input_classes.items():
|
|
85
|
+
self.data_nodes.add(input_name)
|
|
86
|
+
self.class_map[input_name] = input_class
|
|
87
|
+
self.edges.append((input_name, proc_name))
|
|
88
|
+
|
|
89
|
+
for output_name, output_class in output_classes.items():
|
|
90
|
+
self.data_nodes.add(output_name)
|
|
91
|
+
self.class_map[output_name] = output_class
|
|
92
|
+
self.edges.append((proc_name, output_name))
|
|
93
|
+
|
|
94
|
+
def update_class_map(self):
|
|
95
|
+
self.data_nodes = set()
|
|
96
|
+
self.process_nodes = set()
|
|
97
|
+
self.edges = []
|
|
98
|
+
self.class_map: Dict[str, Type[ProcessData]] = {}
|
|
99
|
+
|
|
100
|
+
for input_name, input_class in self.input_classes.items():
|
|
101
|
+
self.data_nodes.add(input_name)
|
|
102
|
+
self.class_map[input_name] = input_class
|
|
103
|
+
self.edges.append((input_name, self.proc_name))
|
|
104
|
+
|
|
105
|
+
for output_name, output_class in self.output_classes.items():
|
|
106
|
+
self.data_nodes.add(output_name)
|
|
107
|
+
self.class_map[output_name] = output_class
|
|
108
|
+
self.edges.append((self.proc_name, output_name))
|
|
109
|
+
|
|
110
|
+
def to_graphviz(self, name: str = None, rankdir="TD", collapsed=True):
|
|
111
|
+
if name is None:
|
|
112
|
+
name = self.name
|
|
113
|
+
dot = graphviz.Digraph(name=name, graph_attr={'rankdir': rankdir})
|
|
114
|
+
|
|
115
|
+
for data in self.data_nodes:
|
|
116
|
+
data_cls = self.class_map[data]
|
|
117
|
+
label = data_cls.__name__
|
|
118
|
+
dot.node(data, shape='box', label=label)
|
|
119
|
+
|
|
120
|
+
for proc in self.process_nodes:
|
|
121
|
+
proc_cls = self.process_class_map[proc]
|
|
122
|
+
# Instantiate dummy to get computation
|
|
123
|
+
label = proc_cls.__name__
|
|
124
|
+
dot.node(proc, shape='component', label=label, color="lightblue", style="filled")
|
|
125
|
+
|
|
126
|
+
for src, dst in self.edges:
|
|
127
|
+
dot.edge(src, dst)
|
|
128
|
+
|
|
129
|
+
return dot
|
|
130
|
+
|
|
131
|
+
def to_graphviz_cluster(self, name: str = None, rankdir="TD", collapsed=True):
|
|
132
|
+
|
|
133
|
+
g = graphviz.Digraph(name=name, graph_attr={'rankdir': rankdir})
|
|
134
|
+
|
|
135
|
+
sg = self.get_subprocess_graph()
|
|
136
|
+
|
|
137
|
+
# Cluster-Subgraph als Box erstellen
|
|
138
|
+
with g.subgraph(name='cluster_use_eol') as cluster:
|
|
139
|
+
# Attribute für die Box setzen
|
|
140
|
+
cluster.attr(label=self.name, style='filled', color='black', peripheries='1', penwidth='1.5',
|
|
141
|
+
fillcolor='#efffff', labelloc="t", labeljust="l")
|
|
142
|
+
|
|
143
|
+
for data in sg.data_nodes:
|
|
144
|
+
if data not in self.data_nodes:
|
|
145
|
+
data_cls = sg.class_map[data]
|
|
146
|
+
label = data_cls.__name__
|
|
147
|
+
cluster.node(data, shape='box', label=label)
|
|
148
|
+
|
|
149
|
+
for proc in sg.process_nodes:
|
|
150
|
+
proc_cls = sg.process_class_map[proc]
|
|
151
|
+
# Instantiate dummy to get computation
|
|
152
|
+
label = proc_cls.__name__
|
|
153
|
+
cluster.node(proc, shape='component', label=label, color="lightblue", style="filled")
|
|
154
|
+
|
|
155
|
+
for data in self.data_nodes:
|
|
156
|
+
data_cls = self.class_map[data]
|
|
157
|
+
label = data_cls.__name__
|
|
158
|
+
g.node(data, shape='box', label=label)
|
|
159
|
+
|
|
160
|
+
for src, dst in sg.edges:
|
|
161
|
+
if src not in self.input_classes and src not in self.output_classes and dst not in self.input_classes and dst not in self.output_classes:
|
|
162
|
+
cluster.edge(src, dst)
|
|
163
|
+
else:
|
|
164
|
+
g.edge(src, dst)
|
|
165
|
+
|
|
166
|
+
return g
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def toposort(graph: Dict[T, Iterable[T]]) -> Iterable[Set[T]]:
|
|
170
|
+
"""Perform a topological sort on a directed acyclic graph (DAG).
|
|
171
|
+
|
|
172
|
+
Dependencies are expressed as a dictionary where keys are nodes and values are
|
|
173
|
+
iterables of dependent nodes (nodes that the key depends on). The output is a
|
|
174
|
+
generator yielding sets of nodes in topological order. The first set contains
|
|
175
|
+
nodes with no dependencies, and each subsequent set contains nodes that depend
|
|
176
|
+
only on nodes in preceding sets.
|
|
177
|
+
|
|
178
|
+
If the graph contains cycles, a CircularDependencyError is raised.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
graph: A dictionary representing the dependency graph.
|
|
182
|
+
|
|
183
|
+
Yields:
|
|
184
|
+
Sets of nodes in topological order.
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
CircularDependencyError: If a cycle is detected in the graph.
|
|
188
|
+
"""
|
|
189
|
+
if not graph:
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
# Create a copy of the graph, converting dependencies to sets and removing self-dependencies.
|
|
193
|
+
graph = {node: set(dep for dep in deps if dep != node) for node, deps in graph.items()}
|
|
194
|
+
|
|
195
|
+
# Identify nodes that appear only as dependencies (no outgoing edges defined).
|
|
196
|
+
extra_nodes = {dep for deps in graph.values() for dep in deps} - set(graph)
|
|
197
|
+
graph.update({node: set() for node in extra_nodes})
|
|
198
|
+
|
|
199
|
+
while True:
|
|
200
|
+
# Find nodes with no remaining dependencies.
|
|
201
|
+
independent_nodes = {node for node, deps in graph.items() if not deps}
|
|
202
|
+
if not independent_nodes:
|
|
203
|
+
break
|
|
204
|
+
yield independent_nodes
|
|
205
|
+
# Remove processed nodes from the graph.
|
|
206
|
+
graph = {
|
|
207
|
+
node: (deps - independent_nodes)
|
|
208
|
+
for node, deps in graph.items()
|
|
209
|
+
if node not in independent_nodes
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if graph:
|
|
213
|
+
raise CircularDependencyError(graph)
|
|
214
|
+
|
|
215
|
+
def get_process_graph(processes: List[Type[ProcessNode]]) -> ProcessGraph:
|
|
216
|
+
g = ProcessGraph()
|
|
217
|
+
for process in processes:
|
|
218
|
+
g.add_process(process)
|
|
219
|
+
dot_graph = g.to_graphviz(name="CombinedProcess", rankdir="TD", collapsed=True)
|
|
220
|
+
return dot_graph
|
|
221
|
+
|
sdata/sclass/zipfile.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import io
|
|
3
|
+
import os
|
|
4
|
+
import base64
|
|
5
|
+
from typing import List, Dict, Any, Optional, Type, Literal, Union, Tuple, BinaryIO, Iterable
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from sdata import SUUID
|
|
9
|
+
from sdata.metadata import Metadata, Attribute
|
|
10
|
+
# from sdata.sclass.blob import Blob # Assuming Blob is in sdata.blob or adjust import
|
|
11
|
+
from sdata.base import Base
|
|
12
|
+
import hashlib
|
|
13
|
+
import datetime
|
|
14
|
+
from sdata.timestamp import get_utc_timestamp
|
|
15
|
+
import zipfile
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
from sdata.sclass.filereference import FileReference, FileReferences
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ZipFile(Base):
|
|
22
|
+
SDATA_CLS = "sdata.sclass.zipfile.ZipFile"
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
**kwargs: Any
|
|
27
|
+
) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Initialize FileReferences with content type, value, and filetype.
|
|
30
|
+
:param kwargs: Keyword arguments passed to Base.__init__ (e.g., name, description).
|
|
31
|
+
:raises ValueError: If invalid content_type or mismatched value type.
|
|
32
|
+
"""
|
|
33
|
+
super().__init__(**kwargs)
|
|
34
|
+
self.filereferences = {}
|
|
35
|
+
|
|
36
|
+
def add(self, filepath) -> None:
|
|
37
|
+
filereference = FileReference.from_file(filepath)
|
|
38
|
+
self.filereferences[filereference.sname] = filereference
|
|
39
|
+
|
|
40
|
+
def get_filereferences(self) -> Iterable[FileReference]:
|
|
41
|
+
return self.filereferences.values()
|
|
42
|
+
|
|
43
|
+
def get_filereferences_df(self) -> pd.DataFrame:
|
|
44
|
+
filereferences = []
|
|
45
|
+
for sname, filereference in self.filereferences.items():
|
|
46
|
+
filereferences.append(filereference.to_dataframe())
|
|
47
|
+
return pd.concat(filereferences)
|
|
48
|
+
|
|
49
|
+
def to_zip(
|
|
50
|
+
self,
|
|
51
|
+
filepath: Optional[Union[str, Path]] = None,
|
|
52
|
+
*,
|
|
53
|
+
compresslevel: int = 6,
|
|
54
|
+
deterministic: bool = True,
|
|
55
|
+
) -> io.BytesIO:
|
|
56
|
+
"""
|
|
57
|
+
Serialisiert das Objekt via to_json() und packt es als data.json in ein ZIP.
|
|
58
|
+
- Wenn 'filepath' gesetzt ist, wird die ZIP-Datei geschrieben.
|
|
59
|
+
- Rückgabe ist immer ein BytesIO, beginnend bei Position 0.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
buf = io.BytesIO()
|
|
64
|
+
compression = zipfile.ZIP_DEFLATED
|
|
65
|
+
|
|
66
|
+
# Optional: deterministische ZIPs (feste Timestamp -> reproduzierbar)
|
|
67
|
+
for filereference in self.filereferences.values():
|
|
68
|
+
arcname = f"{filereference.sname}.sjson"
|
|
69
|
+
json_str = filereference.to_json()
|
|
70
|
+
if deterministic:
|
|
71
|
+
info = zipfile.ZipInfo(arcname, date_time=(1980, 1, 1, 0, 0, 0))
|
|
72
|
+
info.compress_type = compression
|
|
73
|
+
with zipfile.ZipFile(buf, mode="w", compression=compression, compresslevel=compresslevel) as zf:
|
|
74
|
+
zf.writestr(info, json_str)
|
|
75
|
+
else:
|
|
76
|
+
with zipfile.ZipFile(buf, mode="w", compression=compression, compresslevel=compresslevel) as zf:
|
|
77
|
+
zf.writestr(arcname, json_str)
|
|
78
|
+
|
|
79
|
+
buf.seek(0)
|
|
80
|
+
|
|
81
|
+
if filepath is not None:
|
|
82
|
+
p = Path(filepath)
|
|
83
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
p.write_bytes(buf.getvalue())
|
|
85
|
+
buf.seek(0)
|
|
86
|
+
|
|
87
|
+
return buf
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_zip(
|
|
91
|
+
cls,
|
|
92
|
+
src: Union[str, Path, bytes, io.BytesIO, BinaryIO],
|
|
93
|
+
*,
|
|
94
|
+
member: Optional[str] = None,
|
|
95
|
+
encoding: str = "utf-8",
|
|
96
|
+
):
|
|
97
|
+
"""
|
|
98
|
+
Erzeugt eine Instanz aus einem ZIP, das eine JSON-Datei enthält.
|
|
99
|
+
'src' kann ein Dateipfad, Bytes, BytesIO oder beliebiger file-like Stream sein.
|
|
100
|
+
- 'member': Name des JSON-Eintrags im ZIP. Wenn None:
|
|
101
|
+
* nimm die einzige .json-Datei
|
|
102
|
+
* oder, wenn nur ein Eintrag existiert, nimm diesen
|
|
103
|
+
* sonst Fehler (Mehrdeutigkeit).
|
|
104
|
+
"""
|
|
105
|
+
def _open_zipfile(source):
|
|
106
|
+
# Gibt einen Kontextmanager zurück, der ein ZipFile liefert
|
|
107
|
+
if isinstance(source, (str, Path)):
|
|
108
|
+
return zipfile.ZipFile(source, mode="r")
|
|
109
|
+
if isinstance(source, bytes):
|
|
110
|
+
return zipfile.ZipFile(io.BytesIO(source), mode="r")
|
|
111
|
+
if isinstance(source, io.BytesIO):
|
|
112
|
+
source.seek(0)
|
|
113
|
+
return zipfile.ZipFile(source, mode="r")
|
|
114
|
+
# generischer file-like Stream
|
|
115
|
+
try:
|
|
116
|
+
source.seek(0)
|
|
117
|
+
except Exception: # pragma: no cover - defensiv; ZipFile braucht danach seek
|
|
118
|
+
pass
|
|
119
|
+
return zipfile.ZipFile(source, mode="r")
|
|
120
|
+
|
|
121
|
+
with _open_zipfile(src) as zf:
|
|
122
|
+
names = zf.namelist()
|
|
123
|
+
chosen = member
|
|
124
|
+
if chosen is None:
|
|
125
|
+
json_members = [n for n in names if n.lower().endswith(".sjson")]
|
|
126
|
+
if len(json_members) == 1:
|
|
127
|
+
chosen = json_members[0]
|
|
128
|
+
elif len(json_members) == 0 and len(names) == 1:
|
|
129
|
+
chosen = names[0]
|
|
130
|
+
else:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"ZIP enthält {len(names)} Einträge ({names}). Bitte 'member' angeben."
|
|
133
|
+
)
|
|
134
|
+
data = zf.read(chosen).decode(encoding)
|
|
135
|
+
return cls.from_json(data)
|