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.
@@ -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
+ }