Skip to content

Commit

Permalink
Add Specification.detect.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Julian committed Dec 7, 2023
1 parent 4f7e8bc commit ff99d1e
Show file tree
Hide file tree
Showing 3 changed files with 121 additions and 16 deletions.
5 changes: 5 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
Changelog
=========

v0.32.0
-------

* Add ``Specification.detect``, which essentially operates like ``Resource.from_contents`` without constructing a resource (i.e. it simply returns the detected specification).

v0.31.1
-------

Expand Down
102 changes: 86 additions & 16 deletions referencing/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,54 @@ def __call__(
...


def _detect_or_error(contents: D) -> Specification[D]:
if not isinstance(contents, Mapping):
raise exceptions.CannotDetermineSpecification(contents)

jsonschema_dialect_id = contents.get("$schema") # type: ignore[reportUnknownMemberType]
if jsonschema_dialect_id is None:
raise exceptions.CannotDetermineSpecification(contents)

from referencing.jsonschema import specification_with

return specification_with(
jsonschema_dialect_id, # type: ignore[reportUnknownArgumentType]
)


def _detect_or_default(
default: Specification[D],
) -> Callable[[D], Specification[D]]:
def _detect(contents: D) -> Specification[D]:
if not isinstance(contents, Mapping):
return default

jsonschema_dialect_id = contents.get("$schema") # type: ignore[reportUnknownMemberType]
if jsonschema_dialect_id is None:
return default

from referencing.jsonschema import specification_with

return specification_with(
jsonschema_dialect_id, # type: ignore[reportUnknownArgumentType]
default=default,
)

return _detect


class _SpecificationDetector:
def __get__(
self,
instance: Specification[D] | None,
cls: type[Specification[D]],
) -> Callable[[D], Specification[D]]:
if instance is None:
return _detect_or_error
else:
return _detect_or_default(instance)


@frozen
class Specification(Generic[D]):
"""
Expand Down Expand Up @@ -70,6 +118,39 @@ class Specification(Generic[D]):
#: nor internal identifiers.
OPAQUE: ClassVar[Specification[Any]]

#: Attempt to discern which specification applies to the given contents.
#:
#: May be called either as an instance method or as a class method, with
#: slightly different behavior in the following case:
#:
#: Recall that not all contents contains enough internal information about
#: which specification it is written for -- the JSON Schema ``{}``,
#: for instance, is valid under many different dialects and may be
#: interpreted as any one of them.
#:
#: When this method is used as an instance method (i.e. called on a
#: specific specification), that specification is used as the default
#: if the given contents are unidentifiable.
#:
#: On the other hand when called as a class method, an error is raised.
#:
#: To reiterate, ``DRAFT202012.detect({})`` will return ``DRAFT202012``
#: whereas the class method ``Specification.detect({})`` will raise an
#: error.
#:
#: (Note that of course ``DRAFT202012.detect(...)`` may return some other
#: specification when given a schema which *does* identify as being for
#: another version).
#:
#: Raises:
#:
#: `CannotDetermineSpecification`
#:
#: if the given contents don't have any discernible
#: information which could be used to guess which
#: specification they identify as
detect = _SpecificationDetector()

def __repr__(self) -> str:
return f"<Specification name={self.name!r}>"

Expand Down Expand Up @@ -113,10 +194,11 @@ class Resource(Generic[D]):
def from_contents(
cls,
contents: D,
default_specification: Specification[D] | _Unset = _UNSET,
default_specification: type[Specification[D]]
| Specification[D] = Specification,
) -> Resource[D]:
"""
Attempt to discern which specification applies to the given contents.
Create a resource guessing which specification applies to the contents.
Raises:
Expand All @@ -126,20 +208,8 @@ def from_contents(
information which could be used to guess which
specification they identify as
"""
specification = default_specification
if isinstance(contents, Mapping):
jsonschema_dialect_id = contents.get("$schema") # type: ignore[reportUnknownMemberType]
if jsonschema_dialect_id is not None:
from referencing.jsonschema import specification_with

specification = specification_with(
jsonschema_dialect_id, # type: ignore[reportUnknownArgumentType]
default=default_specification,
)

if specification is _UNSET:
raise exceptions.CannotDetermineSpecification(contents)
return cls(contents=contents, specification=specification) # type: ignore[reportUnknownArgumentType]
specification = default_specification.detect(contents)
return specification.create_resource(contents=contents)

@classmethod
def opaque(cls, contents: D) -> Resource[D]:
Expand Down
30 changes: 30 additions & 0 deletions referencing/tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,36 @@ def test_create_resource(self):
)
assert resource.id() == "urn:fixedID"

def test_detect_from_json_schema(self):
schema = {"$schema": "https://json-schema.org/draft/2020-12/schema"}
specification = Specification.detect(schema)
assert specification == DRAFT202012

def test_detect_with_no_discernible_information(self):
with pytest.raises(exceptions.CannotDetermineSpecification):
Specification.detect({"foo": "bar"})

def test_detect_with_no_discernible_information_and_default(self):
specification = Specification.OPAQUE.detect({"foo": "bar"})
assert specification is Specification.OPAQUE

def test_detect_unneeded_default(self):
schema = {"$schema": "https://json-schema.org/draft/2020-12/schema"}
specification = Specification.OPAQUE.detect(schema)
assert specification == DRAFT202012

def test_non_mapping_detect(self):
with pytest.raises(exceptions.CannotDetermineSpecification):
Specification.detect(True)

def test_non_mapping_detect_with_default(self):
specification = ID_AND_CHILDREN.detect(True)
assert specification is ID_AND_CHILDREN

def test_detect_with_fallback(self):
specification = Specification.OPAQUE.detect({"foo": "bar"})
assert specification is Specification.OPAQUE

def test_repr(self):
assert (
repr(ID_AND_CHILDREN) == "<Specification name='id-and-children'>"
Expand Down

0 comments on commit ff99d1e

Please sign in to comment.