From ff99d1e5e46c43c63c0bc45188206d02615c0672 Mon Sep 17 00:00:00 2001 From: Julian Berman Date: Wed, 6 Dec 2023 11:28:19 -0500 Subject: [PATCH] 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. --- docs/changes.rst | 5 ++ referencing/_core.py | 102 +++++++++++++++++++++++++++------ referencing/tests/test_core.py | 30 ++++++++++ 3 files changed, 121 insertions(+), 16 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 461520f..bd0b30b 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -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 ------- diff --git a/referencing/_core.py b/referencing/_core.py index 36899d9..2f4f5f7 100644 --- a/referencing/_core.py +++ b/referencing/_core.py @@ -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]): """ @@ -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"" @@ -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: @@ -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]: diff --git a/referencing/tests/test_core.py b/referencing/tests/test_core.py index 1640b85..4700b4d 100644 --- a/referencing/tests/test_core.py +++ b/referencing/tests/test_core.py @@ -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) == ""