ref-id 0.7.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.
- ref_id/__init__.py +69 -0
- ref_id/build.py +146 -0
- ref_id/canonical.py +171 -0
- ref_id/digest.py +45 -0
- ref_id/encoding.py +78 -0
- ref_id/envelope.py +64 -0
- ref_id/errors.py +65 -0
- ref_id/grammar.py +92 -0
- ref_id/parse.py +236 -0
- ref_id/py.typed +0 -0
- ref_id/relations.py +464 -0
- ref_id/serialise.py +109 -0
- ref_id/spec/ref-id.json +5258 -0
- ref_id/spec/ref-id.json.sha256 +1 -0
- ref_id/spec.py +192 -0
- ref_id/types.py +264 -0
- ref_id/validators.py +258 -0
- ref_id-0.7.0.dist-info/METADATA +70 -0
- ref_id-0.7.0.dist-info/RECORD +21 -0
- ref_id-0.7.0.dist-info/WHEEL +4 -0
- ref_id-0.7.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
09a3a83486d77192e925b77f68826fa451d44006cdf5be0ef4bb2f19bda0e175
|
ref_id/spec.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Loads the embedded `spec/ref-id.json`, verifies its digest against the sidecar, and checks the
|
|
3
|
+
`specVersion` major. The specification's own data is never restated here: it is read dynamically from
|
|
4
|
+
the parsed JSON through the accessors below.
|
|
5
|
+
|
|
6
|
+
Ported from `crates/ref-id/src/spec.rs`.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from importlib import resources
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from .canonical import canonicalise
|
|
17
|
+
from .errors import SpecIntegrityError, SpecVersionError
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .grammar import Grammar
|
|
21
|
+
|
|
22
|
+
__all__ = ["Spec", "embedded_spec_text", "load_spec", "load_spec_from"]
|
|
23
|
+
|
|
24
|
+
# The major version this package was built against. Not spec data — the package's own contract.
|
|
25
|
+
_SUPPORTED_SPEC_MAJOR = "1"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Spec:
|
|
29
|
+
"""The `ref:` specification, read dynamically. Every table the code consults is looked up here."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, root: dict[str, Any]) -> None:
|
|
32
|
+
self._root = root
|
|
33
|
+
self._grammar: Grammar | None = None
|
|
34
|
+
|
|
35
|
+
def value(self, *path: str) -> Any | None:
|
|
36
|
+
current: Any = self._root
|
|
37
|
+
for key in path:
|
|
38
|
+
if not isinstance(current, dict) or key not in current:
|
|
39
|
+
return None
|
|
40
|
+
current = current[key]
|
|
41
|
+
return current
|
|
42
|
+
|
|
43
|
+
def get_str(self, *path: str) -> str:
|
|
44
|
+
value = self.value(*path)
|
|
45
|
+
return value if isinstance(value, str) else ""
|
|
46
|
+
|
|
47
|
+
def get_int(self, *path: str) -> int:
|
|
48
|
+
value = self.value(*path)
|
|
49
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else 0
|
|
50
|
+
|
|
51
|
+
def get_strings(self, *path: str) -> list[str]:
|
|
52
|
+
value = self.value(*path)
|
|
53
|
+
if not isinstance(value, list):
|
|
54
|
+
return []
|
|
55
|
+
return [item for item in value if isinstance(item, str)]
|
|
56
|
+
|
|
57
|
+
def get_object(self, *path: str) -> dict[str, Any] | None:
|
|
58
|
+
value = self.value(*path)
|
|
59
|
+
return value if isinstance(value, dict) else None
|
|
60
|
+
|
|
61
|
+
def get_ints(self, *path: str) -> list[int]:
|
|
62
|
+
value = self.value(*path)
|
|
63
|
+
if not isinstance(value, list):
|
|
64
|
+
return []
|
|
65
|
+
return [item for item in value if isinstance(item, int) and not isinstance(item, bool)]
|
|
66
|
+
|
|
67
|
+
def pointer(self, pointer: str, /) -> Any | None:
|
|
68
|
+
"""Resolves a JSON Pointer (RFC 6901) against the raw document — `refinements.lines.maximum`
|
|
69
|
+
declares `"/version/maximum"` rather than a literal, so a refinement's bound is read from wherever
|
|
70
|
+
it points instead of being assumed to be one fixed key."""
|
|
71
|
+
if pointer == "":
|
|
72
|
+
return self._root
|
|
73
|
+
if not pointer.startswith("/"):
|
|
74
|
+
return None
|
|
75
|
+
segments = [segment.replace("~1", "/").replace("~0", "~") for segment in pointer[1:].split("/")]
|
|
76
|
+
return self.value(*segments)
|
|
77
|
+
|
|
78
|
+
def get_table(self, *path: str) -> list[tuple[str, str]]:
|
|
79
|
+
obj = self.get_object(*path)
|
|
80
|
+
if obj is None:
|
|
81
|
+
return []
|
|
82
|
+
return [(key, value) for key, value in obj.items() if isinstance(value, str)]
|
|
83
|
+
|
|
84
|
+
def spec_version(self) -> str:
|
|
85
|
+
return self.get_str("specVersion")
|
|
86
|
+
|
|
87
|
+
def scheme(self) -> str:
|
|
88
|
+
return self.get_str("scheme")
|
|
89
|
+
|
|
90
|
+
def unknown_type(self) -> str:
|
|
91
|
+
return self.get_str("unknownType")
|
|
92
|
+
|
|
93
|
+
def status(self, name: str) -> str:
|
|
94
|
+
"""A status name this code needs, checked against the vocabulary the spec declares."""
|
|
95
|
+
if name in self.get_strings("statuses"):
|
|
96
|
+
return name
|
|
97
|
+
raise SpecVersionError(f'this package names the status "{name}", which spec {self.spec_version()} does not declare')
|
|
98
|
+
|
|
99
|
+
def part(self, name: str) -> str:
|
|
100
|
+
"""A part name this code needs: one of `spec.parts`, or a declared qualifier or refinement key."""
|
|
101
|
+
qualifiers = self.get_object("qualifiers") or {}
|
|
102
|
+
refinements = self.get_object("refinements") or {}
|
|
103
|
+
if name in self.get_strings("parts") or name in qualifiers or name in refinements:
|
|
104
|
+
return name
|
|
105
|
+
raise SpecVersionError(f'this package names the part "{name}", which spec {self.spec_version()} does not declare')
|
|
106
|
+
|
|
107
|
+
def vectors(self, group: str) -> list[Any]:
|
|
108
|
+
"""The vectors of one group, for a conformance runner."""
|
|
109
|
+
value = self.value("vectors", group)
|
|
110
|
+
return value if isinstance(value, list) else []
|
|
111
|
+
|
|
112
|
+
def vector_classes(self) -> list[str]:
|
|
113
|
+
"""Every vector group name `spec.vectors` declares, so a runner can prove it executes all of them."""
|
|
114
|
+
groups = self.get_object("vectors") or {}
|
|
115
|
+
return list(groups.keys())
|
|
116
|
+
|
|
117
|
+
def grammar_expression(self) -> str:
|
|
118
|
+
"""The canonical grammar expression, for a dialect measurement."""
|
|
119
|
+
return self.get_str("grammar", "expression")
|
|
120
|
+
|
|
121
|
+
def grammar(self) -> Grammar:
|
|
122
|
+
"""The patterns this spec compiles to, lazily compiled and cached — every reader shares one
|
|
123
|
+
instance per loaded spec."""
|
|
124
|
+
from .grammar import Grammar
|
|
125
|
+
|
|
126
|
+
if self._grammar is None:
|
|
127
|
+
self._grammar = Grammar.compile_for(self)
|
|
128
|
+
return self._grammar
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _validate(text: str, sidecar: str) -> Spec:
|
|
132
|
+
try:
|
|
133
|
+
parsed = json.loads(text)
|
|
134
|
+
except json.JSONDecodeError as error:
|
|
135
|
+
raise SpecIntegrityError(f"spec/ref-id.json is not JSON: {error}") from error
|
|
136
|
+
except (ValueError, RecursionError) as error:
|
|
137
|
+
# `json.loads` itself converts a number literal to `int`/`float` and recurses per nesting level:
|
|
138
|
+
# a literal long enough hits CPython's int-conversion digit-count guard (`ValueError`, not
|
|
139
|
+
# `json.JSONDecodeError`), and nesting deep enough hits its call-stack limit (`RecursionError`).
|
|
140
|
+
# Both are the document's shape being unreadable, reported the same way as malformed JSON rather
|
|
141
|
+
# than surfacing CPython's own exception kinds to a caller who would have to know to expect them.
|
|
142
|
+
raise SpecIntegrityError(f"spec/ref-id.json cannot be parsed: {error}") from error
|
|
143
|
+
canonical = canonicalise(parsed)
|
|
144
|
+
computed = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
145
|
+
expected = sidecar.strip()
|
|
146
|
+
if computed != expected:
|
|
147
|
+
raise SpecIntegrityError(f"spec/ref-id.json does not match its sidecar digest (expected {expected}, computed {computed})")
|
|
148
|
+
spec = Spec(parsed)
|
|
149
|
+
major = spec.spec_version().split(".")[0]
|
|
150
|
+
if major != _SUPPORTED_SPEC_MAJOR:
|
|
151
|
+
raise SpecVersionError(f"spec/ref-id.json declares specVersion {spec.spec_version()}; this package supports major {_SUPPORTED_SPEC_MAJOR}")
|
|
152
|
+
return spec
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def load_spec_from(directory: str | os.PathLike[str], /) -> Spec:
|
|
156
|
+
"""Loads and validates a spec + sidecar pair from a directory; the integrity tests use this."""
|
|
157
|
+
directory = os.fspath(directory)
|
|
158
|
+
try:
|
|
159
|
+
with open(os.path.join(directory, "ref-id.json"), encoding="utf-8") as handle:
|
|
160
|
+
text = handle.read()
|
|
161
|
+
with open(os.path.join(directory, "ref-id.json.sha256"), encoding="utf-8") as handle:
|
|
162
|
+
sidecar = handle.read()
|
|
163
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
164
|
+
# A missing file or directory raises `OSError`; a file that is not valid UTF-8 raises
|
|
165
|
+
# `UnicodeDecodeError` from `handle.read()` — neither belongs on a caller who was promised
|
|
166
|
+
# `SpecIntegrityError` for every way this pair can fail to be the specification it claims to be.
|
|
167
|
+
raise SpecIntegrityError(str(error)) from error
|
|
168
|
+
return _validate(text, sidecar)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def embedded_spec_text() -> tuple[str, str]:
|
|
172
|
+
"""The embedded specification's bytes, so a runner can prove them byte-identical to the repository's file."""
|
|
173
|
+
# `ref_id.spec` carries no `__init__.py` of its own — it is a byte-identical copy of `spec/`, not a
|
|
174
|
+
# subpackage — so the anchor is `ref_id` and `spec/` is addressed as a resource path under it.
|
|
175
|
+
# `Traversable.read_text` opens in text mode, which performs universal-newline translation; the
|
|
176
|
+
# digest covers the file's actual bytes, so this reads them raw and decodes them itself instead.
|
|
177
|
+
package = resources.files("ref_id").joinpath("spec")
|
|
178
|
+
json_text = package.joinpath("ref-id.json").read_bytes().decode("utf-8")
|
|
179
|
+
sidecar_text = package.joinpath("ref-id.json.sha256").read_bytes().decode("utf-8")
|
|
180
|
+
return json_text, sidecar_text
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
_CACHED: Spec | None = None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def load_spec() -> Spec:
|
|
187
|
+
"""The validated spec this package embeds, loaded once."""
|
|
188
|
+
global _CACHED
|
|
189
|
+
if _CACHED is None:
|
|
190
|
+
json_text, sidecar_text = embedded_spec_text()
|
|
191
|
+
_CACHED = _validate(json_text, sidecar_text)
|
|
192
|
+
return _CACHED
|
ref_id/types.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The value types `parse`, `serialise`, `build` and `validate_envelope` exchange — frozen, slotted
|
|
3
|
+
dataclasses with snake_case attributes. `to_json()` on `ParseResult` and `EnvelopeResult` returns the
|
|
4
|
+
`openRPC` keys exactly (camelCase, optional keys omitted when unset); `BuildParts.from_json` reads the
|
|
5
|
+
vector shape.
|
|
6
|
+
|
|
7
|
+
Ported from `crates/ref-id/src/types.rs`.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from .errors import BuildError
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BuildParts",
|
|
19
|
+
"EnvelopeResult",
|
|
20
|
+
"Fragment",
|
|
21
|
+
"NestedValue",
|
|
22
|
+
"ParseResult",
|
|
23
|
+
"QualifierRelation",
|
|
24
|
+
"RelateResult",
|
|
25
|
+
"Relation",
|
|
26
|
+
"VerdictContent",
|
|
27
|
+
"VerdictDecidedBy",
|
|
28
|
+
"VerdictIdentity",
|
|
29
|
+
"VerdictResult",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
Pair = tuple[str, str]
|
|
33
|
+
|
|
34
|
+
# `comparison.relate.relations` — one relation between two identifiers in one dimension.
|
|
35
|
+
Relation = Literal["equal", "covers", "coveredBy", "differ"]
|
|
36
|
+
|
|
37
|
+
# `comparison.verdict.axes.identity` and `.content`.
|
|
38
|
+
VerdictIdentity = Literal["same", "covers", "coveredBy", "distinct", "undetermined"]
|
|
39
|
+
VerdictContent = Literal["same", "different", "unknown"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _pairs_json(pairs: tuple[Pair, ...]) -> list[list[str]]:
|
|
43
|
+
return [[key, value] for key, value in pairs]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class Fragment:
|
|
48
|
+
"""The `#path;refinements` a parsed identifier carries, or the parts a producer hands `build`."""
|
|
49
|
+
|
|
50
|
+
path: str
|
|
51
|
+
refinements: tuple[Pair, ...] = ()
|
|
52
|
+
|
|
53
|
+
def to_json(self) -> dict[str, Any]:
|
|
54
|
+
return {"path": self.path, "refinements": _pairs_json(self.refinements)}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class NestedValue:
|
|
59
|
+
"""`BuildParts`' `{"nested": <identifier>}` qualifier value."""
|
|
60
|
+
|
|
61
|
+
nested: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# The value a `BuildParts` qualifier carries: a plain string, or a nested identifier not yet encoded.
|
|
65
|
+
QualifierValue = str | NestedValue
|
|
66
|
+
|
|
67
|
+
# The fragment a producer hands `build`: a bare path, or the path with its refinements.
|
|
68
|
+
FragmentParts = str | Fragment
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class ParseResult:
|
|
73
|
+
"""What `parse()` returns. `status` is one of `spec.statuses`; `part` one of `spec.parts` on
|
|
74
|
+
`malformed`."""
|
|
75
|
+
|
|
76
|
+
input: str
|
|
77
|
+
status: str
|
|
78
|
+
version: int
|
|
79
|
+
explicit_version: bool
|
|
80
|
+
type: str
|
|
81
|
+
locator: str
|
|
82
|
+
qualifiers: tuple[Pair, ...] = ()
|
|
83
|
+
fragment: Fragment | None = None
|
|
84
|
+
version_text: str | None = None
|
|
85
|
+
delegated: str | None = None
|
|
86
|
+
canonical: str | None = None
|
|
87
|
+
nested: tuple[Pair, ...] | None = None
|
|
88
|
+
part: str | None = None
|
|
89
|
+
|
|
90
|
+
def to_json(self) -> dict[str, Any]:
|
|
91
|
+
"""The result as the JSON shape the specification's vectors describe — what a conformance test
|
|
92
|
+
compares."""
|
|
93
|
+
out: dict[str, Any] = {
|
|
94
|
+
"input": self.input,
|
|
95
|
+
"status": self.status,
|
|
96
|
+
"version": self.version,
|
|
97
|
+
"explicitVersion": self.explicit_version,
|
|
98
|
+
"type": self.type,
|
|
99
|
+
"locator": self.locator,
|
|
100
|
+
"qualifiers": _pairs_json(self.qualifiers),
|
|
101
|
+
"fragment": self.fragment.to_json() if self.fragment is not None else None,
|
|
102
|
+
}
|
|
103
|
+
if self.version_text is not None:
|
|
104
|
+
out["versionText"] = self.version_text
|
|
105
|
+
if self.delegated is not None:
|
|
106
|
+
out["delegated"] = self.delegated
|
|
107
|
+
if self.canonical is not None:
|
|
108
|
+
out["canonical"] = self.canonical
|
|
109
|
+
if self.nested is not None:
|
|
110
|
+
out["nested"] = {key: value for key, value in self.nested}
|
|
111
|
+
if self.part is not None:
|
|
112
|
+
out["part"] = self.part
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _refusal(part: str, why: str) -> BuildError:
|
|
117
|
+
"""A `BuildError` whose part the specification names, through the same path `build()` refuses by."""
|
|
118
|
+
from .build import _refuse
|
|
119
|
+
from .spec import load_spec
|
|
120
|
+
|
|
121
|
+
return _refuse(load_spec(), part, why)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, slots=True)
|
|
125
|
+
class BuildParts:
|
|
126
|
+
"""What `build()` takes. `location` never reaches the identifier, so it is read by `from_json` and
|
|
127
|
+
discarded rather than carried as a field."""
|
|
128
|
+
|
|
129
|
+
type: str
|
|
130
|
+
locator: str
|
|
131
|
+
qualifiers: tuple[tuple[str, QualifierValue], ...] = ()
|
|
132
|
+
fragment: FragmentParts | None = None
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_json(cls, obj: Mapping[str, Any], /) -> BuildParts:
|
|
136
|
+
"""Reads the vector shape: `qualifiers` a list of `[key, value]` pairs whose value is a string or
|
|
137
|
+
`{"nested": <identifier>}`; `fragment` a string or `{path, refinements}`. A shape this cannot
|
|
138
|
+
read raises `BuildError` naming the part, never `IndexError`/`KeyError`/`TypeError` — the same
|
|
139
|
+
kind `build()` itself raises for a badly typed part (`crates/ref-id/src/build.rs` has no
|
|
140
|
+
equivalent step; the TypeScript reference takes the JSON shape directly as `BuildParts`, so this
|
|
141
|
+
naming is this port's own)."""
|
|
142
|
+
type_ = obj.get("type")
|
|
143
|
+
if not isinstance(type_, str):
|
|
144
|
+
raise _refusal("type", "parts must have a string type")
|
|
145
|
+
locator = obj.get("locator")
|
|
146
|
+
if not isinstance(locator, str):
|
|
147
|
+
raise _refusal("locator", "parts must have a string locator")
|
|
148
|
+
|
|
149
|
+
qualifiers: list[tuple[str, QualifierValue]] = []
|
|
150
|
+
for pair in obj.get("qualifiers", []):
|
|
151
|
+
if not isinstance(pair, (list, tuple)) or len(pair) != 2 or not isinstance(pair[0], str):
|
|
152
|
+
raise _refusal("state", "a qualifier is a [key, value] pair with a string key")
|
|
153
|
+
key, raw_value = pair[0], pair[1]
|
|
154
|
+
value: QualifierValue
|
|
155
|
+
if isinstance(raw_value, str):
|
|
156
|
+
value = raw_value
|
|
157
|
+
elif isinstance(raw_value, Mapping) and "nested" in raw_value:
|
|
158
|
+
# `build()` raises `BuildError` at this same key if `nested` is not a string.
|
|
159
|
+
value = NestedValue(raw_value["nested"])
|
|
160
|
+
else:
|
|
161
|
+
raise _refusal(key, 'a qualifier value is a string or {"nested": string}')
|
|
162
|
+
qualifiers.append((key, value))
|
|
163
|
+
|
|
164
|
+
fragment_json = obj.get("fragment")
|
|
165
|
+
fragment: FragmentParts | None
|
|
166
|
+
if fragment_json is None:
|
|
167
|
+
fragment = None
|
|
168
|
+
elif isinstance(fragment_json, str):
|
|
169
|
+
fragment = fragment_json
|
|
170
|
+
elif isinstance(fragment_json, Mapping) and isinstance(fragment_json.get("path"), str):
|
|
171
|
+
refinements = tuple((pair[0], pair[1]) for pair in fragment_json.get("refinements", []))
|
|
172
|
+
fragment = Fragment(path=fragment_json["path"], refinements=refinements)
|
|
173
|
+
else:
|
|
174
|
+
raise _refusal("fragment", "a fragment is a string or {path, refinements}")
|
|
175
|
+
|
|
176
|
+
return cls(
|
|
177
|
+
type=type_,
|
|
178
|
+
locator=locator,
|
|
179
|
+
qualifiers=tuple(qualifiers),
|
|
180
|
+
fragment=fragment,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True, slots=True)
|
|
185
|
+
class EnvelopeResult:
|
|
186
|
+
"""Whether an envelope is admissible for the identifier it was requested under."""
|
|
187
|
+
|
|
188
|
+
admissible: bool
|
|
189
|
+
reason: str | None = None
|
|
190
|
+
|
|
191
|
+
def to_json(self) -> dict[str, Any]:
|
|
192
|
+
out: dict[str, Any] = {"admissible": self.admissible}
|
|
193
|
+
if self.reason is not None:
|
|
194
|
+
out["reason"] = self.reason
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(frozen=True, slots=True)
|
|
199
|
+
class QualifierRelation:
|
|
200
|
+
"""One qualifier member of a `RelateResult`: its own relation, and — only where both sides' values are
|
|
201
|
+
a nested `ref:` identifier this operation accepts — the nested pair's own `RelateResult`."""
|
|
202
|
+
|
|
203
|
+
relation: Relation
|
|
204
|
+
nested: RelateResult | None = None
|
|
205
|
+
|
|
206
|
+
def to_json(self) -> dict[str, Any]:
|
|
207
|
+
out: dict[str, Any] = {"relation": self.relation}
|
|
208
|
+
if self.nested is not None:
|
|
209
|
+
out["nested"] = self.nested.to_json()
|
|
210
|
+
return out
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass(frozen=True, slots=True)
|
|
214
|
+
class RelateResult:
|
|
215
|
+
"""What `relate()` returns for a pair it accepts — `comparison.relate.result`: the five fixed
|
|
216
|
+
dimensions always, and a keyed dimension (`fragment_refinements`, `qualifiers`) only for the keys at
|
|
217
|
+
least one side declares."""
|
|
218
|
+
|
|
219
|
+
type: Relation
|
|
220
|
+
version: Relation
|
|
221
|
+
locator_stem: Relation
|
|
222
|
+
locator_version: Relation
|
|
223
|
+
fragment_path: Relation
|
|
224
|
+
fragment_refinements: tuple[tuple[str, Relation], ...] = ()
|
|
225
|
+
qualifiers: tuple[tuple[str, QualifierRelation], ...] = ()
|
|
226
|
+
|
|
227
|
+
def to_json(self) -> dict[str, Any]:
|
|
228
|
+
return {
|
|
229
|
+
"type": self.type,
|
|
230
|
+
"version": self.version,
|
|
231
|
+
"locatorStem": self.locator_stem,
|
|
232
|
+
"locatorVersion": self.locator_version,
|
|
233
|
+
"fragmentPath": self.fragment_path,
|
|
234
|
+
"fragmentRefinements": {key: value for key, value in self.fragment_refinements},
|
|
235
|
+
"qualifiers": {key: value.to_json() for key, value in self.qualifiers},
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True, slots=True)
|
|
240
|
+
class VerdictDecidedBy:
|
|
241
|
+
"""Which members of `relate`'s result decided each axis, by their `decidedBy` path —
|
|
242
|
+
`comparison.verdict.result.decidedBy`."""
|
|
243
|
+
|
|
244
|
+
identity: tuple[str, ...] = ()
|
|
245
|
+
content: tuple[str, ...] = ()
|
|
246
|
+
|
|
247
|
+
def to_json(self) -> dict[str, Any]:
|
|
248
|
+
return {"identity": list(self.identity), "content": list(self.content)}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass(frozen=True, slots=True)
|
|
252
|
+
class VerdictResult:
|
|
253
|
+
"""What `verdict()` returns for a pair `relate` accepts — `comparison.verdict.result`."""
|
|
254
|
+
|
|
255
|
+
identity: VerdictIdentity
|
|
256
|
+
content: VerdictContent
|
|
257
|
+
decided_by: VerdictDecidedBy
|
|
258
|
+
|
|
259
|
+
def to_json(self) -> dict[str, Any]:
|
|
260
|
+
return {
|
|
261
|
+
"identity": self.identity,
|
|
262
|
+
"content": self.content,
|
|
263
|
+
"decidedBy": self.decided_by.to_json(),
|
|
264
|
+
}
|