From 5ff5999d50420251744bc49e758f3b15ad2f8569 Mon Sep 17 00:00:00 2001 From: Julian Berman Date: Thu, 16 Nov 2023 11:55:34 -0500 Subject: [PATCH] Consider properties evaluated when they're behind dynamic refs. This was previously correct for $refs, but not $dynamicRefs, which had no test in the JSON Schema test suite. This behavior is now properly compliant with the 2020 spec (as well as 2019, for $recursiveRef). Refs: json-schema-org/JSON-Schema-Test-Suite#696 --- CHANGELOG.rst | 1 + jsonschema/_legacy_keywords.py | 138 ++++++++++++++++++++++++++++++++- jsonschema/_utils.py | 38 ++++++++- jsonschema/validators.py | 4 +- 4 files changed, 174 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 38825228..e2627ebd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,7 @@ v4.20.0 ======= +* Properly consider items (and properties) to be evaluated by ``unevaluatedItems`` (resp. ``unevaluatedProperties``) when behind a ``$dynamicRef`` as specified by the 2020 and 2019 specifications. * ``jsonschema.exceptions.ErrorTree.__setitem__`` is now deprecated. More broadly, in general users of ``jsonschema`` should never be mutating objects owned by the library. diff --git a/jsonschema/_legacy_keywords.py b/jsonschema/_legacy_keywords.py index e76a84f9..7265511d 100644 --- a/jsonschema/_legacy_keywords.py +++ b/jsonschema/_legacy_keywords.py @@ -1,3 +1,5 @@ +import re + from referencing.jsonschema import lookup_recursive_ref from jsonschema import _utils @@ -249,8 +251,22 @@ def find_evaluated_item_indexes_by_schema(validator, instance, schema): return [] evaluated_indexes = [] - if "$ref" in schema: - resolved = validator._resolver.lookup(schema["$ref"]) + ref = schema.get("$ref") + if ref is not None: + resolved = validator._resolver.lookup(ref) + evaluated_indexes.extend( + find_evaluated_item_indexes_by_schema( + validator.evolve( + schema=resolved.contents, + _resolver=resolved.resolver, + ), + instance, + resolved.contents, + ), + ) + + if "$recursiveRef" in schema: + resolved = lookup_recursive_ref(validator._resolver) evaluated_indexes.extend( find_evaluated_item_indexes_by_schema( validator.evolve( @@ -316,3 +332,121 @@ def unevaluatedItems_draft2019(validator, unevaluatedItems, instance, schema): if unevaluated_items: error = "Unevaluated items are not allowed (%s %s unexpected)" yield ValidationError(error % _utils.extras_msg(unevaluated_items)) + + +def find_evaluated_property_keys_by_schema(validator, instance, schema): + if validator.is_type(schema, "boolean"): + return [] + evaluated_keys = [] + + ref = schema.get("$ref") + if ref is not None: + resolved = validator._resolver.lookup(ref) + evaluated_keys.extend( + find_evaluated_property_keys_by_schema( + validator.evolve( + schema=resolved.contents, + _resolver=resolved.resolver, + ), + instance, + resolved.contents, + ), + ) + + if "$recursiveRef" in schema: + resolved = lookup_recursive_ref(validator._resolver) + evaluated_keys.extend( + find_evaluated_property_keys_by_schema( + validator.evolve( + schema=resolved.contents, + _resolver=resolved.resolver, + ), + instance, + resolved.contents, + ), + ) + + for keyword in [ + "properties", "additionalProperties", "unevaluatedProperties", + ]: + if keyword in schema: + schema_value = schema[keyword] + if validator.is_type(schema_value, "boolean") and schema_value: + evaluated_keys += instance.keys() + + elif validator.is_type(schema_value, "object"): + for property in schema_value: + if property in instance: + evaluated_keys.append(property) + + if "patternProperties" in schema: + for property in instance: + for pattern in schema["patternProperties"]: + if re.search(pattern, property): + evaluated_keys.append(property) + + if "dependentSchemas" in schema: + for property, subschema in schema["dependentSchemas"].items(): + if property not in instance: + continue + evaluated_keys += find_evaluated_property_keys_by_schema( + validator, instance, subschema, + ) + + for keyword in ["allOf", "oneOf", "anyOf"]: + if keyword in schema: + for subschema in schema[keyword]: + errs = next(validator.descend(instance, subschema), None) + if errs is None: + evaluated_keys += find_evaluated_property_keys_by_schema( + validator, instance, subschema, + ) + + if "if" in schema: + if validator.evolve(schema=schema["if"]).is_valid(instance): + evaluated_keys += find_evaluated_property_keys_by_schema( + validator, instance, schema["if"], + ) + if "then" in schema: + evaluated_keys += find_evaluated_property_keys_by_schema( + validator, instance, schema["then"], + ) + else: + if "else" in schema: + evaluated_keys += find_evaluated_property_keys_by_schema( + validator, instance, schema["else"], + ) + + return evaluated_keys + + +def unevaluatedProperties_draft2019(validator, uP, instance, schema): + if not validator.is_type(instance, "object"): + return + evaluated_keys = find_evaluated_property_keys_by_schema( + validator, instance, schema, + ) + unevaluated_keys = [] + for property in instance: + if property not in evaluated_keys: + for _ in validator.descend( + instance[property], + uP, + path=property, + schema_path=property, + ): + # FIXME: Include context for each unevaluated property + # indicating why it's invalid under the subschema. + unevaluated_keys.append(property) + + if unevaluated_keys: + if uP is False: + error = "Unevaluated properties are not allowed (%s %s unexpected)" + extras = sorted(unevaluated_keys, key=str) + yield ValidationError(error % _utils.extras_msg(extras)) + else: + error = ( + "Unevaluated properties are not valid under " + "the given schema (%s %s unevaluated and invalid)" + ) + yield ValidationError(error % _utils.extras_msg(unevaluated_keys)) diff --git a/jsonschema/_utils.py b/jsonschema/_utils.py index a3014b73..9d274fd9 100644 --- a/jsonschema/_utils.py +++ b/jsonschema/_utils.py @@ -197,8 +197,23 @@ def find_evaluated_item_indexes_by_schema(validator, instance, schema): if "items" in schema: return list(range(0, len(instance))) - if "$ref" in schema: - resolved = validator._resolver.lookup(schema["$ref"]) + ref = schema.get("$ref") + if ref is not None: + resolved = validator._resolver.lookup(ref) + evaluated_indexes.extend( + find_evaluated_item_indexes_by_schema( + validator.evolve( + schema=resolved.contents, + _resolver=resolved.resolver, + ), + instance, + resolved.contents, + ), + ) + + dynamicRef = schema.get("$dynamicRef") + if dynamicRef is not None: + resolved = validator._resolver.lookup(dynamicRef) evaluated_indexes.extend( find_evaluated_item_indexes_by_schema( validator.evolve( @@ -258,8 +273,23 @@ def find_evaluated_property_keys_by_schema(validator, instance, schema): return [] evaluated_keys = [] - if "$ref" in schema: - resolved = validator._resolver.lookup(schema["$ref"]) + ref = schema.get("$ref") + if ref is not None: + resolved = validator._resolver.lookup(ref) + evaluated_keys.extend( + find_evaluated_property_keys_by_schema( + validator.evolve( + schema=resolved.contents, + _resolver=resolved.resolver, + ), + instance, + resolved.contents, + ), + ) + + dynamicRef = schema.get("$dynamicRef") + if dynamicRef is not None: + resolved = validator._resolver.lookup(dynamicRef) evaluated_keys.extend( find_evaluated_property_keys_by_schema( validator.evolve( diff --git a/jsonschema/validators.py b/jsonschema/validators.py index 740658ba..b8f6fcb2 100644 --- a/jsonschema/validators.py +++ b/jsonschema/validators.py @@ -782,7 +782,9 @@ def extend( "required": _keywords.required, "type": _keywords.type, "unevaluatedItems": _legacy_keywords.unevaluatedItems_draft2019, - "unevaluatedProperties": _keywords.unevaluatedProperties, + "unevaluatedProperties": ( + _legacy_keywords.unevaluatedProperties_draft2019 + ), "uniqueItems": _keywords.uniqueItems, }, type_checker=_types.draft201909_type_checker,