Skip to content

1365 evaluation order #1366

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions jsonschema/_keywords.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations

from fractions import Fraction
import re
import typing

from jsonschema._utils import (
ensure_list,
Expand All @@ -13,6 +16,58 @@
from jsonschema.exceptions import FormatError, ValidationError


class Keyword:
"""
The Keyword wraps a function to maintain full back-compatibility.

The class adds the ability to annotate the keyword implementation while
behaving like a function, i.e. `Keyword(func)` behaves like `func` itself
in most cases, and allows a more formal description of the behavior and
dependencies.
"""

def __init__(
self,
func: typing.Optional[typing.Callable] = None, /,
needs: typing.Optional[typing.Iterable[str]] = (),
):
self.func = func
self.needs = needs

def __call__(self, validator, property, instance, schema):
if self.func is None:
raise NotImplementedError(f"{self} has no validation method.")
return self.func(validator, property, instance, schema)


def keyword(func: typing.Optional[typing.Callable] = None, /, **kwargs):
"""
Syntactic sugar to decorate a function as a keyword.

The function is not modified, but wrapped by a Keyword object. This allows
the same function to be reused as by multiple validators with different
annotations.

Examples
--------
Functionally equivalent to the un-decorated function.
>>> @keyword
... def myKeyword(validator, property, instance, schema):
... ...

Mark the keyword "else" to be evaluated after "if"
>>> @keyword(needs=["if"])
... def else_(validator, property, instance, schema):
... ...

>>> kw = keyword(else_, needs="if")

"""
if func is None:
return lambda fun: Keyword(fun, **kwargs)
return Keyword(func, **kwargs)


def patternProperties(validator, patternProperties, instance, schema):
if not validator.is_type(instance, "object"):
return
Expand Down Expand Up @@ -389,6 +444,21 @@ def if_(validator, if_schema, instance, schema):
yield from validator.descend(instance, else_, schema_path="else")


@keyword(
needs=[
"prefixItems",
"items",
"contains",
"allOf",
"anyOf",
"oneOf",
"not",
"if",
"then",
"else",
"dependentSchemas",
],
)
def unevaluatedItems(validator, unevaluatedItems, instance, schema):
if not validator.is_type(instance, "array"):
return
Expand All @@ -404,6 +474,21 @@ def unevaluatedItems(validator, unevaluatedItems, instance, schema):
yield ValidationError(error % extras_msg(unevaluated_items))


@keyword(
needs=[
"properties",
"patternProperties",
"additionalProperties",
"allOf",
"anyOf",
"oneOf",
"not",
"if",
"then",
"else",
"dependentSchemas",
],
)
def unevaluatedProperties(validator, unevaluatedProperties, instance, schema):
if not validator.is_type(instance, "object"):
return
Expand Down
29 changes: 29 additions & 0 deletions jsonschema/tests/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,35 @@ def test_unevaluated_properties_on_invalid_type(self):
message = self.message_for(instance="foo", schema=schema)
self.assertEqual(message, "'foo' is not of type 'object'")

def test_unevaluated_properties_on_invalid_inplace_applicators(self):
"""Ensure that Draft 2020-12 11.3 is followed, per #1365."""
schema = {
"unevaluatedProperties": False,
"allOf": [
{"properties": {"foo": {"type": "string"}}},
],
}
instance = {"foo": 1}

# cannot use `self.message_for`, which expects exactly one error
validators.Draft202012Validator.check_schema(schema)
validator = validators.Draft202012Validator(schema)
errors = list(validator.iter_errors(instance))
self.assertTrue(errors, msg=f"No errors were raised for {instance!r}")
self.assertEqual(
len(errors),
2,
msg=f"Expected exactly two errors, found {errors!r}",
)

messages = [error.message for error in errors]
expected = [
"1 is not of type 'string'",
"Unevaluated properties are not allowed ('foo' was unexpected)",
]
self.assertEqual(set(messages), set(expected), "Unexpected messages")
self.assertEqual(messages, expected, "Keywords not evaluated in order")

def test_single_item(self):
schema = {"prefixItems": [{}], "items": False}
message = self.message_for(
Expand Down
39 changes: 25 additions & 14 deletions jsonschema/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,20 +379,31 @@ def iter_errors(self, instance, _schema=None):
)
return

for validator, k, v in validators:
errors = validator(self, v, instance, _schema) or ()
for error in errors:
# set details if not already set by the called fn
error._set(
validator=k,
validator_value=v,
instance=instance,
schema=_schema,
type_checker=self.TYPE_CHECKER,
)
if k not in {"if", "$ref"}:
error.schema_path.appendleft(k)
yield error
todo = {k for _, k, _ in validators}
while todo:
dependent = []
for validator, k, v in validators:
if isinstance(validator, _keywords.Keyword) \
and todo.intersection(validator.needs):
dependent.append([validator, k, v])
continue
errors = validator(self, v, instance, _schema) or ()
for error in errors:
# set details if not already set by the called fn
error._set(
validator=k,
validator_value=v,
instance=instance,
schema=_schema,
type_checker=self.TYPE_CHECKER,
)
if k not in {"if", "$ref"}:
error.schema_path.appendleft(k)
yield error
todo.discard(k)
if dependent == validators:
raise ValueError("Circular dependency between keywords")
validators = dependent

def descend(
self,
Expand Down