Skip to content

Commit ff99d1e

Browse files
committed
Add Specification.detect.
Allows someone to sniff out which specification applies without necessarily immediately constructing a resource. This is concretely needed/useful in referencing.loaders, though I've noticed it once or twice before in things building on top of this library. Also reimplements Resource.from_contents in terms of this method.
1 parent 4f7e8bc commit ff99d1e

File tree

3 files changed

+121
-16
lines changed

3 files changed

+121
-16
lines changed

docs/changes.rst

+5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
Changelog
33
=========
44

5+
v0.32.0
6+
-------
7+
8+
* Add ``Specification.detect``, which essentially operates like ``Resource.from_contents`` without constructing a resource (i.e. it simply returns the detected specification).
9+
510
v0.31.1
611
-------
712

referencing/_core.py

+86-16
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,54 @@ def __call__(
3737
...
3838

3939

40+
def _detect_or_error(contents: D) -> Specification[D]:
41+
if not isinstance(contents, Mapping):
42+
raise exceptions.CannotDetermineSpecification(contents)
43+
44+
jsonschema_dialect_id = contents.get("$schema") # type: ignore[reportUnknownMemberType]
45+
if jsonschema_dialect_id is None:
46+
raise exceptions.CannotDetermineSpecification(contents)
47+
48+
from referencing.jsonschema import specification_with
49+
50+
return specification_with(
51+
jsonschema_dialect_id, # type: ignore[reportUnknownArgumentType]
52+
)
53+
54+
55+
def _detect_or_default(
56+
default: Specification[D],
57+
) -> Callable[[D], Specification[D]]:
58+
def _detect(contents: D) -> Specification[D]:
59+
if not isinstance(contents, Mapping):
60+
return default
61+
62+
jsonschema_dialect_id = contents.get("$schema") # type: ignore[reportUnknownMemberType]
63+
if jsonschema_dialect_id is None:
64+
return default
65+
66+
from referencing.jsonschema import specification_with
67+
68+
return specification_with(
69+
jsonschema_dialect_id, # type: ignore[reportUnknownArgumentType]
70+
default=default,
71+
)
72+
73+
return _detect
74+
75+
76+
class _SpecificationDetector:
77+
def __get__(
78+
self,
79+
instance: Specification[D] | None,
80+
cls: type[Specification[D]],
81+
) -> Callable[[D], Specification[D]]:
82+
if instance is None:
83+
return _detect_or_error
84+
else:
85+
return _detect_or_default(instance)
86+
87+
4088
@frozen
4189
class Specification(Generic[D]):
4290
"""
@@ -70,6 +118,39 @@ class Specification(Generic[D]):
70118
#: nor internal identifiers.
71119
OPAQUE: ClassVar[Specification[Any]]
72120

121+
#: Attempt to discern which specification applies to the given contents.
122+
#:
123+
#: May be called either as an instance method or as a class method, with
124+
#: slightly different behavior in the following case:
125+
#:
126+
#: Recall that not all contents contains enough internal information about
127+
#: which specification it is written for -- the JSON Schema ``{}``,
128+
#: for instance, is valid under many different dialects and may be
129+
#: interpreted as any one of them.
130+
#:
131+
#: When this method is used as an instance method (i.e. called on a
132+
#: specific specification), that specification is used as the default
133+
#: if the given contents are unidentifiable.
134+
#:
135+
#: On the other hand when called as a class method, an error is raised.
136+
#:
137+
#: To reiterate, ``DRAFT202012.detect({})`` will return ``DRAFT202012``
138+
#: whereas the class method ``Specification.detect({})`` will raise an
139+
#: error.
140+
#:
141+
#: (Note that of course ``DRAFT202012.detect(...)`` may return some other
142+
#: specification when given a schema which *does* identify as being for
143+
#: another version).
144+
#:
145+
#: Raises:
146+
#:
147+
#: `CannotDetermineSpecification`
148+
#:
149+
#: if the given contents don't have any discernible
150+
#: information which could be used to guess which
151+
#: specification they identify as
152+
detect = _SpecificationDetector()
153+
73154
def __repr__(self) -> str:
74155
return f"<Specification name={self.name!r}>"
75156

@@ -113,10 +194,11 @@ class Resource(Generic[D]):
113194
def from_contents(
114195
cls,
115196
contents: D,
116-
default_specification: Specification[D] | _Unset = _UNSET,
197+
default_specification: type[Specification[D]]
198+
| Specification[D] = Specification,
117199
) -> Resource[D]:
118200
"""
119-
Attempt to discern which specification applies to the given contents.
201+
Create a resource guessing which specification applies to the contents.
120202
121203
Raises:
122204
@@ -126,20 +208,8 @@ def from_contents(
126208
information which could be used to guess which
127209
specification they identify as
128210
"""
129-
specification = default_specification
130-
if isinstance(contents, Mapping):
131-
jsonschema_dialect_id = contents.get("$schema") # type: ignore[reportUnknownMemberType]
132-
if jsonschema_dialect_id is not None:
133-
from referencing.jsonschema import specification_with
134-
135-
specification = specification_with(
136-
jsonschema_dialect_id, # type: ignore[reportUnknownArgumentType]
137-
default=default_specification,
138-
)
139-
140-
if specification is _UNSET:
141-
raise exceptions.CannotDetermineSpecification(contents)
142-
return cls(contents=contents, specification=specification) # type: ignore[reportUnknownArgumentType]
211+
specification = default_specification.detect(contents)
212+
return specification.create_resource(contents=contents)
143213

144214
@classmethod
145215
def opaque(cls, contents: D) -> Resource[D]:

referencing/tests/test_core.py

+30
Original file line numberDiff line numberDiff line change
@@ -966,6 +966,36 @@ def test_create_resource(self):
966966
)
967967
assert resource.id() == "urn:fixedID"
968968

969+
def test_detect_from_json_schema(self):
970+
schema = {"$schema": "https://json-schema.org/draft/2020-12/schema"}
971+
specification = Specification.detect(schema)
972+
assert specification == DRAFT202012
973+
974+
def test_detect_with_no_discernible_information(self):
975+
with pytest.raises(exceptions.CannotDetermineSpecification):
976+
Specification.detect({"foo": "bar"})
977+
978+
def test_detect_with_no_discernible_information_and_default(self):
979+
specification = Specification.OPAQUE.detect({"foo": "bar"})
980+
assert specification is Specification.OPAQUE
981+
982+
def test_detect_unneeded_default(self):
983+
schema = {"$schema": "https://json-schema.org/draft/2020-12/schema"}
984+
specification = Specification.OPAQUE.detect(schema)
985+
assert specification == DRAFT202012
986+
987+
def test_non_mapping_detect(self):
988+
with pytest.raises(exceptions.CannotDetermineSpecification):
989+
Specification.detect(True)
990+
991+
def test_non_mapping_detect_with_default(self):
992+
specification = ID_AND_CHILDREN.detect(True)
993+
assert specification is ID_AND_CHILDREN
994+
995+
def test_detect_with_fallback(self):
996+
specification = Specification.OPAQUE.detect({"foo": "bar"})
997+
assert specification is Specification.OPAQUE
998+
969999
def test_repr(self):
9701000
assert (
9711001
repr(ID_AND_CHILDREN) == "<Specification name='id-and-children'>"

0 commit comments

Comments
 (0)