diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index b4d4943..196f548 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -38,7 +38,7 @@ jobs: fail-fast: true matrix: os: [ubuntu, macos, windows] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] include: - experimental: false - python-version: "3.12" diff --git a/python_jsonschema_objects/__init__.py b/python_jsonschema_objects/__init__.py index 3290b33..a463ca2 100644 --- a/python_jsonschema_objects/__init__.py +++ b/python_jsonschema_objects/__init__.py @@ -6,29 +6,43 @@ import logging import os.path import warnings +from typing import Optional +import typing import inflection import jsonschema +import referencing.jsonschema +import referencing.retrieval +import referencing._core import six -from jsonschema import Draft4Validator +from referencing import Registry, Resource -from python_jsonschema_objects import classbuilder, markdown_support, util +import python_jsonschema_objects.classbuilder as classbuilder +import python_jsonschema_objects.markdown_support +import python_jsonschema_objects.util from python_jsonschema_objects.validators import ValidationError logger = logging.getLogger(__name__) +__all__ = ["ObjectBuilder", "markdown_support", "ValidationError"] + FILE = __file__ SUPPORTED_VERSIONS = ( - "http://json-schema.org/draft-03/schema#", - "http://json-schema.org/draft-04/schema#", + "http://json-schema.org/draft-03/schema", + "http://json-schema.org/draft-04/schema", ) class ObjectBuilder(object): - def __init__(self, schema_uri, resolved={}, resolver=None, validatorClass=None): - self.mem_resolved = resolved - + def __init__( + self, + schema_uri: typing.Union[typing.AnyStr, typing.Mapping], + resolved: typing.Dict[typing.AnyStr, typing.Mapping] = {}, + registry: Optional[referencing.Registry] = None, + resolver: Optional[referencing.typing.Retrieve] = None, + specification_uri: Optional[str] = None, + ): if isinstance(schema_uri, six.string_types): uri = os.path.normpath(schema_uri) self.basedir = os.path.dirname(uri) @@ -41,7 +55,7 @@ def __init__(self, schema_uri, resolved={}, resolver=None, validatorClass=None): if ( "$schema" in self.schema - and self.schema["$schema"] not in SUPPORTED_VERSIONS + and self.schema["$schema"].rstrip("#") not in SUPPORTED_VERSIONS ): warnings.warn( "Schema version {} not recognized. Some " @@ -50,19 +64,91 @@ def __init__(self, schema_uri, resolved={}, resolver=None, validatorClass=None): ) ) - self.resolver = resolver or jsonschema.RefResolver.from_schema(self.schema) - self.resolver.handlers.update( - {"file": self.relative_file_resolver, "memory": self.memory_resolver} + if registry is not None: + if not isinstance(registry, referencing.Registry): + raise TypeError("registry must be a Registry instance") + + if resolver is not None: + raise AttributeError( + "Cannot specify both registry and resolver. If you provide your own registry, pass the resolver " + "directly to that" + ) + self.registry = registry + else: + if resolver is not None: + + def file_and_memory_handler(uri): + if uri.startswith("file:"): + return Resource.from_contents(self.relative_file_resolver(uri)) + return resolver(uri) + + self.registry = Registry(retrieve=file_and_memory_handler) + else: + + def file_and_memory_handler(uri): + if uri.startswith("file:"): + return Resource.from_contents(self.relative_file_resolver(uri)) + raise RuntimeError( + "No remote resource resolver provided. Cannot resolve {}".format( + uri + ) + ) + + self.registry = Registry(retrieve=file_and_memory_handler) + + if "$schema" not in self.schema: + warnings.warn( + "Schema version not specified. Defaulting to {}".format( + specification_uri or "http://json-schema.org/draft-04/schema" + ) + ) + updated = { + "$schema": specification_uri or "http://json-schema.org/draft-04/schema" + } + updated.update(self.schema) + self.schema = updated + + schema = Resource.from_contents(self.schema) + if schema.id() is None: + warnings.warn("Schema id not specified. Defaulting to 'self'") + updated = {"$id": "self", "id": "self"} + updated.update(self.schema) + self.schema = updated + schema = Resource.from_contents(self.schema) + + self.registry = self.registry.with_resource("", schema) + + if len(resolved) > 0: + warnings.warn( + "Use of 'memory:' URIs is deprecated. Provide a registry with properly resolved references " + "if you want to resolve items externally.", + DeprecationWarning, + ) + for uri, contents in resolved.items(): + self.registry = self.registry.with_resource( + "memory:" + uri, + referencing.Resource.from_contents( + contents, specification_uri or self.schema["$schema"] + ), + ) + + validatorClass = jsonschema.validators.validator_for( + {"$schema": specification_uri or self.schema["$schema"]} ) - validatorClass = validatorClass or Draft4Validator - meta_validator = validatorClass(validatorClass.META_SCHEMA) + meta_validator = validatorClass( + validatorClass.META_SCHEMA, registry=self.registry + ) meta_validator.validate(self.schema) - self.validator = validatorClass(self.schema, resolver=self.resolver) + self.validator = validatorClass(self.schema, registry=self.registry) self._classes = None self._resolved = None + @property + def resolver(self) -> referencing._core.Resolver: + return self.registry.resolver() + @property def schema(self): try: @@ -85,9 +171,6 @@ def get_class(self, uri): self._classes = self.build_classes() return self._resolved.get(uri, None) - def memory_resolver(self, uri): - return self.mem_resolved[uri[7:]] - def relative_file_resolver(self, uri): path = os.path.join(self.basedir, uri[8:]) with codecs.open(path, "r", "utf-8") as fin: @@ -126,10 +209,11 @@ def build_classes(self, strict=False, named_only=False, standardize_names=True): kw = {"strict": strict} builder = classbuilder.ClassBuilder(self.resolver) for nm, defn in six.iteritems(self.schema.get("definitions", {})): - uri = util.resolve_ref_uri( - self.resolver.resolution_scope, "#/definitions/" + nm + resolved = self.resolver.lookup("#/definitions/" + nm) + uri = python_jsonschema_objects.util.resolve_ref_uri( + self.resolver._base_uri, "#/definitions/" + nm ) - builder.construct(uri, defn, **kw) + builder.construct(uri, resolved.contents, **kw) if standardize_names: name_transform = lambda t: inflection.camelize( @@ -152,7 +236,7 @@ def build_classes(self, strict=False, named_only=False, standardize_names=True): elif not named_only: classes[name_transform(uri.split("/")[-1])] = klass - return util.Namespace.from_mapping(classes) + return python_jsonschema_objects.util.Namespace.from_mapping(classes) if __name__ == "__main__": diff --git a/python_jsonschema_objects/classbuilder.py b/python_jsonschema_objects/classbuilder.py index 9ae08c3..b099f2c 100644 --- a/python_jsonschema_objects/classbuilder.py +++ b/python_jsonschema_objects/classbuilder.py @@ -1,9 +1,10 @@ -import collections +import collections.abc import copy import itertools import logging import sys +import referencing._core import six from python_jsonschema_objects import ( @@ -443,7 +444,7 @@ def __call__(self, *a, **kw): class ClassBuilder(object): - def __init__(self, resolver): + def __init__(self, resolver: referencing._core.Resolver): self.resolver = resolver self.resolved = {} self.under_construction = set() @@ -462,10 +463,8 @@ def expand_references(self, source_uri, iterable): return pp def resolve_type(self, ref, source): - """Return a resolved type for a URI, potentially constructing one if - necessary. - """ - uri = util.resolve_ref_uri(self.resolver.resolution_scope, ref) + """Return a resolved type for a URI, potentially constructing one if necessary""" + uri = util.resolve_ref_uri(self.resolver._base_uri, ref) if uri in self.resolved: return self.resolved[uri] @@ -484,9 +483,9 @@ def resolve_type(self, ref, source): "Resolving direct reference object {0} -> {1}", source, uri ) ) - with self.resolver.resolving(ref) as resolved: - self.resolved[uri] = self.construct(uri, resolved, (ProtocolBase,)) - return self.resolved[uri] + resolved = self.resolver.lookup(ref) + self.resolved[uri] = self.construct(uri, resolved.contents, (ProtocolBase,)) + return self.resolved[uri] def construct(self, uri, *args, **kw): """Wrapper to debug things""" diff --git a/python_jsonschema_objects/examples/README.md b/python_jsonschema_objects/examples/README.md index 254582d..aca8dae 100644 --- a/python_jsonschema_objects/examples/README.md +++ b/python_jsonschema_objects/examples/README.md @@ -219,7 +219,7 @@ The schema and code example below show how this works. The `$ref` operator is supported in nearly all locations, and dispatches the actual reference resolution to the -`jsonschema.RefResolver`. +`referencing.Registry` resolver. This example shows using the memory URI (described in more detail below) to create a wrapper object that is just a string literal. @@ -298,6 +298,9 @@ ValidationError: '[u'author']' are required attributes for B #### The "memory:" URI +**"memory:" URIs are deprecated (although they still work). Load resources into a +`referencing.Registry` instead and pass those in** + The ObjectBuilder can be passed a dictionary specifying 'memory' schemas when instantiated. This will allow it to resolve references where the referenced schemas are retrieved diff --git a/setup.py b/setup.py index 7a66678..35e2313 100755 --- a/setup.py +++ b/setup.py @@ -41,13 +41,13 @@ install_requires=[ "inflection>=0.2", "Markdown>=2.4", - "jsonschema>=2.3,<4.18", + "jsonschema>=4.18", "six>=1.5.2", ], + python_requires=">=3.8", cmdclass=versioneer.get_cmdclass(), classifiers=[ "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/test/test_nondefault_resolver_validator.py b/test/test_nondefault_resolver_validator.py index ef18bdf..afe4215 100644 --- a/test/test_nondefault_resolver_validator.py +++ b/test/test_nondefault_resolver_validator.py @@ -1,24 +1,61 @@ -from jsonschema import Draft3Validator, RefResolver -from jsonschema._utils import URIDict, load_schema +import jsonschema.exceptions +import pytest # noqa +import referencing +import referencing.exceptions +import referencing.jsonschema +import python_jsonschema_objects import python_jsonschema_objects as pjo -def test_non_default_resolver_validator(markdown_examples): - ms = URIDict() - draft3 = load_schema("draft3") - draft4 = load_schema("draft4") - ms[draft3["id"]] = draft3 - ms[draft4["id"]] = draft4 - resolver_with_store = RefResolver(draft3["id"], draft3, ms) +def test_custom_spec_validator(markdown_examples): + # This schema shouldn't be valid under DRAFT-03 + schema = { + "$schema": "http://json-schema.org/draft-03/schema", + "title": "other", + "type": "any", # this wasn't valid starting in 04 + } + pjo.ObjectBuilder( + schema, + resolved=markdown_examples, + ) + + with pytest.raises(jsonschema.exceptions.ValidationError): + pjo.ObjectBuilder( + schema, + specification_uri="http://json-schema.org/draft-04/schema", + resolved=markdown_examples, + ) + + +def test_non_default_resolver_finds_refs(): + registry = referencing.Registry() + + remote_schema = { + "$schema": "http://json-schema.org/draft-04/schema", + "type": "number", + } + registry = registry.with_resource( + "https://example.org/schema/example", + referencing.Resource.from_contents(remote_schema), + ) + + schema = { + "$schema": "http://json-schema.org/draft-04/schema", + "title": "other", + "type": "object", + "properties": { + "local": {"type": "string"}, + "remote": {"$ref": "https://example.org/schema/example"}, + }, + } - # 'Other' schema should be valid with draft3 builder = pjo.ObjectBuilder( - markdown_examples["Other"], - resolver=resolver_with_store, - validatorClass=Draft3Validator, - resolved=markdown_examples, + schema, + registry=registry, ) - klasses = builder.build_classes() - a = klasses.Other(MyAddress="where I live") - assert a.MyAddress == "where I live" + ns = builder.build_classes() + + thing = ns.Other(local="foo", remote=1) + with pytest.raises(python_jsonschema_objects.ValidationError): + thing = ns.Other(local="foo", remote="NaN") diff --git a/test/test_pytest.py b/test/test_pytest.py index 0e315d8..080fd26 100644 --- a/test/test_pytest.py +++ b/test/test_pytest.py @@ -12,32 +12,38 @@ @pytest.mark.parametrize( - "version, warn", + "version, warn, error", [ - ("http://json-schema.org/schema#", True), - ("http://json-schema.org/draft-03/schema#", False), - ("http://json-schema.org/draft-04/schema#", False), - ("http://json-schema.org/draft-06/schema#", True), - ("http://json-schema.org/draft-07/schema#", True), + ("http://json-schema.org/schema#", True, True), + ("http://json-schema.org/draft-03/schema#", False, False), + ("http://json-schema.org/draft-04/schema#", False, False), + ("http://json-schema.org/draft-06/schema#", True, False), + ("http://json-schema.org/draft-07/schema#", True, False), ], ) -def test_warnings_on_schema_version(version, warn): +def test_warnings_on_schema_version(version, warn, error): schema = {"$schema": version, "$id": "test", "type": "object", "properties": {}} with warnings.catch_warnings(record=True) as w: - pjs.ObjectBuilder(schema) - - if warn: - assert len(w) == 1 - assert "Schema version %s not recognized" % version in str(w[-1].message) + try: + pjs.ObjectBuilder(schema) + except Exception: + assert error == True # noqa else: - assert len(w) == 0, w[-1].message + warn_msgs = [str(m.message) for m in w] + present = [ + "Schema version %s not recognized" % version in msg for msg in warn_msgs + ] + if warn: + assert any(present) + else: + assert not any(present) def test_schema_validation(): """Test that the ObjectBuilder validates the schema itself.""" schema = { - "$schema": "http://json-schema.org/schema#", + "$schema": "http://json-schema.org/draft-04/schema#", "$id": "test", "type": "object", "properties": { @@ -52,7 +58,7 @@ def test_schema_validation(): def test_regression_9(): schema = { - "$schema": "http://json-schema.org/schema#", + "$schema": "http://json-schema.org/draft-04/schema#", "$id": "test", "type": "object", "properties": { @@ -67,7 +73,7 @@ def test_regression_9(): def test_build_classes_is_idempotent(): schema = { - "$schema": "http://json-schema.org/schema#", + "$schema": "http://json-schema.org/draft-04/schema#", "title": "test", "type": "object", "properties": { @@ -87,7 +93,7 @@ def test_build_classes_is_idempotent(): def test_underscore_properties(): schema = { - "$schema": "http://json-schema.org/schema#", + "$schema": "http://json-schema.org/draft-04/schema#", "title": "AggregateQuery", "type": "object", "properties": {"group": {"type": "object", "properties": {}}}, @@ -108,7 +114,7 @@ def test_underscore_properties(): def test_array_regressions(): schema = { - "$schema": "http://json-schema.org/schema#", + "$schema": "http://json-schema.org/draft-04/schema#", "$id": "test", "type": "object", "properties": { @@ -140,7 +146,7 @@ def test_array_regressions(): def test_arrays_can_have_reffed_items_of_mixed_type(): schema = { - "$schema": "http://json-schema.org/schema#", + "$schema": "http://json-schema.org/draft-04/schema#", "$id": "test", "type": "object", "properties": { @@ -472,7 +478,7 @@ def test_dictionary_transformation(Person, pdict): def test_strict_mode(): schema = { - "$schema": "http://json-schema.org/schema#", + "$schema": "http://json-schema.org/draft-04/schema#", "type": "object", "properties": {"firstName": {"type": "string"}, "lastName": {"type": "string"}}, "$id": "test", @@ -492,7 +498,7 @@ def test_strict_mode(): def test_boolean_in_child_object(): schema = { - "$schema": "http://json-schema.org/schema#", + "$schema": "http://json-schema.org/draft-04/schema#", "$id": "test", "type": "object", "properties": {"data": {"type": "object", "additionalProperties": True}}, @@ -514,7 +520,7 @@ def test_boolean_in_child_object(): def test_default_values(default): default = json.loads(default) schema = { - "$schema": "http://json-schema.org/schema#", + "$schema": "http://json-schema.org/draft-04/schema#", "$id": "test", "type": "object", "properties": {"sample": default}, @@ -537,7 +543,7 @@ def test_justareference_example(markdown_examples): def test_number_multiple_of_validation(): schema = { - "$schema": "http://json-schema.org/schema#", + "$schema": "http://json-schema.org/draft-04/schema#", "$id": "test", "type": "object", "title": "Base", diff --git a/test/test_regression_232.py b/test/test_regression_232.py index ef05635..7ff9e7a 100644 --- a/test/test_regression_232.py +++ b/test/test_regression_232.py @@ -1,4 +1,3 @@ -import jsonschema import pytest import python_jsonschema_objects as pjo @@ -71,15 +70,9 @@ def test_nested_oneof_with_different_types(schema_json): builder = pjo.ObjectBuilder(schema_json) ns = builder.build_classes() - resolver = jsonschema.RefResolver.from_schema(schema_json) - main_obj = schema_json["definitions"]["MainObject"] - test1 = {"location": 12345} test2 = {"location": {"type": "Location"}} test3 = {"location": "unique:12"} - jsonschema.validate(test1, main_obj, resolver=resolver) - jsonschema.validate(test2, main_obj, resolver=resolver) - jsonschema.validate(test3, main_obj, resolver=resolver) obj1 = ns.MainObject(**test1) obj2 = ns.MainObject(**test2) @@ -94,13 +87,8 @@ def test_nested_oneof_with_different_types_by_reference(schema_json): builder = pjo.ObjectBuilder(schema_json) ns = builder.build_classes() - resolver = jsonschema.RefResolver.from_schema(schema_json) - ref_obj = schema_json["definitions"]["RefObject"] - test1 = {"location": 12345} test2 = {"location": {"type": "Location"}} - jsonschema.validate(test1, ref_obj, resolver=resolver) - jsonschema.validate(test2, ref_obj, resolver=resolver) obj1 = ns.RefObject(**test1) obj2 = ns.RefObject(**test2) @@ -113,15 +101,10 @@ def test_nested_oneof_with_different_types_in_additional_properties(schema_json) builder = pjo.ObjectBuilder(schema_json) ns = builder.build_classes() - resolver = jsonschema.RefResolver.from_schema(schema_json) - map_obj = schema_json["definitions"]["MapObject"] - x_prop_name = "location-id" test1 = {x_prop_name: 12345} test2 = {x_prop_name: {"type": "Location"}} - jsonschema.validate(test1, map_obj, resolver=resolver) - jsonschema.validate(test2, map_obj, resolver=resolver) obj1 = ns.MapObject(**test1) obj2 = ns.MapObject(**test2) diff --git a/test/thing-one.json b/test/thing-one.json index e335564..ae53726 100644 --- a/test/thing-one.json +++ b/test/thing-one.json @@ -1,5 +1,5 @@ { - "$schema": "http://json-schema.org/schema#", + "$schema": "http://json-schema.org/draft-04/schema#", "id": "thing_one", "title": "thing_one", "description": "The first thing.", diff --git a/test/thing-two.json b/test/thing-two.json index 58a9420..d473490 100644 --- a/test/thing-two.json +++ b/test/thing-two.json @@ -1,5 +1,5 @@ { - "$schema": "http://json-schema.org/schema#", + "$schema": "http://json-schema.org/draft-04/schema#", "id": "thing_two", "title": "thing_two", "description": "The second thing.", diff --git a/tox.ini b/tox.ini index f8bb557..9218c20 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,9 @@ [tox] -envlist = py{37,38,39,310,311,312}-jsonschema{23,24,25,26,30,40}-markdown{2,3} +envlist = py{38,39,310,311,312}-jsonschema{40}-markdown{2,3} skip_missing_interpreters = true [gh-actions] python = - 3.7: py37 3.8: py38 3.9: py39 3.10: py310 @@ -19,11 +18,6 @@ deps = coverage pytest pytest-mock - jsonschema23: jsonschema~=2.3.0 - jsonschema24: jsonschema~=2.4.0 - jsonschema25: jsonschema~=2.5.0 - jsonschema26: jsonschema~=2.6.0 - jsonschema30: jsonschema~=3.0.0 - jsonschema40: jsonschema~=4.0 + jsonschema40: jsonschema>=4.18 markdown2: Markdown~=2.4 markdown3: Markdown~=3.0