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,399 @@
|
|
|
1
|
+
"""Preserved snapshots and JSON Pointer views, independent of upstream schema runtimes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import asdict, dataclass, fields
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from functools import cached_property
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import MappingProxyType
|
|
14
|
+
from typing import Any, Literal, cast
|
|
15
|
+
|
|
16
|
+
from aind_metadata_reader._wire import canonical, digest, object_fields, parse_json, string, string_array, strings
|
|
17
|
+
from aind_metadata_reader._wire import json_value as _json_value
|
|
18
|
+
from aind_metadata_reader.diagnostics import FieldState, MetadataError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _freeze(value: Any) -> Any:
|
|
22
|
+
if isinstance(value, dict):
|
|
23
|
+
return MappingProxyType({key: _freeze(item) for key, item in value.items()})
|
|
24
|
+
if isinstance(value, list):
|
|
25
|
+
return tuple(_freeze(item) for item in value)
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _thaw(value: Any) -> Any:
|
|
30
|
+
if isinstance(value, Mapping):
|
|
31
|
+
return {key: _thaw(item) for key, item in value.items()}
|
|
32
|
+
if isinstance(value, tuple):
|
|
33
|
+
return [_thaw(item) for item in value]
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def encode_segment(segment: str) -> str:
|
|
38
|
+
"""Escape one JSON Pointer segment, including names containing slash or tilde."""
|
|
39
|
+
return segment.replace("~", "~0").replace("/", "~1")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _segments(pointer: str) -> list[str]:
|
|
43
|
+
if pointer == "":
|
|
44
|
+
return []
|
|
45
|
+
if not pointer.startswith("/"):
|
|
46
|
+
raise MetadataError("JSON Pointer must be empty or start with '/'", code="invalid_pointer", pointer=pointer)
|
|
47
|
+
result = []
|
|
48
|
+
for segment in pointer[1:].split("/"):
|
|
49
|
+
remaining = segment.replace("~0", "").replace("~1", "")
|
|
50
|
+
if "~" in remaining:
|
|
51
|
+
raise MetadataError("Invalid JSON Pointer escape", code="invalid_pointer", pointer=pointer)
|
|
52
|
+
result.append(segment.replace("~1", "/").replace("~0", "~"))
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Source:
|
|
58
|
+
"""Caller-supplied source evidence; these fields make no assertion of authority."""
|
|
59
|
+
|
|
60
|
+
kind: Literal["mapping", "file", "s3", "docdb"] = "mapping"
|
|
61
|
+
locator: str | None = None
|
|
62
|
+
retrieved_at: str | None = None
|
|
63
|
+
revision: str | None = None
|
|
64
|
+
host: str | None = None
|
|
65
|
+
api_version: str | None = None
|
|
66
|
+
database: str | None = None
|
|
67
|
+
collection: str | None = None
|
|
68
|
+
record_id: str | None = None
|
|
69
|
+
asset_location: str | None = None
|
|
70
|
+
derived_from: tuple[str, ...] = ()
|
|
71
|
+
converter_version: str | None = None
|
|
72
|
+
interpretation_id: str | None = None
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
"""Keep source evidence immutable and reject invalid provenance types."""
|
|
76
|
+
if self.kind not in ("mapping", "file", "s3", "docdb"):
|
|
77
|
+
raise MetadataError("Unknown source kind", code="invalid_source")
|
|
78
|
+
for item in fields(self):
|
|
79
|
+
value = getattr(self, item.name)
|
|
80
|
+
if item.name not in ("kind", "derived_from") and value is not None:
|
|
81
|
+
string(value, code="invalid_source")
|
|
82
|
+
object.__setattr__(self, "derived_from", strings(self.derived_from, code="invalid_source"))
|
|
83
|
+
|
|
84
|
+
def record(self, *, legacy: bool = False) -> dict[str, Any]:
|
|
85
|
+
"""Export provenance separately from payload and source-aware identity."""
|
|
86
|
+
result = asdict(self)
|
|
87
|
+
result["derived_from"] = list(self.derived_from)
|
|
88
|
+
if legacy:
|
|
89
|
+
result.pop("interpretation_id")
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_record(cls, value: Any, *, legacy: bool = False) -> Source:
|
|
94
|
+
"""Read exact source fields without converting malformed strings to arrays."""
|
|
95
|
+
names = {item.name for item in fields(cls)}
|
|
96
|
+
if legacy:
|
|
97
|
+
names.remove("interpretation_id")
|
|
98
|
+
data = dict(object_fields(value, names, code="invalid_source"))
|
|
99
|
+
data["derived_from"] = string_array(data["derived_from"], code="invalid_source")
|
|
100
|
+
return cls(**data)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class Snapshot:
|
|
105
|
+
"""Immutable JSON storage with separate semantic-content and original-byte digests.
|
|
106
|
+
|
|
107
|
+
Canonicalization is Python JSON with sorted keys, ASCII escapes, no whitespace,
|
|
108
|
+
and no nonfinite numbers; it is not RFC 8785. Integers retain Python precision;
|
|
109
|
+
fractional values use binary64. Original bytes preserve number spellings.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
_canonical: bytes
|
|
113
|
+
source: Source
|
|
114
|
+
original_bytes: bytes | None = None
|
|
115
|
+
|
|
116
|
+
@cached_property
|
|
117
|
+
def _tree(self) -> Any:
|
|
118
|
+
# Process/axis views select many small subtrees. Repeatedly decoding the
|
|
119
|
+
# entire aggregate for each field would scale quadratically.
|
|
120
|
+
return _freeze(json.loads(self._canonical))
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_mapping(cls, data: Mapping[str, Any], source: Source) -> Snapshot:
|
|
124
|
+
"""Detach data into immutable storage, rejecting implicit JSON coercions."""
|
|
125
|
+
obj = dict(data)
|
|
126
|
+
_json_value(obj)
|
|
127
|
+
return cls(canonical(obj), source)
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def from_bytes(cls, payload: bytes, source: Source) -> Snapshot:
|
|
131
|
+
"""Keep exact transport bytes and reject duplicate keys or nonfinite numbers."""
|
|
132
|
+
obj = parse_json(payload)
|
|
133
|
+
if not isinstance(obj, dict):
|
|
134
|
+
raise MetadataError("Expected a JSON object", code="invalid_json")
|
|
135
|
+
snapshot = cls.from_mapping(obj, source)
|
|
136
|
+
return cls(snapshot._canonical, source, payload)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def content_digest(self) -> str:
|
|
140
|
+
"""Identify parsed content under the documented canonicalization scheme."""
|
|
141
|
+
return "python-json-v1:sha256:" + hashlib.sha256(self._canonical).hexdigest()
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def original_digest(self) -> str | None:
|
|
145
|
+
"""Identify exact bytes when the caller supplied them."""
|
|
146
|
+
return None if self.original_bytes is None else "sha256:" + hashlib.sha256(self.original_bytes).hexdigest()
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def snapshot_id(self) -> str:
|
|
150
|
+
"""Identify exact captured content, source evidence, bytes, and derivation."""
|
|
151
|
+
return digest(
|
|
152
|
+
"metadata-snapshot-v1",
|
|
153
|
+
{
|
|
154
|
+
"content_digest": self.content_digest,
|
|
155
|
+
"original_digest": self.original_digest,
|
|
156
|
+
"source": self.source.record(),
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def raw(self) -> dict[str, Any]:
|
|
162
|
+
"""Return an independent copy of the complete snapshot."""
|
|
163
|
+
return cast(dict[str, Any], json.loads(self._canonical))
|
|
164
|
+
|
|
165
|
+
def archive(self) -> dict[str, Any]:
|
|
166
|
+
"""Export an offline snapshot with original bytes and source context intact."""
|
|
167
|
+
return {
|
|
168
|
+
"format": "metadata-snapshot/2",
|
|
169
|
+
"payload": self.raw,
|
|
170
|
+
"source": self.source.record(),
|
|
171
|
+
"original_bytes": None if self.original_bytes is None else base64.b64encode(self.original_bytes).decode(),
|
|
172
|
+
"content_digest": self.content_digest,
|
|
173
|
+
"original_digest": self.original_digest,
|
|
174
|
+
"snapshot_id": self.snapshot_id,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
@classmethod
|
|
178
|
+
def from_archive(cls, archive: dict[str, Any]) -> Snapshot:
|
|
179
|
+
"""Restore a retained snapshot, rejecting mismatched bytes, content, or digests."""
|
|
180
|
+
if not isinstance(archive, dict) or archive.get("format") not in ("metadata-snapshot/1", "metadata-snapshot/2"):
|
|
181
|
+
raise MetadataError("Unsupported snapshot archive", code="unsupported_archive")
|
|
182
|
+
legacy = archive["format"] == "metadata-snapshot/1"
|
|
183
|
+
names = {"format", "payload", "source", "original_bytes", "content_digest", "original_digest"}
|
|
184
|
+
object_fields(archive, names if legacy else names | {"snapshot_id"}, code="invalid_archive")
|
|
185
|
+
if not isinstance(archive["payload"], dict):
|
|
186
|
+
raise MetadataError("Archive payload must be an object", code="invalid_archive")
|
|
187
|
+
try:
|
|
188
|
+
source = Source.from_record(archive["source"], legacy=legacy)
|
|
189
|
+
snapshot = cls.from_mapping(archive["payload"], source)
|
|
190
|
+
if archive["original_bytes"] is not None:
|
|
191
|
+
payload = base64.b64decode(string(archive["original_bytes"], code="invalid_archive"), validate=True)
|
|
192
|
+
original = cls.from_bytes(payload, source)
|
|
193
|
+
if original.content_digest != snapshot.content_digest:
|
|
194
|
+
raise MetadataError("Original bytes do not match archived content", code="archive_mismatch")
|
|
195
|
+
snapshot = original
|
|
196
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
197
|
+
if isinstance(error, MetadataError):
|
|
198
|
+
raise
|
|
199
|
+
raise MetadataError(f"Invalid snapshot archive: {error}", code="invalid_archive") from error
|
|
200
|
+
if snapshot.content_digest != archive.get("content_digest") or snapshot.original_digest != archive.get(
|
|
201
|
+
"original_digest"
|
|
202
|
+
):
|
|
203
|
+
raise MetadataError("Snapshot digest mismatch", code="archive_mismatch")
|
|
204
|
+
if not legacy and archive["snapshot_id"] != snapshot.snapshot_id:
|
|
205
|
+
raise MetadataError("Snapshot identity mismatch", code="archive_mismatch")
|
|
206
|
+
return snapshot
|
|
207
|
+
|
|
208
|
+
def field(self, pointer: str) -> Field:
|
|
209
|
+
"""Locate a value in this snapshot, preserving its availability state."""
|
|
210
|
+
value: Any = self._tree
|
|
211
|
+
for segment in _segments(pointer):
|
|
212
|
+
if isinstance(value, Mapping) and segment in value:
|
|
213
|
+
value = value[segment]
|
|
214
|
+
elif isinstance(value, tuple) and segment.isascii() and segment.isdigit() and str(int(segment)) == segment:
|
|
215
|
+
if int(segment) >= len(value):
|
|
216
|
+
return Field(self, pointer, FieldState.ABSENT)
|
|
217
|
+
value = value[int(segment)]
|
|
218
|
+
else:
|
|
219
|
+
return Field(self, pointer, FieldState.ABSENT)
|
|
220
|
+
state = FieldState.NULL if value is None else FieldState.PRESENT
|
|
221
|
+
return Field(self, pointer, state, json.dumps(_thaw(value), allow_nan=False))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True)
|
|
225
|
+
class Field:
|
|
226
|
+
"""An immutable field reference retaining the parent snapshot and source pointer."""
|
|
227
|
+
|
|
228
|
+
snapshot: Snapshot
|
|
229
|
+
pointer: str
|
|
230
|
+
state: FieldState
|
|
231
|
+
_json: str = "null"
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def value(self) -> Any:
|
|
235
|
+
"""Return the value, requiring callers to check state to distinguish missing/null."""
|
|
236
|
+
return json.loads(self._json)
|
|
237
|
+
|
|
238
|
+
def require(self) -> Any:
|
|
239
|
+
"""Require a recorded non-null value with a structured failure otherwise."""
|
|
240
|
+
if self.state != FieldState.PRESENT:
|
|
241
|
+
raise MetadataError(
|
|
242
|
+
f"Required field is {self.state}",
|
|
243
|
+
code=self.state.value,
|
|
244
|
+
pointer=self.pointer,
|
|
245
|
+
snapshot_id=self.snapshot.snapshot_id,
|
|
246
|
+
)
|
|
247
|
+
return self.value
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass(frozen=True, init=False)
|
|
251
|
+
class Document:
|
|
252
|
+
"""An object view into a snapshot; child views retain aggregate identity and context."""
|
|
253
|
+
|
|
254
|
+
snapshot: Snapshot
|
|
255
|
+
pointer: str
|
|
256
|
+
|
|
257
|
+
def __init__(self, data: Mapping[str, Any], *, source: str | None = None, provenance: Source | None = None) -> None:
|
|
258
|
+
"""Create a snapshot without inferring transport or authority from a locator."""
|
|
259
|
+
if source is not None and provenance is not None and source != provenance.locator:
|
|
260
|
+
raise MetadataError("Conflicting source and provenance locators", code="conflicting_source")
|
|
261
|
+
object.__setattr__(self, "snapshot", Snapshot.from_mapping(data, provenance or Source(locator=source)))
|
|
262
|
+
object.__setattr__(self, "pointer", "")
|
|
263
|
+
|
|
264
|
+
@classmethod
|
|
265
|
+
def from_snapshot(cls, snapshot: Snapshot, pointer: str = "") -> Document:
|
|
266
|
+
"""Select an object without copying away its parent context."""
|
|
267
|
+
field = snapshot.field(pointer)
|
|
268
|
+
value = field.require()
|
|
269
|
+
if not isinstance(value, dict):
|
|
270
|
+
raise MetadataError("Expected an object", pointer=pointer, snapshot_id=snapshot.snapshot_id)
|
|
271
|
+
obj = object.__new__(cls)
|
|
272
|
+
object.__setattr__(obj, "snapshot", snapshot)
|
|
273
|
+
object.__setattr__(obj, "pointer", pointer)
|
|
274
|
+
return obj
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def source(self) -> str | None:
|
|
278
|
+
"""Return the original source locator."""
|
|
279
|
+
return self.snapshot.source.locator
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def raw(self) -> dict[str, Any]:
|
|
283
|
+
"""Return the selected object, retaining unknown fields and source values."""
|
|
284
|
+
return cast(dict[str, Any], self.snapshot.field(self.pointer).require())
|
|
285
|
+
|
|
286
|
+
def field(self, pointer: str) -> Field:
|
|
287
|
+
"""Resolve a JSON Pointer relative to this object, returning a root-qualified reference."""
|
|
288
|
+
_segments(pointer)
|
|
289
|
+
return self.snapshot.field(self.pointer + pointer)
|
|
290
|
+
|
|
291
|
+
def child(self, pointer: str) -> Document:
|
|
292
|
+
"""Select a nested object with the same root snapshot."""
|
|
293
|
+
_segments(pointer)
|
|
294
|
+
return Document.from_snapshot(self.snapshot, self.pointer + pointer)
|
|
295
|
+
|
|
296
|
+
def core(self, name: str) -> Document:
|
|
297
|
+
"""Select a core while retaining the aggregate's provenance and recorded IDs."""
|
|
298
|
+
return self.child("/" + encode_segment(name))
|
|
299
|
+
|
|
300
|
+
def reference(self) -> SnapshotReference:
|
|
301
|
+
"""Retain a portable, source-qualified reference without copying the payload."""
|
|
302
|
+
return SnapshotReference(
|
|
303
|
+
self.snapshot.snapshot_id,
|
|
304
|
+
self.snapshot.content_digest,
|
|
305
|
+
self.snapshot.original_digest,
|
|
306
|
+
self.snapshot.source,
|
|
307
|
+
self.pointer,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def optional_string(self, pointer: str) -> str | None:
|
|
311
|
+
"""Read an optional string, preserving absent/null distinctions through field()."""
|
|
312
|
+
field = self.field(pointer)
|
|
313
|
+
if field.state != FieldState.PRESENT:
|
|
314
|
+
return None
|
|
315
|
+
value = field.value
|
|
316
|
+
return value if isinstance(value, str) else None
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def schema_version(self) -> str | None:
|
|
320
|
+
"""Return the recorded core version without manufacturing one."""
|
|
321
|
+
return self.optional_string("/schema_version")
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def described_by(self) -> str | None:
|
|
325
|
+
"""Return the recorded schema identifier."""
|
|
326
|
+
return self.optional_string("/describedBy")
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def schema_family(self) -> str | None:
|
|
330
|
+
"""Recognize the namespace, retaining unknown identifiers for dispatch diagnostics."""
|
|
331
|
+
identifier = self.described_by or ""
|
|
332
|
+
if "/biodata_schema/" in identifier:
|
|
333
|
+
return "biodata"
|
|
334
|
+
if "/aind_data_schema/" in identifier:
|
|
335
|
+
return "aind"
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@dataclass(frozen=True)
|
|
340
|
+
class SnapshotReference:
|
|
341
|
+
"""An exact captured snapshot and an absolute pointer into its retained payload."""
|
|
342
|
+
|
|
343
|
+
snapshot_id: str
|
|
344
|
+
content_digest: str
|
|
345
|
+
original_digest: str | None
|
|
346
|
+
source: Source
|
|
347
|
+
pointer: str = ""
|
|
348
|
+
|
|
349
|
+
def __post_init__(self) -> None:
|
|
350
|
+
"""Validate self-contained identity and pointer without claiming availability."""
|
|
351
|
+
string(self.snapshot_id, code="invalid_reference")
|
|
352
|
+
string(self.content_digest, code="invalid_reference")
|
|
353
|
+
if self.original_digest is not None:
|
|
354
|
+
string(self.original_digest, code="invalid_reference")
|
|
355
|
+
if not isinstance(self.source, Source):
|
|
356
|
+
raise MetadataError("Reference source must be Source", code="invalid_reference")
|
|
357
|
+
string(self.pointer, code="invalid_reference", empty=True)
|
|
358
|
+
_segments(self.pointer)
|
|
359
|
+
expected = digest(
|
|
360
|
+
"metadata-snapshot-v1",
|
|
361
|
+
{
|
|
362
|
+
"content_digest": self.content_digest,
|
|
363
|
+
"original_digest": self.original_digest,
|
|
364
|
+
"source": self.source.record(),
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
if self.snapshot_id != expected:
|
|
368
|
+
raise MetadataError("Reference identity does not match its source and digests", code="invalid_reference")
|
|
369
|
+
|
|
370
|
+
def record(self) -> dict[str, Any]:
|
|
371
|
+
"""Export a structured reference; archives must be retained separately."""
|
|
372
|
+
return {
|
|
373
|
+
"snapshot_id": self.snapshot_id,
|
|
374
|
+
"content_digest": self.content_digest,
|
|
375
|
+
"original_digest": self.original_digest,
|
|
376
|
+
"source": self.source.record(),
|
|
377
|
+
"pointer": self.pointer,
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
@classmethod
|
|
381
|
+
def from_record(cls, value: Any) -> SnapshotReference:
|
|
382
|
+
"""Restore a structured reference without resolving or fetching its source."""
|
|
383
|
+
data = dict(
|
|
384
|
+
object_fields(
|
|
385
|
+
value,
|
|
386
|
+
{"snapshot_id", "content_digest", "original_digest", "source", "pointer"},
|
|
387
|
+
code="invalid_reference",
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
data["source"] = Source.from_record(data["source"])
|
|
391
|
+
return cls(**data)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def read_json(path: str | Path) -> Document:
|
|
395
|
+
"""Read a local snapshot without modifying it or consulting metadata services."""
|
|
396
|
+
path = Path(path)
|
|
397
|
+
payload = path.read_bytes()
|
|
398
|
+
source = Source(kind="file", locator=str(path), retrieved_at=datetime.now(timezone.utc).isoformat())
|
|
399
|
+
return Document.from_snapshot(Snapshot.from_bytes(payload, source))
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Declared, capability-specific compatibility; package majors are not reader contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from aind_metadata_reader._wire import object_fields, string, string_array, strings
|
|
12
|
+
from aind_metadata_reader.diagnostics import FieldState, MetadataError, UnsupportedVersionError
|
|
13
|
+
from aind_metadata_reader.document import Document, SnapshotReference
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Format:
|
|
18
|
+
"""One supported representation and the exact core versions currently exercised."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
family: str
|
|
22
|
+
core: Literal["acquisition", "processing"]
|
|
23
|
+
layout: Literal["legacy", "configured", "flat"]
|
|
24
|
+
versions: tuple[str, ...]
|
|
25
|
+
capabilities: tuple[str, ...]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
FORMATS = (
|
|
29
|
+
Format(
|
|
30
|
+
"aind.acquisition.legacy",
|
|
31
|
+
"aind",
|
|
32
|
+
"acquisition",
|
|
33
|
+
"legacy",
|
|
34
|
+
("1.0.0", "1.0.3", "1.0.4"),
|
|
35
|
+
("axes", "array_dimensions"),
|
|
36
|
+
),
|
|
37
|
+
Format(
|
|
38
|
+
"aind.acquisition.configured", "aind", "acquisition", "configured", ("2.0.0", "2.5.3", "2.5.4"), ("frames",)
|
|
39
|
+
),
|
|
40
|
+
Format("biodata.acquisition.configured", "biodata", "acquisition", "configured", ("3.0.0",), ("frames",)),
|
|
41
|
+
Format(
|
|
42
|
+
"aind.processing.legacy", "aind", "processing", "legacy", ("1.1.0", "1.1.4"), ("processes", "input_locations")
|
|
43
|
+
),
|
|
44
|
+
Format(
|
|
45
|
+
"aind.processing.flat",
|
|
46
|
+
"aind",
|
|
47
|
+
"processing",
|
|
48
|
+
"flat",
|
|
49
|
+
("2.0.0", "2.2.5", "2.3.1", "2.3.2", "2.3.3"),
|
|
50
|
+
("processes", "input_assets"),
|
|
51
|
+
),
|
|
52
|
+
Format("biodata.processing.flat", "biodata", "processing", "flat", ("3.0.0",), ("processes", "input_assets")),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class ReaderSelection:
|
|
58
|
+
"""Interpretation identity and evidence, without relabeling the source schema."""
|
|
59
|
+
|
|
60
|
+
format: Format
|
|
61
|
+
declared_version: str | None
|
|
62
|
+
basis: tuple[str, ...]
|
|
63
|
+
|
|
64
|
+
def record_for(self, document: Document) -> ReaderRecord:
|
|
65
|
+
"""Attach the actual reader selection and installed package version to an input."""
|
|
66
|
+
return ReaderRecord(
|
|
67
|
+
document.reference(), self.format.name, self.declared_version, self.basis, version("aind-metadata-reader")
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class ReaderRecord:
|
|
73
|
+
"""Historical reader provenance, restored without invoking today's dispatch table."""
|
|
74
|
+
|
|
75
|
+
document: SnapshotReference
|
|
76
|
+
format_name: str
|
|
77
|
+
declared_version: str | None
|
|
78
|
+
basis: tuple[str, ...]
|
|
79
|
+
reader_version: str
|
|
80
|
+
|
|
81
|
+
def __post_init__(self) -> None:
|
|
82
|
+
"""Detach selection evidence and require explicit implementation attribution."""
|
|
83
|
+
if not isinstance(self.document, SnapshotReference):
|
|
84
|
+
raise MetadataError("Reader input must be a snapshot reference", code="invalid_reader_record")
|
|
85
|
+
string(self.format_name, code="invalid_reader_record")
|
|
86
|
+
string(self.reader_version, code="invalid_reader_record")
|
|
87
|
+
if self.declared_version is not None:
|
|
88
|
+
string(self.declared_version, code="invalid_reader_record")
|
|
89
|
+
object.__setattr__(self, "basis", strings(self.basis, code="invalid_reader_record"))
|
|
90
|
+
|
|
91
|
+
def record(self) -> dict[str, Any]:
|
|
92
|
+
"""Export the selection without embedding a mutable current registry entry."""
|
|
93
|
+
return {
|
|
94
|
+
"document": self.document.record(),
|
|
95
|
+
"format_name": self.format_name,
|
|
96
|
+
"declared_version": self.declared_version,
|
|
97
|
+
"basis": list(self.basis),
|
|
98
|
+
"reader_version": self.reader_version,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def from_record(cls, value: Any) -> ReaderRecord:
|
|
103
|
+
"""Restore saved reader provenance independently of present reader support."""
|
|
104
|
+
data = dict(
|
|
105
|
+
object_fields(
|
|
106
|
+
value,
|
|
107
|
+
{"document", "format_name", "declared_version", "basis", "reader_version"},
|
|
108
|
+
code="invalid_reader_record",
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
data["document"] = SnapshotReference.from_record(data["document"])
|
|
112
|
+
data["basis"] = string_array(data["basis"], code="invalid_reader_record")
|
|
113
|
+
return cls(**data)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def select_reader(
|
|
117
|
+
document: Document, core: Literal["acquisition", "processing"], hint: str | None = None
|
|
118
|
+
) -> ReaderSelection:
|
|
119
|
+
"""Require consistent format identity; unversioned configured records need a hint."""
|
|
120
|
+
version = _declared_string(document, "/schema_version")
|
|
121
|
+
identifier = _declared_string(document, "/describedBy")
|
|
122
|
+
basis = []
|
|
123
|
+
if version is not None:
|
|
124
|
+
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.+-]+)?", version):
|
|
125
|
+
raise MetadataError(
|
|
126
|
+
"Invalid schema_version", code="invalid_version", pointer=document.field("/schema_version").pointer
|
|
127
|
+
)
|
|
128
|
+
basis.append("recorded core schema_version")
|
|
129
|
+
candidates = [f for f in FORMATS if f.core == core]
|
|
130
|
+
if identifier is not None:
|
|
131
|
+
stem = urlparse(identifier).path.rsplit("/", 1)[-1]
|
|
132
|
+
if document.schema_family is None or stem != core + ".py":
|
|
133
|
+
raise UnsupportedVersionError(
|
|
134
|
+
f"Unrecognized schema identifier for {core}: {identifier}",
|
|
135
|
+
code="unsupported_format",
|
|
136
|
+
pointer=document.field("/describedBy").pointer,
|
|
137
|
+
snapshot_id=document.snapshot.snapshot_id,
|
|
138
|
+
)
|
|
139
|
+
candidates = [f for f in candidates if f.family == document.schema_family]
|
|
140
|
+
basis.append("recorded schema namespace and core type")
|
|
141
|
+
if hint is not None:
|
|
142
|
+
candidates = [f for f in candidates if f.name == hint]
|
|
143
|
+
basis.append("caller-supplied format hint: " + hint)
|
|
144
|
+
if version is not None:
|
|
145
|
+
candidates = [f for f in candidates if version in f.versions]
|
|
146
|
+
elif hint is None:
|
|
147
|
+
marker = "/axes" if core == "acquisition" else "/processing_pipeline"
|
|
148
|
+
if document.field(marker).state == FieldState.PRESENT:
|
|
149
|
+
candidates = [f for f in candidates if f.layout == "legacy"]
|
|
150
|
+
basis.append("unversioned legacy structural marker: " + marker)
|
|
151
|
+
else:
|
|
152
|
+
candidates = []
|
|
153
|
+
if len(candidates) != 1:
|
|
154
|
+
raise UnsupportedVersionError(
|
|
155
|
+
f"No unique supported {core} format for schema_version {version!r}; format is not a registered format",
|
|
156
|
+
code="unsupported_format",
|
|
157
|
+
pointer=document.pointer,
|
|
158
|
+
snapshot_id=document.snapshot.snapshot_id,
|
|
159
|
+
)
|
|
160
|
+
return ReaderSelection(candidates[0], version, tuple(basis))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _declared_string(document: Document, pointer: str) -> str | None:
|
|
164
|
+
field = document.field(pointer)
|
|
165
|
+
if field.state != FieldState.PRESENT:
|
|
166
|
+
return None
|
|
167
|
+
if not isinstance(field.value, str):
|
|
168
|
+
raise MetadataError(
|
|
169
|
+
"Schema identity field must be a string", pointer=field.pointer, snapshot_id=document.snapshot.snapshot_id
|
|
170
|
+
)
|
|
171
|
+
return field.value
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Partial interpretations retain their raw fields and explain unavailable meanings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
from aind_metadata_reader._enum import StringEnum
|
|
10
|
+
from aind_metadata_reader.diagnostics import Diagnostic, FieldState, MetadataError
|
|
11
|
+
from aind_metadata_reader.document import Document, Field
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ReadStatus(StringEnum):
|
|
17
|
+
"""Availability of an interpretation, independent of schema validation."""
|
|
18
|
+
|
|
19
|
+
AVAILABLE = "available"
|
|
20
|
+
ABSENT = "absent"
|
|
21
|
+
NULL = "null"
|
|
22
|
+
INVALID = "invalid"
|
|
23
|
+
UNSUPPORTED = "unsupported"
|
|
24
|
+
AMBIGUOUS = "ambiguous"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ReadValue(Generic[T]):
|
|
29
|
+
"""A raw field and its optional interpretation; inspection itself never requires it."""
|
|
30
|
+
|
|
31
|
+
field: Field
|
|
32
|
+
status: ReadStatus
|
|
33
|
+
value: T | None = None
|
|
34
|
+
diagnostics: tuple[Diagnostic, ...] = ()
|
|
35
|
+
|
|
36
|
+
def require(self) -> T:
|
|
37
|
+
"""Assert an operation's requirement explicitly, without gating ordinary reading."""
|
|
38
|
+
if self.status != ReadStatus.AVAILABLE:
|
|
39
|
+
diagnostic = (
|
|
40
|
+
self.diagnostics[0] if self.diagnostics else issue(self.field, self.status.value, "Value unavailable")
|
|
41
|
+
)
|
|
42
|
+
raise MetadataError(
|
|
43
|
+
diagnostic.message, code=diagnostic.code, pointer=diagnostic.pointer, snapshot_id=diagnostic.snapshot_id
|
|
44
|
+
)
|
|
45
|
+
from typing import cast
|
|
46
|
+
|
|
47
|
+
return cast(T, self.value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def issue(field: Field, code: str, message: str) -> Diagnostic:
|
|
51
|
+
"""Keep diagnostics tied to the actual recorded field and complete capture."""
|
|
52
|
+
return Diagnostic(code, message, field.pointer, field.snapshot.snapshot_id)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def unavailable(field: Field, status: ReadStatus, message: str, code: str | None = None) -> ReadValue[Any]:
|
|
56
|
+
"""Describe a limitation without discarding the source field or inventing a value."""
|
|
57
|
+
return ReadValue(field, status, diagnostics=(issue(field, code or status.value, message),))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def observe(field: Field, decoder: Callable[[Any], T]) -> ReadValue[T]:
|
|
61
|
+
"""Interpret one field independently of sibling validity."""
|
|
62
|
+
if field.state != FieldState.PRESENT:
|
|
63
|
+
status = ReadStatus.ABSENT if field.state == FieldState.ABSENT else ReadStatus.NULL
|
|
64
|
+
return ReadValue(field, status)
|
|
65
|
+
try:
|
|
66
|
+
return ReadValue(field, ReadStatus.AVAILABLE, decoder(field.value))
|
|
67
|
+
except (ValueError, TypeError) as error:
|
|
68
|
+
message = error.diagnostic.message if isinstance(error, MetadataError) else str(error)
|
|
69
|
+
code = error.diagnostic.code if isinstance(error, MetadataError) else "invalid_structure"
|
|
70
|
+
return unavailable(field, ReadStatus.INVALID, message, code)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def as_string(value: Any) -> str:
|
|
74
|
+
"""Keep vocabulary values open while rejecting implicit string conversions."""
|
|
75
|
+
if not isinstance(value, str):
|
|
76
|
+
raise MetadataError("Expected a string")
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def as_object(value: Any) -> dict[str, Any]:
|
|
81
|
+
"""Recognize an object without enforcing today's complete schema."""
|
|
82
|
+
if not isinstance(value, dict):
|
|
83
|
+
raise MetadataError("Expected an object")
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def object_view(field: Field) -> ReadValue[Document]:
|
|
88
|
+
"""Preserve non-object entries as unavailable views of their actual raw fields."""
|
|
89
|
+
obj = observe(field, as_object)
|
|
90
|
+
if obj.status != ReadStatus.AVAILABLE:
|
|
91
|
+
return ReadValue(field, obj.status, diagnostics=obj.diagnostics)
|
|
92
|
+
return ReadValue(field, ReadStatus.AVAILABLE, Document.from_snapshot(field.snapshot, field.pointer))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def children(document: Document, pointer: str) -> ReadValue[tuple[ReadValue[Document], ...]]:
|
|
96
|
+
"""Retain every list position, including malformed entries between valid siblings."""
|
|
97
|
+
field = document.field(pointer)
|
|
98
|
+
if field.state != FieldState.PRESENT:
|
|
99
|
+
return observe(field, lambda _: ())
|
|
100
|
+
raw = field.value
|
|
101
|
+
if not isinstance(raw, list):
|
|
102
|
+
return unavailable(field, ReadStatus.INVALID, "Expected a list of objects")
|
|
103
|
+
entries = tuple(object_view(document.field(f"{pointer}/{i}")) for i in range(len(raw)))
|
|
104
|
+
return ReadValue(field, ReadStatus.AVAILABLE, entries, tuple(d for entry in entries for d in entry.diagnostics))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def diagnostics(*values: ReadValue[Any]) -> tuple[Diagnostic, ...]:
|
|
108
|
+
"""Collect local interpretation issues; absence/null are availability, not schema verdicts."""
|
|
109
|
+
return tuple(d for value in values for d in value.diagnostics)
|