Skip to content

Commit a2122f7

Browse files
committed
Add support for dynamic / recursive references.
Haven't added dynamic-specific tests to the suite, but this code passes all of the draft 2020 and draft2019 tests... except for 1 2019 test :/ Will keep diagnosing what's up with that one, but this should be very very close to correct (probably fully correct for 2020 at least, other than still needing a pass on making sure we correctly discover schemas in all keywords).
1 parent 50eb9a1 commit a2122f7

File tree

6 files changed

+191
-21
lines changed

6 files changed

+191
-21
lines changed

referencing/_core.py

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
from urllib.parse import unquote, urldefrag, urljoin
66

77
from attrs import evolve, field
8-
from pyrsistent import m, pmap, s
9-
from pyrsistent.typing import PMap, PSet
8+
from pyrsistent import m, plist, pmap, s
9+
from pyrsistent.typing import PList, PMap, PSet
1010

1111
from referencing import exceptions
1212
from referencing._attrs import frozen
@@ -354,6 +354,11 @@ class Resolver(Generic[D]):
354354

355355
_base_uri: str = field(alias="base_uri")
356356
_registry: Registry[D] = field(alias="registry")
357+
_previous: PList[URI] = field(
358+
default=plist(), # type: ignore[reportUnknownArgumentType]
359+
repr=False,
360+
alias="previous",
361+
)
357362

358363
def lookup(self, ref: URI) -> Resolved[D]:
359364
"""
@@ -378,24 +383,21 @@ def lookup(self, ref: URI) -> Resolved[D]:
378383
except KeyError:
379384
raise exceptions.Unresolvable(ref=ref) from None
380385

386+
resolver = self._evolve(registry=registry, base_uri=uri)
381387
if fragment.startswith("/"):
382-
return resource.pointer(
383-
pointer=fragment,
384-
resolver=evolve(self, registry=registry, base_uri=uri),
385-
)
388+
return resource.pointer(pointer=fragment, resolver=resolver)
386389

387390
if fragment:
388391
try:
389392
anchor = registry.anchor(uri, fragment)
390393
except LookupError:
391394
registry = registry.crawl()
395+
resolver = evolve(resolver, registry=registry)
392396
anchor = registry.anchor(uri, fragment)
393397

394-
resource = anchor.resolve()
395-
return Resolved(
396-
contents=resource.contents,
397-
resolver=evolve(self, registry=registry, base_uri=uri),
398-
)
398+
return anchor.resolve(resolver=resolver)
399+
400+
return Resolved(contents=resource.contents, resolver=resolver)
399401

400402
def in_subresource(self, subresource: Resource[D]) -> Resolver[D]:
401403
"""
@@ -404,7 +406,23 @@ def in_subresource(self, subresource: Resource[D]) -> Resolver[D]:
404406
id = subresource.id()
405407
if id is None:
406408
return self
407-
return evolve(self, base_uri=urljoin(self._base_uri, id))
409+
return self._evolve(base_uri=urljoin(self._base_uri, id))
410+
411+
def dynamic_scope(self) -> Iterable[tuple[URI, Registry[D]]]:
412+
"""
413+
In specs with such a notion, return the URIs in the dynamic scope.
414+
"""
415+
for uri in self._previous:
416+
yield uri, self._registry
417+
418+
def _evolve(self, base_uri: str, **kwargs: Any):
419+
"""
420+
Evolve, appending to the dynamic scope.
421+
"""
422+
previous = self._previous
423+
if self._base_uri and base_uri != self._base_uri:
424+
previous = previous.cons(self._base_uri)
425+
return evolve(self, base_uri=base_uri, previous=previous, **kwargs)
408426

409427

410428
@frozen
@@ -416,8 +434,8 @@ class Anchor(Generic[D]):
416434
name: str
417435
resource: Resource[D]
418436

419-
def resolve(self):
437+
def resolve(self, resolver: Resolver[D]):
420438
"""
421439
Return the resource for this anchor.
422440
"""
423-
return self.resource
441+
return Resolved(contents=self.resource.contents, resolver=resolver)

referencing/jsonschema.py

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99

1010
from referencing import Anchor, Registry, Resource, Specification
1111
from referencing._attrs import frozen
12-
from referencing.typing import URI, Mapping
12+
from referencing._core import Resolved as _Resolved, Resolver as _Resolver
13+
from referencing.typing import URI, Anchor as AnchorType, Mapping
1314

1415
#: A JSON Schema which is a JSON object
1516
ObjectSchema = Mapping[str, Any]
@@ -51,6 +52,27 @@ def _legacy_id(contents: ObjectSchema) -> URI | None:
5152
return id
5253

5354

55+
def _anchor(
56+
specification: Specification[Schema],
57+
contents: Schema,
58+
) -> Iterable[AnchorType[Schema]]:
59+
if isinstance(contents, bool):
60+
return
61+
anchor = contents.get("$anchor")
62+
if anchor is not None:
63+
yield Anchor(
64+
name=anchor,
65+
resource=specification.create_resource(contents),
66+
)
67+
68+
dynamic_anchor = contents.get("$dynamicAnchor")
69+
if dynamic_anchor is not None:
70+
yield DynamicAnchor(
71+
name=dynamic_anchor,
72+
resource=specification.create_resource(contents),
73+
)
74+
75+
5476
def _anchor_2019(
5577
specification: Specification[Schema],
5678
contents: Schema,
@@ -131,8 +153,12 @@ def subresources_of(contents: Schema) -> Iterable[ObjectSchema]:
131153
DRAFT202012 = Specification(
132154
name="draft2020-12",
133155
id_of=_dollar_id,
134-
subresources_of=lambda contents: [],
135-
anchors_in=lambda specification, contents: [],
156+
subresources_of=_subresources_of(
157+
in_value={"additionalProperties", "if", "then", "else", "not"},
158+
in_subarray={"allOf", "anyOf", "oneOf"},
159+
in_subvalues={"$defs", "properties"},
160+
),
161+
anchors_in=_anchor,
136162
)
137163
DRAFT201909 = Specification(
138164
name="draft2019-09",
@@ -220,3 +246,52 @@ def specification_with(
220246
if default is None: # type: ignore[reportUnnecessaryComparison]
221247
raise UnknownDialect(dialect_id)
222248
return default
249+
250+
251+
@frozen
252+
class DynamicAnchor:
253+
"""
254+
Dynamic anchors, introduced in draft 2020.
255+
"""
256+
257+
name: str
258+
resource: Resource[Schema]
259+
260+
def resolve(self, resolver: _Resolver[Schema]):
261+
"""
262+
Resolve this anchor dynamically.
263+
"""
264+
last = self.resource
265+
for uri, registry in resolver.dynamic_scope():
266+
try:
267+
anchor = registry.anchor(uri, self.name)
268+
except LookupError:
269+
continue
270+
if isinstance(anchor, DynamicAnchor):
271+
last = anchor.resource
272+
return _Resolved(
273+
contents=last.contents,
274+
resolver=resolver.in_subresource(last),
275+
)
276+
277+
278+
def lookup_recursive_ref(resolver: _Resolver[Schema]) -> _Resolved[Schema]:
279+
"""
280+
Recursive references (via recursive anchors), present only in draft 2019.
281+
282+
As per the 2019 specification (§ 8.2.4.2.1), only the ``#`` recursive
283+
reference is supported (and is therefore assumed to be the relevant
284+
reference).
285+
"""
286+
resolved = resolver.lookup("#")
287+
if isinstance(resolved.contents, Mapping) and resolved.contents.get(
288+
"$recursiveAnchor"
289+
):
290+
for uri, _ in resolver.dynamic_scope():
291+
next_resolved = resolver.lookup(uri)
292+
if not isinstance(
293+
next_resolved.contents, Mapping
294+
) or not next_resolved.contents.get("$recursiveAnchor"):
295+
break
296+
resolved = next_resolved
297+
return resolved

referencing/tests/test_core.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,33 @@ def test_in_pointer_subresource(self):
546546
third = second.resolver.lookup("grandchild")
547547
assert third.contents == {"ID": "grandchild"}
548548

549+
def test_dynamic_scope(self):
550+
root = ID_AND_CHILDREN.create_resource(
551+
{
552+
"ID": "http://example.com/",
553+
"children": [
554+
{
555+
"ID": "child/",
556+
"children": [{"ID": "grandchild"}],
557+
},
558+
],
559+
},
560+
)
561+
registry = Registry().with_resource(root.id(), root)
562+
563+
resolver = registry.resolver()
564+
first = resolver.lookup("http://example.com/")
565+
second = first.resolver.lookup("#/children/0")
566+
third = second.resolver.lookup("grandchild")
567+
assert list(third.resolver.dynamic_scope()) == [
568+
("http://example.com/child/", third.resolver._registry),
569+
("http://example.com/", third.resolver._registry),
570+
]
571+
assert list(second.resolver.dynamic_scope()) == [
572+
("http://example.com/", second.resolver._registry),
573+
]
574+
assert list(first.resolver.dynamic_scope()) == []
575+
549576

550577
class TestSpecification:
551578
def test_create_resource(self):

referencing/tests/test_jsonschema.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22

3-
from referencing import Resource, Specification
3+
from referencing import Registry, Resource, Specification
44
import referencing.jsonschema
55

66

@@ -154,3 +154,53 @@ def test_specification_with_default():
154154
default=Specification.OPAQUE,
155155
)
156156
assert specification is Specification.OPAQUE
157+
158+
159+
# FIXME: These two also ideally should live in the referencing suite.
160+
def test_lookup_recursive_ref_to_bool():
161+
TRUE = referencing.jsonschema.DRAFT201909.create_resource(True)
162+
registry = Registry({"http://example.com": TRUE})
163+
resolved = referencing.jsonschema.lookup_recursive_ref(
164+
resolver=registry.resolver(base_uri="http://example.com"),
165+
)
166+
assert resolved.contents == TRUE.contents
167+
168+
169+
def test_multiple_lookup_recursive_ref_to_bool():
170+
TRUE = referencing.jsonschema.DRAFT201909.create_resource(True)
171+
root = referencing.jsonschema.DRAFT201909.create_resource(
172+
{
173+
"$id": "http://example.com",
174+
"$recursiveAnchor": True,
175+
"$defs": {
176+
"foo": {
177+
"$id": "foo",
178+
"$recursiveAnchor": True,
179+
"$defs": {
180+
"bar": True,
181+
"baz": {
182+
"$recursiveAnchor": True,
183+
"$anchor": "fooAnchor",
184+
},
185+
},
186+
},
187+
},
188+
},
189+
)
190+
resolver = (
191+
Registry()
192+
.with_resources(
193+
[
194+
("http://example.com", root),
195+
("http://example.com/foo/", TRUE),
196+
("http://example.com/foo/bar", root),
197+
],
198+
)
199+
.resolver()
200+
)
201+
202+
first = resolver.lookup("http://example.com")
203+
second = first.resolver.lookup("foo/")
204+
resolver = second.resolver.lookup("bar").resolver
205+
fourth = referencing.jsonschema.lookup_recursive_ref(resolver=resolver)
206+
assert fourth.contents == root.contents

referencing/typing.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515

1616
if TYPE_CHECKING:
17-
from referencing._core import Resource
17+
from referencing._core import Resolved, Resolver
1818

1919
#: A URI which identifies a `Resource`.
2020
URI = str
@@ -38,7 +38,7 @@ def name(self) -> str:
3838
"""
3939
...
4040

41-
def resolve(self) -> Resource[D]:
41+
def resolve(self, resolver: Resolver[D]) -> Resolved[D]:
4242
"""
4343
Return the resource for this anchor.
4444
"""

suite

Submodule suite updated 28 files

0 commit comments

Comments
 (0)