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