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,63 @@
1
+ """Read historical AIND metadata without rewriting source records."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from aind_metadata_reader.applicability import Applicability, RuleEvaluation
6
+ from aind_metadata_reader.components import ArtifactReference, CodeView
7
+ from aind_metadata_reader.diagnostics import Diagnostic, FieldState, MetadataError, UnsupportedVersionError
8
+ from aind_metadata_reader.document import Document, Field, Snapshot, SnapshotReference, Source, read_json
9
+ from aind_metadata_reader.formats import FORMATS, Format, ReaderRecord, ReaderSelection
10
+ from aind_metadata_reader.inspection import ReadStatus, ReadValue
11
+ from aind_metadata_reader.interpretation import Bundle, Change, Interpretation, apply_changes, replay
12
+ from aind_metadata_reader.readers import (
13
+ Acquisition,
14
+ Axis,
15
+ CoordinateFrame,
16
+ Process,
17
+ Processing,
18
+ read_acquisition,
19
+ read_processing,
20
+ )
21
+ from aind_metadata_reader.validation import ValidationReport, validate
22
+
23
+ __all__ = [
24
+ "FORMATS",
25
+ "Acquisition",
26
+ "Applicability",
27
+ "ArtifactReference",
28
+ "Axis",
29
+ "Bundle",
30
+ "Change",
31
+ "CodeView",
32
+ "CoordinateFrame",
33
+ "Diagnostic",
34
+ "Document",
35
+ "Field",
36
+ "FieldState",
37
+ "Format",
38
+ "Interpretation",
39
+ "MetadataError",
40
+ "Process",
41
+ "Processing",
42
+ "ReadStatus",
43
+ "ReadValue",
44
+ "ReaderRecord",
45
+ "ReaderSelection",
46
+ "RuleEvaluation",
47
+ "Snapshot",
48
+ "SnapshotReference",
49
+ "Source",
50
+ "UnsupportedVersionError",
51
+ "ValidationReport",
52
+ "apply_changes",
53
+ "read_acquisition",
54
+ "read_json",
55
+ "read_processing",
56
+ "replay",
57
+ "validate",
58
+ ]
59
+
60
+ try:
61
+ __version__ = version("aind-metadata-reader")
62
+ except PackageNotFoundError:
63
+ __version__ = "0.0.0.dev0"
@@ -0,0 +1,10 @@
1
+ """String-valued enums with consistent display on every supported Python version."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class StringEnum(str, Enum):
7
+ """Retain string comparison/JSON encoding and display values rather than member names."""
8
+
9
+ def __str__(self) -> str:
10
+ return str(self.value)
@@ -0,0 +1,84 @@
1
+ """Strict primitives shared by independently versioned saved formats."""
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ from typing import Any
7
+
8
+ from aind_metadata_reader.diagnostics import MetadataError
9
+
10
+
11
+ def json_value(value: Any) -> None:
12
+ """Reject implicit conversions and nonfinite numbers at JSON boundaries."""
13
+ if value is None or isinstance(value, (str, bool, int)):
14
+ return
15
+ if isinstance(value, float) and math.isfinite(value):
16
+ return
17
+ if isinstance(value, list):
18
+ for item in value:
19
+ json_value(item)
20
+ return
21
+ if isinstance(value, dict) and all(isinstance(key, str) for key in value):
22
+ for item in value.values():
23
+ json_value(item)
24
+ return
25
+ raise MetadataError("Expected finite JSON values with string object keys", code="invalid_json")
26
+
27
+
28
+ def _pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
29
+ obj: dict[str, Any] = {}
30
+ for key, value in pairs:
31
+ if key in obj:
32
+ raise MetadataError(f"Duplicate JSON key {key!r}", code="duplicate_key")
33
+ obj[key] = value
34
+ return obj
35
+
36
+
37
+ def parse_json(value: str | bytes) -> Any:
38
+ """Decode JSON without discarding duplicate keys or accepting nonfinite values."""
39
+ try:
40
+ result = json.loads(value, object_pairs_hook=_pairs)
41
+ except (ValueError, UnicodeError, TypeError) as error:
42
+ if isinstance(error, MetadataError):
43
+ raise
44
+ raise MetadataError(f"Invalid JSON: {error}", code="invalid_json") from error
45
+ json_value(result)
46
+ return result
47
+
48
+
49
+ def canonical(value: Any) -> bytes:
50
+ """Use the documented Python JSON convention for identities, never string coercion."""
51
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False).encode()
52
+
53
+
54
+ def digest(scheme: str, value: Any) -> str:
55
+ """Namespace identity algorithms separately from file-format versions."""
56
+ return scheme + ":sha256:" + hashlib.sha256(canonical(value)).hexdigest()
57
+
58
+
59
+ def object_fields(value: Any, names: set[str], *, code: str = "invalid_manifest") -> dict[str, Any]:
60
+ """Require an exact field set; extensions need a new declared format version."""
61
+ if not isinstance(value, dict) or set(value) != names:
62
+ raise MetadataError("Saved record has missing or unknown fields", code=code)
63
+ return value
64
+
65
+
66
+ def string(value: Any, *, code: str = "invalid_manifest", empty: bool = False) -> str:
67
+ """Require a string without coercing other JSON types."""
68
+ if not isinstance(value, str) or not empty and not value.strip():
69
+ raise MetadataError("Expected a nonempty string", code=code)
70
+ return value
71
+
72
+
73
+ def string_array(value: Any, *, code: str = "invalid_manifest") -> tuple[str, ...]:
74
+ """Accept JSON arrays, never strings iterated as individual characters."""
75
+ if not isinstance(value, list):
76
+ raise MetadataError("Expected an array of strings", code=code)
77
+ return tuple(string(item, code=code) for item in value)
78
+
79
+
80
+ def strings(value: Any, *, code: str) -> tuple[str, ...]:
81
+ """Detach caller-supplied sequences into immutable, validated string tuples."""
82
+ if not isinstance(value, (tuple, list)):
83
+ raise MetadataError("Expected a sequence of strings", code=code)
84
+ return tuple(string(item, code=code) for item in value)
@@ -0,0 +1,132 @@
1
+ """Versioned decisions link exact inputs and proposed operations without applying them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from aind_metadata_reader._enum import StringEnum
9
+ from aind_metadata_reader._wire import digest, object_fields, string, string_array, strings
10
+ from aind_metadata_reader.diagnostics import MetadataError
11
+ from aind_metadata_reader.document import SnapshotReference
12
+ from aind_metadata_reader.formats import ReaderRecord
13
+ from aind_metadata_reader.interpretation import Change, _records, _validate
14
+
15
+
16
+ class Applicability(StringEnum):
17
+ """A negative conclusion differs from insufficient evidence."""
18
+
19
+ APPLICABLE = "applicable"
20
+ NOT_APPLICABLE = "not_applicable"
21
+ UNKNOWN = "unknown"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class RuleEvaluation:
26
+ """A versioned decision bound to exact inputs, reader provenance, and proposals."""
27
+
28
+ rule_id: str
29
+ rule_revision: str
30
+ outcome: Applicability
31
+ reason: str
32
+ evidence: tuple[str, ...] = ()
33
+ changes: tuple[Change, ...] = ()
34
+ inputs: tuple[SnapshotReference, ...] = ()
35
+ readers: tuple[ReaderRecord, ...] = ()
36
+ implementation: str = "manual/1"
37
+ _legacy: bool = field(default=False, repr=False)
38
+
39
+ def __post_init__(self) -> None:
40
+ """Validate links as well as the distinction between proposed and applied edits."""
41
+ for value in (self.rule_id, self.rule_revision, self.reason, self.implementation):
42
+ string(value, code="invalid_evaluation")
43
+ object.__setattr__(self, "evidence", strings(self.evidence, code="invalid_evaluation"))
44
+ for name in ("changes", "inputs", "readers"):
45
+ object.__setattr__(self, name, tuple(getattr(self, name)))
46
+ if not isinstance(self.outcome, Applicability):
47
+ raise MetadataError("Unknown applicability outcome", code="invalid_evaluation")
48
+ if (self.outcome == Applicability.APPLICABLE) != bool(self.changes):
49
+ raise MetadataError("Only applicable corrections carry proposals", code="invalid_evaluation")
50
+ if not self._legacy and (not self.inputs or any(not isinstance(ref, SnapshotReference) for ref in self.inputs)):
51
+ raise MetadataError("An evaluation needs exact input references", code="invalid_evaluation")
52
+ if any(not isinstance(r, ReaderRecord) or r.document not in self.inputs for r in self.readers):
53
+ raise MetadataError("Reader provenance must identify an evaluation input", code="invalid_evaluation")
54
+ for change in self.changes:
55
+ _validate(change, allow_unbound=self._legacy)
56
+ if (change.rule_id, change.rule_revision) != (self.rule_id, self.rule_revision):
57
+ raise MetadataError("Proposal identifies a different rule revision", code="invalid_evaluation")
58
+ if not self._legacy and (
59
+ change.implementation != self.implementation
60
+ or change.references != self.inputs
61
+ or change.readers != self.readers
62
+ ):
63
+ raise MetadataError("Proposal provenance differs from the evaluated inputs", code="invalid_evaluation")
64
+ if len({c.operation_id for c in self.changes}) != len(self.changes):
65
+ raise MetadataError("Proposal operation identities must be unique", code="invalid_evaluation")
66
+
67
+ def _body(self) -> dict[str, Any]:
68
+ result = {
69
+ "format": "metadata-rule-evaluation/1" if self._legacy else "metadata-rule-evaluation/2",
70
+ "rule_id": self.rule_id,
71
+ "rule_revision": self.rule_revision,
72
+ "outcome": self.outcome.value,
73
+ "reason": self.reason,
74
+ "evidence": list(self.evidence),
75
+ "changes": [c.record(legacy=self._legacy) for c in self.changes],
76
+ }
77
+ if not self._legacy:
78
+ result.update(
79
+ inputs=[ref.record() for ref in self.inputs],
80
+ readers=[r.record() for r in self.readers],
81
+ implementation=self.implementation,
82
+ )
83
+ return result
84
+
85
+ @property
86
+ def evaluation_id(self) -> str:
87
+ """Identify the retained decision independently of whether proposals were applied."""
88
+ return digest("metadata-rule-evaluation-v1", self._body())
89
+
90
+ def record(self) -> dict[str, Any]:
91
+ """Retain historical v1 records verbatim; new decisions use the qualified v2 format."""
92
+ record = self._body()
93
+ if not self._legacy:
94
+ record["evaluation_id"] = self.evaluation_id
95
+ return record
96
+
97
+ @classmethod
98
+ def from_record(cls, value: Any) -> RuleEvaluation:
99
+ """Restore decisions for audit and replay without running their implementation."""
100
+ if not isinstance(value, dict) or value.get("format") not in (
101
+ "metadata-rule-evaluation/1",
102
+ "metadata-rule-evaluation/2",
103
+ ):
104
+ raise MetadataError("Unsupported evaluation record", code="unsupported_evaluation")
105
+ legacy = value["format"] == "metadata-rule-evaluation/1"
106
+ names = {"format", "rule_id", "rule_revision", "outcome", "reason", "evidence", "changes"}
107
+ data = dict(
108
+ object_fields(
109
+ value,
110
+ names if legacy else names | {"inputs", "readers", "implementation", "evaluation_id"},
111
+ code="invalid_evaluation",
112
+ )
113
+ )
114
+ try:
115
+ outcome = Applicability(data["outcome"])
116
+ except (TypeError, ValueError) as error:
117
+ raise MetadataError("Unknown applicability outcome", code="invalid_evaluation") from error
118
+ result = cls(
119
+ data["rule_id"],
120
+ data["rule_revision"],
121
+ outcome,
122
+ data["reason"],
123
+ string_array(data["evidence"], code="invalid_evaluation"),
124
+ _records(data["changes"], lambda item: Change.from_record(item, legacy=legacy)),
125
+ () if legacy else _records(data["inputs"], SnapshotReference.from_record),
126
+ () if legacy else _records(data["readers"], ReaderRecord.from_record),
127
+ "legacy-unrecorded" if legacy else data["implementation"],
128
+ legacy,
129
+ )
130
+ if not legacy and data["evaluation_id"] != result.evaluation_id:
131
+ raise MetadataError("Evaluation identity mismatch", code="evaluation_mismatch")
132
+ return result
@@ -0,0 +1,125 @@
1
+ """Small views preserve code and reference meanings across historical representations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Literal
7
+
8
+ from aind_metadata_reader.diagnostics import Diagnostic, FieldState
9
+ from aind_metadata_reader.document import Document, Field
10
+ from aind_metadata_reader.inspection import ReadStatus, ReadValue, as_object, as_string, observe, unavailable
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ArtifactReference:
15
+ """A file, asset, group, or uninterpreted entry with its exact source preserved."""
16
+
17
+ field: Field
18
+ kind: Literal["file", "asset", "asset_group", "unknown"]
19
+
20
+ @property
21
+ def raw(self) -> Any:
22
+ """Return recorded content without manufacturing an asset ID or file path."""
23
+ return self.field.value
24
+
25
+ @property
26
+ def location(self) -> ReadValue[str]:
27
+ """Keep asset URLs distinct from file-level locations in kind and provenance."""
28
+ if self.kind == "file":
29
+ return observe(self.field, as_string)
30
+ if self.kind == "asset":
31
+ return observe(self.field.snapshot.field(self.field.pointer + "/url"), as_string)
32
+ return unavailable(self.field, ReadStatus.UNSUPPORTED, "Reference does not establish one location")
33
+
34
+ @property
35
+ def members(self) -> ReadValue[tuple[ArtifactReference, ...]]:
36
+ """Retain grouped assets and nesting rather than flattening their meaning."""
37
+ if self.kind != "asset_group":
38
+ return unavailable(self.field, ReadStatus.UNSUPPORTED, "Reference is not an asset group")
39
+ return references(self.field.snapshot.field(self.field.pointer + "/assets"), "assets")
40
+
41
+
42
+ def references(field: Field, representation: Literal["paths", "assets"]) -> ReadValue[tuple[ArtifactReference, ...]]:
43
+ """Read known reference structures while retaining malformed and unfamiliar members."""
44
+ if field.state != FieldState.PRESENT:
45
+ return observe(field, lambda _: ())
46
+ value = field.value
47
+ if representation == "paths" and isinstance(value, str):
48
+ return ReadValue(field, ReadStatus.AVAILABLE, (ArtifactReference(field, "file"),))
49
+ if not isinstance(value, list):
50
+ return unavailable(field, ReadStatus.INVALID, "Expected recorded paths or an array of asset references")
51
+ items = []
52
+ issues: list[Diagnostic] = []
53
+ for i, item in enumerate(value):
54
+ entry = field.snapshot.field(field.pointer + "/" + str(i))
55
+ kind: Literal["file", "asset", "asset_group", "unknown"] = "unknown"
56
+ if representation == "paths" and isinstance(item, str):
57
+ kind = "file"
58
+ elif representation == "assets" and isinstance(item, dict):
59
+ marker = item.get("object_type")
60
+ if marker == "Data asset":
61
+ kind = "asset"
62
+ elif marker == "Combined data":
63
+ kind = "asset_group"
64
+ if kind == "unknown":
65
+ issues.extend(
66
+ unavailable(
67
+ entry, ReadStatus.UNSUPPORTED, "Reference entry has no established interpretation"
68
+ ).diagnostics
69
+ )
70
+ items.append(ArtifactReference(entry, kind))
71
+ return ReadValue(field, ReadStatus.AVAILABLE, tuple(items), tuple(issues))
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class CodeView:
76
+ """Code interpretation can be unavailable without preventing process inspection."""
77
+
78
+ field: Field
79
+ representation: Literal["legacy", "nested"]
80
+
81
+ def _field(self, legacy: str, nested: str) -> Field:
82
+ return self.field.snapshot.field(
83
+ self.field.pointer + "/" + (legacy if self.representation == "legacy" else nested)
84
+ )
85
+
86
+ def _read(self, legacy: str, nested: str, decoder: Any) -> ReadValue[Any]:
87
+ if self.field.state != FieldState.PRESENT or not isinstance(self.field.value, dict):
88
+ status = (
89
+ ReadStatus.INVALID if self.field.state == FieldState.PRESENT else ReadStatus(self.field.state.value)
90
+ )
91
+ return unavailable(self.field, status, "Code object is unavailable")
92
+ return observe(self._field(legacy, nested), decoder)
93
+
94
+ @property
95
+ def version(self) -> ReadValue[str]:
96
+ """Read a producer code version, independent of its pipeline version."""
97
+ return self._read("code_version", "version", as_string)
98
+
99
+ @property
100
+ def url(self) -> ReadValue[str]:
101
+ """Retain the recorded repository location without filling missing names."""
102
+ return self._read("code_url", "url", as_string)
103
+
104
+ @property
105
+ def parameters(self) -> ReadValue[dict[str, Any]]:
106
+ """Inspect parameters without replacing invalid values or supplying defaults."""
107
+ return self._read("parameters", "parameters", as_object)
108
+
109
+ @property
110
+ def inputs(self) -> ReadValue[tuple[ArtifactReference, ...]]:
111
+ """Expose file versus asset/group semantics explicitly."""
112
+ if self.field.state != FieldState.PRESENT or not isinstance(self.field.value, dict):
113
+ status = (
114
+ ReadStatus.INVALID if self.field.state == FieldState.PRESENT else ReadStatus(self.field.state.value)
115
+ )
116
+ return unavailable(self.field, status, "Code object cannot establish input references")
117
+ field = self._field("input_location", "input_data")
118
+ return references(field, "paths" if self.representation == "legacy" else "assets")
119
+
120
+ @property
121
+ def document(self) -> Document | None:
122
+ """Select a valid code object while keeping invalid raw content in field."""
123
+ if self.field.state == FieldState.PRESENT and isinstance(self.field.value, dict):
124
+ return Document.from_snapshot(self.field.snapshot, self.field.pointer)
125
+ return None
@@ -0,0 +1,38 @@
1
+ """Machine-readable failures and field availability without fabricated values."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from aind_metadata_reader._enum import StringEnum
6
+
7
+
8
+ class FieldState(StringEnum):
9
+ """Distinguish recorded values from absent and explicitly null fields."""
10
+
11
+ PRESENT = "present"
12
+ ABSENT = "absent"
13
+ NULL = "null"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Diagnostic:
18
+ """A failure tied to a location in a preserved snapshot."""
19
+
20
+ code: str
21
+ message: str
22
+ pointer: str = ""
23
+ snapshot_id: str | None = None
24
+
25
+
26
+ class MetadataError(ValueError):
27
+ """A requested interpretation cannot be established from the record."""
28
+
29
+ def __init__(
30
+ self, message: str, *, code: str = "invalid_structure", pointer: str = "", snapshot_id: str | None = None
31
+ ) -> None:
32
+ """Attach structured context while keeping a readable exception message."""
33
+ self.diagnostic = Diagnostic(code, message, pointer, snapshot_id)
34
+ super().__init__(f"{message} (at {pointer or '/'})")
35
+
36
+
37
+ class UnsupportedVersionError(MetadataError):
38
+ """No supported reader matches the declared representation."""