Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
Julian committed Aug 25, 2021
1 parent 15ddd2f commit 9896ba2
Show file tree
Hide file tree
Showing 6 changed files with 287 additions and 187 deletions.
63 changes: 63 additions & 0 deletions jsonschema/_annotation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""
Support for JSON Schema annotation collection.
"""

from collections import deque

import attr

from jsonschema._utils import __no_init_subclass__


@attr.s
class Annotator:
"""
An annotator supervises validation of an instance, annotating as it goes.
Whereas validators, type checkers, format checkers and the like
are generally stateless, an annotator is *stateful*. It tracks
the incremental progress as validation –or more broadly pure
annotation– of an instance is progressing.
"""

_validator = attr.ib(
repr=lambda validator: f"<{validator.__class__.__name__}>",
kw_only=True,
)

def __attrs_post_init__(self):
self._scope_stack = deque([self._validator.ID_OF(self._validator.schema)])

def descend(self, instance, schema, path=None, schema_path=None):
validator = attr.evolve(self._validator, schema=schema)
for error in validator.iter_errors(instance):
if path is not None:
error.path.appendleft(path)
if schema_path is not None:
error.schema_path.appendleft(schema_path)
yield error

__init_subclass__ = __no_init_subclass__

# TODO: IMPROVEME / belongs on ref resolver?
def scopes_moving_outward(self):
yield self.resolver.resolution_scope, self._validator.schema
for each in reversed(self.resolver._scopes_stack[1:]):
yield self.resolver.resolve(each)

def descend_at_ref(self, instance, ref):
scope, resolved = self._validator.resolver.resolve(
ref=ref,
resolution_scope=self._scope_stack[-1],
)
self._scope_stack.append(scope)
yield from self.descend(instance=instance, schema=resolved)
self._scope_stack.pop()

# TODO: REMOVEME
@property
def format_checker(self): return self._validator.format_checker
@property
def is_valid(self): return self._validator.is_valid
@property
def is_type(self): return self._validator.is_type
14 changes: 6 additions & 8 deletions jsonschema/_legacy_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,14 +206,12 @@ def contains_draft6_draft7(validator, contains, instance, schema):
)


def recursiveRef(validator, recursiveRef, instance, schema):
scope_stack = validator.resolver.scopes_stack_copy
lookup_url, target = validator.resolver.resolution_scope, validator.schema

for each in reversed(scope_stack[1:]):
lookup_url, next_target = validator.resolver.resolve(each)
if next_target.get("$recursiveAnchor"):
target = next_target
def recursiveRef(annotator, recursiveRef, instance, schema):
outward = (schema for _, schema in annotator.scopes_moving_outward())
target = next(outward)
for each in outward:
if each.get("$recursiveAnchor"):
target = each
else:
break

Expand Down
11 changes: 11 additions & 0 deletions jsonschema/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ def load_vocabulary(name):
return vocabulary


def __no_init_subclass__(*args, **kwargs):
"""
Warn users that subclassing is not part of the public API of objects.
"""
raise RuntimeError(
"jsonschema classes do not support subclassing. "
"If an API is missing which prevents extension, please "
"file a ticket at https://github.com/Julian/jsonschema/issues."
)


def format_as_index(container, indices):
"""
Construct a single string containing indexing operations for the indices.
Expand Down
21 changes: 4 additions & 17 deletions jsonschema/_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,35 +282,22 @@ def enum(validator, enums, instance, schema):
yield ValidationError(f"{instance!r} is not one of {enums!r}")


def ref(validator, ref, instance, schema):
resolve = getattr(validator.resolver, "resolve", None)
if resolve is None:
with validator.resolver.resolving(ref) as resolved:
yield from validator.descend(instance, resolved)
else:
scope, resolved = validator.resolver.resolve(ref)
validator.resolver.push_scope(scope)

try:
yield from validator.descend(instance, resolved)
finally:
validator.resolver.pop_scope()
def ref(annotator, ref, instance, schema):
yield from annotator.descend_at_ref(instance=instance, ref=ref)


def dynamicRef(validator, dynamicRef, instance, schema):
_, fragment = urldefrag(dynamicRef)
scope_stack = validator.resolver.scopes_stack_copy

for url in scope_stack:
for url in []:
lookup_url = urljoin(url, dynamicRef)
with validator.resolver.resolving(lookup_url) as subschema:
if ("$dynamicAnchor" in subschema
and fragment == subschema["$dynamicAnchor"]):
yield from validator.descend(instance, subschema)
break
else:
with validator.resolver.resolving(dynamicRef) as subschema:
yield from validator.descend(instance, subschema)
yield from validator.descend_at_ref(instance, dynamicRef)


def type(validator, types, instance, schema):
Expand Down
156 changes: 156 additions & 0 deletions jsonschema/tests/test_annotation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
from unittest import TestCase

from jsonschema._annotation import Annotator
from jsonschema.exceptions import UnknownType
from jsonschema.validators import _LATEST_VERSION, extend


class TestAnnotator(TestCase):
def test_descend(self):
annotator = Annotator(validator=_LATEST_VERSION({}))
errors = {
error.message
for error in annotator.descend(instance=37, schema=False)
}
self.assertEqual(errors, {"False schema does not allow 37"})

def test_descend_multiple_errors(self):
annotator = Annotator(validator=_LATEST_VERSION({}))
errors = {
error.message
for error in annotator.descend(
instance=37,
schema={"type": "string", "minimum": 38},
)
}
self.assertEqual(
errors, {
"37 is less than the minimum of 38",
"37 is not of type 'string'",
},
)

def test_descend_extend_path(self):
annotator = Annotator(validator=_LATEST_VERSION({}))
errors = {
(
error.message,
tuple(error.absolute_path),
tuple(error.absolute_schema_path),
) for error in annotator.descend(
instance={"b": {"c": 37}},
schema={
"properties": {"b": {"const": "a"}},
"minProperties": 2,
},
path="a",
)
}
self.assertEqual(
errors, {
(
"{'b': {'c': 37}} does not have enough properties",
("a",),
("minProperties",)
),
(
"'a' was expected",
("a", "b"),
("properties", "b", "const"),
),
},
)

def test_descend_extend_schema_path(self):
annotator = Annotator(validator=_LATEST_VERSION({}))
errors = {
(
error.message,
tuple(error.absolute_path),
tuple(error.absolute_schema_path),
) for error in annotator.descend(
instance={"b": {"c": 37}},
schema={
"properties": {"b": {"const": "a"}},
"minProperties": 2,
},
schema_path="no37",
)
}
self.assertEqual(
errors, {
(
"{'b': {'c': 37}} does not have enough properties",
(),
("no37", "minProperties")
),
(
"'a' was expected",
("b",),
("no37", "properties", "b", "const"),
),
},
)

def test_descend_extend_both_paths(self):
annotator = Annotator(validator=_LATEST_VERSION({}))
errors = {
(
error.message,
tuple(error.absolute_path),
tuple(error.absolute_schema_path),
) for error in annotator.descend(
instance={"b": {"c": 37}},
schema={
"properties": {"b": {"const": "a"}},
"minProperties": 2,
},
path="foo",
schema_path="no37",
)
}
self.assertEqual(
errors, {
(
"{'b': {'c': 37}} does not have enough properties",
("foo",),
("no37", "minProperties")
),
(
"'a' was expected",
("foo", "b"),
("no37", "properties", "b", "const"),
),
},
)

def test_is_type(self):
annotator = Annotator(validator=_LATEST_VERSION({}))
self.assertTrue(annotator.is_type("foo", "string"))

def test_is_not_type(self):
annotator = Annotator(validator=_LATEST_VERSION({}))
self.assertFalse(annotator.is_type(37, "string"))

def test_is_unknown_type(self):
annotator = Annotator(validator=_LATEST_VERSION({}))
with self.assertRaises(UnknownType) as e:
self.assertFalse(annotator.is_type(37, "boopety"))
self.assertEqual(
vars(e.exception),
{"type": "boopety", "instance": 37, "schema": {}},
)

def test_repr(self):
validator = extend(_LATEST_VERSION)({})
annotator = Annotator(validator=validator)
self.assertEqual(
repr(annotator),
"Annotator(_validator=<Validator>)",
)

def test_it_does_not_allow_subclassing(self):
with self.assertRaises(RuntimeError) as e:
class NoNo(Annotator):
pass
self.assertIn("support subclassing", str(e.exception))
Loading

0 comments on commit 9896ba2

Please sign in to comment.