diff --git a/jsonschema/__init__.py b/jsonschema/__init__.py index 619a7eabb..c1cb2e91d 100644 --- a/jsonschema/__init__.py +++ b/jsonschema/__init__.py @@ -14,6 +14,7 @@ draft4_format_checker, draft6_format_checker, draft7_format_checker, + draft202012_format_checker, ) from jsonschema._types import TypeChecker from jsonschema.exceptions import ( @@ -28,6 +29,7 @@ Draft4Validator, Draft6Validator, Draft7Validator, + Draft202012Validator, RefResolver, validate, ) diff --git a/jsonschema/_format.py b/jsonschema/_format.py index bdd893da8..19f428302 100644 --- a/jsonschema/_format.py +++ b/jsonschema/_format.py @@ -1,3 +1,4 @@ +from uuid import UUID import datetime import ipaddress import re @@ -131,13 +132,14 @@ def conforms(self, instance, format): draft4_format_checker = FormatChecker() draft6_format_checker = FormatChecker() draft7_format_checker = FormatChecker() - +draft202012_format_checker = FormatChecker() _draft_checkers = dict( draft3=draft3_format_checker, draft4=draft4_format_checker, draft6=draft6_format_checker, draft7=draft7_format_checker, + draft202012=draft202012_format_checker, ) @@ -147,12 +149,14 @@ def _checks_drafts( draft4=None, draft6=None, draft7=None, + draft202012=None, raises=(), ): draft3 = draft3 or name draft4 = draft4 or name draft6 = draft6 or name draft7 = draft7 or name + draft202012 = draft202012 or name def wrap(func): if draft3: @@ -163,13 +167,17 @@ def wrap(func): func = _draft_checkers["draft6"].checks(draft6, raises)(func) if draft7: func = _draft_checkers["draft7"].checks(draft7, raises)(func) + if draft202012: + func = _draft_checkers["draft202012"].checks( + draft202012, raises + )(func) # Oy. This is bad global state, but relied upon for now, until # deprecation. See https://github.com/Julian/jsonschema/issues/519 # and test_format_checkers_come_with_defaults - FormatChecker.cls_checks(draft7 or draft6 or draft4 or draft3, raises)( - func, - ) + FormatChecker.cls_checks( + draft202012 or draft7 or draft6 or draft4 or draft3, raises + )(func) return func return wrap @@ -187,6 +195,7 @@ def is_email(instance): draft4="ipv4", draft6="ipv4", draft7="ipv4", + draft202012="ipv4", raises=ipaddress.AddressValueError, ) def is_ipv4(instance): @@ -213,6 +222,7 @@ def is_ipv6(instance): draft4="hostname", draft6="hostname", draft7="hostname", + draft202012="hostname", ) def is_host_name(instance): if not isinstance(instance, str): @@ -228,6 +238,7 @@ def is_host_name(instance): else: @_checks_drafts( draft7="idn-hostname", + draft202012="idn-hostname", raises=(idna.IDNAError, UnicodeError), ) def is_idn_host_name(instance): @@ -254,6 +265,7 @@ def is_uri(instance): @_checks_drafts( draft6="uri-reference", draft7="uri-reference", + draft202012="uri-reference", raises=ValueError, ) def is_uri_reference(instance): @@ -262,19 +274,30 @@ def is_uri_reference(instance): return validate_rfc3986(instance, rule="URI_reference") else: - @_checks_drafts(draft7="iri", raises=ValueError) + @_checks_drafts( + draft7="iri", + draft202012="iri", + raises=ValueError, + ) def is_iri(instance): if not isinstance(instance, str): return True return rfc3987.parse(instance, rule="IRI") - @_checks_drafts(draft7="iri-reference", raises=ValueError) + @_checks_drafts( + draft7="iri-reference", + draft202012="iri-reference", + raises=ValueError, + ) def is_iri_reference(instance): if not isinstance(instance, str): return True return rfc3987.parse(instance, rule="IRI_reference") - @_checks_drafts(name="uri", raises=ValueError) + @_checks_drafts( + name="uri", + raises=ValueError, + ) def is_uri(instance): if not isinstance(instance, str): return True @@ -283,6 +306,7 @@ def is_uri(instance): @_checks_drafts( draft6="uri-reference", draft7="uri-reference", + draft202012="uri-reference", raises=ValueError, ) def is_uri_reference(instance): @@ -306,7 +330,10 @@ def is_datetime(instance): return True return validate_rfc3339(instance.upper()) - @_checks_drafts(draft7="time") + @_checks_drafts( + draft7="time", + draft202012="time", + ) def is_time(instance): if not isinstance(instance, str): return True @@ -327,7 +354,12 @@ def _is_date(instance): return datetime.datetime.strptime(instance, "%Y-%m-%d") -@_checks_drafts(draft3="date", draft7="date", raises=ValueError) +@_checks_drafts( + draft3="date", + draft7="date", + draft202012="date", + raises=ValueError, +) def is_date(instance): if not isinstance(instance, str): return True @@ -377,6 +409,7 @@ def is_css3_color(instance): @_checks_drafts( draft6="json-pointer", draft7="json-pointer", + draft202012="json-pointer", raises=jsonpointer.JsonPointerException, ) def is_json_pointer(instance): @@ -390,6 +423,7 @@ def is_json_pointer(instance): # into a new external library. @_checks_drafts( draft7="relative-json-pointer", + draft202012="relative-json-pointer", raises=jsonpointer.JsonPointerException, ) def is_relative_json_pointer(instance): @@ -400,6 +434,10 @@ def is_relative_json_pointer(instance): non_negative_integer, rest = [], "" for i, character in enumerate(instance): if character.isdigit(): + # digits with a leading "0" are not allowed + if i > 0 and int(instance[i-1]) == 0: + return False + non_negative_integer.append(character) continue @@ -419,8 +457,36 @@ def is_relative_json_pointer(instance): @_checks_drafts( draft6="uri-template", draft7="uri-template", + draft202012="uri-template", ) def is_uri_template(instance): if not isinstance(instance, str): return True return uri_template.validate(instance) + + +try: + import isoduration +except ImportError: # pragma: no cover + pass +else: + @_checks_drafts( + draft202012="duration", + raises=isoduration.DurationParsingException, + ) + def is_duration(instance): + if not isinstance(instance, str): + return True + return isoduration.parse_duration(instance) + + +@_checks_drafts( + draft202012="uuid", + raises=ValueError, +) +def is_uuid(instance): + if not isinstance(instance, str): + return True + if "-" not in instance: + raise ValueError("Invalid UUID format") + return UUID(instance) diff --git a/jsonschema/_legacy_validators.py b/jsonschema/_legacy_validators.py index 80f798b7a..194c68fa9 100644 --- a/jsonschema/_legacy_validators.py +++ b/jsonschema/_legacy_validators.py @@ -2,6 +2,18 @@ from jsonschema.exceptions import ValidationError +def ignore_ref_siblings(schema): + """ + Returns a list of validators that should apply for the given schema + Used for draft7 and earlier + """ + ref = schema.get(u"$ref") + if ref is not None: + return [(u"$ref", ref)] + else: + return schema.items() + + def dependencies_draft3(validator, dependencies, instance, schema): if not validator.is_type(instance, "object"): return @@ -27,6 +39,37 @@ def dependencies_draft3(validator, dependencies, instance, schema): yield ValidationError(message % (each, property)) +def dependencies_draft4_draft6_draft7( + validator, + dependencies, + instance, + schema, +): + """ + Support for the ``dependencies`` validator from pre-draft 2019-09. + + In later drafts, the validator was split into separate + ``dependentRequired`` and ``dependentSchemas`` validators. + """ + if not validator.is_type(instance, "object"): + return + + for property, dependency in dependencies.items(): + if property not in instance: + continue + + if validator.is_type(dependency, "array"): + for each in dependency: + if each not in instance: + message = "%r is a dependency of %r" + yield ValidationError(message % (each, property)) + else: + for error in validator.descend( + instance, dependency, schema_path=property, + ): + yield error + + def disallow_draft3(validator, disallow, instance, schema): for disallowed in _utils.ensure_list(disallow): if validator.is_valid(instance, {"type": [disallowed]}): @@ -61,6 +104,22 @@ def items_draft3_draft4(validator, items, instance, schema): yield error +def items_draft6_draft7(validator, items, instance, schema): + if not validator.is_type(instance, "array"): + return + + if validator.is_type(items, "array"): + for (index, item), subschema in zip(enumerate(instance), items): + for error in validator.descend( + item, subschema, path=index, schema_path=index, + ): + yield error + else: + for index, item in enumerate(instance): + for error in validator.descend(item, items, path=index): + yield error + + def minimum_draft3_draft4(validator, minimum, instance, schema): if not validator.is_type(instance, "number"): return @@ -138,3 +197,13 @@ def type_draft3(validator, types, instance, schema): yield ValidationError( _utils.types_msg(instance, types), context=all_errors, ) + + +def contains_draft6_draft7(validator, contains, instance, schema): + if not validator.is_type(instance, "array"): + return + + if not any(validator.is_valid(element, contains) for element in instance): + yield ValidationError( + "None of %r are valid under the given schema" % (instance,) + ) diff --git a/jsonschema/_types.py b/jsonschema/_types.py index 50bcf99c9..3eab7b006 100644 --- a/jsonschema/_types.py +++ b/jsonschema/_types.py @@ -185,3 +185,4 @@ def remove(self, *types): ), ) draft7_type_checker = draft6_type_checker +draft202012_type_checker = draft7_type_checker diff --git a/jsonschema/_utils.py b/jsonschema/_utils.py index 97b7f8f5a..1b81a09a9 100644 --- a/jsonschema/_utils.py +++ b/jsonschema/_utils.py @@ -1,9 +1,12 @@ from collections.abc import Mapping, MutableMapping, Sequence +from pathlib import Path from urllib.parse import urlsplit import itertools import json +import os import pkgutil import re +import sys class URIDict(MutableMapping): @@ -55,6 +58,19 @@ def load_schema(name): return json.loads(data.decode("utf-8")) +def load_vocabulary(name): + """ + Load all schema files from ./schemas/``name`` and return them as a list. + """ + vocabulary = [] + base_path = os.path.dirname(sys.modules["jsonschema"].__file__) + pathlist = Path(os.path.join(base_path, 'schemas', name)).glob('*.json') + for path in pathlist: + with open(path) as data: + vocabulary.append(json.load(data)) + return vocabulary + + def format_as_index(indices): """ Construct a single string containing indexing operations for the indices. @@ -232,3 +248,145 @@ def uniq(container): seen.append(e) return True + + +def find_evaluated_item_indexes_by_schema(validator, instance, schema): + """ + Get all indexes of items that get evaluated under the current schema + + Covers all keywords related to unevaluatedItems: items, prefixItems, if, + then, else, contains, unevaluatedItems, allOf, oneOf, anyOf + """ + if validator.is_type(schema, "boolean"): + return [] + evaluated_indexes = [] + + if "items" in schema: + return list(range(0, len(instance))) + + if "$ref" in schema: + scope, resolved = validator.resolver.resolve(schema["$ref"]) + validator.resolver.push_scope(scope) + + try: + evaluated_indexes += find_evaluated_item_indexes_by_schema( + validator, instance, resolved) + finally: + validator.resolver.pop_scope() + + if "prefixItems" in schema: + evaluated_indexes += list(range(0, len(schema["prefixItems"]))) + + if "if" in schema: + if validator.is_valid(instance, schema["if"]): + evaluated_indexes += find_evaluated_item_indexes_by_schema( + validator, instance, schema["if"] + ) + if "then" in schema: + evaluated_indexes += find_evaluated_item_indexes_by_schema( + validator, instance, schema["then"] + ) + else: + if "else" in schema: + evaluated_indexes += find_evaluated_item_indexes_by_schema( + validator, instance, schema["else"] + ) + + for keyword in ["contains", "unevaluatedItems"]: + if keyword in schema: + for k, v in enumerate(instance): + if validator.is_valid(v, schema[keyword]): + evaluated_indexes.append(k) + + for keyword in ["allOf", "oneOf", "anyOf"]: + if keyword in schema: + for subschema in schema[keyword]: + errs = list(validator.descend(instance, subschema)) + if not errs: + evaluated_indexes += find_evaluated_item_indexes_by_schema( + validator, instance, subschema + ) + + return evaluated_indexes + + +def find_evaluated_property_keys_by_schema(validator, instance, schema): + """ + Get all keys of items that get evaluated under the current schema + + Covers all keywords related to unevaluatedProperties: properties, + additionalProperties, unevaluatedProperties, patternProperties, + dependentSchemas, allOf, oneOf, anyOf, if, then, else + """ + if validator.is_type(schema, "boolean"): + return [] + evaluated_keys = [] + + if "$ref" in schema: + scope, resolved = validator.resolver.resolve(schema["$ref"]) + validator.resolver.push_scope(scope) + + try: + evaluated_keys += find_evaluated_property_keys_by_schema( + validator, instance, resolved + ) + finally: + validator.resolver.pop_scope() + + for keyword in [ + "properties", "additionalProperties", "unevaluatedProperties" + ]: + if keyword in schema: + if validator.is_type(schema[keyword], "boolean"): + for property, value in instance.items(): + if validator.is_valid({property: value}, schema[keyword]): + evaluated_keys.append(property) + + if validator.is_type(schema[keyword], "object"): + for property, subschema in schema[keyword].items(): + if property in instance and validator.is_valid( + instance[property], subschema + ): + evaluated_keys.append(property) + + if "patternProperties" in schema: + for property, value in instance.items(): + for pattern, subschema in schema["patternProperties"].items(): + if re.search(pattern, property) and validator.is_valid( + {property: value}, schema["patternProperties"] + ): + 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 = list(validator.descend(instance, subschema)) + if not errs: + evaluated_keys += find_evaluated_property_keys_by_schema( + validator, instance, subschema + ) + + if "if" in schema: + if validator.is_valid(instance, schema["if"]): + 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 diff --git a/jsonschema/_validators.py b/jsonschema/_validators.py index 0f7b6fb1c..b6a2f9c8d 100644 --- a/jsonschema/_validators.py +++ b/jsonschema/_validators.py @@ -1,4 +1,5 @@ from fractions import Fraction +from urllib.parse import urldefrag, urljoin import re from jsonschema._utils import ( @@ -6,6 +7,8 @@ equal, extras_msg, find_additional_properties, + find_evaluated_item_indexes_by_schema, + find_evaluated_property_keys_by_schema, types_msg, unbool, uniq, @@ -70,14 +73,17 @@ def items(validator, items, instance, schema): if not validator.is_type(instance, "array"): return - if validator.is_type(items, "array"): - for (index, item), subschema in zip(enumerate(instance), items): - for error in validator.descend( - item, subschema, path=index, schema_path=index, - ): - yield error + if validator.is_type(items, "boolean") and 'prefixItems' in schema: + if not items: + if len(instance) > len(schema['prefixItems']): + yield ValidationError( + "%r has more items than defined in prefixItems" % instance + ) else: - for index, item in enumerate(instance): + non_prefixed_items = instance[len(schema['prefixItems']):] \ + if 'prefixItems' in schema else instance + + for index, item in enumerate(non_prefixed_items): for error in validator.descend(item, items, path=index): yield error @@ -111,10 +117,56 @@ def contains(validator, contains, instance, schema): if not validator.is_type(instance, "array"): return - if not any(validator.is_valid(element, contains) for element in instance): + min_contains = max_contains = None + + if 'minContains' in schema: + min_contains = schema['minContains'] + + if 'maxContains' in schema: + max_contains = schema['maxContains'] + + # minContains set to 0 will ignore contains + if min_contains == 0: + return + + matches = sum(1 for each in instance if validator.is_valid(each, contains)) + + # default contains behavior + if not matches: yield ValidationError( "None of %r are valid under the given schema" % (instance,) ) + return + + if min_contains and max_contains is None: + if matches < min_contains: + yield ValidationError( + "Too few matches under the given schema. " + "Expected %d but there were only %d." % ( + min_contains, matches + ) + ) + return + + if min_contains is None and max_contains: + if matches > max_contains: + yield ValidationError( + "Too many matches under the given schema. " + "Expected %d but there were only %d." % ( + max_contains, matches + ) + ) + return + + if min_contains and max_contains: + if matches < min_contains or matches > max_contains: + yield ValidationError( + "Invalid number or matches under the given schema, " + "expected between %d and %d, got %d" % ( + min_contains, max_contains, matches + ) + ) + return def exclusiveMinimum(validator, minimum, instance, schema): @@ -233,24 +285,29 @@ def maxLength(validator, mL, instance, schema): yield ValidationError("%r is too long" % (instance,)) -def dependencies(validator, dependencies, instance, schema): +def dependentRequired(validator, dependentRequired, instance, schema): if not validator.is_type(instance, "object"): return - for property, dependency in dependencies.items(): + for property, dependency in dependentRequired.items(): if property not in instance: continue - if validator.is_type(dependency, "array"): - for each in dependency: - if each not in instance: - message = "%r is a dependency of %r" - yield ValidationError(message % (each, property)) - else: - for error in validator.descend( + for each in dependency: + if each not in instance: + message = "%r is a dependency of %r" + yield ValidationError(message % (each, property)) + + +def dependentSchemas(validator, dependentSchemas, instance, schema): + for property, dependency in dependentSchemas.items(): + if property not in instance: + continue + + for error in validator.descend( instance, dependency, schema_path=property, - ): - yield error + ): + yield error def enum(validator, enums, instance, schema): @@ -279,6 +336,41 @@ def ref(validator, ref, instance, schema): validator.resolver.pop_scope() +def dynamicRef(validator, dynamicRef, instance, schema): + _, fragment = urldefrag(dynamicRef) + scope_stack = validator.resolver.scopes_stack_copy + + for url in scope_stack: + lookup_url = urljoin(url, dynamicRef) + with validator.resolver.resolving(lookup_url) as lookup_schema: + if ("$dynamicAnchor" in lookup_schema + and fragment == lookup_schema["$dynamicAnchor"]): + subschema = lookup_schema + for error in validator.descend(instance, subschema): + yield error + break + else: + with validator.resolver.resolving(dynamicRef) as lookup_schema: + subschema = lookup_schema + for error in validator.descend(instance, subschema): + yield error + + +def defs(validator, defs, instance, schema): + if not validator.is_type(instance, "object"): + return + + if '$defs' in instance: + for definition, subschema in instance['$defs'].items(): + for error in validator.descend( + subschema, + schema, + path=definition, + schema_path=definition, + ): + yield error + + def type(validator, types, instance, schema): types = ensure_list(types) @@ -384,3 +476,51 @@ def if_(validator, if_schema, instance, schema): else_ = schema[u"else"] for error in validator.descend(instance, else_, schema_path="else"): yield error + + +def unevaluatedItems(validator, unevaluatedItems, instance, schema): + evaluated_item_indexes = find_evaluated_item_indexes_by_schema( + validator, instance, schema + ) + unevaluated_items = [] + for k, v in enumerate(instance): + if k not in evaluated_item_indexes: + for error in validator.descend( + v, unevaluatedItems, schema_path="unevaluatedItems" + ): + unevaluated_items.append(v) + + if len(unevaluated_items): + error = "Unevaluated items are not allowed (%s %s unexpected)" + yield ValidationError(error % extras_msg(unevaluated_items)) + + +def unevaluatedProperties(validator, unevaluatedProperties, instance, schema): + evaluated_property_keys = find_evaluated_property_keys_by_schema( + validator, instance, schema + ) + unevaluated_property_keys = [] + for property, subschema in instance.items(): + if property not in evaluated_property_keys: + for error in validator.descend( + instance[property], + unevaluatedProperties, + path=property, + schema_path=property, + ): + unevaluated_property_keys.append(property) + + if len(unevaluated_property_keys): + error = "Unevaluated properties are not allowed (%s %s unexpected)" + yield ValidationError(error % extras_msg(unevaluated_property_keys)) + + +def prefixItems(validator, prefixItems, instance, schema): + if not validator.is_type(instance, "array"): + return + + for k, v in enumerate(instance[:min(len(prefixItems), len(instance))]): + for error in validator.descend( + v, prefixItems[k], schema_path="prefixItems" + ): + yield error diff --git a/jsonschema/schemas/draft2020-12.json b/jsonschema/schemas/draft2020-12.json new file mode 100644 index 000000000..d5e2d31c3 --- /dev/null +++ b/jsonschema/schemas/draft2020-12.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/schema", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true + }, + "$dynamicAnchor": "meta", + + "title": "Core and Validation specifications meta-schema", + "allOf": [ + {"$ref": "meta/core"}, + {"$ref": "meta/applicator"}, + {"$ref": "meta/unevaluated"}, + {"$ref": "meta/validation"}, + {"$ref": "meta/meta-data"}, + {"$ref": "meta/format-annotation"}, + {"$ref": "meta/content"} + ], + "type": ["object", "boolean"], + "$comment": "This meta-schema also defines keywords that have appeared in previous drafts in order to prevent incompatible extensions as they remain in common use.", + "properties": { + "definitions": { + "$comment": "\"definitions\" has been replaced by \"$defs\".", + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "deprecated": true, + "default": {} + }, + "dependencies": { + "$comment": "\"dependencies\" has been split and replaced by \"dependentSchemas\" and \"dependentRequired\" in order to serve their differing semantics.", + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$dynamicRef": "#meta" }, + { "$ref": "meta/validation#/$defs/stringArray" } + ] + }, + "deprecated": true, + "default": {} + }, + "$recursiveAnchor": { + "$comment": "\"$recursiveAnchor\" has been replaced by \"$dynamicAnchor\".", + "$ref": "meta/core#/$defs/anchorString", + "deprecated": true + }, + "$recursiveRef": { + "$comment": "\"$recursiveRef\" has been replaced by \"$dynamicRef\".", + "$ref": "meta/core#/$defs/uriReferenceString", + "deprecated": true + } + } +} diff --git a/jsonschema/schemas/draft2020-12/applicator.json b/jsonschema/schemas/draft2020-12/applicator.json new file mode 100644 index 000000000..ca6992309 --- /dev/null +++ b/jsonschema/schemas/draft2020-12/applicator.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/applicator", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/applicator": true + }, + "$dynamicAnchor": "meta", + + "title": "Applicator vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "prefixItems": { "$ref": "#/$defs/schemaArray" }, + "items": { "$dynamicRef": "#meta" }, + "contains": { "$dynamicRef": "#meta" }, + "additionalProperties": { "$dynamicRef": "#meta" }, + "properties": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "propertyNames": { "format": "regex" }, + "default": {} + }, + "dependentSchemas": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "default": {} + }, + "propertyNames": { "$dynamicRef": "#meta" }, + "if": { "$dynamicRef": "#meta" }, + "then": { "$dynamicRef": "#meta" }, + "else": { "$dynamicRef": "#meta" }, + "allOf": { "$ref": "#/$defs/schemaArray" }, + "anyOf": { "$ref": "#/$defs/schemaArray" }, + "oneOf": { "$ref": "#/$defs/schemaArray" }, + "not": { "$dynamicRef": "#meta" } + }, + "$defs": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$dynamicRef": "#meta" } + } + } +} diff --git a/jsonschema/schemas/draft2020-12/content.json b/jsonschema/schemas/draft2020-12/content.json new file mode 100644 index 000000000..2f6e056a9 --- /dev/null +++ b/jsonschema/schemas/draft2020-12/content.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/content", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/content": true + }, + "$dynamicAnchor": "meta", + + "title": "Content vocabulary meta-schema", + + "type": ["object", "boolean"], + "properties": { + "contentEncoding": { "type": "string" }, + "contentMediaType": { "type": "string" }, + "contentSchema": { "$dynamicRef": "#meta" } + } +} diff --git a/jsonschema/schemas/draft2020-12/core.json b/jsonschema/schemas/draft2020-12/core.json new file mode 100644 index 000000000..dfc092d96 --- /dev/null +++ b/jsonschema/schemas/draft2020-12/core.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/core", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true + }, + "$dynamicAnchor": "meta", + + "title": "Core vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "$id": { + "$ref": "#/$defs/uriReferenceString", + "$comment": "Non-empty fragments not allowed.", + "pattern": "^[^#]*#?$" + }, + "$schema": { "$ref": "#/$defs/uriString" }, + "$ref": { "$ref": "#/$defs/uriReferenceString" }, + "$anchor": { "$ref": "#/$defs/anchorString" }, + "$dynamicRef": { "$ref": "#/$defs/uriReferenceString" }, + "$dynamicAnchor": { "$ref": "#/$defs/anchorString" }, + "$vocabulary": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/uriString" }, + "additionalProperties": { + "type": "boolean" + } + }, + "$comment": { + "type": "string" + }, + "$defs": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" } + } + }, + "$defs": { + "anchorString": { + "type": "string", + "pattern": "^[A-Za-z_][-A-Za-z0-9._]*$" + }, + "uriString": { + "type": "string", + "format": "uri" + }, + "uriReferenceString": { + "type": "string", + "format": "uri-reference" + } + } +} diff --git a/jsonschema/schemas/draft2020-12/format-annotation.json b/jsonschema/schemas/draft2020-12/format-annotation.json new file mode 100644 index 000000000..51ef7ea11 --- /dev/null +++ b/jsonschema/schemas/draft2020-12/format-annotation.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/format-annotation", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true + }, + "$dynamicAnchor": "meta", + + "title": "Format vocabulary meta-schema for annotation results", + "type": ["object", "boolean"], + "properties": { + "format": { "type": "string" } + } +} diff --git a/jsonschema/schemas/draft2020-12/meta-data.json b/jsonschema/schemas/draft2020-12/meta-data.json new file mode 100644 index 000000000..05cbc22af --- /dev/null +++ b/jsonschema/schemas/draft2020-12/meta-data.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/meta-data", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/meta-data": true + }, + "$dynamicAnchor": "meta", + + "title": "Meta-data vocabulary meta-schema", + + "type": ["object", "boolean"], + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": true, + "deprecated": { + "type": "boolean", + "default": false + }, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": true + } + } +} diff --git a/jsonschema/schemas/draft2020-12/unevaluated.json b/jsonschema/schemas/draft2020-12/unevaluated.json new file mode 100644 index 000000000..5f62a3ffa --- /dev/null +++ b/jsonschema/schemas/draft2020-12/unevaluated.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/unevaluated", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true + }, + "$dynamicAnchor": "meta", + + "title": "Unevaluated applicator vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "unevaluatedItems": { "$dynamicRef": "#meta" }, + "unevaluatedProperties": { "$dynamicRef": "#meta" } + } +} diff --git a/jsonschema/schemas/draft2020-12/validation.json b/jsonschema/schemas/draft2020-12/validation.json new file mode 100644 index 000000000..606b87ba2 --- /dev/null +++ b/jsonschema/schemas/draft2020-12/validation.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/validation", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/validation": true + }, + "$dynamicAnchor": "meta", + + "title": "Validation vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "type": { + "anyOf": [ + { "$ref": "#/$defs/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/$defs/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "const": true, + "enum": { + "type": "array", + "items": true + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { "$ref": "#/$defs/nonNegativeInteger" }, + "minLength": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "maxItems": { "$ref": "#/$defs/nonNegativeInteger" }, + "minItems": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxContains": { "$ref": "#/$defs/nonNegativeInteger" }, + "minContains": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 1 + }, + "maxProperties": { "$ref": "#/$defs/nonNegativeInteger" }, + "minProperties": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/$defs/stringArray" }, + "dependentRequired": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/stringArray" + } + } + }, + "$defs": { + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 0 + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + } +} diff --git a/jsonschema/tests/test_cli.py b/jsonschema/tests/test_cli.py index 884a037ab..ae4f13d7e 100644 --- a/jsonschema/tests/test_cli.py +++ b/jsonschema/tests/test_cli.py @@ -10,7 +10,7 @@ import sys import tempfile -from jsonschema import Draft4Validator, Draft7Validator, __version__, cli +from jsonschema import Draft4Validator, Draft202012Validator, __version__, cli from jsonschema.exceptions import ( RefResolutionError, SchemaError, @@ -336,7 +336,7 @@ def test_invalid_schema_multiple_errors(self): exit_code=1, stderr="""\ - 57: 57 is not valid under any of the given schemas + 57: 57 is not of type 'object', 'boolean' """, ) @@ -776,7 +776,7 @@ def test_it_validates_using_the_latest_validator_when_unspecified(self): # is hidden inside the CLI, so guard that that's the case, and # this test will have to be updated when versions change until # we can think of a better way to ensure this behavior. - self.assertIs(Draft7Validator, _LATEST_VERSION) + self.assertIs(Draft202012Validator, _LATEST_VERSION) self.assertOutputs( files=dict(some_schema='{"const": "check"}', some_instance='"a"'), diff --git a/jsonschema/tests/test_format.py b/jsonschema/tests/test_format.py index 6dba484e6..06f841c3b 100644 --- a/jsonschema/tests/test_format.py +++ b/jsonschema/tests/test_format.py @@ -79,10 +79,29 @@ def test_format_checkers_come_with_defaults(self): def test_repr(self): checker = FormatChecker(formats=()) - checker.checks("foo")(lambda thing: True) - checker.checks("bar")(lambda thing: True) - checker.checks("baz")(lambda thing: True) + checker.checks("foo")(lambda thing: True) # pragma: no cover + checker.checks("bar")(lambda thing: True) # pragma: no cover + checker.checks("baz")(lambda thing: True) # pragma: no cover self.assertEqual( repr(checker), "", ) + + def test_duration_format(self): + try: + from jsonschema._format import is_duration # noqa: F401 + except ImportError: # pragma: no cover + pass + else: + checker = FormatChecker() + self.assertTrue(checker.conforms(1, "duration")) + self.assertTrue(checker.conforms("P4Y", "duration")) + self.assertFalse(checker.conforms("test", "duration")) + + def test_uuid_format(self): + checker = FormatChecker() + self.assertTrue(checker.conforms(1, "uuid")) + self.assertTrue( + checker.conforms("6e6659ec-4503-4428-9f03-2e2ea4d6c278", "uuid") + ) + self.assertFalse(checker.conforms("test", "uuid")) diff --git a/jsonschema/tests/test_jsonschema_test_suite.py b/jsonschema/tests/test_jsonschema_test_suite.py index 40e3d8592..b1e072302 100644 --- a/jsonschema/tests/test_jsonschema_test_suite.py +++ b/jsonschema/tests/test_jsonschema_test_suite.py @@ -13,10 +13,12 @@ Draft4Validator, Draft6Validator, Draft7Validator, + Draft202012Validator, draft3_format_checker, draft4_format_checker, draft6_format_checker, draft7_format_checker, + draft202012_format_checker, ) from jsonschema.tests._helpers import bug from jsonschema.tests._suite import Suite @@ -26,6 +28,7 @@ DRAFT4 = SUITE.version(name="draft4") DRAFT6 = SUITE.version(name="draft6") DRAFT7 = SUITE.version(name="draft7") +DRAFT202012 = SUITE.version(name="draft2020-12") def skip(message, **kwargs): @@ -398,3 +401,63 @@ def leap_second(test): )(test) ), ) + + +TestDraft202012 = DRAFT202012.to_unittest_testcase( + DRAFT202012.tests(), + DRAFT202012.optional_tests_of(name="bignum"), + DRAFT202012.optional_tests_of(name="float-overflow"), + DRAFT202012.optional_tests_of(name="non-bmp-regex"), + DRAFT202012.optional_tests_of(name="refOfUnknownKeyword"), + Validator=Draft202012Validator, + skip=lambda test: ( + narrow_unicode_build(test) + or skip( + message="Issue: Resolving of dynamicRef based on dynamic scope", + subject="dynamicRef", + case_description="A $dynamicRef that initially resolves to a " + "schema with a matching $dynamicAnchor should " + "resolve to the first $dynamicAnchor in the " + "dynamic scope", + description='The recursive part is not valid against the root', + )(test) + or skip( + message="Issue: Resolving of dynamicRef based on dynamic scope", + subject="dynamicRef", + case_description="multiple dynamic paths to the $dynamicRef " + "keyword", + description="recurse to integerNode - floats are not allowed", + )(test) + or skip( + message="Issue: Resolving of dynamicRef based on dynamic scope", + subject="dynamicRef", + case_description="after leaving a dynamic scope, it should not be " + "used by a $dynamicRef", + description="/then/$defs/thingy is the final stop for the " + "$dynamicRef", + )(test) + or skip( + message="Issue: Resolving of dynamicRef based on dynamic scope", + subject="dynamicRef", + case_description="after leaving a dynamic scope, it should not be " + 'used by a $dynamicRef', + description="string matches /$defs/thingy, but the $dynamicRef " + "does not stop here", + )(test) + ), +) + + +TestDraft202012Format = DRAFT202012.to_unittest_testcase( + DRAFT202012.format_tests(), + Validator=Draft202012Validator, + format_checker=draft202012_format_checker, + skip=lambda test: ( + complex_email_validation(test) + or missing_date_fromisoformat(test) + or allowed_leading_zeros(test) + or leap_second(test) + or missing_format(draft202012_format_checker)(test) + or complex_email_validation(test) + ) +) diff --git a/jsonschema/tests/test_validators.py b/jsonschema/tests/test_validators.py index af5dfebce..bb3fefb30 100644 --- a/jsonschema/tests/test_validators.py +++ b/jsonschema/tests/test_validators.py @@ -383,6 +383,40 @@ def test_False_schema(self): ) self.assertIn("False schema does not allow 'something'", message) + def test_unevaluated_properties(self): + schema = { + "type": "object", + "unevaluatedProperties": False + } + message = self.message_for( + instance={ + "foo": "foo", + "bar": "bar", + }, + schema=schema, + cls=validators.Draft202012Validator, + ) + self.assertIn( + "Unevaluated properties are not allowed " + "('foo', 'bar' were unexpected)", + message, + ) + + def test_unevaluated_items(self): + schema = { + "type": "array", + "unevaluatedItems": False + } + message = self.message_for( + instance=["foo", "bar"], + schema=schema, + cls=validators.Draft202012Validator, + ) + self.assertIn( + "Unevaluated items are not allowed ('foo', 'bar' were unexpected)", + message, + ) + class TestValidationErrorDetails(TestCase): # TODO: These really need unit tests for each individual validator, rather @@ -1244,6 +1278,12 @@ class TestDraft7Validator(ValidatorTestMixin, TestCase): invalid = {"type": "integer"}, "foo" +class TestDraft202012Validator(ValidatorTestMixin, TestCase): + Validator = validators.Draft202012Validator + valid = {}, {} + invalid = {"type": "integer"}, "foo" + + class TestValidatorFor(SynchronousTestCase): def test_draft_3(self): schema = {"$schema": "http://json-schema.org/draft-03/schema"} @@ -1297,6 +1337,19 @@ def test_draft_7(self): validators.Draft7Validator, ) + def test_draft_202012(self): + schema = {"$schema": "https://json-schema.org/draft/2020-12/schema"} + self.assertIs( + validators.validator_for(schema), + validators.Draft202012Validator, + ) + + schema = {"$schema": "https://json-schema.org/draft/2020-12/schema#"} + self.assertIs( + validators.validator_for(schema), + validators.Draft202012Validator, + ) + def test_True(self): self.assertIs( validators.validator_for(True), @@ -1410,8 +1463,23 @@ def test_draft7_validator_is_chosen(self): Validator=validators.Draft7Validator, ) - def test_draft7_validator_is_the_default(self): - self.assertUses(schema={}, Validator=validators.Draft7Validator) + def test_draft202012_validator_is_chosen(self): + self.assertUses( + schema={ + "$schema": "https://json-schema.org/draft/2020-12/schema#" + }, + Validator=validators.Draft202012Validator, + ) + # Make sure it works without the empty fragment + self.assertUses( + schema={ + "$schema": "https://json-schema.org/draft/2020-12/schema" + }, + Validator=validators.Draft202012Validator, + ) + + def test_draft202012_validator_is_the_default(self): + self.assertUses(schema={}, Validator=validators.Draft202012Validator) def test_validation_error_message(self): with self.assertRaises(exceptions.ValidationError) as e: diff --git a/jsonschema/validators.py b/jsonschema/validators.py index 70d46c2fd..a1d4214dc 100644 --- a/jsonschema/validators.py +++ b/jsonschema/validators.py @@ -26,6 +26,7 @@ validators = {} meta_schemas = _utils.URIDict() +_VOCABULARIES = _utils.URIDict() def validates(version): @@ -51,8 +52,12 @@ def validates(version): def _validates(cls): validators[version] = cls meta_schema_id = cls.ID_OF(cls.META_SCHEMA) - if meta_schema_id: - meta_schemas[meta_schema_id] = cls + meta_schemas[meta_schema_id] = cls + + for vocabulary in cls.VOCABULARY_SCHEMAS: + vocabulary_id = cls.ID_OF(vocabulary) + _VOCABULARIES[vocabulary_id] = vocabulary + return cls return _validates @@ -63,12 +68,22 @@ def _id_of(schema): return schema.get(u"$id", u"") +def _store_schema_list(): + return [ + (id, validator.META_SCHEMA) for id, validator in meta_schemas.items() + ] + [ + (id, schema) for id, schema in _VOCABULARIES.items() + ] + + def create( meta_schema, + vocabulary_schemas=(), validators=(), version=None, type_checker=_types.draft7_type_checker, id_of=_id_of, + applicable_validators=lambda schema: schema.items(), ): """ Create a new validator class. @@ -111,6 +126,11 @@ def create( A function that given a schema, returns its ID. + applicable_validators (collections.abc.Callable): + + A function that returns a list of validators that should apply + to a given schema + Returns: a new `jsonschema.IValidator` class @@ -120,6 +140,7 @@ class Validator: VALIDATORS = dict(validators) META_SCHEMA = dict(meta_schema) + VOCABULARY_SCHEMAS = list(vocabulary_schemas) TYPE_CHECKER = type_checker ID_OF = staticmethod(id_of) @@ -161,12 +182,7 @@ def iter_errors(self, instance, _schema=None): if scope: self.resolver.push_scope(scope) try: - ref = _schema.get(u"$ref") - if ref is not None: - validators = [(u"$ref", ref)] - else: - validators = _schema.items() - + validators = applicable_validators(_schema) for k, v in validators: validator = self.VALIDATORS.get(k) if validator is None: @@ -312,6 +328,7 @@ def extend(validator, validators=(), version=None, type_checker=None): type_checker=_types.draft3_type_checker, version="draft3", id_of=lambda schema: schema.get(u"id", ""), + applicable_validators=_legacy_validators.ignore_ref_siblings, ) Draft4Validator = create( @@ -322,7 +339,7 @@ def extend(validator, validators=(), version=None, type_checker=None): u"additionalProperties": _validators.additionalProperties, u"allOf": _validators.allOf, u"anyOf": _validators.anyOf, - u"dependencies": _validators.dependencies, + u"dependencies": _legacy_validators.dependencies_draft4_draft6_draft7, u"enum": _validators.enum, u"format": _validators.format, u"items": _legacy_validators.items_draft3_draft4, @@ -347,6 +364,7 @@ def extend(validator, validators=(), version=None, type_checker=None): type_checker=_types.draft4_type_checker, version="draft4", id_of=lambda schema: schema.get(u"id", ""), + applicable_validators=_legacy_validators.ignore_ref_siblings, ) Draft6Validator = create( @@ -358,13 +376,13 @@ def extend(validator, validators=(), version=None, type_checker=None): u"allOf": _validators.allOf, u"anyOf": _validators.anyOf, u"const": _validators.const, - u"contains": _validators.contains, - u"dependencies": _validators.dependencies, + u"contains": _legacy_validators.contains_draft6_draft7, + u"dependencies": _legacy_validators.dependencies_draft4_draft6_draft7, u"enum": _validators.enum, u"exclusiveMaximum": _validators.exclusiveMaximum, u"exclusiveMinimum": _validators.exclusiveMinimum, u"format": _validators.format, - u"items": _validators.items, + u"items": _legacy_validators.items_draft6_draft7, u"maxItems": _validators.maxItems, u"maxLength": _validators.maxLength, u"maxProperties": _validators.maxProperties, @@ -386,6 +404,7 @@ def extend(validator, validators=(), version=None, type_checker=None): }, type_checker=_types.draft6_type_checker, version="draft6", + applicable_validators=_legacy_validators.ignore_ref_siblings, ) Draft7Validator = create( @@ -397,14 +416,14 @@ def extend(validator, validators=(), version=None, type_checker=None): u"allOf": _validators.allOf, u"anyOf": _validators.anyOf, u"const": _validators.const, - u"contains": _validators.contains, - u"dependencies": _validators.dependencies, + u"contains": _legacy_validators.contains_draft6_draft7, + u"dependencies": _legacy_validators.dependencies_draft4_draft6_draft7, u"enum": _validators.enum, u"exclusiveMaximum": _validators.exclusiveMaximum, u"exclusiveMinimum": _validators.exclusiveMinimum, u"format": _validators.format, u"if": _validators.if_, - u"items": _validators.items, + u"items": _legacy_validators.items_draft6_draft7, u"maxItems": _validators.maxItems, u"maxLength": _validators.maxLength, u"maxProperties": _validators.maxProperties, @@ -426,9 +445,57 @@ def extend(validator, validators=(), version=None, type_checker=None): }, type_checker=_types.draft7_type_checker, version="draft7", + applicable_validators=_legacy_validators.ignore_ref_siblings, ) -_LATEST_VERSION = Draft7Validator +Draft202012Validator = create( + meta_schema=_utils.load_schema("draft2020-12"), + vocabulary_schemas=_utils.load_vocabulary("draft2020-12"), + validators={ + u"$ref": _validators.ref, + u"$defs": _validators.defs, + u"$dynamicRef": _validators.dynamicRef, + u"additionalItems": _validators.additionalItems, + u"additionalProperties": _validators.additionalProperties, + u"allOf": _validators.allOf, + u"anyOf": _validators.anyOf, + u"const": _validators.const, + u"contains": _validators.contains, + u"dependentRequired": _validators.dependentRequired, + u"dependentSchemas": _validators.dependentSchemas, + u"enum": _validators.enum, + u"exclusiveMaximum": _validators.exclusiveMaximum, + u"exclusiveMinimum": _validators.exclusiveMinimum, + u"format": _validators.format, + u"if": _validators.if_, + u"items": _validators.items, + u"maxItems": _validators.maxItems, + u"maxLength": _validators.maxLength, + u"maxProperties": _validators.maxProperties, + u"maximum": _validators.maximum, + u"minItems": _validators.minItems, + u"minLength": _validators.minLength, + u"minProperties": _validators.minProperties, + u"minimum": _validators.minimum, + u"multipleOf": _validators.multipleOf, + u"oneOf": _validators.oneOf, + u"not": _validators.not_, + u"pattern": _validators.pattern, + u"patternProperties": _validators.patternProperties, + u"properties": _validators.properties, + u"propertyNames": _validators.propertyNames, + u"required": _validators.required, + u"type": _validators.type, + u"uniqueItems": _validators.uniqueItems, + u"unevaluatedItems": _validators.unevaluatedItems, + u"unevaluatedProperties": _validators.unevaluatedProperties, + u"prefixItems": _validators.prefixItems, + }, + type_checker=_types.draft202012_type_checker, + version="draft2020-12", +) + +_LATEST_VERSION = Draft202012Validator class RefResolver(object): @@ -495,10 +562,7 @@ def __init__( self.handlers = dict(handlers) self._scopes_stack = [base_uri] - self.store = _utils.URIDict( - (id, validator.META_SCHEMA) - for id, validator in meta_schemas.items() - ) + self.store = _utils.URIDict(_store_schema_list()) self.store.update(store) self.store[base_uri] = referrer @@ -560,6 +624,13 @@ def resolution_scope(self): """ return self._scopes_stack[-1] + @property + def scopes_stack_copy(self): + """ + Retrieve a copy of the stack of resolution scopes. + """ + return self._scopes_stack.copy() + @property def base_uri(self): """ @@ -600,11 +671,42 @@ def resolving(self, ref): finally: self.pop_scope() + def _finditem(self, schema, key): + results = [] + if isinstance(schema, dict): + if key in schema: + results.append(schema) + + for k, v in schema.items(): + if isinstance(v, dict): + results += self._finditem(v, key) + + return results + + def resolve_local(self, url, schema): + """ + Resolve the given reference within the schema + """ + uri, fragment = urldefrag(url) + + for subschema in self._finditem(schema, "$id"): + target_uri = self._urljoin_cache( + self.resolution_scope, subschema['$id'] + ) + if target_uri.rstrip("/") == uri.rstrip("/"): + if fragment: + subschema = self.resolve_fragment(subschema, fragment) + return subschema + def resolve(self, ref): """ Resolve the given reference. """ - url = self._urljoin_cache(self.resolution_scope, ref) + url = self._urljoin_cache(self.resolution_scope, ref).rstrip("/") + local_resolve = self.resolve_local(url, self.referrer) + + if local_resolve: + return url, local_resolve return url, self._remote_cache(url) def resolve_from_url(self, url): @@ -638,8 +740,16 @@ def resolve_fragment(self, document, fragment): """ fragment = fragment.lstrip(u"/") - parts = unquote(fragment).split(u"/") if fragment else [] + # Resolve fragment via $anchor or $dynamicAnchor + if fragment: + for keyword in ["$anchor", "$dynamicAnchor"]: + for subschema in self._finditem(document, keyword): + if fragment == subschema[keyword]: + return subschema + + # Resolve via path + parts = unquote(fragment).split(u"/") if fragment else [] for part in parts: part = part.replace(u"~1", u"/").replace(u"~0", u"~") diff --git a/setup.cfg b/setup.cfg index 880672691..d78daafd0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,6 +39,7 @@ format = strict-rfc3339 webcolors uri_template + isoduration;python_version>'3.6' format_nongpl = fqdn idna @@ -47,13 +48,14 @@ format_nongpl = rfc3986-validator>0.1.0 rfc3339-validator uri_template + isoduration;python_version>'3.6' [options.entry_points] console_scripts = jsonschema = jsonschema.cli:main [options.package_data] -jsonschema = schemas/*.json +jsonschema = schemas/*.json, schemas/*/*.json [bdist_wheel] universal = 1