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/grammar.py ADDED
@@ -0,0 +1,92 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Compiles every pattern the specification declares through this dialect's declared adaptations, and
3
+ holds the only structural literals this package writes.
4
+
5
+ Ported from `crates/ref-id/src/grammar.rs`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import TYPE_CHECKING
11
+
12
+ from .errors import SpecVersionError
13
+
14
+ if TYPE_CHECKING:
15
+ from .spec import Spec
16
+
17
+ __all__: list[str] = []
18
+
19
+ # The dialect this port compiles for. Its adaptation list, when the spec declares one, is applied to
20
+ # every pattern.
21
+ _DIALECT = "python-re"
22
+
23
+ # The structural literals of the scheme, each written exactly once, here. Their sources are
24
+ # `spec.grammar.expression` (the field separator, the fragment introducer, the line breaks the character
25
+ # classes exclude) and the two pair grammars (the key/value separator).
26
+ FIELD = ":"
27
+ FRAGMENT_INTRODUCER = "#"
28
+ PAIR = "="
29
+ LINE_BREAKS = ("\r", "\n")
30
+
31
+
32
+ def scheme_prefix(spec: Spec) -> str:
33
+ return f"{spec.scheme()}{FIELD}"
34
+
35
+
36
+ def _assert_structure(spec: Spec) -> None:
37
+ expression = spec.grammar_expression()
38
+ for literal in (f"^{scheme_prefix(spec)}", FRAGMENT_INTRODUCER, "\\r", "\\n"):
39
+ if literal not in expression:
40
+ raise SpecVersionError(f"spec.grammar.expression does not carry {literal!r}; this package's structural literals do not match")
41
+ for pair in (spec.get_str("grammar", "state", "pair"), spec.get_str("grammar", "fragment", "pair")):
42
+ if f"){PAIR}(" not in pair:
43
+ raise SpecVersionError(f"a pair grammar does not separate key and value with {PAIR!r}; this package's structural literals do not match")
44
+
45
+
46
+ def compile_pattern(spec: Spec, pattern: str) -> re.Pattern[str]:
47
+ """Applies this dialect's declared adaptations — the replace pairs in order, then an `anchor` in place of
48
+ the `$` that ends the pattern — then compiles."""
49
+ adapted = pattern
50
+ replacements = spec.value("grammar", "adaptations", _DIALECT, "replace") or []
51
+ for pair in replacements:
52
+ if not (isinstance(pair, list) and len(pair) == 2):
53
+ continue
54
+ source, target = pair
55
+ if isinstance(source, str) and isinstance(target, str):
56
+ adapted = adapted.replace(source, target)
57
+ anchor = spec.value("grammar", "adaptations", _DIALECT, "anchor")
58
+ if isinstance(anchor, str) and adapted.endswith("$"):
59
+ adapted = adapted[:-1] + anchor
60
+ try:
61
+ return re.compile(adapted)
62
+ except re.error as error:
63
+ raise SpecVersionError(f"pattern {pattern} does not compile in {_DIALECT}: {error}") from error
64
+
65
+
66
+ class Grammar:
67
+ """The compiled patterns every parse and build step needs, plus a lazily-compiled cache for every
68
+ other pattern the specification declares (a form, a dispatch entry, a refinement)."""
69
+
70
+ def __init__(self, top: re.Pattern[str], state_pair: re.Pattern[str], fragment_pair: re.Pattern[str]) -> None:
71
+ self.top = top
72
+ self.state_pair = state_pair
73
+ self.fragment_pair = fragment_pair
74
+ self._others: dict[str, re.Pattern[str]] = {}
75
+
76
+ @classmethod
77
+ def compile_for(cls, spec: Spec) -> Grammar:
78
+ _assert_structure(spec)
79
+ return cls(
80
+ top=compile_pattern(spec, spec.grammar_expression()),
81
+ state_pair=compile_pattern(spec, spec.get_str("grammar", "state", "pair")),
82
+ fragment_pair=compile_pattern(spec, spec.get_str("grammar", "fragment", "pair")),
83
+ )
84
+
85
+ def matches(self, spec: Spec, source: str, value: str) -> bool:
86
+ """Whether any other pattern the spec declares (a form, a dispatch entry, a refinement) matches
87
+ the whole value."""
88
+ compiled = self._others.get(source)
89
+ if compiled is None:
90
+ compiled = compile_pattern(spec, source)
91
+ self._others[source] = compiled
92
+ return compiled.fullmatch(value) is not None
ref_id/parse.py ADDED
@@ -0,0 +1,236 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Decomposes a `ref:` identifier string against the loaded spec. Order, and the precedence it
3
+ produces: grammar -> version -> state and fragment decomposition, including the repeated-key policy ->
4
+ (unsupported version stops here, decomposed and unvalidated) -> the two sides must not trade keys ->
5
+ qualifier values against their forms -> refinement values against their patterns and range constraints ->
6
+ dispatch: unknown type is `uncovered`, else the locator goes to its validator.
7
+
8
+ Ported from `crates/ref-id/src/parse.rs`.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import replace
13
+ from typing import Any
14
+
15
+ from .encoding import decode, strictly_encoded, table_for
16
+ from .grammar import Grammar, scheme_prefix
17
+ from .spec import Spec, load_spec
18
+ from .types import Fragment, ParseResult
19
+ from .validators import (
20
+ assert_implemented,
21
+ delegated_string,
22
+ range_holds,
23
+ validate_locator,
24
+ )
25
+
26
+ __all__ = ["parse"]
27
+
28
+
29
+ def _bounded_int(text: str, maximum: int) -> int:
30
+ """A digit-only literal capped at `maximum`, without ever handing CPython's `int()` a literal longer
31
+ than `maximum` itself can be — which keeps this independent of `sys.get_int_max_str_digits()`. Leading
32
+ zeros are stripped first (`"007"` and `"7"` must compare the same); a literal whose stripped digit
33
+ count exceeds `str(maximum)`'s is above `maximum` on digit count alone, so it is never converted.
34
+ Whatever survives to `int()` is no longer than `maximum`'s own digit count, which is always small."""
35
+ stripped = text.lstrip("0") or "0"
36
+ if len(stripped) > len(str(maximum)):
37
+ return maximum
38
+ return min(int(stripped), maximum)
39
+
40
+
41
+ class _Parser:
42
+ def __init__(self, spec: Spec, grammar: Grammar) -> None:
43
+ self.spec = spec
44
+ self.grammar = grammar
45
+
46
+ def _malformed(self, input_: str, part: str, head: ParseResult | None) -> ParseResult:
47
+ return self._malformed_at(input_, self.spec.part(part), head)
48
+
49
+ def _malformed_at(self, input_: str, part: str, head: ParseResult | None) -> ParseResult:
50
+ """Malformed at a key the identifier itself carries — reported verbatim, not checked against the
51
+ vocabulary this package names, because an undeclared key is carried through by policy."""
52
+ base = head if head is not None else ParseResult(
53
+ input="",
54
+ status="",
55
+ version=self.spec.get_int("version", "default"),
56
+ explicit_version=False,
57
+ type="",
58
+ locator="",
59
+ )
60
+ return replace(
61
+ base,
62
+ input=input_,
63
+ status=self.spec.status("malformed"),
64
+ delegated=None,
65
+ canonical=None,
66
+ nested=None,
67
+ part=part,
68
+ )
69
+
70
+ def _pairs(self, pattern: Any, segments: list[str], failed_part: str) -> tuple[str, Any]:
71
+ """Splits `key=value` segments with the given pair grammar; a repeated key is malformed at that
72
+ key. Returns `("ok", tuple[Pair, ...])`, `("failed", part)` or `("repeated", key)`."""
73
+ out: list[tuple[str, str]] = []
74
+ seen: set[str] = set()
75
+ for segment in segments:
76
+ match = pattern.fullmatch(segment)
77
+ if match is None:
78
+ return "failed", failed_part
79
+ key = match.group("key") or ""
80
+ if key in seen:
81
+ return "repeated", key
82
+ seen.add(key)
83
+ out.append((key, match.group("value") or ""))
84
+ return "ok", tuple(out)
85
+
86
+ def _state(self, raw: str) -> tuple[str, Any]:
87
+ segments = raw.split(self.spec.get_str("grammar", "state", "separator"))
88
+ return self._pairs(self.grammar.state_pair, segments, "state")
89
+
90
+ def _fragment(self, raw: str) -> tuple[str, Any]:
91
+ segments = raw.split(self.spec.get_str("grammar", "fragment", "separator"))
92
+ path = segments[0] if segments else ""
93
+ if not path:
94
+ return "failed", "fragment"
95
+ kind, value = self._pairs(self.grammar.fragment_pair, segments[1:], "fragment")
96
+ if kind == "ok":
97
+ return "ok", Fragment(path=path, refinements=value)
98
+ return kind, value
99
+
100
+ def _try_nested(self, form: dict[str, Any], value: str, depth: int) -> str | None:
101
+ """A qualifier value that nests an identifier: strictly encoded, decoded with the form's table,
102
+ parsed one level down."""
103
+ max_depth = form.get("depth", 0)
104
+ if not isinstance(max_depth, int) or depth >= max_depth or not value.startswith(scheme_prefix(self.spec)):
105
+ return None
106
+ table = table_for(self.spec, form)
107
+ if not strictly_encoded(value, table):
108
+ return None
109
+ decoded = decode(value, table)
110
+ inner = self.parse(decoded, depth + 1)
111
+ return None if inner.status == self.spec.status("malformed") else decoded
112
+
113
+ def _match_forms(self, names: list[str], value: str, depth: int) -> tuple[bool, str | None]:
114
+ forms = self.spec.get_object("forms") or {}
115
+ for name in names:
116
+ form = forms.get(name)
117
+ if not isinstance(form, dict):
118
+ continue
119
+ if form.get("nested") is True:
120
+ nested = self._try_nested(form, value, depth)
121
+ if nested is not None:
122
+ return True, nested
123
+ continue
124
+ pattern = form.get("pattern")
125
+ if isinstance(pattern, str) and self.grammar.matches(self.spec, pattern, value):
126
+ return True, None
127
+ return False, None
128
+
129
+ def parse(self, input_: str, depth: int) -> ParseResult:
130
+ match = self.grammar.top.fullmatch(input_)
131
+ if match is None:
132
+ return self._malformed(input_, "grammar", None)
133
+
134
+ def opt(name: str) -> str | None:
135
+ return match.groupdict().get(name)
136
+
137
+ version_text = opt("version")
138
+ type_ = opt("type") or ""
139
+ locator = opt("locator") or ""
140
+ explicit_version = version_text is not None
141
+ # `[0-9]+` guarantees a digit-only literal, but `int()` on one long enough hits CPython's
142
+ # int-conversion digit-count guard (`sys.get_int_max_str_digits()`, 4300 by default and settable
143
+ # as low as 640) — a literal that long is already far above `version.maximum`, so `_bounded_int`
144
+ # decides by digit count before ever converting; `versionText` below keeps the literal exactly as
145
+ # written regardless.
146
+ version = self.spec.get_int("version", "default")
147
+ if version_text is not None:
148
+ maximum = self.spec.get_int("version", "maximum")
149
+ version = _bounded_int(version_text, maximum)
150
+
151
+ head = ParseResult(
152
+ input=input_,
153
+ status="",
154
+ version=version,
155
+ explicit_version=explicit_version,
156
+ type=type_,
157
+ locator=locator,
158
+ version_text=version_text,
159
+ )
160
+
161
+ state_raw = opt("state")
162
+ if state_raw is not None:
163
+ kind, value = self._state(state_raw)
164
+ if kind == "failed":
165
+ return self._malformed(input_, value, head)
166
+ if kind == "repeated":
167
+ return self._malformed_at(input_, value, head)
168
+ head = replace(head, qualifiers=value)
169
+
170
+ fragment_raw = opt("fragment")
171
+ if fragment_raw is not None:
172
+ kind, value = self._fragment(fragment_raw)
173
+ if kind == "failed":
174
+ return self._malformed(input_, value, head)
175
+ if kind == "repeated":
176
+ return self._malformed_at(input_, value, head)
177
+ head = replace(head, fragment=value)
178
+
179
+ base = replace(head, status=self.spec.status("ok"), delegated=delegated_string(self.spec, type_, locator))
180
+
181
+ if version not in self.spec.get_ints("version", "supported"):
182
+ return replace(base, status=self.spec.status("unsupported"))
183
+
184
+ refinements_table = self.spec.get_object("refinements") or {}
185
+ qualifiers_table = self.spec.get_object("qualifiers") or {}
186
+ for key, _value in head.qualifiers:
187
+ if key in refinements_table:
188
+ return self._malformed(input_, key, head)
189
+ if head.fragment is not None:
190
+ for key, _value in head.fragment.refinements:
191
+ if key in qualifiers_table:
192
+ return self._malformed(input_, key, head)
193
+
194
+ nested: list[tuple[str, str]] = []
195
+ for key, value in head.qualifiers:
196
+ declared = qualifiers_table.get(key)
197
+ if not isinstance(declared, dict):
198
+ continue # unknownKey: carry-through
199
+ forms = [name for name in declared.get("forms", []) if isinstance(name, str)]
200
+ matches, inner = self._match_forms(forms, value, depth)
201
+ if not matches:
202
+ return self._malformed(input_, key, head)
203
+ if inner is not None:
204
+ nested.append((key, inner))
205
+ if nested:
206
+ base = replace(base, nested=tuple(nested))
207
+
208
+ if head.fragment is not None:
209
+ for key, value in head.fragment.refinements:
210
+ declared = refinements_table.get(key)
211
+ if not isinstance(declared, dict):
212
+ continue # unknownRefinement: carry-through
213
+ pattern = declared.get("pattern", "")
214
+ if not self.grammar.matches(self.spec, pattern, value) or not range_holds(self.spec, declared, value):
215
+ return self._malformed(input_, key, head)
216
+
217
+ dispatch = self.spec.get_object("dispatch") or {}
218
+ entry = dispatch.get(type_)
219
+ if not isinstance(entry, dict) or base.delegated is None:
220
+ return replace(base, status=self.spec.unknown_type())
221
+ validation = validate_locator(self.spec, entry, base.delegated)
222
+ if validation is None:
223
+ return replace(base, status=self.spec.unknown_type())
224
+ if not validation.ok:
225
+ return self._malformed(input_, "locator", head)
226
+ return replace(base, canonical=validation.canonical)
227
+
228
+
229
+ def parse(input_: str, /) -> ParseResult:
230
+ """Parses a `ref:` identifier string against the embedded spec. Never raises for an identifier
231
+ problem; it raises only when the embedded specification itself cannot be honoured."""
232
+ if not isinstance(input_, str):
233
+ raise TypeError("parse() takes a string")
234
+ spec = load_spec()
235
+ assert_implemented(spec)
236
+ return _Parser(spec, spec.grammar()).parse(input_, 0)
ref_id/py.typed ADDED
File without changes