diff --git a/jsonschema/_keywords.py b/jsonschema/_keywords.py index f30f95419..786d0e2e5 100644 --- a/jsonschema/_keywords.py +++ b/jsonschema/_keywords.py @@ -1,5 +1,8 @@ +from __future__ import annotations + from fractions import Fraction import re +import typing from jsonschema._utils import ( ensure_list, @@ -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 @@ -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 @@ -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 diff --git a/jsonschema/tests/test_validators.py b/jsonschema/tests/test_validators.py index 28cc40273..11d545197 100644 --- a/jsonschema/tests/test_validators.py +++ b/jsonschema/tests/test_validators.py @@ -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( diff --git a/jsonschema/validators.py b/jsonschema/validators.py index b8ca3bd45..d58740816 100644 --- a/jsonschema/validators.py +++ b/jsonschema/validators.py @@ -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,