Skip to content

Commit d67518e

Browse files
committed
Simple anchor support.
1 parent e350eb3 commit d67518e

File tree

6 files changed

+209
-15
lines changed

6 files changed

+209
-15
lines changed

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
D=("data", "D"),
4646
ObjectSchema=("data", "ObjectSchema"),
4747
Schema=("data", "Schema"),
48+
URI=("attr", "URI"), # ?!?!?! Sphinx...
4849
)
4950

5051

referencing/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""
22
Cross-specification, implementation-agnostic JSON referencing.
33
"""
4-
from referencing._core import Registry, Resource, Specification
4+
from referencing._core import Anchor, Registry, Resource, Specification
55

6-
__all__ = ["Registry", "Resource", "Specification"]
6+
__all__ = ["Anchor", "Registry", "Resource", "Specification"]

referencing/_core.py

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from referencing import exceptions
1212
from referencing._attrs import frozen
13-
from referencing.typing import URI, D, Mapping
13+
from referencing.typing import URI, Anchor as AnchorType, D, Mapping
1414

1515

1616
@frozen
@@ -32,13 +32,25 @@ class Specification(Generic[D]):
3232
#: the subresources themselves).
3333
subresources_of: Callable[[D], Iterable[D]]
3434

35+
#: Retrieve the anchors contained in the given document.
36+
_anchors_in: Callable[
37+
[Specification[D], D],
38+
Iterable[AnchorType[D]],
39+
] = field(alias="anchors_in")
40+
3541
#: An opaque specification where resources have no subresources
3642
#: nor internal identifiers.
3743
OPAQUE: ClassVar[Specification[Any]]
3844

3945
def __repr__(self):
4046
return f"<Specification name={self.name!r}>"
4147

48+
def anchors_in(self, contents: D):
49+
"""
50+
Retrieve the anchors contained in the given document.
51+
"""
52+
return self._anchors_in(self, contents)
53+
4254
def create_resource(self, contents: D) -> Resource[D]:
4355
"""
4456
Create a resource which is interpreted using this specification.
@@ -50,6 +62,7 @@ def create_resource(self, contents: D) -> Resource[D]:
5062
name="opaque",
5163
id_of=lambda contents: None,
5264
subresources_of=lambda contents: [],
65+
anchors_in=lambda specification, contents: [],
5366
)
5467

5568

@@ -126,6 +139,12 @@ def subresources(self) -> Iterable[Resource[D]]:
126139
for each in self._specification.subresources_of(self.contents)
127140
)
128141

142+
def anchors(self) -> Iterable[AnchorType[D]]:
143+
"""
144+
Retrieve this resource's (specification-specific) identifier.
145+
"""
146+
return self._specification.anchors_in(self.contents)
147+
129148
def pointer(self, pointer: str, resolver: Resolver[D]) -> Resolved[D]:
130149
"""
131150
Resolve the given JSON pointer.
@@ -168,6 +187,7 @@ class Registry(Mapping[URI, Resource[D]]):
168187
"""
169188

170189
_resources: PMap[URI, Resource[D]] = field(default=m(), converter=pmap) # type: ignore[reportUnknownArgumentType] # noqa: E501
190+
_anchors: PMap[tuple[URI, str], AnchorType[D]] = field(default=m()) # type: ignore[reportUnknownArgumentType] # noqa: E501
171191
_uncrawled: PSet[URI] = field(default=s()) # type: ignore[reportUnknownArgumentType] # noqa: E501
172192

173193
def __getitem__(self, uri: URI) -> Resource[D]:
@@ -201,6 +221,12 @@ def __repr__(self) -> str:
201221
summary = f"{pluralized}"
202222
return f"<Registry ({size} {summary})>"
203223

224+
def anchor(self, uri: URI, name: str):
225+
"""
226+
Retrieve the given anchor, which must already have been found.
227+
"""
228+
return self._anchors[uri, name]
229+
204230
def contents(self, uri: URI) -> D:
205231
"""
206232
Retrieve the contents identified by the given URI.
@@ -212,17 +238,24 @@ def crawl(self) -> Registry[D]:
212238
Immediately crawl all added resources, discovering subresources.
213239
"""
214240
resources = self._resources.evolver()
241+
anchors = self._anchors.evolver()
215242
uncrawled = [(uri, resources[uri]) for uri in self._uncrawled]
216243
while uncrawled:
217244
uri, resource = uncrawled.pop()
245+
218246
id = resource.id()
219-
if id is None:
220-
pass
221-
else:
247+
if id is not None:
222248
uri = urljoin(uri, id)
223249
resources[uri] = resource
250+
for each in resource.anchors():
251+
anchors.set((uri, each.name), each)
224252
uncrawled.extend((uri, each) for each in resource.subresources())
225-
return evolve(self, resources=resources.persistent(), uncrawled=s())
253+
return evolve(
254+
self,
255+
resources=resources.persistent(),
256+
anchors=anchors.persistent(),
257+
uncrawled=s(),
258+
)
226259

227260
def with_resource(self, uri: URI, resource: Resource[D]):
228261
"""
@@ -315,7 +348,10 @@ def lookup(self, ref: URI) -> Resolved[D]:
315348
316349
if the reference isn't resolvable
317350
"""
318-
uri, fragment = urldefrag(urljoin(self._base_uri, ref))
351+
if ref.startswith("#"):
352+
uri, fragment = self._base_uri, ref[1:]
353+
else:
354+
uri, fragment = urldefrag(urljoin(self._base_uri, ref))
319355
registry = self._registry
320356
resource = registry.get(uri)
321357
if resource is None:
@@ -325,8 +361,37 @@ def lookup(self, ref: URI) -> Resolved[D]:
325361
except KeyError:
326362
raise exceptions.Unresolvable(ref=ref) from None
327363

328-
resolver = evolve(self, registry=registry, base_uri=uri)
329364
if fragment.startswith("/"):
330-
return resource.pointer(pointer=fragment, resolver=resolver)
365+
return resource.pointer(
366+
pointer=fragment,
367+
resolver=evolve(self, registry=registry, base_uri=uri),
368+
)
369+
370+
if fragment:
371+
try:
372+
anchor = registry.anchor(uri, fragment)
373+
except LookupError:
374+
registry = registry.crawl()
375+
anchor = registry.anchor(uri, fragment)
376+
377+
resource = anchor.resolve()
378+
return Resolved(
379+
contents=resource.contents,
380+
resolver=evolve(self, registry=registry, base_uri=uri),
381+
)
382+
331383

332-
return Resolved(contents=resource.contents, resolver=resolver)
384+
@frozen
385+
class Anchor(Generic[D]):
386+
"""
387+
A simple anchor in a `Resource`.
388+
"""
389+
390+
name: str
391+
resource: Resource[D]
392+
393+
def resolve(self):
394+
"""
395+
Return the resource for this anchor.
396+
"""
397+
return self.resource

referencing/jsonschema.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from collections.abc import Set
88
from typing import Any, Iterable, Union
99

10-
from referencing import Registry, Resource, Specification
10+
from referencing import Anchor, Registry, Resource, Specification
1111
from referencing._attrs import frozen
1212
from referencing.typing import URI, Mapping
1313

@@ -38,13 +38,30 @@ def _dollar_id(contents: Schema) -> URI | None:
3838
def _dollar_id_pre2019(contents: Schema) -> URI | None:
3939
if isinstance(contents, bool) or "$ref" in contents:
4040
return
41-
return contents.get("$id")
41+
id = contents.get("$id")
42+
if id is not None and not id.startswith("#"):
43+
return id
4244

4345

4446
def _legacy_id(contents: ObjectSchema) -> URI | None:
4547
return contents.get("id")
4648

4749

50+
def _legacy_anchor_in_id(
51+
specification: Specification[ObjectSchema],
52+
contents: ObjectSchema,
53+
) -> Iterable[Anchor[ObjectSchema]]:
54+
id = contents.get("$id", "")
55+
if not id.startswith("#"):
56+
return []
57+
return [
58+
Anchor(
59+
name=id[1:],
60+
resource=specification.create_resource(contents),
61+
),
62+
]
63+
64+
4865
def _subresources_of(
4966
in_value: Set[str] = frozenset(),
5067
in_subvalues: Set[str] = frozenset(),
@@ -75,11 +92,13 @@ def subresources_of(resource: ObjectSchema) -> Iterable[ObjectSchema]:
7592
name="draft2020-12",
7693
id_of=_dollar_id,
7794
subresources_of=lambda contents: [],
95+
anchors_in=lambda specification, contents: [],
7896
)
7997
DRAFT201909 = Specification(
8098
name="draft2019-09",
8199
id_of=_dollar_id,
82100
subresources_of=lambda contents: [],
101+
anchors_in=lambda specification, contents: [],
83102
)
84103
DRAFT7 = Specification(
85104
name="draft-07",
@@ -89,21 +108,25 @@ def subresources_of(resource: ObjectSchema) -> Iterable[ObjectSchema]:
89108
in_subarray={"allOf", "anyOf", "oneOf"},
90109
in_subvalues={"definitions"},
91110
),
111+
anchors_in=_legacy_anchor_in_id,
92112
)
93113
DRAFT6 = Specification(
94114
name="draft-06",
95115
id_of=_dollar_id,
96116
subresources_of=lambda contents: [],
117+
anchors_in=lambda specification, contents: [],
97118
)
98119
DRAFT4 = Specification(
99120
name="draft-04",
100121
id_of=_legacy_id,
101122
subresources_of=lambda contents: [],
123+
anchors_in=lambda specification, contents: [],
102124
)
103125
DRAFT3 = Specification(
104126
name="draft-03",
105127
id_of=_legacy_id,
106128
subresources_of=lambda contents: [],
129+
anchors_in=lambda specification, contents: [],
107130
)
108131

109132

referencing/tests/test_core.py

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

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

66
ID_AND_CHILDREN = Specification(
77
name="id-and-children",
88
id_of=lambda contents: contents.get("ID"),
99
subresources_of=lambda contents: contents.get("children", []),
10+
anchors_in=lambda specification, contents: [
11+
Anchor(
12+
name=name,
13+
resource=specification.create_resource(contents=each),
14+
)
15+
for name, each in contents.get("anchors", {}).items()
16+
],
1017
)
1118

1219

@@ -91,6 +98,30 @@ def test_crawl_finds_a_subresource(self):
9198
expected = ID_AND_CHILDREN.create_resource({"ID": child_id, "foo": 12})
9299
assert registry.crawl()[child_id] == expected
93100

101+
def test_crawl_finds_anchors_with_id(self):
102+
resource = ID_AND_CHILDREN.create_resource(
103+
{"ID": "urn:bar", "anchors": {"foo": 12}},
104+
)
105+
registry = Registry().with_resource(resource.id(), resource)
106+
with pytest.raises(LookupError):
107+
registry.anchor(resource.id(), "foo")
108+
109+
assert registry.crawl().anchor(resource.id(), "foo") == Anchor(
110+
name="foo",
111+
resource=ID_AND_CHILDREN.create_resource(12),
112+
)
113+
114+
def test_crawl_finds_anchors_no_id(self):
115+
resource = ID_AND_CHILDREN.create_resource({"anchors": {"foo": 12}})
116+
registry = Registry().with_resource("urn:root", resource)
117+
with pytest.raises(LookupError):
118+
registry.anchor("urn:root", "foo")
119+
120+
assert registry.crawl().anchor("urn:root", "foo") == Anchor(
121+
name="foo",
122+
resource=ID_AND_CHILDREN.create_resource(12),
123+
)
124+
94125
def test_contents(self):
95126
resource = Resource.opaque({"foo": "bar"})
96127
uri = "urn:example"
@@ -278,6 +309,7 @@ def test_id_delegates_to_specification(self):
278309
name="",
279310
id_of=lambda contents: "urn:fixedID",
280311
subresources_of=lambda contents: [],
312+
anchors_in=lambda specification, contents: [],
281313
)
282314
resource = Resource(
283315
contents={"foo": "baz"},
@@ -298,6 +330,16 @@ def test_subresource_with_different_specification(self):
298330
DRAFT202012.create_resource(schema),
299331
]
300332

333+
def test_anchors_delegates_to_specification(self):
334+
resource = ID_AND_CHILDREN.create_resource(
335+
{"anchors": {"foo": {}, "bar": 1, "baz": ""}},
336+
)
337+
assert list(resource.anchors()) == [
338+
Anchor(name="foo", resource=ID_AND_CHILDREN.create_resource({})),
339+
Anchor(name="bar", resource=ID_AND_CHILDREN.create_resource(1)),
340+
Anchor(name="baz", resource=ID_AND_CHILDREN.create_resource("")),
341+
]
342+
301343
def test_pointer_to_mapping(self):
302344
resource = Resource.opaque(contents={"foo": "baz"})
303345
resolver = Registry().resolver()
@@ -336,6 +378,23 @@ def test_lookup_subresource(self):
336378
resolved = resolver.lookup("http://example.com/a")
337379
assert resolved.contents == {"ID": "http://example.com/a", "foo": 12}
338380

381+
def test_lookup_anchor_with_id(self):
382+
root = ID_AND_CHILDREN.create_resource(
383+
{
384+
"ID": "http://example.com/",
385+
"anchors": {"foo": 12},
386+
},
387+
)
388+
resolver = Registry().with_resource(root.id(), root).resolver()
389+
resolved = resolver.lookup("http://example.com/#foo")
390+
assert resolved.contents == 12
391+
392+
def test_lookup_anchor_without_id(self):
393+
root = ID_AND_CHILDREN.create_resource({"anchors": {"foo": 12}})
394+
resolver = Registry().with_resource("urn:example", root).resolver()
395+
resolved = resolver.lookup("urn:example#foo")
396+
assert resolved.contents == 12
397+
339398
def test_multiple_lookup(self):
340399
"""
341400
Continuing to lookup resources maintains the new base URI.
@@ -382,6 +441,17 @@ def test_multiple_lookup_pointer(self):
382441
second = first.resolver.lookup("#/foo")
383442
assert second.contents == "bar"
384443

444+
def test_multiple_lookup_anchor(self):
445+
root = ID_AND_CHILDREN.create_resource({"anchors": {"foo": 12}})
446+
registry = Registry().with_resource("http://example.com/", root)
447+
448+
resolver = registry.resolver()
449+
first = resolver.lookup("http://example.com/")
450+
assert first.contents == {"anchors": {"foo": 12}}
451+
452+
second = first.resolver.lookup("#foo")
453+
assert second.contents == 12
454+
385455
def test_lookup_non_existent_pointer(self):
386456
resource = Resource.opaque({"foo": {}})
387457
resolver = Registry({"http://example.com/1": resource}).resolver()
@@ -412,6 +482,7 @@ def test_create_resource(self):
412482
name="",
413483
id_of=lambda contents: "urn:fixedID",
414484
subresources_of=lambda contents: [],
485+
anchors_in=lambda specification, contents: [],
415486
)
416487
resource = specification.create_resource(contents={"foo": "baz"})
417488
assert resource == Resource(
@@ -446,6 +517,14 @@ def test_no_subresources(self, thing):
446517

447518
assert list(Specification.OPAQUE.subresources_of(thing)) == []
448519

520+
@pytest.mark.parametrize("thing", THINGS)
521+
def test_no_anchors(self, thing):
522+
"""
523+
An arbitrary thing has no anchors.
524+
"""
525+
526+
assert list(Specification.OPAQUE.anchors_in(thing)) == []
527+
449528

450529
@pytest.mark.parametrize(
451530
"cls",

0 commit comments

Comments
 (0)