aind-metadata-reader 0.2.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.
- aind_metadata_reader/__init__.py +63 -0
- aind_metadata_reader/_enum.py +10 -0
- aind_metadata_reader/_wire.py +84 -0
- aind_metadata_reader/applicability.py +132 -0
- aind_metadata_reader/components.py +125 -0
- aind_metadata_reader/diagnostics.py +38 -0
- aind_metadata_reader/document.py +399 -0
- aind_metadata_reader/formats.py +171 -0
- aind_metadata_reader/inspection.py +109 -0
- aind_metadata_reader/interpretation.py +478 -0
- aind_metadata_reader/py.typed +1 -0
- aind_metadata_reader/readers.py +541 -0
- aind_metadata_reader/smartspim.py +214 -0
- aind_metadata_reader/validation.py +89 -0
- aind_metadata_reader-0.2.0.dist-info/METADATA +396 -0
- aind_metadata_reader-0.2.0.dist-info/RECORD +18 -0
- aind_metadata_reader-0.2.0.dist-info/WHEEL +4 -0
- aind_metadata_reader-0.2.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
"""Partial semantic views never require metadata to satisfy an upstream schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from aind_metadata_reader._wire import canonical
|
|
10
|
+
from aind_metadata_reader.components import ArtifactReference, CodeView, references
|
|
11
|
+
from aind_metadata_reader.diagnostics import Diagnostic, FieldState, MetadataError
|
|
12
|
+
from aind_metadata_reader.document import Document, Field
|
|
13
|
+
from aind_metadata_reader.formats import ReaderSelection, select_reader
|
|
14
|
+
from aind_metadata_reader.inspection import (
|
|
15
|
+
ReadStatus,
|
|
16
|
+
ReadValue,
|
|
17
|
+
as_string,
|
|
18
|
+
children,
|
|
19
|
+
diagnostics,
|
|
20
|
+
issue,
|
|
21
|
+
object_view,
|
|
22
|
+
observe,
|
|
23
|
+
unavailable,
|
|
24
|
+
)
|
|
25
|
+
from aind_metadata_reader.validation import ValidationReport, validate
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _core(document: Document, name: str) -> ReadValue[Document]:
|
|
29
|
+
field = document.field("/" + name)
|
|
30
|
+
return object_view(document.field("") if field.state == FieldState.ABSENT else field)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _context(
|
|
34
|
+
document: Document, core: Literal["processing", "acquisition"], hint: str | None
|
|
35
|
+
) -> ReadValue[ReaderSelection]:
|
|
36
|
+
try:
|
|
37
|
+
return ReadValue(document.field(""), ReadStatus.AVAILABLE, select_reader(document, core, hint))
|
|
38
|
+
except (ValueError, TypeError) as error:
|
|
39
|
+
diagnostic = (
|
|
40
|
+
error.diagnostic
|
|
41
|
+
if isinstance(error, MetadataError)
|
|
42
|
+
else issue(document.field(""), "unsupported_format", str(error))
|
|
43
|
+
)
|
|
44
|
+
return ReadValue(document.field(""), ReadStatus.UNSUPPORTED, diagnostics=(diagnostic,))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_DIRECTIONS = {
|
|
48
|
+
"lefttoright": "R",
|
|
49
|
+
"leftright": "R",
|
|
50
|
+
"righttoleft": "L",
|
|
51
|
+
"rightleft": "L",
|
|
52
|
+
"anteriortoposterior": "P",
|
|
53
|
+
"anteriorposterior": "P",
|
|
54
|
+
"posteriortoanterior": "A",
|
|
55
|
+
"posterioranterior": "A",
|
|
56
|
+
"superiortoinferior": "I",
|
|
57
|
+
"superiorinferior": "I",
|
|
58
|
+
"inferiortosuperior": "S",
|
|
59
|
+
"inferiorsuperior": "S",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Axis:
|
|
65
|
+
"""An inspected axis can retain incomplete or unfamiliar recorded meanings."""
|
|
66
|
+
|
|
67
|
+
document: Document
|
|
68
|
+
name: str | None
|
|
69
|
+
position: int
|
|
70
|
+
direction: str | None
|
|
71
|
+
unit: str | None
|
|
72
|
+
dimension: int | None = None
|
|
73
|
+
field: Field | None = None
|
|
74
|
+
diagnostics: tuple[Diagnostic, ...] = ()
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def anatomical_direction(self) -> str | None:
|
|
78
|
+
"""Return recognized ITK direction letters; unfamiliar terms remain in direction."""
|
|
79
|
+
return _DIRECTIONS.get(re.sub(r"[^a-z]", "", (self.direction or "").lower()))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class CoordinateFrame:
|
|
84
|
+
"""One recorded frame and independent interpretations of its axes and orientation."""
|
|
85
|
+
|
|
86
|
+
document: Document
|
|
87
|
+
scope: Literal["legacy", "local", "global"]
|
|
88
|
+
name: str | None
|
|
89
|
+
axes: tuple[Axis, ...]
|
|
90
|
+
configuration: Document | None = None
|
|
91
|
+
axes_read: ReadValue[tuple[Axis, ...]] | None = None
|
|
92
|
+
diagnostics: tuple[Diagnostic, ...] = ()
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def orientation(self) -> ReadValue[str]:
|
|
96
|
+
"""Explain when recorded axes cannot establish a cardinal 3D orientation."""
|
|
97
|
+
letters = "".join(axis.anatomical_direction or "?" for axis in self.axes)
|
|
98
|
+
if len(letters) == 3 and all(sum(letter in pair for letter in letters) == 1 for pair in ("LR", "AP", "SI")):
|
|
99
|
+
return ReadValue(self.document.field("/axes"), ReadStatus.AVAILABLE, letters)
|
|
100
|
+
return unavailable(
|
|
101
|
+
self.document.field("/axes"),
|
|
102
|
+
ReadStatus.UNSUPPORTED,
|
|
103
|
+
"Axes do not establish a cardinal 3D orientation",
|
|
104
|
+
"unsupported_geometry",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def anatomical_orientation(self) -> str | None:
|
|
109
|
+
"""Inspect orientation without making a valid cardinal frame a reading prerequisite."""
|
|
110
|
+
return self.orientation.value
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _dimension(value: Any) -> int:
|
|
114
|
+
if isinstance(value, bool) or not (
|
|
115
|
+
isinstance(value, int) or isinstance(value, str) and value.isascii() and value.isdigit()
|
|
116
|
+
):
|
|
117
|
+
raise MetadataError("Array dimension must be a nonnegative integer")
|
|
118
|
+
number = int(value)
|
|
119
|
+
if number < 0:
|
|
120
|
+
raise MetadataError("Array dimension must be nonnegative")
|
|
121
|
+
return number
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _axes(document: Document, legacy: bool) -> ReadValue[tuple[Axis, ...]]:
|
|
125
|
+
entries = children(document, "/axes")
|
|
126
|
+
if entries.status != ReadStatus.AVAILABLE:
|
|
127
|
+
return ReadValue(entries.field, entries.status, diagnostics=entries.diagnostics)
|
|
128
|
+
axes = []
|
|
129
|
+
issues = list(entries.diagnostics)
|
|
130
|
+
for i, entry in enumerate(entries.value or ()):
|
|
131
|
+
if entry.value is None:
|
|
132
|
+
axes.append(Axis(document, None, i, None, None, field=entry.field, diagnostics=entry.diagnostics))
|
|
133
|
+
continue
|
|
134
|
+
axis = entry.value
|
|
135
|
+
name = observe(axis.field("/name"), as_string)
|
|
136
|
+
direction = observe(axis.field("/direction"), as_string)
|
|
137
|
+
unit = observe(axis.field("/unit") if legacy else document.field("/axis_unit"), as_string)
|
|
138
|
+
dimension = (
|
|
139
|
+
observe(axis.field("/dimension"), _dimension)
|
|
140
|
+
if legacy
|
|
141
|
+
else ReadValue(axis.field("/dimension"), ReadStatus.UNSUPPORTED)
|
|
142
|
+
)
|
|
143
|
+
local = diagnostics(name, direction, unit, dimension)
|
|
144
|
+
if direction.value is not None and re.sub(r"[^a-z]", "", direction.value.lower()) not in _DIRECTIONS:
|
|
145
|
+
local += (issue(direction.field, "unsupported_direction", "Unfamiliar anatomical direction retained"),)
|
|
146
|
+
axes.append(
|
|
147
|
+
Axis(
|
|
148
|
+
axis,
|
|
149
|
+
name.value.lower() if name.value is not None else None,
|
|
150
|
+
i,
|
|
151
|
+
direction.value,
|
|
152
|
+
unit.value,
|
|
153
|
+
dimension.value,
|
|
154
|
+
entry.field,
|
|
155
|
+
local,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
issues.extend(local)
|
|
159
|
+
if not axes:
|
|
160
|
+
issues.append(issue(entries.field, "absent", "No axes recorded"))
|
|
161
|
+
if len({axis.name for axis in axes}) != len(axes):
|
|
162
|
+
issues.append(issue(entries.field, "ambiguous", "Axis names do not identify unique coordinates"))
|
|
163
|
+
return ReadValue(entries.field, ReadStatus.AVAILABLE, tuple(axes), tuple(issues))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _frame_entries(
|
|
167
|
+
document: Document, names: tuple[str, ...], scope: Literal["local", "global"], configuration: Document | None = None
|
|
168
|
+
) -> tuple[ReadValue[CoordinateFrame], ...]:
|
|
169
|
+
fields = [document.field("/" + name) for name in names]
|
|
170
|
+
present = [f for f in fields if f.state == FieldState.PRESENT]
|
|
171
|
+
if not present:
|
|
172
|
+
return tuple(ReadValue(f, ReadStatus.NULL) for f in fields if f.state == FieldState.NULL)
|
|
173
|
+
conflict = len(present) > 1 and any(canonical(f.value) != canonical(present[0].value) for f in present[1:])
|
|
174
|
+
# Equal aliases identify one recorded frame; conflicting aliases remain separate.
|
|
175
|
+
selected = present if conflict else present[:1]
|
|
176
|
+
results: list[ReadValue[CoordinateFrame]] = []
|
|
177
|
+
for source in selected:
|
|
178
|
+
frame = object_view(source)
|
|
179
|
+
if frame.value is None:
|
|
180
|
+
results.append(ReadValue(source, frame.status, diagnostics=frame.diagnostics))
|
|
181
|
+
continue
|
|
182
|
+
axes = _axes(frame.value, False)
|
|
183
|
+
frame_issues = axes.diagnostics
|
|
184
|
+
if conflict:
|
|
185
|
+
frame_issues += (issue(source, "ambiguous", "Conflicting deprecated and replacement coordinate systems"),)
|
|
186
|
+
view = CoordinateFrame(
|
|
187
|
+
frame.value,
|
|
188
|
+
scope,
|
|
189
|
+
observe(frame.value.field("/name"), as_string).value,
|
|
190
|
+
axes.value or (),
|
|
191
|
+
configuration,
|
|
192
|
+
axes,
|
|
193
|
+
frame_issues,
|
|
194
|
+
)
|
|
195
|
+
results.append(ReadValue(source, ReadStatus.AVAILABLE, view, frame_issues))
|
|
196
|
+
return tuple(results)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True)
|
|
200
|
+
class Acquisition:
|
|
201
|
+
"""Inspection preserves a core even when some or all imaging capabilities are unavailable."""
|
|
202
|
+
|
|
203
|
+
document: Document
|
|
204
|
+
reader: ReaderSelection | None
|
|
205
|
+
frames: tuple[CoordinateFrame, ...]
|
|
206
|
+
frame_scope: str | None = None
|
|
207
|
+
diagnostics: tuple[Diagnostic, ...] = ()
|
|
208
|
+
frame_entries: ReadValue[tuple[ReadValue[CoordinateFrame], ...]] | None = None
|
|
209
|
+
source: Field | None = None
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def raw(self) -> Any:
|
|
213
|
+
"""Retain the selected core even when it is not an object."""
|
|
214
|
+
return self.source.value if self.source is not None else self.document.raw
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def validation(self) -> ValidationReport:
|
|
218
|
+
"""Reading makes no schema-validation claim."""
|
|
219
|
+
return validate(self.source if self.source is not None else self.document)
|
|
220
|
+
|
|
221
|
+
def select_frame(self, *, pointer: str | None = None, scope: str | None = None) -> ReadValue[CoordinateFrame]:
|
|
222
|
+
"""Return an inspectable selection instead of requiring an unambiguous frame."""
|
|
223
|
+
scope = self.frame_scope if scope is None else scope
|
|
224
|
+
frames = tuple(
|
|
225
|
+
f
|
|
226
|
+
for f in self.frames
|
|
227
|
+
if (scope is None or f.scope == scope) and (pointer is None or f.document.pointer == pointer)
|
|
228
|
+
)
|
|
229
|
+
if len(frames) == 1:
|
|
230
|
+
return ReadValue(frames[0].document.field(""), ReadStatus.AVAILABLE, frames[0])
|
|
231
|
+
return unavailable(
|
|
232
|
+
self.document.field(""),
|
|
233
|
+
ReadStatus.AMBIGUOUS if frames else ReadStatus.ABSENT,
|
|
234
|
+
f"Expected one coordinate frame, found {len(frames)}; select a frame pointer or scope",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def one_frame(self, *, pointer: str | None = None, scope: str | None = None) -> CoordinateFrame:
|
|
238
|
+
"""Explicitly require one frame; ordinary inspection uses select_frame()."""
|
|
239
|
+
return self.select_frame(pointer=pointer, scope=scope).require()
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def axes(self) -> tuple[Axis, ...]:
|
|
243
|
+
"""Inspect available axes; selection diagnostics remain in select_frame()."""
|
|
244
|
+
frame = self.select_frame().value
|
|
245
|
+
return () if frame is None else frame.axes
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def array_mapping(self) -> ReadValue[tuple[Axis, ...]]:
|
|
249
|
+
"""Expose whether explicit dimensions establish a usable array mapping."""
|
|
250
|
+
frame = self.select_frame()
|
|
251
|
+
if frame.value is None:
|
|
252
|
+
return ReadValue(frame.field, frame.status, diagnostics=frame.diagnostics)
|
|
253
|
+
axes = frame.value.axes
|
|
254
|
+
if not axes or any(a.dimension is None for a in axes):
|
|
255
|
+
return unavailable(
|
|
256
|
+
frame.field, ReadStatus.UNSUPPORTED, "Array dimensions are not established", "unsupported_capability"
|
|
257
|
+
)
|
|
258
|
+
if len({a.dimension for a in axes}) != len(axes):
|
|
259
|
+
return unavailable(frame.field, ReadStatus.INVALID, "Array dimensions are not unique")
|
|
260
|
+
return ReadValue(frame.field, ReadStatus.AVAILABLE, tuple(sorted(axes, key=lambda a: a.dimension or 0)))
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def array_axes(self) -> tuple[Axis, ...]:
|
|
264
|
+
"""Return available array axes; array_mapping explains unavailable interpretations."""
|
|
265
|
+
return self.array_mapping.value or ()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def read_acquisition(
|
|
269
|
+
document: Document,
|
|
270
|
+
*,
|
|
271
|
+
device_name: str | None = None,
|
|
272
|
+
stream_index: int | None = None,
|
|
273
|
+
frame_scope: Literal["legacy", "local", "global"] | None = None,
|
|
274
|
+
format_hint: str | None = None,
|
|
275
|
+
) -> Acquisition:
|
|
276
|
+
"""Inspect recognized components without requiring valid metadata or guessing unknown layouts."""
|
|
277
|
+
core = _core(document, "acquisition")
|
|
278
|
+
if core.value is None:
|
|
279
|
+
missing: ReadValue[tuple[ReadValue[CoordinateFrame], ...]] = ReadValue(
|
|
280
|
+
core.field, core.status, diagnostics=core.diagnostics
|
|
281
|
+
)
|
|
282
|
+
return Acquisition(document, None, (), frame_scope, core.diagnostics, missing, core.field)
|
|
283
|
+
document = core.value
|
|
284
|
+
context = _context(document, "acquisition", format_hint)
|
|
285
|
+
if context.value is None:
|
|
286
|
+
unavailable_entries: ReadValue[tuple[ReadValue[CoordinateFrame], ...]] = ReadValue(
|
|
287
|
+
document.field(""), ReadStatus.UNSUPPORTED, diagnostics=context.diagnostics
|
|
288
|
+
)
|
|
289
|
+
return Acquisition(document, None, (), frame_scope, context.diagnostics, unavailable_entries, core.field)
|
|
290
|
+
reader = context.value
|
|
291
|
+
if reader.format.layout == "legacy":
|
|
292
|
+
axes = _axes(document, True)
|
|
293
|
+
frame = CoordinateFrame(
|
|
294
|
+
document, "legacy", None, axes.value or (), axes_read=axes, diagnostics=axes.diagnostics
|
|
295
|
+
)
|
|
296
|
+
issues = axes.diagnostics
|
|
297
|
+
if device_name is not None or stream_index is not None:
|
|
298
|
+
issues += (
|
|
299
|
+
issue(core.field, "unsupported_selection", "Legacy acquisition has no device/stream selection mapping"),
|
|
300
|
+
)
|
|
301
|
+
selected: ReadValue[tuple[ReadValue[CoordinateFrame], ...]] = ReadValue(
|
|
302
|
+
core.field, ReadStatus.UNSUPPORTED, diagnostics=issues
|
|
303
|
+
)
|
|
304
|
+
return Acquisition(document, reader, (), frame_scope, issues, selected, core.field)
|
|
305
|
+
entries: ReadValue[tuple[ReadValue[CoordinateFrame], ...]] = ReadValue(
|
|
306
|
+
core.field, ReadStatus.AVAILABLE, (ReadValue(core.field, ReadStatus.AVAILABLE, frame, issues),), issues
|
|
307
|
+
)
|
|
308
|
+
return Acquisition(document, reader, (frame,), frame_scope, issues, entries, core.field)
|
|
309
|
+
return _configured_acquisition(document, reader, device_name, stream_index, frame_scope)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _configured_acquisition(
|
|
313
|
+
document: Document, reader: ReaderSelection, device: str | None, stream: int | None, scope: str | None
|
|
314
|
+
) -> Acquisition:
|
|
315
|
+
biodata = reader.format.family == "biodata"
|
|
316
|
+
global_names = ("global_coordinate_system",) if biodata else ("global_coordinate_system", "coordinate_system")
|
|
317
|
+
local_names = ("local_coordinate_system",) if biodata else ("local_coordinate_system", "coordinate_system")
|
|
318
|
+
entries = list(_frame_entries(document, global_names, "global"))
|
|
319
|
+
streams = children(document, "/data_streams")
|
|
320
|
+
issues = list(streams.diagnostics)
|
|
321
|
+
selected = 0
|
|
322
|
+
for i, entry in enumerate(streams.value or ()):
|
|
323
|
+
if stream is not None and stream != i or entry.value is None:
|
|
324
|
+
continue
|
|
325
|
+
configs = children(entry.value, "/configurations")
|
|
326
|
+
issues.extend(configs.diagnostics)
|
|
327
|
+
for config in configs.value or ():
|
|
328
|
+
if config.value is None:
|
|
329
|
+
continue
|
|
330
|
+
doc = config.value
|
|
331
|
+
if doc.field("/object_type").value != "Imaging config":
|
|
332
|
+
continue
|
|
333
|
+
if device is not None and doc.field("/device_name").value != device:
|
|
334
|
+
continue
|
|
335
|
+
selected += 1
|
|
336
|
+
entries.extend(_frame_entries(doc, local_names, "local", doc))
|
|
337
|
+
if (device is not None and not selected) or (stream is not None and not 0 <= stream < len(streams.value or ())):
|
|
338
|
+
issues.append(issue(document.field(""), "absent", "No imaging configuration matches the requested selection"))
|
|
339
|
+
entries = []
|
|
340
|
+
issues.extend(d for entry in entries for d in entry.diagnostics)
|
|
341
|
+
frames = tuple(entry.value for entry in entries if entry.value is not None)
|
|
342
|
+
result = ReadValue(document.field(""), ReadStatus.AVAILABLE, tuple(entries), tuple(issues))
|
|
343
|
+
return Acquisition(document, reader, frames, scope, tuple(issues), result, document.field(""))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@dataclass(frozen=True)
|
|
347
|
+
class Process:
|
|
348
|
+
"""A process remains inspectable when code, inputs, or pipeline relationships are incomplete."""
|
|
349
|
+
|
|
350
|
+
document: Document
|
|
351
|
+
legacy: bool
|
|
352
|
+
process_type: str | None
|
|
353
|
+
name: str | None
|
|
354
|
+
code_version: str | None
|
|
355
|
+
code_url: str | None
|
|
356
|
+
pipeline_name: str | None
|
|
357
|
+
pipeline_version: str | None
|
|
358
|
+
input_location: str | None
|
|
359
|
+
output_location: str | None
|
|
360
|
+
pipeline: Document | None = None
|
|
361
|
+
reader: ReaderSelection | None = None
|
|
362
|
+
code: CodeView | None = None
|
|
363
|
+
outputs: ReadValue[tuple[ArtifactReference, ...]] | None = None
|
|
364
|
+
diagnostics: tuple[Diagnostic, ...] = ()
|
|
365
|
+
|
|
366
|
+
@property
|
|
367
|
+
def parameters_field(self) -> Field:
|
|
368
|
+
"""Retain the actual parameters field, including malformed code parents."""
|
|
369
|
+
return self.code.parameters.field if self.code is not None else self.document.field("/parameters")
|
|
370
|
+
|
|
371
|
+
@property
|
|
372
|
+
def parameters(self) -> dict[str, Any] | None:
|
|
373
|
+
"""Return available parameters; code.parameters explains any limitation."""
|
|
374
|
+
return None if self.code is None else self.code.parameters.value
|
|
375
|
+
|
|
376
|
+
@property
|
|
377
|
+
def inputs(self) -> ReadValue[tuple[ArtifactReference, ...]]:
|
|
378
|
+
"""Keep file, asset, and grouped-asset references distinct."""
|
|
379
|
+
return (
|
|
380
|
+
self.code.inputs
|
|
381
|
+
if self.code is not None
|
|
382
|
+
else unavailable(self.document.field(""), ReadStatus.UNSUPPORTED, "Code view unavailable")
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
@property
|
|
386
|
+
def input_data(self) -> list[dict[str, Any]] | None:
|
|
387
|
+
"""Compatibility access to recorded nested asset objects without guessing file paths."""
|
|
388
|
+
value = self.document.field("/code/input_data").value
|
|
389
|
+
return value if isinstance(value, list) and all(isinstance(v, dict) for v in value) else None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@dataclass(frozen=True)
|
|
393
|
+
class Processing:
|
|
394
|
+
"""Ordered partial processes and every original entry, including uninterpretable nodes."""
|
|
395
|
+
|
|
396
|
+
document: Document
|
|
397
|
+
reader: ReaderSelection | None
|
|
398
|
+
processes: tuple[Process, ...]
|
|
399
|
+
diagnostics: tuple[Diagnostic, ...] = ()
|
|
400
|
+
process_entries: ReadValue[tuple[ReadValue[Process], ...]] | None = None
|
|
401
|
+
source: Field | None = None
|
|
402
|
+
|
|
403
|
+
@property
|
|
404
|
+
def raw(self) -> Any:
|
|
405
|
+
"""Retain malformed or unsupported cores without interpreting them as empty data."""
|
|
406
|
+
return self.source.value if self.source is not None else self.document.raw
|
|
407
|
+
|
|
408
|
+
@property
|
|
409
|
+
def validation(self) -> ValidationReport:
|
|
410
|
+
"""Interpretation availability makes no upstream-schema validity claim."""
|
|
411
|
+
return validate(self.source if self.source is not None else self.document)
|
|
412
|
+
|
|
413
|
+
def find(self, process_type: str) -> tuple[Process, ...]:
|
|
414
|
+
"""Match recorded vocabulary values without a closed enumeration of allowed nouns."""
|
|
415
|
+
return tuple(p for p in self.processes if p.process_type == process_type)
|
|
416
|
+
|
|
417
|
+
def select(self, process_type: str) -> ReadValue[Process]:
|
|
418
|
+
"""Inspect an ambiguous or unavailable selection without raising."""
|
|
419
|
+
matches = self.find(process_type)
|
|
420
|
+
if len(matches) == 1:
|
|
421
|
+
return ReadValue(matches[0].document.field(""), ReadStatus.AVAILABLE, matches[0])
|
|
422
|
+
return unavailable(
|
|
423
|
+
self.document.field(""),
|
|
424
|
+
ReadStatus.AMBIGUOUS if matches else ReadStatus.ABSENT,
|
|
425
|
+
f"Expected one {process_type!r} process, found {len(matches)}",
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
def one(self, process_type: str) -> Process:
|
|
429
|
+
"""Explicitly require one matching process; use select() for ordinary inspection."""
|
|
430
|
+
return self.select(process_type).require()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _process(item: Document, reader: ReaderSelection, container: Document, pipelines: tuple[Document, ...]) -> Process:
|
|
434
|
+
legacy = reader.format.layout == "legacy"
|
|
435
|
+
code = CodeView(item.field("") if legacy else item.field("/code"), "legacy" if legacy else "nested")
|
|
436
|
+
process_type = observe(item.field("/name" if legacy else "/process_type"), as_string)
|
|
437
|
+
name = observe(item.field("/name"), as_string)
|
|
438
|
+
in_legacy_pipeline = legacy and item.pointer.startswith(container.pointer + "/data_processes/")
|
|
439
|
+
pipeline_name = observe(
|
|
440
|
+
container.field("/pipeline_name") if in_legacy_pipeline else item.field("/pipeline_name"), as_string
|
|
441
|
+
)
|
|
442
|
+
matches = (
|
|
443
|
+
(container,)
|
|
444
|
+
if in_legacy_pipeline
|
|
445
|
+
else tuple(
|
|
446
|
+
p for p in pipelines if pipeline_name.value is not None and p.field("/name").value == pipeline_name.value
|
|
447
|
+
)
|
|
448
|
+
)
|
|
449
|
+
pipeline = matches[0] if len(matches) == 1 else None
|
|
450
|
+
pv = (
|
|
451
|
+
observe(pipeline.field("/pipeline_version" if legacy else "/version"), as_string)
|
|
452
|
+
if pipeline is not None
|
|
453
|
+
else None
|
|
454
|
+
)
|
|
455
|
+
output = references(item.field("/output_location" if legacy else "/output_path"), "paths")
|
|
456
|
+
singleton = output.value[0].location.value if output.value is not None and len(output.value) == 1 else None
|
|
457
|
+
input_path = observe(item.field("/input_location"), as_string).value if legacy else None
|
|
458
|
+
issues = diagnostics(
|
|
459
|
+
process_type, name, pipeline_name, code.version, code.url, code.parameters, code.inputs, output
|
|
460
|
+
)
|
|
461
|
+
if pv is not None:
|
|
462
|
+
issues += pv.diagnostics
|
|
463
|
+
if pipeline_name.value is not None and len(matches) != 1:
|
|
464
|
+
issues += (
|
|
465
|
+
issue(
|
|
466
|
+
pipeline_name.field,
|
|
467
|
+
"unresolved_reference" if not matches else "ambiguous",
|
|
468
|
+
"Pipeline reference does not identify one recorded pipeline",
|
|
469
|
+
),
|
|
470
|
+
)
|
|
471
|
+
return Process(
|
|
472
|
+
item,
|
|
473
|
+
legacy,
|
|
474
|
+
process_type.value,
|
|
475
|
+
name.value,
|
|
476
|
+
code.version.value,
|
|
477
|
+
code.url.value,
|
|
478
|
+
pipeline_name.value,
|
|
479
|
+
None if pv is None else pv.value,
|
|
480
|
+
input_path,
|
|
481
|
+
singleton,
|
|
482
|
+
pipeline,
|
|
483
|
+
reader,
|
|
484
|
+
code,
|
|
485
|
+
output,
|
|
486
|
+
issues,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def read_processing(document: Document, *, format_hint: str | None = None) -> Processing:
|
|
491
|
+
"""Inspect known parts independently; preserve unknown versions and invalid entries with diagnostics."""
|
|
492
|
+
core = _core(document, "processing")
|
|
493
|
+
if core.value is None:
|
|
494
|
+
missing: ReadValue[tuple[ReadValue[Process], ...]] = ReadValue(
|
|
495
|
+
core.field, core.status, diagnostics=core.diagnostics
|
|
496
|
+
)
|
|
497
|
+
return Processing(document, None, (), core.diagnostics, missing, core.field)
|
|
498
|
+
document = core.value
|
|
499
|
+
context = _context(document, "processing", format_hint)
|
|
500
|
+
if context.value is None:
|
|
501
|
+
unavailable_entries: ReadValue[tuple[ReadValue[Process], ...]] = ReadValue(
|
|
502
|
+
core.field, ReadStatus.UNSUPPORTED, diagnostics=context.diagnostics
|
|
503
|
+
)
|
|
504
|
+
return Processing(document, None, (), context.diagnostics, unavailable_entries, core.field)
|
|
505
|
+
reader = context.value
|
|
506
|
+
legacy = reader.format.layout == "legacy"
|
|
507
|
+
container = object_view(document.field("/processing_pipeline")) if legacy else object_view(core.field)
|
|
508
|
+
pipeline_entries = children(document, "/pipelines") if not legacy else None
|
|
509
|
+
pipelines = (
|
|
510
|
+
tuple(entry.value for entry in pipeline_entries.value or () if entry.value is not None)
|
|
511
|
+
if pipeline_entries is not None
|
|
512
|
+
else ()
|
|
513
|
+
)
|
|
514
|
+
collections = [children(container.value, "/data_processes")] if container.value is not None else []
|
|
515
|
+
if legacy:
|
|
516
|
+
collections.append(children(document, "/analyses"))
|
|
517
|
+
entries: list[ReadValue[Process]] = []
|
|
518
|
+
issues = list(container.diagnostics) + ([] if pipeline_entries is None else list(pipeline_entries.diagnostics))
|
|
519
|
+
for collection in collections:
|
|
520
|
+
issues.extend(collection.diagnostics)
|
|
521
|
+
for entry in collection.value or ():
|
|
522
|
+
if entry.value is None:
|
|
523
|
+
entries.append(ReadValue(entry.field, entry.status, diagnostics=entry.diagnostics))
|
|
524
|
+
continue
|
|
525
|
+
view = _process(entry.value, reader, container.value or document, pipelines)
|
|
526
|
+
entries.append(ReadValue(entry.field, ReadStatus.AVAILABLE, view, view.diagnostics))
|
|
527
|
+
issues.extend(view.diagnostics)
|
|
528
|
+
processes = tuple(entry.value for entry in entries if entry.value is not None)
|
|
529
|
+
status = _collection_status(collections, container)
|
|
530
|
+
inspected = ReadValue(core.field, status, tuple(entries) if status == ReadStatus.AVAILABLE else None, tuple(issues))
|
|
531
|
+
return Processing(document, reader, processes, tuple(issues), inspected, core.field)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _collection_status(
|
|
535
|
+
collections: list[ReadValue[tuple[ReadValue[Document], ...]]], container: ReadValue[Document]
|
|
536
|
+
) -> ReadStatus:
|
|
537
|
+
if any(c.status == ReadStatus.AVAILABLE for c in collections):
|
|
538
|
+
return ReadStatus.AVAILABLE
|
|
539
|
+
if container.status != ReadStatus.AVAILABLE:
|
|
540
|
+
return container.status
|
|
541
|
+
return next((c.status for c in collections if c.status != ReadStatus.ABSENT), ReadStatus.ABSENT)
|