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,478 @@
|
|
|
1
|
+
"""Explicit, replayable field changes with source-qualified derivation identities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from dataclasses import dataclass, field, fields, replace
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from importlib.metadata import version
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeVar
|
|
11
|
+
|
|
12
|
+
from aind_metadata_reader._wire import canonical, digest, object_fields, parse_json, string, string_array, strings
|
|
13
|
+
from aind_metadata_reader.diagnostics import FieldState, MetadataError
|
|
14
|
+
from aind_metadata_reader.document import Document, Snapshot, SnapshotReference, Source, _segments
|
|
15
|
+
from aind_metadata_reader.formats import ReaderRecord
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from aind_metadata_reader.applicability import RuleEvaluation
|
|
19
|
+
|
|
20
|
+
_Record = TypeVar("_Record")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Bundle:
|
|
25
|
+
"""Named input snapshots with attributed consistency evidence, never inferred authority."""
|
|
26
|
+
|
|
27
|
+
entries: tuple[tuple[str, Document], ...]
|
|
28
|
+
consistency: Literal["unknown", "asserted", "established"] = "unknown"
|
|
29
|
+
evidence: tuple[str, ...] = ()
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
"""Require unique names and explicit evidence for stronger consistency claims."""
|
|
33
|
+
object.__setattr__(self, "entries", tuple(self.entries))
|
|
34
|
+
object.__setattr__(self, "evidence", strings(self.evidence, code="invalid_bundle"))
|
|
35
|
+
if len({name for name, _ in self.entries}) != len(self.entries):
|
|
36
|
+
raise MetadataError("Bundle names must be unique", code="ambiguous")
|
|
37
|
+
if self.consistency not in ("unknown", "asserted", "established"):
|
|
38
|
+
raise MetadataError("Invalid bundle consistency", code="invalid_bundle")
|
|
39
|
+
if self.consistency != "unknown" and not self.evidence:
|
|
40
|
+
raise MetadataError("Bundle consistency needs explicit evidence", code="missing_evidence")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Change:
|
|
45
|
+
"""An attributed replacement, bound to an exact input and optional supporting inputs."""
|
|
46
|
+
|
|
47
|
+
operation_id: str
|
|
48
|
+
rule_id: str
|
|
49
|
+
rule_revision: str
|
|
50
|
+
implementation: str
|
|
51
|
+
kind: Literal["correction", "inference", "processing_frame"]
|
|
52
|
+
source_digest: str
|
|
53
|
+
pointer: str
|
|
54
|
+
before_state: str
|
|
55
|
+
before_json: str
|
|
56
|
+
after_json: str
|
|
57
|
+
author: str
|
|
58
|
+
reason: str
|
|
59
|
+
evidence: tuple[str, ...]
|
|
60
|
+
scope: str
|
|
61
|
+
assumptions: tuple[str, ...] = ()
|
|
62
|
+
source: Source = field(default_factory=Source)
|
|
63
|
+
target: SnapshotReference | None = None
|
|
64
|
+
references: tuple[SnapshotReference, ...] = ()
|
|
65
|
+
readers: tuple[ReaderRecord, ...] = ()
|
|
66
|
+
|
|
67
|
+
def __post_init__(self) -> None:
|
|
68
|
+
"""Detach mutable caller sequences without weakening application-time checks."""
|
|
69
|
+
object.__setattr__(self, "evidence", strings(self.evidence, code="invalid_change"))
|
|
70
|
+
object.__setattr__(self, "assumptions", strings(self.assumptions, code="invalid_change"))
|
|
71
|
+
object.__setattr__(self, "references", tuple(self.references))
|
|
72
|
+
object.__setattr__(self, "readers", tuple(self.readers))
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def propose(
|
|
76
|
+
cls,
|
|
77
|
+
document: Document,
|
|
78
|
+
pointer: str,
|
|
79
|
+
value: Any,
|
|
80
|
+
*,
|
|
81
|
+
operation_id: str,
|
|
82
|
+
rule_id: str,
|
|
83
|
+
rule_revision: str,
|
|
84
|
+
implementation: str,
|
|
85
|
+
kind: Literal["correction", "inference", "processing_frame"],
|
|
86
|
+
author: str,
|
|
87
|
+
reason: str,
|
|
88
|
+
evidence: tuple[str, ...],
|
|
89
|
+
scope: str,
|
|
90
|
+
assumptions: tuple[str, ...] = (),
|
|
91
|
+
references: tuple[SnapshotReference, ...] = (),
|
|
92
|
+
readers: tuple[ReaderRecord, ...] = (),
|
|
93
|
+
) -> Change:
|
|
94
|
+
"""Capture source preconditions without modifying the recorded snapshot."""
|
|
95
|
+
before = document.field(pointer)
|
|
96
|
+
replacement = Document({"value": value}).raw["value"]
|
|
97
|
+
return cls(
|
|
98
|
+
operation_id,
|
|
99
|
+
rule_id,
|
|
100
|
+
rule_revision,
|
|
101
|
+
implementation,
|
|
102
|
+
kind,
|
|
103
|
+
document.snapshot.content_digest,
|
|
104
|
+
before.pointer,
|
|
105
|
+
before.state.value,
|
|
106
|
+
json.dumps(before.value, allow_nan=False),
|
|
107
|
+
json.dumps(replacement, allow_nan=False),
|
|
108
|
+
author,
|
|
109
|
+
reason,
|
|
110
|
+
evidence,
|
|
111
|
+
scope,
|
|
112
|
+
assumptions,
|
|
113
|
+
document.snapshot.source,
|
|
114
|
+
replace(document.reference(), pointer=before.pointer),
|
|
115
|
+
references,
|
|
116
|
+
readers,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def record(self, *, legacy: bool = False) -> dict[str, Any]:
|
|
120
|
+
"""Serialize replacements as JSON text while retaining absent/null preconditions."""
|
|
121
|
+
result = {
|
|
122
|
+
f.name: getattr(self, f.name)
|
|
123
|
+
for f in fields(self)
|
|
124
|
+
if f.name not in ("source", "target", "references", "readers")
|
|
125
|
+
}
|
|
126
|
+
result["evidence"] = list(self.evidence)
|
|
127
|
+
result["assumptions"] = list(self.assumptions)
|
|
128
|
+
result["source"] = self.source.record(legacy=legacy)
|
|
129
|
+
if not legacy:
|
|
130
|
+
result.update(
|
|
131
|
+
target=None if self.target is None else self.target.record(),
|
|
132
|
+
references=[ref.record() for ref in self.references],
|
|
133
|
+
readers=[reader.record() for reader in self.readers],
|
|
134
|
+
)
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def from_record(cls, value: Any, *, legacy_document: Document | None = None, legacy: bool = False) -> Change:
|
|
139
|
+
"""Restore a proposal strictly; v1 targets derive from verified manifest inputs."""
|
|
140
|
+
names = {f.name for f in fields(cls)}
|
|
141
|
+
legacy = legacy or legacy_document is not None
|
|
142
|
+
data = dict(object_fields(value, names - {"target", "references", "readers"} if legacy else names))
|
|
143
|
+
data["evidence"] = string_array(data["evidence"])
|
|
144
|
+
data["assumptions"] = string_array(data["assumptions"])
|
|
145
|
+
data["source"] = Source.from_record(data["source"], legacy=legacy)
|
|
146
|
+
string(data["pointer"], empty=True)
|
|
147
|
+
if legacy_document is not None:
|
|
148
|
+
data["target"] = replace(legacy_document.reference(), pointer=data["pointer"])
|
|
149
|
+
elif not legacy:
|
|
150
|
+
data["target"] = SnapshotReference.from_record(data["target"])
|
|
151
|
+
data["references"] = _records(data["references"], SnapshotReference.from_record)
|
|
152
|
+
data["readers"] = _records(data["readers"], ReaderRecord.from_record)
|
|
153
|
+
change = cls(**data)
|
|
154
|
+
_validate(change, allow_unbound=legacy and legacy_document is None)
|
|
155
|
+
return change
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _records(value: Any, factory: Callable[[Any], _Record]) -> tuple[_Record, ...]:
|
|
159
|
+
if not isinstance(value, list):
|
|
160
|
+
raise MetadataError("Expected a saved-record array", code="invalid_manifest")
|
|
161
|
+
return tuple(factory(item) for item in value)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _application_record(
|
|
165
|
+
document: Document,
|
|
166
|
+
changes: tuple[Change, ...],
|
|
167
|
+
evaluations: tuple[RuleEvaluation, ...],
|
|
168
|
+
applied_at: str,
|
|
169
|
+
writer_version: str,
|
|
170
|
+
) -> dict[str, Any]:
|
|
171
|
+
return {
|
|
172
|
+
"format": "metadata-interpretation/2",
|
|
173
|
+
"input": document.reference().record(),
|
|
174
|
+
"changes": [c.record() for c in changes],
|
|
175
|
+
"evaluations": [e.record() for e in evaluations],
|
|
176
|
+
"applied_at": applied_at,
|
|
177
|
+
"writer_version": writer_version,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class Interpretation:
|
|
183
|
+
"""Original and derived views, with linked decisions and applied operations."""
|
|
184
|
+
|
|
185
|
+
original: Document
|
|
186
|
+
derived: Document
|
|
187
|
+
changes: tuple[Change, ...]
|
|
188
|
+
changed: tuple[bool, ...]
|
|
189
|
+
applied_at: str
|
|
190
|
+
evaluations: tuple[RuleEvaluation, ...] = ()
|
|
191
|
+
writer_version: str = "unknown"
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def interpretation_id(self) -> str:
|
|
195
|
+
"""Bind the derivation to its inputs, decisions, operations, time, and writer."""
|
|
196
|
+
return digest(
|
|
197
|
+
"metadata-interpretation-v1",
|
|
198
|
+
_application_record(self.original, self.changes, self.evaluations, self.applied_at, self.writer_version),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def manifest(self) -> dict[str, Any]:
|
|
202
|
+
"""Export an independently versioned application record, not a source-schema file."""
|
|
203
|
+
record = _application_record(
|
|
204
|
+
self.original, self.changes, self.evaluations, self.applied_at, self.writer_version
|
|
205
|
+
)
|
|
206
|
+
links = {
|
|
207
|
+
change.operation_id: evaluation.evaluation_id
|
|
208
|
+
for evaluation in self.evaluations
|
|
209
|
+
for change in evaluation.changes
|
|
210
|
+
if change in self.changes
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
**record,
|
|
214
|
+
"interpretation_id": self.interpretation_id,
|
|
215
|
+
"changed": list(self.changed),
|
|
216
|
+
"result_digest": self.derived.snapshot.content_digest,
|
|
217
|
+
"result_snapshot_id": self.derived.snapshot.snapshot_id,
|
|
218
|
+
"applied_operations": links,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _validate(change: Change, *, allow_unbound: bool = False) -> None:
|
|
223
|
+
for value in (
|
|
224
|
+
change.operation_id,
|
|
225
|
+
change.rule_id,
|
|
226
|
+
change.rule_revision,
|
|
227
|
+
change.implementation,
|
|
228
|
+
change.author,
|
|
229
|
+
change.reason,
|
|
230
|
+
change.scope,
|
|
231
|
+
change.source_digest,
|
|
232
|
+
):
|
|
233
|
+
string(value, code="invalid_change")
|
|
234
|
+
if not change.evidence:
|
|
235
|
+
raise MetadataError("A change needs attributed evidence", code="missing_evidence")
|
|
236
|
+
if change.kind not in ("correction", "inference", "processing_frame"):
|
|
237
|
+
raise MetadataError("Unknown change kind", code="invalid_change")
|
|
238
|
+
if change.kind == "inference" and not change.assumptions:
|
|
239
|
+
raise MetadataError("An inference must record its assumptions", code="missing_assumptions")
|
|
240
|
+
if change.before_state not in tuple(state.value for state in FieldState):
|
|
241
|
+
raise MetadataError("Invalid source field state", code="invalid_change")
|
|
242
|
+
if not _segments(change.pointer):
|
|
243
|
+
raise MetadataError("Whole-document replacement is not a field correction", code="invalid_change")
|
|
244
|
+
if not (allow_unbound and change.target is None) and (
|
|
245
|
+
not isinstance(change.target, SnapshotReference)
|
|
246
|
+
or (
|
|
247
|
+
change.target.source != change.source
|
|
248
|
+
or change.target.content_digest != change.source_digest
|
|
249
|
+
or change.target.pointer != change.pointer
|
|
250
|
+
)
|
|
251
|
+
):
|
|
252
|
+
raise MetadataError("Proposal must identify its exact target snapshot and field", code="invalid_change")
|
|
253
|
+
if any(not isinstance(ref, SnapshotReference) for ref in change.references):
|
|
254
|
+
raise MetadataError("Expected structured evidence references", code="invalid_change")
|
|
255
|
+
if any(
|
|
256
|
+
not isinstance(reader, ReaderRecord) or reader.document not in change.references for reader in change.readers
|
|
257
|
+
):
|
|
258
|
+
raise MetadataError("Reader provenance must identify a referenced input", code="invalid_change")
|
|
259
|
+
parse_json(change.before_json)
|
|
260
|
+
parse_json(change.after_json)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _replace(data: dict[str, Any], pointer: str, value: Any) -> None:
|
|
264
|
+
segments = _segments(pointer)
|
|
265
|
+
parent: Any = data
|
|
266
|
+
for segment in segments[:-1]:
|
|
267
|
+
if isinstance(parent, dict) and segment in parent:
|
|
268
|
+
parent = parent[segment]
|
|
269
|
+
elif (
|
|
270
|
+
isinstance(parent, list)
|
|
271
|
+
and segment.isascii()
|
|
272
|
+
and segment.isdigit()
|
|
273
|
+
and str(int(segment)) == segment
|
|
274
|
+
and int(segment) < len(parent)
|
|
275
|
+
):
|
|
276
|
+
parent = parent[int(segment)]
|
|
277
|
+
else:
|
|
278
|
+
raise MetadataError("Correction parent is absent", code="absent", pointer=pointer)
|
|
279
|
+
leaf = segments[-1]
|
|
280
|
+
if isinstance(parent, dict):
|
|
281
|
+
parent[leaf] = value
|
|
282
|
+
elif (
|
|
283
|
+
isinstance(parent, list)
|
|
284
|
+
and leaf.isascii()
|
|
285
|
+
and leaf.isdigit()
|
|
286
|
+
and str(int(leaf)) == leaf
|
|
287
|
+
and int(leaf) < len(parent)
|
|
288
|
+
):
|
|
289
|
+
parent[int(leaf)] = value
|
|
290
|
+
else:
|
|
291
|
+
raise MetadataError(
|
|
292
|
+
"Cannot replace this field without inferring parent structure", code="invalid_change", pointer=pointer
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _apply_operations(document: Document, operations: tuple[Change, ...]) -> tuple[dict[str, Any], tuple[bool, ...]]:
|
|
297
|
+
data = document.snapshot.raw
|
|
298
|
+
ids: set[str] = set()
|
|
299
|
+
targets: list[str] = []
|
|
300
|
+
changed = []
|
|
301
|
+
for change in operations:
|
|
302
|
+
_validate(change)
|
|
303
|
+
if change.source_digest != document.snapshot.content_digest:
|
|
304
|
+
raise MetadataError("Change targets another snapshot revision", code="stale_source", pointer=change.pointer)
|
|
305
|
+
if change.source != document.snapshot.source:
|
|
306
|
+
raise MetadataError(
|
|
307
|
+
"Change targets another source identity", code="conflicting_source", pointer=change.pointer
|
|
308
|
+
)
|
|
309
|
+
if change.target is None or change.target.snapshot_id != document.snapshot.snapshot_id:
|
|
310
|
+
raise MetadataError("Change targets another snapshot capture", code="stale_source", pointer=change.pointer)
|
|
311
|
+
if (
|
|
312
|
+
document.pointer
|
|
313
|
+
and change.pointer != document.pointer
|
|
314
|
+
and not change.pointer.startswith(document.pointer + "/")
|
|
315
|
+
):
|
|
316
|
+
raise MetadataError("Change is outside the selected document", code="outside_focus", pointer=change.pointer)
|
|
317
|
+
if change.operation_id in ids or any(
|
|
318
|
+
change.pointer == p or change.pointer.startswith(p + "/") or p.startswith(change.pointer + "/")
|
|
319
|
+
for p in targets
|
|
320
|
+
):
|
|
321
|
+
raise MetadataError(
|
|
322
|
+
"Conflicting changes require explicit resolution", code="conflicting_changes", pointer=change.pointer
|
|
323
|
+
)
|
|
324
|
+
before = parse_json(change.before_json)
|
|
325
|
+
after = parse_json(change.after_json)
|
|
326
|
+
original = document.snapshot.field(change.pointer)
|
|
327
|
+
if original.state != change.before_state or canonical(original.value) != canonical(before):
|
|
328
|
+
raise MetadataError(
|
|
329
|
+
"Source value precondition does not match", code="stale_precondition", pointer=change.pointer
|
|
330
|
+
)
|
|
331
|
+
_replace(data, change.pointer, after)
|
|
332
|
+
changed.append(original.state == FieldState.ABSENT or canonical(before) != canonical(after))
|
|
333
|
+
ids.add(change.operation_id)
|
|
334
|
+
targets.append(change.pointer)
|
|
335
|
+
return data, tuple(changed)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _check_evaluations(
|
|
339
|
+
document: Document, operations: tuple[Change, ...], evaluations: tuple[RuleEvaluation, ...]
|
|
340
|
+
) -> None:
|
|
341
|
+
seen: set[str] = set()
|
|
342
|
+
proposals: dict[str, Change] = {}
|
|
343
|
+
for evaluation in evaluations:
|
|
344
|
+
if evaluation.evaluation_id in seen:
|
|
345
|
+
raise MetadataError("Repeated evaluation", code="conflicting_evaluations")
|
|
346
|
+
seen.add(evaluation.evaluation_id)
|
|
347
|
+
if not any(
|
|
348
|
+
ref.snapshot_id == document.snapshot.snapshot_id and ref.pointer == document.pointer
|
|
349
|
+
for ref in evaluation.inputs
|
|
350
|
+
):
|
|
351
|
+
raise MetadataError("Evaluation does not identify this input view", code="conflicting_evaluations")
|
|
352
|
+
for change in evaluation.changes:
|
|
353
|
+
if change.operation_id in proposals:
|
|
354
|
+
raise MetadataError("Evaluations have ambiguous operation identities", code="conflicting_evaluations")
|
|
355
|
+
proposals[change.operation_id] = change
|
|
356
|
+
for operation in operations:
|
|
357
|
+
if operation.operation_id in proposals and operation != proposals[operation.operation_id]:
|
|
358
|
+
raise MetadataError("Applied operation differs from evaluated proposal", code="conflicting_evaluations")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _apply(
|
|
362
|
+
document: Document,
|
|
363
|
+
operations: tuple[Change, ...],
|
|
364
|
+
evaluations: tuple[RuleEvaluation, ...],
|
|
365
|
+
applied_at: str,
|
|
366
|
+
writer_version: str,
|
|
367
|
+
) -> Interpretation:
|
|
368
|
+
try:
|
|
369
|
+
# Python 3.10 does not accept the UTC Z suffix directly. Preserve the
|
|
370
|
+
# recorded spelling in manifests while checking the equivalent offset.
|
|
371
|
+
parsed_time = (
|
|
372
|
+
applied_at[:-1] + "+00:00" if isinstance(applied_at, str) and applied_at.endswith("Z") else applied_at
|
|
373
|
+
)
|
|
374
|
+
timestamp = datetime.fromisoformat(parsed_time)
|
|
375
|
+
if timestamp.utcoffset() is None:
|
|
376
|
+
raise ValueError("Timezone required")
|
|
377
|
+
except (ValueError, TypeError) as error:
|
|
378
|
+
raise MetadataError("Record an ISO application time with timezone", code="invalid_timestamp") from error
|
|
379
|
+
string(writer_version)
|
|
380
|
+
_check_evaluations(document, operations, evaluations)
|
|
381
|
+
data, changed = _apply_operations(document, operations)
|
|
382
|
+
identity = digest(
|
|
383
|
+
"metadata-interpretation-v1", _application_record(document, operations, evaluations, applied_at, writer_version)
|
|
384
|
+
)
|
|
385
|
+
source = Source(derived_from=(document.snapshot.snapshot_id,), interpretation_id=identity)
|
|
386
|
+
derived = Document.from_snapshot(Snapshot.from_mapping(data, source), document.pointer)
|
|
387
|
+
return Interpretation(document, derived, operations, changed, applied_at, evaluations, writer_version)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def apply_changes(
|
|
391
|
+
document: Document, changes: Iterable[Change], *, applied_at: str, evaluations: Iterable[RuleEvaluation] = ()
|
|
392
|
+
) -> Interpretation:
|
|
393
|
+
"""Apply explicit proposals within this view, retaining any supplied rule decisions.
|
|
394
|
+
|
|
395
|
+
Supply evaluations even for unknown and negative decisions to retain their
|
|
396
|
+
evidence. Applied operations are linked to matching evaluated proposals;
|
|
397
|
+
proposed operations may be deliberately left unapplied.
|
|
398
|
+
"""
|
|
399
|
+
return _apply(document, tuple(changes), tuple(evaluations), applied_at, version("aind-metadata-reader"))
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _check_result(result: Interpretation, manifest: dict[str, Any], *, legacy: bool = False) -> None:
|
|
403
|
+
flags = manifest["changed"]
|
|
404
|
+
if not isinstance(flags, list) or any(type(flag) is not bool for flag in flags):
|
|
405
|
+
raise MetadataError("Changed flags must be booleans", code="invalid_manifest")
|
|
406
|
+
if list(result.changed) != flags or result.derived.snapshot.content_digest != manifest["result_digest"]:
|
|
407
|
+
raise MetadataError("Manifest result does not match recorded output", code="manifest_mismatch")
|
|
408
|
+
if not legacy and result.manifest() != manifest:
|
|
409
|
+
raise MetadataError("Manifest identities or operation links do not match", code="manifest_mismatch")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _replay_v1(document: Document, manifest: dict[str, Any]) -> Interpretation:
|
|
413
|
+
object_fields(
|
|
414
|
+
manifest,
|
|
415
|
+
{
|
|
416
|
+
"format",
|
|
417
|
+
"source_digest",
|
|
418
|
+
"focus_pointer",
|
|
419
|
+
"source",
|
|
420
|
+
"original_digest",
|
|
421
|
+
"changes",
|
|
422
|
+
"changed",
|
|
423
|
+
"result_digest",
|
|
424
|
+
"applied_at",
|
|
425
|
+
},
|
|
426
|
+
)
|
|
427
|
+
if (
|
|
428
|
+
manifest["source_digest"] != document.snapshot.content_digest
|
|
429
|
+
or manifest["focus_pointer"] != document.pointer
|
|
430
|
+
or manifest["original_digest"] != document.snapshot.original_digest
|
|
431
|
+
):
|
|
432
|
+
raise MetadataError("Manifest targets another source or object", code="stale_source")
|
|
433
|
+
if Source.from_record(manifest["source"], legacy=True) != document.snapshot.source:
|
|
434
|
+
raise MetadataError("Manifest source identity does not match", code="conflicting_source")
|
|
435
|
+
operations = _records(manifest["changes"], lambda item: Change.from_record(item, legacy_document=document))
|
|
436
|
+
result = apply_changes(document, operations, applied_at=manifest["applied_at"])
|
|
437
|
+
_check_result(result, manifest, legacy=True)
|
|
438
|
+
return result
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def replay(document: Document, manifest: dict[str, Any]) -> Interpretation:
|
|
442
|
+
"""Apply recorded replacements to exact archived inputs; never rerun reader/rule code.
|
|
443
|
+
|
|
444
|
+
Historical v1 records are read explicitly, not relabeled as v2 inputs. Their
|
|
445
|
+
verified target is bound to the current source-qualified identity on replay;
|
|
446
|
+
newly exported results use v2 and do not invent historical reader provenance.
|
|
447
|
+
"""
|
|
448
|
+
from aind_metadata_reader.applicability import RuleEvaluation
|
|
449
|
+
|
|
450
|
+
if not isinstance(manifest, dict):
|
|
451
|
+
raise MetadataError("Expected an interpretation manifest object", code="invalid_manifest")
|
|
452
|
+
if manifest.get("format") == "metadata-interpretation/1":
|
|
453
|
+
return _replay_v1(document, manifest)
|
|
454
|
+
if manifest.get("format") != "metadata-interpretation/2":
|
|
455
|
+
raise MetadataError("Unsupported interpretation manifest", code="unsupported_manifest")
|
|
456
|
+
object_fields(
|
|
457
|
+
manifest,
|
|
458
|
+
{
|
|
459
|
+
"format",
|
|
460
|
+
"input",
|
|
461
|
+
"changes",
|
|
462
|
+
"evaluations",
|
|
463
|
+
"applied_at",
|
|
464
|
+
"writer_version",
|
|
465
|
+
"interpretation_id",
|
|
466
|
+
"changed",
|
|
467
|
+
"result_digest",
|
|
468
|
+
"result_snapshot_id",
|
|
469
|
+
"applied_operations",
|
|
470
|
+
},
|
|
471
|
+
)
|
|
472
|
+
if SnapshotReference.from_record(manifest["input"]) != document.reference():
|
|
473
|
+
raise MetadataError("Manifest targets another snapshot or view", code="stale_source")
|
|
474
|
+
operations = _records(manifest["changes"], Change.from_record)
|
|
475
|
+
evaluations = _records(manifest["evaluations"], RuleEvaluation.from_record)
|
|
476
|
+
result = _apply(document, operations, evaluations, manifest["applied_at"], manifest["writer_version"])
|
|
477
|
+
_check_result(result, manifest)
|
|
478
|
+
return result
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|