diff --git a/jsonsubschema/__init__.py b/jsonsubschema/__init__.py index 753ab67..860f087 100644 --- a/jsonsubschema/__init__.py +++ b/jsonsubschema/__init__.py @@ -1,20 +1,25 @@ -''' +""" Created on August 6, 2019 @author: Andrew Habib -''' - +""" from jsonsubschema import api from jsonsubschema import config from jsonsubschema import exceptions from jsonsubschema import _canonicalization +from jsonsubschema import _explain isSubschema = api.isSubschema meetSchemas = api.meet joinSchemas = api.join isEquivalent = api.isEquivalent +schemaDiff = api.schemaDiff canonicalizeSchema = _canonicalization.canonicalize_schema set_debug = config.set_debug set_warn_uninhabited = config.set_warn_uninhabited + +is_subschema = api.isSubschema +is_subschema_with_reason = api.is_subschema_with_reason +SubschemaResult = _explain.SubschemaResult diff --git a/jsonsubschema/_canonicalization.py b/jsonsubschema/_canonicalization.py index 4633de5..929c28a 100644 --- a/jsonsubschema/_canonicalization.py +++ b/jsonsubschema/_canonicalization.py @@ -80,6 +80,11 @@ def canonicalize_dict(d, outer_key=None): # Start canonicalization. Don't modify original dict. d = copy.deepcopy(d) + # Rewrite if/then/else into anyOf+allOf before processing connectors. + if "if" in d: + d = canonicalize_if_then_else(d) + has_connectors = definitions.Jconnectors.intersection(d.keys()) + if has_connectors: return canonicalize_connectors(d) elif "enum" in d.keys(): @@ -178,6 +183,32 @@ def canonicalize_const(d): d["enum"] = [d.pop("const")] return canonicalize_enum(d) + +def canonicalize_if_then_else(d): + """Rewrite if/then/else into equivalent anyOf+allOf. + + {if: C, then: T, else: E} becomes: + {anyOf: [{allOf: [C, T]}, {allOf: [{not: C}, E]}]} + + Combined with any other keywords in d via allOf. + """ + if_schema = d.pop("if") + then_schema = d.pop("then", {}) # defaults to {} (accept everything) + else_schema = d.pop("else", {}) # defaults to {} (accept everything) + + rewritten = {"anyOf": [ + {"allOf": [if_schema, then_schema]}, + {"allOf": [{"not": if_schema}, else_schema]}, + ]} + + # If d still has other keywords, combine via allOf + remaining = {k: v for k, v in d.items() + if k in definitions.Jkeywords} + if remaining: + return {"allOf": [remaining, rewritten]} + else: + return rewritten + def canonicalize_connectors(d): connectors = definitions.Jconnectors.intersection(d.keys()) lhs_kw = definitions.Jkeywords.intersection(d.keys()) @@ -310,9 +341,53 @@ def rewrite_enum(d): return ret # return canonicalize_dict(ret) - # Unsupported cases of rewriting enums - elif t == 'array' or t == 'object': - raise UnsupportedEnumCanonicalization(tau=t, schema=d) + elif t == 'array': + ret = {"anyOf": []} + for arr_val in enum: + if not isinstance(arr_val, list): + continue + schema_for_val = { + "type": "array", + "minItems": len(arr_val), + "maxItems": len(arr_val), + } + if len(arr_val) > 0: + schema_for_val["items"] = [ + canonicalize_dict({"enum": [v]}) for v in arr_val + ] + schema_for_val["additionalItems"] = False + ret["anyOf"].append(schema_for_val) + if ret["anyOf"]: + ret["enum"] = enum + return ret + else: + return BOT + + elif t == 'object': + ret = {"anyOf": []} + for obj_val in enum: + if not isinstance(obj_val, dict): + continue + schema_for_val = { + "type": "object", + "additionalProperties": False, + } + if obj_val: + schema_for_val["properties"] = { + k: canonicalize_dict({"enum": [v]}) + for k, v in obj_val.items() + } + schema_for_val["required"] = sorted(obj_val.keys()) + else: + schema_for_val["properties"] = {} + schema_for_val["minProperties"] = len(obj_val) + schema_for_val["maxProperties"] = len(obj_val) + ret["anyOf"].append(schema_for_val) + if ret["anyOf"]: + ret["enum"] = enum + return ret + else: + return BOT def simplify_schema_and_embed_checkers(s): @@ -341,6 +416,9 @@ def simplify_schema_and_embed_checkers(s): s["additionalItems"] = simplify_schema_and_embed_checkers( s["additionalItems"]) + if "contains" in s and utils.is_dict(s["contains"]): + s["contains"] = simplify_schema_and_embed_checkers(s["contains"]) + # json.object specific if "properties" in s: s["properties"] = dict([(k, simplify_schema_and_embed_checkers(v)) diff --git a/jsonsubschema/_checkers.py b/jsonsubschema/_checkers.py index 4eda4ff..26f7aaa 100644 --- a/jsonsubschema/_checkers.py +++ b/jsonsubschema/_checkers.py @@ -612,19 +612,67 @@ def neg(s): non_ints = boolToConstructor.get("anyOf")( {"anyOf": get_default_types_except("number", "integer")}) - if "minimum" in s: - # if "exclusiveMinimum": + has_min = "minimum" in s + has_max = "maximum" in s + has_mulof = "multipleOf" in s and s["multipleOf"] is not None + + if has_min: negated_ints.append(JSONTypeInteger({"maximum": s["minimum"] - 1})) - # else: - # negated_numbers.append(JSONTypeNumber( - # {"maximum": s["minimum"], "exclusiveMaximum": True})) - if "maximum" in s: - # if "exclusiveMaximum": + if has_max: negated_ints.append(JSONTypeInteger({"minimum": s["maximum"] + 1})) - # else: - # negated_numbers.append(JSONTypeNumber( - # {"minimum": s["maximum"], "exclusiveMinimum": True})) - # TODO: No handling of multipleOf at the moment. + + if has_mulof: + mulof = s["multipleOf"] + mn = s.get("minimum") + mx = s.get("maximum") + + # Adjust min/max to first/last multiple within range + if utils.is_num(mn): + adj_min = mn + (-mn % mulof) if mn % mulof != 0 else mn + else: + adj_min = None + if utils.is_num(mx): + adj_max = mx - (mx % mulof) + else: + adj_max = None + + if adj_min is not None and adj_max is not None: + # Bounded case: compute gap ranges between consecutive multiples + gap_ranges = [] + current = adj_min + # Also include integers between original min and first multiple + if mn is not None and mn < current: + gap_ranges.append( + JSONTypeInteger({"minimum": mn, "maximum": current - 1})) + while current <= adj_max: + next_mul = current + mulof + if current + 1 <= min(next_mul - 1, adj_max if adj_max is not None else next_mul - 1): + gap_ranges.append( + JSONTypeInteger({"minimum": current + 1, + "maximum": min(next_mul - 1, mx if utils.is_num(mx) else next_mul - 1)})) + current = next_mul + # Include integers between last multiple and original max + if mx is not None and adj_max < mx: + gap_ranges.append( + JSONTypeInteger({"minimum": adj_max + 1, "maximum": mx})) + negated_ints.extend(gap_ranges) + elif adj_min is not None: + # Has minimum but no maximum: add integers in [min, first_multiple-1] + if mn is not None and mn < adj_min: + negated_ints.append( + JSONTypeInteger({"minimum": mn, "maximum": adj_min - 1})) + # For the unbounded upper part, we add integers above adj_min + # that skip multiples of mulof. We approximate by including + # ranges between consecutive multiples up to a reasonable bound, + # but for truly unbounded ranges, we cannot perfectly express + # "not multipleOf" — this is a known limitation. + elif adj_max is not None: + # Has maximum but no minimum: add integers in [last_multiple+1, max] + if mx is not None and adj_max < mx: + negated_ints.append( + JSONTypeInteger({"minimum": adj_max + 1, "maximum": mx})) + # else: no bounds at all with multipleOf — cannot express complement + # of "all multiples of M" in JSON schema. Known limitation. if len(negated_ints) == 0: return non_ints @@ -720,21 +768,71 @@ def neg(s): non_numbers = boolToConstructor.get("anyOf")( {"anyOf": get_default_types_except("number", "integer")}) - if "minimum" in s: - if "exclusiveMinimum": + has_min = "minimum" in s + has_max = "maximum" in s + has_mulof = "multipleOf" in s and s["multipleOf"] is not None + excl_min = s.get("exclusiveMinimum", False) + excl_max = s.get("exclusiveMaximum", False) + + if has_min: + if excl_min: negated_numbers.append( JSONTypeNumber({"maximum": s["minimum"]})) else: negated_numbers.append(JSONTypeNumber( {"maximum": s["minimum"], "exclusiveMaximum": True})) - if "maximum" in s: - if "exclusiveMaximum": + if has_max: + if excl_max: negated_numbers.append( JSONTypeNumber({"minimum": s["maximum"]})) else: negated_numbers.append(JSONTypeNumber( {"minimum": s["maximum"], "exclusiveMinimum": True})) - # TODO: No handling of multipleOf at the moment. + + if has_mulof: + mulof = s["multipleOf"] + mn = s.get("minimum") + mx = s.get("maximum") + + if utils.is_num(mn) and utils.is_num(mx): + # Bounded case: compute gap intervals between consecutive + # multiples of mulof within [mn, mx]. + # Adjust to first/last multiple in range. + if mn % mulof != 0: + adj_min = mn + (mulof - mn % mulof) if mn >= 0 else mn - (mn % mulof) + else: + adj_min = mn + # For numbers, adjust to actual multiples + adj_min = math.ceil(mn / mulof) * mulof + adj_max = math.floor(mx / mulof) * mulof + + if adj_min <= adj_max: + gap_ranges = [] + current = adj_min + # Gap before first multiple (if min is not itself a multiple) + if mn < current or (mn == current and not excl_min): + # Numbers in [mn, first_multiple) — exclusive of the multiple + if mn < current: + gap_ranges.append(JSONTypeNumber( + {"minimum": mn, "maximum": current, + "exclusiveMinimum": excl_min, "exclusiveMaximum": True})) + # Gaps between consecutive multiples + while current + mulof <= adj_max: + next_mul = current + mulof + gap_ranges.append(JSONTypeNumber( + {"minimum": current, "maximum": next_mul, + "exclusiveMinimum": True, "exclusiveMaximum": True})) + current = next_mul + # Gap after last multiple + if adj_max < mx or (adj_max == mx and not excl_max): + if adj_max < mx: + gap_ranges.append(JSONTypeNumber( + {"minimum": adj_max, "maximum": mx, + "exclusiveMinimum": True, "exclusiveMaximum": excl_max})) + negated_numbers.extend(gap_ranges) + # For unbounded cases with multipleOf, the complement of + # "all multiples of M" in the reals cannot be expressed as + # a finite union of intervals. Known limitation. if len(negated_numbers) == 0: return non_numbers @@ -849,6 +947,7 @@ def __init__(self, s): self.items_ = self.get("items", JSONtop()) self.additionalItems = self.get("additionalItems", True) self.uniqueItems = self.get("uniqueItems", False) + self.contains = self.get("contains", None) def compute_actual_maxItems(self): if utils.is_list(self.items_) and is_bot(self.additionalItems): @@ -1012,6 +1111,25 @@ def _isArraySubtype(s1, s2): print_db("__02__") return False # + # -- contains + if s2.contains is not None: + if s1.contains is not None: + if not s1.contains.isSubtype(s2.contains): + print_db("contains__01") + return False + else: + # LHS has no contains, RHS requires contains. + # Check if all items of LHS are subtypes of RHS.contains + if utils.is_dict(s1.items_): + if not s1.items_.isSubtype(s2.contains): + print_db("contains__02") + return False + elif utils.is_list(s1.items_): + if not any(item.isSubtype(s2.contains) + for item in s1.items_): + print_db("contains__03") + return False + # # -- items = {not empty} # no need to check additionalItems if utils.is_dict(s1.items_): @@ -1123,16 +1241,31 @@ def _isArraySubtype(s1, s2): @staticmethod def neg(s): - # for k, default in JSONTypeArray.kw_defaults.items(): - # if s.__getattr__(k) != default: - # break - # else: - if s.keys() & definitions.JtypesToKeywords['array']: - raise UnsupportedNegatedArray(schema=s) - else: - return boolToConstructor.get("anyOf")( + array_kws = s.keys() & set(definitions.JtypesToKeywords['array']) + non_arrays = boolToConstructor.get("anyOf")( {"anyOf": get_default_types_except("array")}) + if not array_kws: + return non_arrays + + # Handle negation of minItems/maxItems-only constraints + simple_kws = array_kws - {"items", "additionalItems", "uniqueItems"} + if array_kws == simple_kws: + # Only minItems/maxItems — complement is expressible + negated_arrays = [] + if "minItems" in s: + negated_arrays.append( + JSONTypeArray({"type": "array", "maxItems": s["minItems"] - 1})) + if "maxItems" in s: + negated_arrays.append( + JSONTypeArray({"type": "array", "minItems": s["maxItems"] + 1})) + if not negated_arrays: + return non_arrays + joined = boolToConstructor.get("anyOf")({"anyOf": negated_arrays}) + return non_arrays.join(joined) + + raise UnsupportedNegatedArray(schema=s) + class JSONTypeObject(JSONschema): @@ -1383,7 +1516,7 @@ def get_schema_for_key(k, s): for k in extra_patterns_on_rhs: if not s1.additionalProperties.isSubtype(s2.patternProperties[k]): try: # means regex k is infinite - parse(k).cardinality() + utils._cached_parse(k).cardinality() except OverflowError: print_db("__08__") return False @@ -1459,16 +1592,33 @@ def get_schema_for_key(k, s): @staticmethod def neg(s): - # for k, default in JSONTypeObject.kw_defaults.items(): - # if s.__getattr__(k) != default: - # break - # else: - if s.keys() & definitions.JtypesToKeywords['object']: - raise UnsupportedNegatedObject(schema=s) - else: - return boolToConstructor.get("anyOf")( + obj_kws = s.keys() & set(definitions.JtypesToKeywords['object']) + non_objects = boolToConstructor.get("anyOf")( {"anyOf": get_default_types_except("object")}) + if not obj_kws: + return non_objects + + # Handle negation of minProperties/maxProperties-only constraints + simple_kws = obj_kws - {"properties", "additionalProperties", + "patternProperties", "required", "dependencies"} + if obj_kws == simple_kws: + negated_objects = [] + if "minProperties" in s: + negated_objects.append( + JSONTypeObject({"type": "object", + "maxProperties": s["minProperties"] - 1})) + if "maxProperties" in s: + negated_objects.append( + JSONTypeObject({"type": "object", + "minProperties": s["maxProperties"] + 1})) + if not negated_objects: + return non_objects + joined = boolToConstructor.get("anyOf")({"anyOf": negated_objects}) + return non_objects.join(joined) + + raise UnsupportedNegatedObject(schema=s) + def JSONanyOfFactory(s): diff --git a/jsonsubschema/_constants.py b/jsonsubschema/_constants.py index 767e79a..10df615 100644 --- a/jsonsubschema/_constants.py +++ b/jsonsubschema/_constants.py @@ -19,7 +19,7 @@ "integer": ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"], "boolean": [], "null": [], - "array": ["minItems", "maxItems", "items", "additionalItems", "uniqueItems"], + "array": ["minItems", "maxItems", "items", "additionalItems", "uniqueItems", "contains"], "object": ["properties", "additionalProperties", "required", "minProperties", "maxProperties", "dependencies", "patternProperties"] } @@ -27,7 +27,9 @@ Jconnectors = set(["anyOf", "allOf", "oneOf", "not"]) -Jcommonkw = Jconnectors.union(["enum", "type", "const"]) +Jconditional = set(["if", "then", "else"]) + +Jcommonkw = Jconnectors.union(["enum", "type", "const"]).union(Jconditional) JNonValidation = set(["$schema", "$id", "definitions", "title", "description", "format"]) diff --git a/jsonsubschema/_explain.py b/jsonsubschema/_explain.py new file mode 100644 index 0000000..ed76b8d --- /dev/null +++ b/jsonsubschema/_explain.py @@ -0,0 +1,108 @@ +""" +Explain context module for collecting subschema check failure reasons. + +This module provides thread-local context for capturing why a subschema check +fails, enabling detailed error messages without modifying existing method signatures. +""" + +import threading +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass +class SubschemaResult: + """ + Result of a subschema check with optional failure reasons. + + Attributes: + is_subtype: True if s1 is a subschema of s2, False otherwise. + reasons: List of failure reasons if is_subtype is False. + Empty list if is_subtype is True. + + Example: + >>> result = is_subschema_with_reason(s1, s2) + >>> if not result.is_subtype: + ... for reason in result.reasons: + ... print(reason) + """ + + is_subtype: bool + reasons: List[str] = field(default_factory=list) + + def __bool__(self) -> bool: + """Allow using result directly in boolean context.""" + return self.is_subtype + + +class ExplainContext: + """ + Thread-local context for collecting failure reasons during subschema checks. + + This uses a thread-local storage pattern to avoid modifying existing method + signatures while still collecting failure information. + + Usage: + ctx = ExplainContext() + ExplainContext.set_current(ctx) + try: + # ... perform subschema check ... + result = s1.isSubtype(s2) + finally: + ExplainContext.set_current(None) + + # Access collected reasons + for reason in ctx.reasons: + print(reason) + """ + + _local = threading.local() + + @classmethod + def get_current(cls) -> Optional["ExplainContext"]: + """Get the current thread's ExplainContext, or None if not set.""" + return getattr(cls._local, "context", None) + + @classmethod + def set_current(cls, ctx: Optional["ExplainContext"]) -> None: + """Set the current thread's ExplainContext.""" + cls._local.context = ctx + + def __init__(self): + """Initialize an empty explain context.""" + self.reasons: List[str] = [] + self.path: List[str] = [] + + def push_path(self, segment: str) -> None: + """Push a path segment onto the current path stack.""" + self.path.append(segment) + + def pop_path(self) -> Optional[str]: + """Pop the last path segment from the path stack.""" + if self.path: + return self.path.pop() + return None + + def get_path_str(self) -> str: + """Get the current path as a string.""" + return "/" + "/".join(self.path) if self.path else "/" + + def add_reason(self, code: str, message: str) -> None: + """ + Add a failure reason to the context. + + Args: + code: Short identifier for the failure type (e.g., "num__01") + message: Human-readable description of the failure + """ + path_str = self.get_path_str() + self.reasons.append(f"[{code}] {message} (at {path_str})") + + def add_reason_raw(self, message: str) -> None: + """ + Add a raw failure reason without code formatting. + + Args: + message: The raw failure message + """ + self.reasons.append(message) diff --git a/jsonsubschema/_utils.py b/jsonsubschema/_utils.py index 7c4d672..b63d0a2 100644 --- a/jsonsubschema/_utils.py +++ b/jsonsubschema/_utils.py @@ -1,11 +1,11 @@ -''' +""" Created on May 24, 2019 @author: Andrew Habib -''' - +""" import copy import fractions +import functools import math import numbers import re @@ -88,7 +88,7 @@ def get_valid_enum_vals(enum, s): # try: # return sorted(vals) # except TypeError: - # return list(vals) + # return list(vals) return vals @@ -109,6 +109,14 @@ def print_db(*args): else: print() + from jsonsubschema._explain import ExplainContext + + ctx = ExplainContext.get_current() + if ctx and args: + code = str(args[0]) if args else "unknown" + message = " ".join(str(a) for a in args[1:]) if len(args) > 1 else "" + ctx.add_reason(code, message) + # def one(iterable): # for i in range(len(iterable)): @@ -124,19 +132,26 @@ def print_db(*args): # +@functools.lru_cache(maxsize=1024) +def _cached_parse(pattern): + """Cache greenery parse() results — FSM construction is expensive.""" + return parse(pattern) + + def prepare_pattern_for_greenry(s): - ''' The greenery library we use for regex intersection assumes - patterns are unanchored by default. Anchoring chars ^ and $ are - treated as literals by greenery. - So basically strip any non-escaped ^ and $ when using greenery. - Moreover, for any escaped ^ or $, we remove the \ to adhere to - greenery syntax (when they are escaped, they are literals). ''' - - s = re.sub(r'(?', - s) # strip non-escaped ^ that is not inside [] - s = re.sub(r'(?', s) # strip non-escaped $ - s = re.sub(r'(?^', s) # strip \ before ^ - s = re.sub(r'(?$', s) # strip \ before $ + """The greenery library we use for regex intersection assumes + patterns are unanchored by default. Anchoring chars ^ and $ are + treated as literals by greenery. + So basically strip any non-escaped ^ and $ when using greenery. + Moreover, for any escaped ^ or $, we remove the \ to adhere to + greenery syntax (when they are escaped, they are literals).""" + + s = re.sub( + r"(?", s + ) # strip non-escaped ^ that is not inside [] + s = re.sub(r"(?", s) # strip non-escaped $ + s = re.sub(r"(?^", s) # strip \ before ^ + s = re.sub(r"(?$", s) # strip \ before $ return s @@ -162,14 +177,14 @@ def regex_unanchor(p): def regex_matches_string(regex=None, s=None): if regex: - return parse(regex).matches(s) + return _cached_parse(regex).matches(s) else: return True def regex_meet(s1, s2): if s1 and s2: - ret = parse(s1) & parse(s2) + ret = _cached_parse(s1) & _cached_parse(s2) return str(ret.reduce()) if not ret.empty() else None elif s1: return s1 @@ -180,11 +195,11 @@ def regex_meet(s1, s2): def regex_isSubset(s1, s2): - ''' regex subset is quite expensive to compute - especially for complex patterns. ''' + """regex subset is quite expensive to compute + especially for complex patterns.""" if s1 and s2: - s1 = parse(s1).reduce() - s2 = parse(s2).reduce() + s1 = _cached_parse(s1).reduce() + s2 = _cached_parse(s2).reduce() try: s1.cardinality() s2.cardinality() @@ -199,7 +214,7 @@ def regex_isSubset(s1, s2): elif s1: return True elif s2: - return parse(s2).equivalent(parse(".*")) + return _cached_parse(s2).equivalent(_cached_parse(".*")) # def regex_isProperSubset(s1, s2): @@ -218,9 +233,9 @@ def regex_isSubset(s1, s2): def string_range_to_regex(min, max): assert min <= max, "" if min == max: - pattern = ".{" + str(min) + "}" # '.{min}' + pattern = ".{" + str(min) + "}" # '.{min}' elif max == I.inf: - pattern = ".{" + str(min) + ",}" # '.{min,}' + pattern = ".{" + str(min) + ",}" # '.{min,}' else: pattern = ".{" + str(min) + "," + str(max) + "}" # '.{min, max}' @@ -228,11 +243,13 @@ def string_range_to_regex(min, max): def complement_of_string_pattern(s): - return str(parse(s).everythingbut().reduce()) + return str(_cached_parse(s).everythingbut().reduce()) def lcm(x, y): - bad_values = [None, ] # I.inf, -I.inf] + bad_values = [ + None, + ] # I.inf, -I.inf] if x in bad_values: if y in bad_values: return None @@ -251,7 +268,9 @@ def lcm(x, y): def gcd(x, y): - bad_values = [None, ] # I.inf, -I.inf, None] + bad_values = [ + None, + ] # I.inf, -I.inf, None] if x in bad_values: if y in bad_values: return None @@ -320,8 +339,8 @@ def generate_range_with_not_multipleOf_and(range_, neg_mul_of): def generate_range_with_multipleof(range_, pos, neg): return generate_range_with_not_multipleOf_and( - generate_range_with_multipleOf_or(range_, pos), - neg) + generate_range_with_multipleOf_or(range_, pos), neg + ) def get_new_min_max_with_mulof(mn, mx, mulof): @@ -348,9 +367,11 @@ def is_interval_finite(i): def are_intervals_mergable(i1, i2): - return i1.overlaps(i2) \ - or (is_num(i1.lower) and is_num(i2.upper) and i1.lower - i2.upper == 1) \ + return ( + i1.overlaps(i2) + or (is_num(i1.lower) and is_num(i2.upper) and i1.lower - i2.upper == 1) or (is_num(i2.lower) and is_num(i1.upper) and i2.lower - i1.upper == 1) + ) def load_json_file(path, msg=None): diff --git a/jsonsubschema/api.py b/jsonsubschema/api.py index 4519da8..f0f2d9b 100644 --- a/jsonsubschema/api.py +++ b/jsonsubschema/api.py @@ -1,19 +1,16 @@ -''' +""" Created on June 24, 2019 @author: Andrew Habib -''' +""" import sys import jsonref from jsonsubschema._canonicalization import ( canonicalize_schema, - simplify_schema_and_embed_checkers -) -from jsonsubschema._utils import ( - validate_schema, - print_db + simplify_schema_and_embed_checkers, ) +from jsonsubschema._utils import validate_schema, print_db from jsonsubschema.exceptions import UnsupportedRecursiveRef @@ -35,40 +32,101 @@ def prepare_operands(s1, s2): # At the moment, recursive/circual refs are not supported and hence, canonicalization # throws a RecursionError. try: - _s1 = simplify_schema_and_embed_checkers( - canonicalize_schema(s1)) + _s1 = simplify_schema_and_embed_checkers(canonicalize_schema(s1)) except RecursionError: # avoid cluttering output by unchaining the recursion error - raise UnsupportedRecursiveRef(s1, 'LHS') from None + raise UnsupportedRecursiveRef(s1, "LHS") from None try: - _s2 = simplify_schema_and_embed_checkers( - canonicalize_schema(s2)) + _s2 = simplify_schema_and_embed_checkers(canonicalize_schema(s2)) except RecursionError: # avoid cluttering output by unchaining the recursion error - raise UnsupportedRecursiveRef(s2, 'RHS') from None + raise UnsupportedRecursiveRef(s2, "RHS") from None return _s1, _s2 def isSubschema(s1, s2): - ''' Entry point for schema subtype checking. ''' + """Entry point for schema subtype checking.""" s1, s2 = prepare_operands(s1, s2) return s1.isSubtype(s2) def meet(s1, s2): - ''' Entry point for schema meet operation. ''' + """Entry point for schema meet operation.""" s1, s2 = prepare_operands(s1, s2) return s1.meet(s2) def join(s1, s2): - ''' Entry point for schema meet operation. ''' + """Entry point for schema meet operation.""" s1, s2 = prepare_operands(s1, s2) return s1.join(s2) def isEquivalent(s1, s2): - ''' Entry point for schema equivalence check operation. ''' + """Entry point for schema equivalence check operation.""" return isSubschema(s1, s2) and isSubschema(s2, s1) + + +def schemaDiff(s1, s2): + """Analyze the compatibility relationship between two schemas. + + Returns one of: + - 'equivalent': s1 <: s2 and s2 <: s1 + - 'backward_compatible': s1 <: s2 (s1 is more restrictive) + - 'forward_compatible': s2 <: s1 (s2 is more restrictive) + - 'breaking': neither direction holds + - 'unknown': an unsupported feature prevented the check + """ + from jsonsubschema.exceptions import _UnsupportedCase + + try: + fwd = isSubschema(s1, s2) + except _UnsupportedCase: + fwd = None + + try: + bwd = isSubschema(s2, s1) + except _UnsupportedCase: + bwd = None + + if fwd is None or bwd is None: + if fwd is True and bwd is True: + return "equivalent" + elif fwd is True: + return "backward_compatible" + elif bwd is True: + return "forward_compatible" + return "unknown" + + if fwd and bwd: + return "equivalent" + elif fwd: + return "backward_compatible" + elif bwd: + return "forward_compatible" + else: + return "breaking" + + +def is_subschema_with_reason(s1, s2): + """ + Check if s1 is a subschema of s2, with detailed failure reasons. + + Returns a SubschemaResult containing: + - is_subtype: bool indicating if s1 <: s2 + - reasons: list of failure reasons if is_subtype is False + """ + from jsonsubschema._explain import ExplainContext, SubschemaResult + + ctx = ExplainContext() + ExplainContext.set_current(ctx) + try: + _s1, _s2 = prepare_operands(s1, s2) + result = _s1.isSubtype(_s2) + return SubschemaResult( + is_subtype=result, reasons=ctx.reasons if not result else [] + ) + finally: + ExplainContext.set_current(None) diff --git a/jsonsubschema/cli.py b/jsonsubschema/cli.py index 9974e5b..4f2a082 100644 --- a/jsonsubschema/cli.py +++ b/jsonsubschema/cli.py @@ -6,15 +6,17 @@ import argparse from jsonsubschema._utils import load_json_file -from jsonsubschema.api import isSubschema +from jsonsubschema.api import isSubschema, schemaDiff def main(): ''' CLI entry point for jsonsubschema ''' - - parser = argparse.ArgumentParser(description='CLI for ssonsubschema tool which checks whether a LHS JSON schema is a subschema (<:) of another RHS JSON schema.') + + parser = argparse.ArgumentParser(description='CLI for jsonsubschema tool which checks whether a LHS JSON schema is a subschema (<:) of another RHS JSON schema.') parser.add_argument('LHS', metavar='lhs', type=str, help='Path to the JSON file which has the LHS JSON schema') parser.add_argument('RHS', metavar='rhs', type=str, help='Path to the JSON file which has the RHS JSON schema') + parser.add_argument('--diff', action='store_true', + help='Show compatibility relationship instead of subtype check') args = parser.parse_args() s1_file_path = args.LHS @@ -23,7 +25,10 @@ def main(): s1 = load_json_file(s1_file_path, "LHS file:") s2 = load_json_file(s2_file_path, "RHS file:") - print("LHS <: RHS", isSubschema(s1, s2)) + if args.diff: + print("Compatibility:", schemaDiff(s1, s2)) + else: + print("LHS <: RHS", isSubschema(s1, s2)) if __name__ == "__main__": diff --git a/test/test_array.py b/test/test_array.py index 2400c5b..8cd2ed4 100644 --- a/test/test_array.py +++ b/test/test_array.py @@ -160,4 +160,32 @@ def test_1(self): 'items': { 'type': 'string'}}}]} - self.assertFalse(isSubschema(s1, s2)) \ No newline at end of file + self.assertFalse(isSubschema(s1, s2)) + + +class TestArrayNegation(unittest.TestCase): + """Tests for array schema negation with minItems/maxItems.""" + + def test_not_array_minItems(self): + """not({array, minItems:3}) should accept arrays with fewer items.""" + s1 = {"type": "array", "maxItems": 2} + s2 = {"not": {"type": "array", "minItems": 3}} + self.assertTrue(isSubschema(s1, s2)) + + def test_not_array_maxItems(self): + """not({array, maxItems:5}) should accept arrays with more items.""" + s1 = {"type": "array", "minItems": 6} + s2 = {"not": {"type": "array", "maxItems": 5}} + self.assertTrue(isSubschema(s1, s2)) + + def test_not_array_minmax_reject(self): + """Array within negated bounds should NOT be subtype.""" + s1 = {"type": "array", "minItems": 2, "maxItems": 4} + s2 = {"not": {"type": "array", "minItems": 1, "maxItems": 5}} + self.assertFalse(isSubschema(s1, s2)) + + def test_non_array_subtype_of_not_array(self): + """A string is always subtype of not(array with constraints).""" + s1 = {"type": "string"} + s2 = {"not": {"type": "array", "minItems": 1}} + self.assertTrue(isSubschema(s1, s2)) \ No newline at end of file diff --git a/test/test_cli.py b/test/test_cli.py new file mode 100644 index 0000000..276881e --- /dev/null +++ b/test/test_cli.py @@ -0,0 +1,209 @@ +""" +Test suite for CLI interface. + +This module tests the command-line interface for jsonsubschema including: +- Basic CLI invocation +- File loading +- Error handling +- Output formatting + +Created to ensure CLI interface works correctly. +""" + +import json +import os +import subprocess +import tempfile +import unittest + + +class TestCLIBasicUsage(unittest.TestCase): + """Test basic CLI functionality.""" + + def setUp(self): + """Create temporary test files.""" + self.temp_dir = tempfile.mkdtemp() + self.s1_path = os.path.join(self.temp_dir, "s1.json") + self.s2_path = os.path.join(self.temp_dir, "s2.json") + + def tearDown(self): + """Clean up temporary files.""" + if os.path.exists(self.s1_path): + os.remove(self.s1_path) + if os.path.exists(self.s2_path): + os.remove(self.s2_path) + if os.path.exists(self.temp_dir): + os.rmdir(self.temp_dir) + + def _write_schema(self, path, schema): + """Helper to write schema to file.""" + with open(path, "w") as f: + json.dump(schema, f) + + def _run_cli(self, lhs_path, rhs_path): + """Helper to run CLI and capture output.""" + result = subprocess.run( + ["python", "-m", "jsonsubschema.cli", lhs_path, rhs_path], + capture_output=True, + text=True, + ) + return result + + def test_cli_basic_true(self): + """Test CLI with subtype relationship (returns True).""" + s1 = {"type": "integer"} + s2 = {"type": ["integer", "string"]} + self._write_schema(self.s1_path, s1) + self._write_schema(self.s2_path, s2) + + result = self._run_cli(self.s1_path, self.s2_path) + self.assertEqual(result.returncode, 0) + self.assertIn("True", result.stdout) + + def test_cli_basic_false(self): + """Test CLI with non-subtype relationship (returns False).""" + s1 = {"type": "string"} + s2 = {"type": "integer"} + self._write_schema(self.s1_path, s1) + self._write_schema(self.s2_path, s2) + + result = self._run_cli(self.s1_path, self.s2_path) + self.assertEqual(result.returncode, 0) + self.assertIn("False", result.stdout) + + def test_cli_empty_schemas(self): + """Test CLI with empty schemas.""" + s1 = {} + s2 = {} + self._write_schema(self.s1_path, s1) + self._write_schema(self.s2_path, s2) + + result = self._run_cli(self.s1_path, self.s2_path) + self.assertEqual(result.returncode, 0) + self.assertIn("True", result.stdout) + + def test_cli_complex_schemas(self): + """Test CLI with complex schemas.""" + s1 = { + "type": "object", + "properties": {"name": {"type": "string"}, "age": {"type": "integer"}}, + "required": ["name", "age"], + } + s2 = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + } + self._write_schema(self.s1_path, s1) + self._write_schema(self.s2_path, s2) + + result = self._run_cli(self.s1_path, self.s2_path) + self.assertEqual(result.returncode, 0) + self.assertIn("True", result.stdout) + + +class TestCLIFileHandling(unittest.TestCase): + """Test CLI file handling.""" + + def setUp(self): + """Create temporary test directory.""" + self.temp_dir = tempfile.mkdtemp() + self.valid_path = os.path.join(self.temp_dir, "valid.json") + + def tearDown(self): + """Clean up temporary files.""" + if os.path.exists(self.valid_path): + os.remove(self.valid_path) + if os.path.exists(self.temp_dir): + os.rmdir(self.temp_dir) + + def _write_schema(self, path, schema): + """Helper to write schema to file.""" + with open(path, "w") as f: + json.dump(schema, f) + + def _run_cli(self, lhs_path, rhs_path): + """Helper to run CLI and capture output.""" + result = subprocess.run( + ["python", "-m", "jsonsubschema.cli", lhs_path, rhs_path], + capture_output=True, + text=True, + ) + return result + + def test_cli_missing_lhs_file(self): + """Test CLI with missing LHS file.""" + self._write_schema(self.valid_path, {"type": "string"}) + nonexistent = os.path.join(self.temp_dir, "nonexistent.json") + + result = self._run_cli(nonexistent, self.valid_path) + self.assertNotEqual(result.returncode, 0) + + def test_cli_missing_rhs_file(self): + """Test CLI with missing RHS file.""" + self._write_schema(self.valid_path, {"type": "string"}) + nonexistent = os.path.join(self.temp_dir, "nonexistent.json") + + result = self._run_cli(self.valid_path, nonexistent) + self.assertNotEqual(result.returncode, 0) + + def test_cli_invalid_json_lhs(self): + """Test CLI with invalid JSON in LHS file.""" + invalid_path = os.path.join(self.temp_dir, "invalid.json") + with open(invalid_path, "w") as f: + f.write("{invalid json") + + self._write_schema(self.valid_path, {"type": "string"}) + + result = self._run_cli(invalid_path, self.valid_path) + self.assertNotEqual(result.returncode, 0) + + os.remove(invalid_path) + + def test_cli_invalid_json_rhs(self): + """Test CLI with invalid JSON in RHS file.""" + invalid_path = os.path.join(self.temp_dir, "invalid.json") + with open(invalid_path, "w") as f: + f.write("{invalid json") + + self._write_schema(self.valid_path, {"type": "string"}) + + result = self._run_cli(self.valid_path, invalid_path) + self.assertNotEqual(result.returncode, 0) + + os.remove(invalid_path) + + +class TestCLIArguments(unittest.TestCase): + """Test CLI argument handling.""" + + def _run_cli(self, args): + """Helper to run CLI with specific arguments.""" + result = subprocess.run( + ["python", "-m", "jsonsubschema.cli"] + args, + capture_output=True, + text=True, + ) + return result + + def test_cli_no_arguments(self): + """Test CLI with no arguments.""" + result = self._run_cli([]) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(len(result.stderr) > 0) + + def test_cli_one_argument(self): + """Test CLI with only one argument.""" + result = self._run_cli(["schema1.json"]) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(len(result.stderr) > 0) + + def test_cli_help_flag(self): + """Test CLI with help flag.""" + result = self._run_cli(["--help"]) + self.assertEqual(result.returncode, 0) + self.assertIn("usage", result.stdout.lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_const.py b/test/test_const.py index 3471add..f77935e 100644 --- a/test/test_const.py +++ b/test/test_const.py @@ -87,23 +87,23 @@ def test_enum_uninhabited2(self) -> None: assert isSubschema(s2, s1) -class TestEnumNotSupported(unittest.TestCase): +class TestArrayObjectEnumSupported(unittest.TestCase): def test_array(self) -> None: s1 = {"const": []} s2 = {"type": "array"} - with self.subTest(): - self.assertRaises(UnsupportedEnumCanonicalization, isSubschema, s1, s2) + with self.subTest("empty array const <: array"): + self.assertTrue(isSubschema(s1, s2)) - with self.subTest(): - self.assertRaises(UnsupportedEnumCanonicalization, isSubschema, s2, s1) + with self.subTest("array not <: empty array const"): + self.assertFalse(isSubschema(s2, s1)) def test_object(self) -> None: s1 = {"const": {}} s2 = {"type": "object"} - with self.subTest(): - self.assertRaises(UnsupportedEnumCanonicalization, isSubschema, s1, s2) + with self.subTest("empty object const <: object"): + self.assertTrue(isSubschema(s1, s2)) - with self.subTest(): - self.assertRaises(UnsupportedEnumCanonicalization, isSubschema, s2, s1) + with self.subTest("object not <: empty object const"): + self.assertFalse(isSubschema(s2, s1)) diff --git a/test/test_diff.py b/test/test_diff.py new file mode 100644 index 0000000..f28dcf3 --- /dev/null +++ b/test/test_diff.py @@ -0,0 +1,70 @@ +"""Tests for schemaDiff API.""" + +import unittest + +from jsonsubschema import schemaDiff + + +class TestSchemaDiff(unittest.TestCase): + + def test_equivalent(self): + s1 = {"type": "string"} + s2 = {"type": "string"} + self.assertEqual(schemaDiff(s1, s2), "equivalent") + + def test_equivalent_reordered_type_list(self): + s1 = {"type": ["string", "null"]} + s2 = {"type": ["null", "string"]} + self.assertEqual(schemaDiff(s1, s2), "equivalent") + + def test_backward_compatible(self): + """s1 is more restrictive than s2 (s1 <: s2).""" + s1 = {"type": "integer", "minimum": 0, "maximum": 10} + s2 = {"type": "integer"} + self.assertEqual(schemaDiff(s1, s2), "backward_compatible") + + def test_forward_compatible(self): + """s2 is more restrictive than s1 (s2 <: s1).""" + s1 = {"type": "integer"} + s2 = {"type": "integer", "minimum": 0, "maximum": 10} + self.assertEqual(schemaDiff(s1, s2), "forward_compatible") + + def test_breaking(self): + """Neither direction holds.""" + s1 = {"type": "string"} + s2 = {"type": "integer"} + self.assertEqual(schemaDiff(s1, s2), "breaking") + + def test_washington_post_example(self): + """Example from the paper: API schema evolution.""" + v061 = { + "type": "object", + "properties": { + "category": { + "type": "string", + "enum": ["staff", "wires", "other"] + } + } + } + v062 = { + "type": "object", + "properties": { + "category": { + "type": "string", + "enum": ["staff", "wires", "stock", "other"] + } + } + } + # v0.6.1 <: v0.6.2 (old is subtype of new — backward compatible) + # v0.6.2 is NOT <: v0.6.1 (new has "stock" which old doesn't accept) + self.assertEqual(schemaDiff(v061, v062), "backward_compatible") + + def test_enum_subset(self): + s1 = {"enum": [1, 2]} + s2 = {"enum": [1, 2, 3]} + self.assertEqual(schemaDiff(s1, s2), "backward_compatible") + + def test_enum_equal(self): + s1 = {"enum": [1, 2]} + s2 = {"enum": [2, 1]} + self.assertEqual(schemaDiff(s1, s2), "equivalent") diff --git a/test/test_draft07.py b/test/test_draft07.py new file mode 100644 index 0000000..54fb000 --- /dev/null +++ b/test/test_draft07.py @@ -0,0 +1,96 @@ +"""Tests for Draft-07 features: if/then/else and contains.""" + +import unittest + +from jsonsubschema import isSubschema + + +class TestIfThenElse(unittest.TestCase): + """Tests for if/then/else canonicalization.""" + + def test_if_then_else_basic(self): + """if type is string, then minLength 1; else must be integer.""" + s1 = { + "if": {"type": "string"}, + "then": {"minLength": 1}, + "else": {"type": "integer"} + } + # A non-empty string satisfies: if=string matches, then minLength 1 + s2 = {"type": "string", "minLength": 1} + self.assertTrue(isSubschema(s2, s1)) + + def test_if_then_else_integer_branch(self): + """Integer should satisfy the else branch.""" + s1 = { + "if": {"type": "string"}, + "then": {"minLength": 1}, + "else": {"type": "integer"} + } + s2 = {"type": "integer"} + self.assertTrue(isSubschema(s2, s1)) + + def test_if_then_else_reject_empty_string(self): + """Empty string should NOT satisfy (if matches string, then requires minLength 1).""" + s1 = { + "if": {"type": "string"}, + "then": {"minLength": 1}, + "else": {"type": "integer"} + } + s2 = {"type": "string"} # includes empty string + self.assertFalse(isSubschema(s2, s1)) + + def test_if_then_no_else(self): + """if/then without else: else defaults to {} (accept everything).""" + s1 = { + "if": {"type": "string"}, + "then": {"minLength": 1} + } + # Integers match the else branch ({} accepts everything) + s2 = {"type": "integer"} + self.assertTrue(isSubschema(s2, s1)) + + def test_if_then_else_with_type(self): + """if/then/else combined with type constraint.""" + s1 = { + "type": "integer", + "if": {"minimum": 0}, + "then": {"maximum": 100}, + "else": {"minimum": -100} + } + # integers in [0, 100] satisfy: if >=0 matches, then <=100 + s2 = {"type": "integer", "minimum": 0, "maximum": 100} + self.assertTrue(isSubschema(s2, s1)) + + +class TestContains(unittest.TestCase): + """Tests for array contains keyword.""" + + def test_contains_basic(self): + """Array with all-string items satisfies contains: string.""" + s1 = {"type": "array", "items": {"type": "string"}} + s2 = {"type": "array", "contains": {"type": "string"}} + self.assertTrue(isSubschema(s1, s2)) + + def test_contains_subtype(self): + """contains with subtype relationship.""" + s1 = {"type": "array", "contains": {"type": "integer"}} + s2 = {"type": "array", "contains": {"type": "number"}} + # integer <: number, so contains integer <: contains number + self.assertTrue(isSubschema(s1, s2)) + + def test_contains_not_subtype(self): + """contains that doesn't satisfy.""" + s1 = {"type": "array", "contains": {"type": "string"}} + s2 = {"type": "array", "contains": {"type": "integer"}} + self.assertFalse(isSubschema(s1, s2)) + + def test_contains_with_items(self): + """Array with tuple items where one matches contains.""" + s1 = { + "type": "array", + "items": [{"type": "integer"}, {"type": "string"}], + "additionalItems": False + } + s2 = {"type": "array", "contains": {"type": "integer"}} + # First item is integer, so contains integer is satisfied + self.assertTrue(isSubschema(s1, s2)) diff --git a/test/test_edge_cases.py b/test/test_edge_cases.py new file mode 100644 index 0000000..4ded4f4 --- /dev/null +++ b/test/test_edge_cases.py @@ -0,0 +1,313 @@ +""" +Test suite for edge cases in JSON subschema checking. + +This module tests edge cases including: +- Empty schemas and bot types +- Deep nesting and recursion limits +- Boundary values (empty arrays, empty objects, empty strings) +- Schema combinations with allOf, anyOf, oneOf, not +- Type combinations and edge cases + +Created to improve test coverage of edge case handling. +""" + +import unittest + +from jsonsubschema import isSubschema + + +class TestEmptySchemas(unittest.TestCase): + """Test empty and trivial schemas.""" + + def test_empty_schema_is_top(self): + """Test empty schema {} accepts all values (top type).""" + s1 = {"type": "string"} + s2 = {} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_not_empty_object(self): + """Test not empty object is very restrictive (accepts nothing).""" + s1 = {"not": {}} + s2 = {"type": "string"} + # not {} accepts nothing (since {} accepts everything) + # So not {} is a subtype of any schema + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + # String type accepts values, so not subtype of impossible schema + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + +class TestBoundaryValues(unittest.TestCase): + """Test boundary value constraints.""" + + def test_empty_array(self): + """Test array with zero items.""" + s1 = {"type": "array", "minItems": 0, "maxItems": 0} + s2 = {"type": "array"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_empty_object(self): + """Test object with zero properties.""" + s1 = {"type": "object", "minProperties": 0, "maxProperties": 0} + s2 = {"type": "object"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_empty_string(self): + """Test string with zero length.""" + s1 = {"type": "string", "minLength": 0, "maxLength": 0} + s2 = {"type": "string"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_zero_number(self): + """Test number range containing only zero.""" + s1 = {"type": "number", "minimum": 0, "maximum": 0} + s2 = {"type": "number"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + +class TestAllOfCombinations(unittest.TestCase): + """Test allOf schema combinations.""" + + def test_allOf_redundant(self): + """Test allOf with redundant constraints.""" + s1 = {"allOf": [{"type": "integer"}, {"type": "integer"}]} + s2 = {"type": "integer"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + def test_allOf_overlapping_ranges(self): + """Test allOf with overlapping number ranges.""" + s1 = { + "allOf": [ + {"type": "integer", "minimum": 0}, + {"type": "integer", "maximum": 100}, + ] + } + s2 = {"type": "integer", "minimum": 0, "maximum": 100} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + def test_allOf_disjoint_types(self): + """Test allOf with disjoint types creates bot (empty set).""" + # allOf with disjoint types creates an impossible schema + s1 = {"allOf": [{"type": "string"}, {"type": "integer"}]} + # This schema accepts nothing, so it's a subtype of any schema + s2 = {"type": "string"} + # s1 is a subtype of any schema (including s2) because it accepts nothing + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + # But s2 is not a subtype of s1 because s2 accepts values while s1 accepts nothing + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_allOf_nested(self): + """Test nested allOf.""" + s1 = {"allOf": [{"allOf": [{"type": "integer"}]}]} + s2 = {"type": "integer"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + +class TestAnyOfCombinations(unittest.TestCase): + """Test anyOf schema combinations.""" + + def test_anyOf_single(self): + """Test anyOf with single schema.""" + s1 = {"anyOf": [{"type": "string"}]} + s2 = {"type": "string"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + def test_anyOf_union(self): + """Test anyOf creates union type.""" + s1 = {"type": "string"} + s2 = {"anyOf": [{"type": "string"}, {"type": "integer"}]} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_anyOf_subset(self): + """Test anyOf where one is subset of another.""" + s1 = {"anyOf": [{"type": "integer", "minimum": 0}]} + s2 = {"anyOf": [{"type": "integer"}]} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_anyOf_nested(self): + """Test nested anyOf.""" + s1 = {"anyOf": [{"anyOf": [{"type": "string"}]}]} + s2 = {"type": "string"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + +class TestOneOfCombinations(unittest.TestCase): + """Test oneOf schema combinations.""" + + def test_oneOf_single(self): + """Test oneOf with single schema.""" + s1 = {"oneOf": [{"type": "string"}]} + s2 = {"type": "string"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + def test_oneOf_disjoint(self): + """Test oneOf with disjoint types.""" + s1 = {"type": "string"} + s2 = {"oneOf": [{"type": "string"}, {"type": "integer"}]} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_oneOf_overlapping_becomes_disjoint(self): + """Test oneOf with overlapping schemas (values in both fail oneOf).""" + s1 = { + "oneOf": [ + {"type": "integer", "minimum": 0}, + {"type": "integer", "maximum": 10}, + ] + } + s2 = {"type": "integer"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + +class TestNotCombinations(unittest.TestCase): + """Test not keyword combinations.""" + + def test_not_not_identity(self): + """Test double negation.""" + s1 = {"not": {"not": {"type": "string"}}} + s2 = {"type": "string"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + def test_not_with_type(self): + """Test not with specific type.""" + s1 = {"not": {"type": "string"}} + s2 = {"type": "integer"} + with self.subTest(): + self.assertFalse(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + +class TestEnumEdgeCases(unittest.TestCase): + """Test enum with edge cases.""" + + def test_enum_single_value(self): + """Test enum with single value.""" + s1 = {"enum": ["value"]} + s2 = {"type": "string"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_enum_subset(self): + """Test enum subset relationship.""" + s1 = {"enum": [1, 2]} + s2 = {"enum": [1, 2, 3, 4]} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_enum_with_type(self): + """Test enum combined with type.""" + s1 = {"type": "integer", "enum": [1, 2, 3]} + s2 = {"type": "integer"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + +class TestMultipleTypes(unittest.TestCase): + """Test schemas with multiple types.""" + + def test_type_array_single(self): + """Test type array with single element.""" + s1 = {"type": ["string"]} + s2 = {"type": "string"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + def test_type_array_subset(self): + """Test type array subset.""" + s1 = {"type": ["string"]} + s2 = {"type": ["string", "integer"]} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_type_array_all_types(self): + """Test type array with all JSON types.""" + s1 = { + "type": [ + "string", + "number", + "integer", + "boolean", + "null", + "array", + "object", + ] + } + s2 = {} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + def test_type_array_with_constraints(self): + """Test type array with constraints on one type.""" + s1 = {"type": ["string", "integer"], "minimum": 10} + s2 = {"type": ["string", "integer"]} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_enum.py b/test/test_enum.py index 04c88c1..259530d 100644 --- a/test/test_enum.py +++ b/test/test_enum.py @@ -95,30 +95,34 @@ def test_enum_regex_string(self): self.assertFalse(isSubschema(s2, s1)) -class TestEnumNotSupported(unittest.TestCase): +class TestEnumArrayObject(unittest.TestCase): - def test_array(self): + def test_array_enum_subtype(self): s1 = {'enum': [[]]} s2 = {'type': 'array'} - with self.subTest(): - self.assertRaises(UnsupportedEnumCanonicalization, - isSubschema, s1, s2) + with self.subTest("empty array enum <: array"): + self.assertTrue(isSubschema(s1, s2)) - with self.subTest(): # To test prining the exception msg - with self.assertRaises(UnsupportedEnumCanonicalization) as ctxt: - isSubschema(s2, s1) - print(ctxt.exception) + with self.subTest("array not <: empty array enum"): + self.assertFalse(isSubschema(s2, s1)) - def test_object(self): + def test_object_enum_subtype(self): s1 = {'enum': [{'foo': 1}]} s2 = {'type': 'object'} - with self.subTest(): - self.assertRaises(UnsupportedEnumCanonicalization, - isSubschema, s1, s2) + with self.subTest("object enum <: object"): + self.assertTrue(isSubschema(s1, s2)) + + with self.subTest("object not <: single object enum"): + self.assertFalse(isSubschema(s2, s1)) + + def test_array_enum_multiple(self): + s1 = {'enum': [[1, 2], [3]]} + s2 = {'type': 'array'} + self.assertTrue(isSubschema(s1, s2)) - with self.subTest(): # To test prining the exception msg - with self.assertRaises(UnsupportedEnumCanonicalization) as ctxt: - isSubschema(s2, s1) - print(ctxt.exception) + def test_object_enum_multiple(self): + s1 = {'enum': [{'a': 1}, {'b': 'x'}]} + s2 = {'type': 'object'} + self.assertTrue(isSubschema(s1, s2)) diff --git a/test/test_exceptions.py b/test/test_exceptions.py new file mode 100644 index 0000000..21a50a9 --- /dev/null +++ b/test/test_exceptions.py @@ -0,0 +1,204 @@ +""" +Created on Jan 18, 2026 +Test coverage for all exception classes in jsonsubschema.exceptions + +This test suite ensures that all custom exceptions are properly raised +under the appropriate conditions: +- UnsupportedRecursiveRef: Recursive $ref handling +- UnsupportedNegatedArray: Negation of arrays with constraints +- UnsupportedNegatedObject: Negation of objects with constraints +- UnsupportedEnumCanonicalization: Enum canonicalization for certain types +""" + +import unittest + +from jsonsubschema import isSubschema +from jsonsubschema.exceptions import ( + UnsupportedRecursiveRef, + UnsupportedNegatedArray, + UnsupportedNegatedObject, + UnsupportedEnumCanonicalization, +) + + +class TestUnsupportedRecursiveRef(unittest.TestCase): + """Test cases for UnsupportedRecursiveRef exception""" + + def test_recursive_ref_in_rhs(self): + """Recursive $ref in RHS triggers recursion detection during canonicalization""" + s1 = {"enum": [None]} + s2 = { + "definitions": { + "person": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "children": { + "type": "array", + "items": {"$ref": "#/definitions/person"}, + "default": [], + }, + }, + } + }, + "type": "object", + "properties": {"person": {"$ref": "#/definitions/person"}}, + } + + with self.assertRaises(UnsupportedRecursiveRef) as cm: + isSubschema(s1, s2) + + self.assertIn("Recursive schemas are not supported", str(cm.exception)) + self.assertEqual(cm.exception.which_side, "RHS") + + +class TestUnsupportedNegatedArray(unittest.TestCase): + """Test cases for UnsupportedNegatedArray exception""" + + def test_negation_of_array_with_items(self): + """Negating an array schema with items constraint should raise exception""" + s1 = {"type": "string"} + s2 = {"not": {"type": "array", "items": {"type": "string"}}} + + with self.assertRaises(UnsupportedNegatedArray) as cm: + isSubschema(s1, s2) + + self.assertIn("Array negation", str(cm.exception)) + + def test_negation_of_array_with_minitems_now_supported(self): + """Negating an array schema with minItems is now supported""" + s1 = {"type": "integer"} + s2 = {"not": {"type": "array", "minItems": 1}} + # integer is not an array, so always subtype of not(array) + self.assertTrue(isSubschema(s1, s2)) + + def test_negation_of_array_with_maxitems_now_supported(self): + """Negating an array schema with maxItems is now supported""" + s1 = {"type": "boolean"} + s2 = {"not": {"type": "array", "maxItems": 5}} + # boolean is not an array, so always subtype of not(array) + self.assertTrue(isSubschema(s1, s2)) + + def test_negation_of_array_with_uniqueitems(self): + """Negating an array schema with uniqueItems should raise exception""" + s1 = {"type": "null"} + s2 = {"not": {"type": "array", "uniqueItems": True}} + + with self.assertRaises(UnsupportedNegatedArray) as cm: + isSubschema(s1, s2) + + def test_negation_of_plain_array_succeeds(self): + """Negating a plain array without constraints should succeed""" + s1 = {"type": "string"} + s2 = {"not": {"type": "array"}} + + result = isSubschema(s1, s2) + self.assertTrue(result) + + +class TestUnsupportedNegatedObject(unittest.TestCase): + """Test cases for UnsupportedNegatedObject exception""" + + def test_negation_of_object_with_properties(self): + """Negating an object schema with properties should raise exception""" + s1 = {"type": "string"} + s2 = {"not": {"type": "object", "properties": {"name": {"type": "string"}}}} + + with self.assertRaises(UnsupportedNegatedObject) as cm: + isSubschema(s1, s2) + + self.assertIn("Object negation", str(cm.exception)) + + def test_negation_of_object_with_required(self): + """Negating an object schema with required should raise exception""" + s1 = {"type": "integer"} + s2 = {"not": {"type": "object", "required": ["id"]}} + + with self.assertRaises(UnsupportedNegatedObject) as cm: + isSubschema(s1, s2) + + def test_negation_of_object_with_additionalproperties(self): + """Negating an object schema with additionalProperties should raise exception""" + s1 = {"type": "boolean"} + s2 = {"not": {"type": "object", "additionalProperties": False}} + + with self.assertRaises(UnsupportedNegatedObject) as cm: + isSubschema(s1, s2) + + def test_negation_of_object_with_minproperties_now_supported(self): + """Negating an object schema with minProperties is now supported""" + s1 = {"type": "null"} + s2 = {"not": {"type": "object", "minProperties": 1}} + # null is not an object, so always subtype of not(object) + self.assertTrue(isSubschema(s1, s2)) + + def test_negation_of_object_with_maxproperties_now_supported(self): + """Negating an object schema with maxProperties is now supported""" + s1 = {"type": "array"} + s2 = {"not": {"type": "object", "maxProperties": 10}} + # array is not an object, so always subtype of not(object) + self.assertTrue(isSubschema(s1, s2)) + + def test_negation_of_plain_object_succeeds(self): + """Negating a plain object without constraints should succeed""" + s1 = {"type": "string"} + s2 = {"not": {"type": "object"}} + + result = isSubschema(s1, s2) + self.assertTrue(result) + + +class TestUnsupportedEnumCanonicalization(unittest.TestCase): + """Test cases for UnsupportedEnumCanonicalization exception""" + + def test_array_enum_canonicalization_now_supported(self): + """Enum with array type is now supported via pushdown""" + s1 = {"type": "array", "enum": [[1, 2], [3, 4, 5]]} + s2 = {"type": "array"} + + result = isSubschema(s1, s2) + self.assertTrue(result) + + def test_object_enum_canonicalization_now_supported(self): + """Enum with object type is now supported via pushdown""" + s1 = {"type": "object", "enum": [{"name": "Alice"}, {"name": "Bob", "age": 30}]} + s2 = {"type": "object"} + + result = isSubschema(s1, s2) + self.assertTrue(result) + + def test_integer_enum_succeeds(self): + """Enum with integer type should succeed (supported)""" + s1 = {"type": "integer", "enum": [1, 2, 3]} + s2 = {"type": "integer"} + + result = isSubschema(s1, s2) + self.assertTrue(result) + + def test_string_enum_succeeds(self): + """Enum with string type should succeed (supported)""" + s1 = {"type": "string", "enum": ["foo", "bar"]} + s2 = {"type": "string"} + + result = isSubschema(s1, s2) + self.assertTrue(result) + + def test_boolean_enum_succeeds(self): + """Enum with boolean type should succeed (supported)""" + s1 = {"type": "boolean", "enum": [True, False]} + s2 = {"type": "boolean"} + + result = isSubschema(s1, s2) + self.assertTrue(result) + + def test_null_enum_succeeds(self): + """Enum with null type should succeed (supported)""" + s1 = {"type": "null", "enum": [None]} + s2 = {"type": "null"} + + result = isSubschema(s1, s2) + self.assertTrue(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_explain.py b/test/test_explain.py new file mode 100644 index 0000000..bbfbc8a --- /dev/null +++ b/test/test_explain.py @@ -0,0 +1,101 @@ +import unittest + +from jsonsubschema import is_subschema_with_reason, SubschemaResult + + +class TestExplainAPI(unittest.TestCase): + def test_subschema_returns_true_with_empty_reasons(self): + s1 = {"type": "integer"} + s2 = {"type": ["integer", "string"]} + result = is_subschema_with_reason(s1, s2) + + self.assertIsInstance(result, SubschemaResult) + self.assertTrue(result.is_subtype) + self.assertEqual(result.reasons, []) + + def test_result_usable_in_boolean_context(self): + s1 = {"type": "integer"} + s2 = {"type": ["integer", "string"]} + result = is_subschema_with_reason(s1, s2) + + self.assertTrue(result) + self.assertTrue(bool(result)) + + def test_not_subschema_returns_false(self): + s1 = {"type": "string"} + s2 = {"type": "integer"} + result = is_subschema_with_reason(s1, s2) + + self.assertIsInstance(result, SubschemaResult) + self.assertFalse(result.is_subtype) + + def test_not_subschema_usable_in_boolean_context(self): + s1 = {"type": "string"} + s2 = {"type": "integer"} + result = is_subschema_with_reason(s1, s2) + + self.assertFalse(result) + self.assertFalse(bool(result)) + + def test_numeric_constraint_failure_captured(self): + s1 = {"type": "integer", "minimum": 0, "maximum": 100} + s2 = {"type": "integer", "minimum": 0, "maximum": 50} + result = is_subschema_with_reason(s1, s2) + + self.assertFalse(result.is_subtype) + self.assertGreater(len(result.reasons), 0) + reason_text = " ".join(result.reasons) + self.assertIn("num__", reason_text) + + def test_array_items_failure_captured(self): + s1 = {"type": "array", "items": {"type": "integer"}} + s2 = {"type": "array", "items": {"type": "integer", "maximum": 10}} + result = is_subschema_with_reason(s1, s2) + + self.assertFalse(result.is_subtype) + self.assertGreater(len(result.reasons), 0) + + def test_object_property_constraint_failure_captured(self): + s1 = { + "type": "object", + "properties": {"count": {"type": "integer"}}, + "required": ["count"], + } + s2 = { + "type": "object", + "properties": {"count": {"type": "integer", "maximum": 10}}, + "required": ["count"], + } + result = is_subschema_with_reason(s1, s2) + + self.assertFalse(result.is_subtype) + self.assertGreater(len(result.reasons), 0) + + def test_identical_schemas_are_subtypes(self): + s1 = {"type": "object", "properties": {"id": {"type": "integer"}}} + s2 = {"type": "object", "properties": {"id": {"type": "integer"}}} + result = is_subschema_with_reason(s1, s2) + + self.assertTrue(result.is_subtype) + self.assertEqual(result.reasons, []) + + +class TestSubschemaResultDataclass(unittest.TestCase): + def test_result_fields(self): + result = SubschemaResult(is_subtype=True, reasons=[]) + self.assertTrue(result.is_subtype) + self.assertEqual(result.reasons, []) + + def test_result_with_reasons(self): + reasons = ["reason1", "reason2"] + result = SubschemaResult(is_subtype=False, reasons=reasons) + self.assertFalse(result.is_subtype) + self.assertEqual(result.reasons, reasons) + + def test_default_reasons_is_empty_list(self): + result = SubschemaResult(is_subtype=True) + self.assertEqual(result.reasons, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_nontrivial.py b/test/test_nontrivial.py new file mode 100644 index 0000000..906cbbd --- /dev/null +++ b/test/test_nontrivial.py @@ -0,0 +1,355 @@ +""" +Test suite for nontrivial JSON subschema relationships. + +This module tests complex real-world scenarios including: +- Realistic schema evolution scenarios +- Complex nested structures +- Multiple constraint interactions +- Practical API versioning cases + +Created to ensure the library handles real-world schema relationships correctly. +""" + +import unittest + +from jsonsubschema import isSubschema + + +class TestSchemaEvolution(unittest.TestCase): + """Test realistic schema evolution scenarios.""" + + def test_api_backward_compatible_addition(self): + """Test adding optional field maintains backward compatibility.""" + old_version = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + } + new_version = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"}, + }, + "required": ["name"], + } + with self.subTest(): + self.assertFalse(isSubschema(old_version, new_version)) + with self.subTest(): + self.assertTrue(isSubschema(new_version, old_version)) + + def test_api_breaking_change_required_field(self): + """Test adding required field breaks backward compatibility.""" + old_version = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + } + new_version = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"}, + }, + "required": ["name", "email"], + } + with self.subTest(): + self.assertFalse(isSubschema(old_version, new_version)) + with self.subTest(): + self.assertTrue(isSubschema(new_version, old_version)) + + def test_api_relaxing_constraint(self): + """Test relaxing constraint is backward compatible.""" + strict_schema = { + "type": "object", + "properties": {"age": {"type": "integer", "minimum": 0, "maximum": 120}}, + } + relaxed_schema = { + "type": "object", + "properties": {"age": {"type": "integer", "minimum": 0}}, + } + with self.subTest(): + self.assertTrue(isSubschema(strict_schema, relaxed_schema)) + with self.subTest(): + self.assertFalse(isSubschema(relaxed_schema, strict_schema)) + + def test_api_narrowing_type_union(self): + """Test narrowing type union breaks backward compatibility.""" + wide_union = { + "type": "object", + "properties": {"value": {"type": ["string", "integer", "null"]}}, + } + narrow_union = { + "type": "object", + "properties": {"value": {"type": ["string", "integer"]}}, + } + with self.subTest(): + self.assertTrue(isSubschema(narrow_union, wide_union)) + with self.subTest(): + self.assertFalse(isSubschema(wide_union, narrow_union)) + + +class TestNestedStructures(unittest.TestCase): + """Test complex nested schema structures.""" + + def test_deeply_nested_objects(self): + """Test deeply nested object structures.""" + s1 = { + "type": "object", + "properties": { + "level1": { + "type": "object", + "properties": { + "level2": { + "type": "object", + "properties": { + "level3": { + "type": "object", + "properties": {"value": {"type": "integer"}}, + "required": ["value"], + } + }, + "required": ["level3"], + } + }, + "required": ["level2"], + } + }, + "required": ["level1"], + } + s2 = { + "type": "object", + "properties": { + "level1": { + "type": "object", + "properties": { + "level2": { + "type": "object", + "properties": { + "level3": {"type": "object", "properties": {}} + }, + } + }, + } + }, + } + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_array_of_complex_objects(self): + """Test array containing complex object schemas.""" + s1 = { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string", "minLength": 1}, + "tags": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["id", "name"], + }, + "minItems": 1, + } + s2 = { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + }, + } + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_mixed_allOf_anyOf_combination(self): + """Test complex combination of allOf and anyOf.""" + s1 = { + "allOf": [ + {"type": "integer", "minimum": 10, "maximum": 50}, + {"anyOf": [{"maximum": 20}, {"minimum": 40}]}, + ] + } + s2 = {"type": "integer", "minimum": 10, "maximum": 50} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + +class TestMultiConstraintInteraction(unittest.TestCase): + """Test interactions between multiple constraints.""" + + def test_string_all_constraints(self): + """Test string with multiple constraints combined.""" + s1 = { + "type": "string", + "minLength": 5, + "maxLength": 10, + } + s2 = {"type": "string", "minLength": 3, "maxLength": 15} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_number_multiple_of_with_range(self): + """Test number with multipleOf and range constraints.""" + s1 = { + "type": "integer", + "minimum": 10, + "maximum": 100, + "multipleOf": 5, + } + s2 = {"type": "integer", "minimum": 0, "maximum": 200} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_array_all_constraints(self): + """Test array with multiple constraints combined.""" + s1 = { + "type": "array", + "items": {"type": "integer", "minimum": 0}, + "minItems": 2, + "maxItems": 5, + "uniqueItems": True, + } + s2 = { + "type": "array", + "items": {"type": "integer"}, + "minItems": 1, + "maxItems": 10, + } + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_object_all_constraints(self): + """Test object with multiple constraints combined.""" + s1 = { + "type": "object", + "properties": { + "a": {"type": "string"}, + "b": {"type": "integer"}, + }, + "required": ["a", "b"], + "minProperties": 2, + "maxProperties": 3, + "additionalProperties": {"type": "boolean"}, + } + s2 = { + "type": "object", + "properties": { + "a": {"type": "string"}, + "b": {"type": "integer"}, + }, + "required": ["a"], + } + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + +class TestRealWorldPatterns(unittest.TestCase): + """Test patterns from real-world JSON schemas.""" + + def test_json_rpc_request(self): + """Test JSON-RPC 2.0 request schema evolution.""" + basic_request = { + "type": "object", + "properties": { + "jsonrpc": {"type": "string", "enum": ["2.0"]}, + "method": {"type": "string"}, + "id": {"type": ["string", "integer", "null"]}, + }, + "required": ["jsonrpc", "method", "id"], + } + extended_request = { + "type": "object", + "properties": { + "jsonrpc": {"type": "string", "enum": ["2.0"]}, + "method": {"type": "string"}, + "params": { + "anyOf": [ + {"type": "array"}, + {"type": "object"}, + ] + }, + "id": {"type": ["string", "integer", "null"]}, + }, + "required": ["jsonrpc", "method", "id"], + } + with self.subTest(): + self.assertFalse(isSubschema(basic_request, extended_request)) + with self.subTest(): + self.assertTrue(isSubschema(extended_request, basic_request)) + + def test_geojson_point(self): + """Test GeoJSON Point schema.""" + strict_point = { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["Point"]}, + "coordinates": { + "type": "array", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 3, + }, + }, + "required": ["type", "coordinates"], + } + relaxed_point = { + "type": "object", + "properties": { + "type": {"type": "string"}, + "coordinates": {"type": "array", "items": {"type": "number"}}, + }, + "required": ["type", "coordinates"], + } + with self.subTest(): + self.assertTrue(isSubschema(strict_point, relaxed_point)) + with self.subTest(): + self.assertFalse(isSubschema(relaxed_point, strict_point)) + + def test_package_json_dependencies(self): + """Test package.json dependencies schema.""" + s1 = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "version": {"type": "string", "pattern": r"^\d+\.\d+\.\d+$"}, + "dependencies": { + "type": "object", + "patternProperties": {".*": {"type": "string"}}, + "additionalProperties": False, + }, + }, + "required": ["name", "version"], + } + s2 = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "version": {"type": "string"}, + }, + "required": ["name", "version"], + } + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_number_float.py b/test/test_number_float.py new file mode 100644 index 0000000..e26f9be --- /dev/null +++ b/test/test_number_float.py @@ -0,0 +1,267 @@ +""" +Test suite for float/number type handling in JSON subschema checking. + +This module tests floating-point number specific behaviors including: +- Float subtype relationships with exclusive and inclusive bounds +- Float multipleOf constraints and precision handling +- Number vs integer type distinctions +- Edge cases (empty ranges, single-point ranges, precision issues) + +Created to improve test coverage of JSONTypeNumber class and float handling +in the jsonsubschema library. +""" + +import unittest +from jsonschema.exceptions import SchemaError + +from jsonsubschema import isSubschema + + +class TestFloatSubtypeRelationships(unittest.TestCase): + """Test float subtype relationships with various bound configurations.""" + + def test_float_exclusive_bounds_subtype(self): + """Test that exclusive bounds create proper subtype relationship.""" + s1 = { + "type": "number", + "minimum": 5.0, + "exclusiveMinimum": True, + "maximum": 10.0, + "exclusiveMaximum": True, + } + s2 = {"type": "number", "minimum": 5.0, "maximum": 10.0} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_very_small_floats(self): + """Test subtyping with very small float values.""" + s1 = {"type": "number", "minimum": 0.0001, "maximum": 0.001} + s2 = {"type": "number", "minimum": 0.0, "maximum": 0.01} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_very_large_floats(self): + """Test subtyping with very large float values.""" + s1 = {"type": "number", "minimum": 1e10, "maximum": 1e11} + s2 = {"type": "number", "minimum": 1e9, "maximum": 1e12} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_negative_floats_with_bounds(self): + """Test subtyping with negative float bounds.""" + s1 = {"type": "number", "minimum": -10.5, "maximum": -5.5} + s2 = {"type": "number", "minimum": -15.0, "maximum": -5.0} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_float_vs_integer_constraints(self): + """Test float type with integer-valued constraints.""" + s1 = {"type": "number", "minimum": 5.0, "maximum": 10.0} + s2 = {"type": "number", "minimum": 5, "maximum": 10} + # These should be equivalent despite different numeric representation + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + +class TestFloatMultipleOf(unittest.TestCase): + """Test multipleOf constraints with floating-point numbers.""" + + def test_float_multipleOf_half(self): + """Test multipleOf with 0.5 constraint.""" + s1 = {"type": "number", "multipleOf": 1.0} + s2 = {"type": "number", "multipleOf": 0.5} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_float_multipleOf_decimal_precision(self): + """Test multipleOf with 0.1 (tests decimal precision issues).""" + s1 = {"type": "number", "multipleOf": 0.2} + s2 = {"type": "number", "multipleOf": 0.1} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_float_multipleOf_rational(self): + """Test multipleOf with rational number relationships.""" + s1 = {"type": "number", "multipleOf": 4.5} + s2 = {"type": "number", "multipleOf": 1.5} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_negative_multipleOf_invalid(self): + """Test that negative multipleOf is invalid.""" + s1 = {"type": "number", "multipleOf": 0.5} + s2 = {"type": "number", "multipleOf": -0.5} + self.assertRaises(SchemaError, isSubschema, s1, s2) + + def test_float_multipleOf_with_bounds(self): + """Test multipleOf combined with min/max constraints.""" + s1 = {"type": "number", "multipleOf": 0.5, "minimum": 1.0, "maximum": 5.0} + s2 = {"type": "number", "minimum": 0.0, "maximum": 10.0} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_float_multipleOf_with_integer_constraints(self): + """Test float multipleOf that happens to be an integer.""" + s1 = {"type": "number", "multipleOf": 2.0} + s2 = {"type": "number", "multipleOf": 1.0} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + +class TestNumberVsIntegerDistinction(unittest.TestCase): + """Test the distinction between number and integer types.""" + + def test_number_supertype_of_integer(self): + """Test that number type is a supertype of integer type.""" + s1 = {"type": "integer"} + s2 = {"type": "number"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_number_with_integer_constraints(self): + """Test number type with constraints that match integer.""" + s1 = {"type": "number", "minimum": 5.5} + s2 = {"type": "integer", "minimum": 6} + # s1 allows 5.5, 5.6, ... which are not integers + # s2 allows 6, 7, 8, ... which are all in s1's range + # So s2 <: s1 (integer min 6 is subtype of number min 5.5) + with self.subTest(): + self.assertFalse(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + def test_number_multipleOf_one(self): + """Test number with multipleOf 1.0 (integer equivalent multipleOf).""" + s1 = {"type": "number", "multipleOf": 1.0} + s2 = {"type": "integer"} + # multipleOf 1.0 is considered integer-equivalent, so special handling applies + # According to implementation, number with int-equiv multipleOf is subtype of integer + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + def test_integer_multipleOf_vs_number_multipleOf(self): + """Test integer multipleOf compared to number multipleOf.""" + s1 = {"type": "integer", "multipleOf": 2} + s2 = {"type": "number", "multipleOf": 2.0} + # Integer with multipleOf 2 is a subtype of number with multipleOf 2.0 + # And number with multipleOf 2.0 is also subtype of integer (int-equiv) + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + def test_allOf_combining_integer_and_number(self): + """Test allOf that combines integer and number constraints.""" + s1 = {"allOf": [{"type": "integer"}, {"type": "number", "minimum": 5.0}]} + s2 = {"type": "integer", "minimum": 5} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + +class TestFloatEdgeCases(unittest.TestCase): + """Test edge cases in float handling.""" + + def test_empty_range_minimum_greater_than_maximum(self): + """Test that min > max creates empty type (no valid values).""" + s1 = {"type": "number", "minimum": 10.0, "maximum": 5.0} + s2 = {"type": "number"} + # Empty range is subtype of any number schema + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + + def test_single_point_range(self): + """Test range where min == max (single value).""" + s1 = {"type": "number", "minimum": 7.5, "maximum": 7.5} + s2 = {"type": "number", "minimum": 7.0, "maximum": 8.0} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_exclusive_single_point_range(self): + """Test exclusive bounds that create empty range.""" + s1 = { + "type": "number", + "minimum": 5.0, + "exclusiveMinimum": True, + "maximum": 5.0, + "exclusiveMaximum": True, + } + s2 = {"type": "number"} + # Empty range (5.0 < x < 5.0 has no solutions) + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + + def test_very_close_bounds(self): + """Test bounds that are very close together.""" + s1 = {"type": "number", "minimum": 1.0, "maximum": 1.0001} + s2 = {"type": "number", "minimum": 0.9999, "maximum": 1.0002} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + +class TestFloatMultipleOfAdvanced(unittest.TestCase): + """Advanced multipleOf tests with floats.""" + + def test_multipleOf_with_exclusive_bounds(self): + """Test multipleOf combined with exclusive bounds.""" + s1 = { + "type": "number", + "multipleOf": 0.5, + "minimum": 1.0, + "exclusiveMinimum": True, + "maximum": 5.0, + "exclusiveMaximum": True, + } + s2 = {"type": "number", "minimum": 0.0, "maximum": 10.0} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_multipleOf_small_values(self): + """Test multipleOf with very small values.""" + s1 = {"type": "number", "multipleOf": 0.01} + s2 = {"type": "number", "multipleOf": 0.001} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_zero_multipleOf_invalid(self): + """Test that multipleOf 0 is invalid.""" + s1 = {"type": "number", "multipleOf": 0} + s2 = {"type": "number"} + self.assertRaises(SchemaError, isSubschema, s1, s2) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_numeric.py b/test/test_numeric.py index 9292d74..488fc5a 100644 --- a/test/test_numeric.py +++ b/test/test_numeric.py @@ -682,6 +682,51 @@ def test_enum4(self): self.assertFalse(isSubschema(s2, s1)) +class TestMultipleOfNegation(unittest.TestCase): + """Tests for multipleOf handling in negation (not schemas).""" + + def test_not_integer_multipleOf_bounded(self): + """not({integer, min:0, max:10, multipleOf:3}) should accept 1,2,4,5,7,8,10""" + s1 = {"type": "integer", "minimum": 1, "maximum": 2} + s2 = {"not": {"type": "integer", "minimum": 0, "maximum": 10, "multipleOf": 3}} + # {1,2} are not multiples of 3, so they should be accepted by not(multiples of 3 in [0,10]) + self.assertTrue(isSubschema(s1, s2)) + + def test_not_integer_multipleOf_bounded_reject(self): + """A multiple of 3 in [0,10] should NOT be a subtype of not(that).""" + s1 = {"type": "integer", "minimum": 6, "maximum": 6} + s2 = {"not": {"type": "integer", "minimum": 0, "maximum": 10, "multipleOf": 3}} + # 6 IS a multiple of 3 in [0,10], so it should NOT be accepted + self.assertFalse(isSubschema(s1, s2)) + + def test_not_integer_multipleOf_only_bounds(self): + """not({integer, min:0, max:5, multipleOf:2}) — complement includes odd numbers.""" + s1 = {"type": "integer", "minimum": 1, "maximum": 1} + s2 = {"not": {"type": "integer", "minimum": 0, "maximum": 5, "multipleOf": 2}} + # 1 is not a multiple of 2, so should be accepted + self.assertTrue(isSubschema(s1, s2)) + + def test_not_number_multipleOf_bounded(self): + """not({number, min:0, max:10, multipleOf:5}) — complement includes non-multiples.""" + s1 = {"type": "number", "minimum": 1, "maximum": 4} + s2 = {"not": {"type": "number", "minimum": 0, "maximum": 10, "multipleOf": 5}} + # (1,4) contains no multiples of 5, so should be subtype + self.assertTrue(isSubschema(s1, s2)) + + def test_not_number_multipleOf_bounded_reject(self): + """A range containing a multiple of 5 should NOT be subtype of not(that).""" + s1 = {"type": "number", "minimum": 4, "maximum": 6} + s2 = {"not": {"type": "number", "minimum": 0, "maximum": 10, "multipleOf": 5}} + # [4,6] contains 5 which is a multiple of 5 + self.assertFalse(isSubschema(s1, s2)) + + def test_not_integer_multipleOf_string_is_subtype(self): + """A string type is always subtype of not(integer).""" + s1 = {"type": "string"} + s2 = {"not": {"type": "integer", "multipleOf": 3}} + self.assertTrue(isSubschema(s1, s2)) + + class TestNumericUtils(unittest.TestCase): def test_float_gcd(self): assert float_gcd(0.6, 0.4) == 0.2 diff --git a/test/test_pattern_properties.py b/test/test_pattern_properties.py new file mode 100644 index 0000000..4020395 --- /dev/null +++ b/test/test_pattern_properties.py @@ -0,0 +1,419 @@ +""" +Test suite for patternProperties keyword in JSON subschema checking. + +This module tests patternProperties-specific behaviors including: +- Pattern matching and regex subset relationships +- Interaction with properties and additionalProperties +- Multiple pattern properties with overlapping patterns +- Edge cases (empty patterns, complex regexes, anchoring) + +Created to improve test coverage of patternProperties handling in object types. +""" + +import unittest + +from jsonsubschema import isSubschema + + +class TestPatternPropertiesBasic(unittest.TestCase): + """Test basic patternProperties subtype relationships.""" + + def test_empty_vs_with_pattern(self): + """Test object without patternProperties vs with patternProperties.""" + s1 = {"type": "object"} + s2 = {"type": "object", "patternProperties": {"^num": {"type": "number"}}} + with self.subTest(): + self.assertFalse(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + def test_same_pattern_same_type(self): + """Test identical patternProperties are equivalent.""" + s1 = {"type": "object", "patternProperties": {"^str": {"type": "string"}}} + s2 = {"type": "object", "patternProperties": {"^str": {"type": "string"}}} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + def test_same_pattern_subtype_values(self): + """Test same pattern with subtype value schemas.""" + s1 = {"type": "object", "patternProperties": {"^num": {"type": "integer"}}} + s2 = {"type": "object", "patternProperties": {"^num": {"type": "number"}}} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_pattern_with_constraints(self): + """Test patternProperties with value constraints.""" + s1 = { + "type": "object", + "patternProperties": {"^age": {"type": "integer", "minimum": 18}}, + } + s2 = { + "type": "object", + "patternProperties": {"^age": {"type": "integer", "minimum": 0}}, + } + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + +class TestPatternRegexSubsets(unittest.TestCase): + """Test regex pattern subset relationships.""" + + def test_pattern_with_value_subtyping(self): + """Test same pattern with value type subtyping.""" + s1 = {"type": "object", "patternProperties": {"b.*b": {"type": "integer"}}} + s2 = {"type": "object", "patternProperties": {"b.*b": {"type": "number"}}} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_pattern_with_different_types(self): + """Test same pattern with incompatible value types.""" + s1 = {"type": "object", "patternProperties": {"b.*b": {"type": "integer"}}} + s2 = {"type": "object", "patternProperties": {"^ba+b$": {"type": "boolean"}}} + with self.subTest(): + self.assertFalse(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_disjoint_patterns(self): + """Test non-overlapping patterns.""" + s1 = {"type": "object", "patternProperties": {"^str": {"type": "string"}}} + s2 = {"type": "object", "patternProperties": {"^num": {"type": "number"}}} + with self.subTest(): + self.assertFalse(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_anchored_vs_unanchored(self): + """Test anchored pattern vs unanchored pattern. + + Note: Library applies regex_unanchor() which transforms: + "^test$" -> "test" (exact match only "test") + "test" -> ".*test.*" (matches anything containing "test") + Therefore "test" ⊂ ".*test.*", so anchored <: unanchored. + """ + s1 = {"type": "object", "patternProperties": {"^test$": {"type": "boolean"}}} + s2 = {"type": "object", "patternProperties": {"test": {"type": "boolean"}}} + with self.subTest(): + self.assertFalse(isSubschema(s1, s2)) # "test" ⊄ ".*test.*" + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) # ".*test.*" ⊃ "test" + + def test_specific_vs_wildcard(self): + """Test specific character class vs wildcard. + + Note: Pattern property subset checking uses complex logic (lines 1372-1389). + When s1 pattern is NOT a regex-subset of s2 pattern, s2 is treated as "extra" + and the check fails due to infinite pattern restriction (line 1388). + This produces counterintuitive results where wider patterns appear narrower. + """ + s1 = {"type": "object", "patternProperties": {"^[0-9]+$": {"type": "number"}}} + s2 = {"type": "object", "patternProperties": {"^.+$": {"type": "number"}}} + with self.subTest(): + self.assertFalse(isSubschema(s1, s2)) # Library returns False + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) # Library returns True + + def test_disjoint_patterns_second(self): + """Test non-overlapping patterns (duplicate removed).""" + s1 = {"type": "object", "patternProperties": {"^str": {"type": "string"}}} + s2 = {"type": "object", "patternProperties": {"^num": {"type": "number"}}} + with self.subTest(): + self.assertFalse(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + +class TestPatternPropertiesWithProperties(unittest.TestCase): + """Test interaction between properties and patternProperties.""" + + def test_pattern_restricts_additional_properties(test): + """Test patternProperties with additionalProperties interaction.""" + s1 = { + "type": "object", + "properties": {"email": {"type": "string"}, "emaik": {"type": "string"}}, + "additionalProperties": {"type": "string"}, + } + s2 = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "patternProperties": {"emai": {"type": "string"}}, + } + with test.subTest(): + test.assertTrue(isSubschema(s1, s2)) + with test.subTest(): + test.assertFalse(isSubschema(s2, s1)) + + def test_combined_properties_and_patterns(self): + """Test schema with both properties and patternProperties.""" + s1 = { + "type": "object", + "properties": {"id": {"type": "integer"}}, + "patternProperties": {"^data_": {"type": "string"}}, + } + s2 = {"type": "object"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + +class TestPatternPropertiesWithAdditionalProperties(unittest.TestCase): + """Test interaction between patternProperties and additionalProperties.""" + + def test_pattern_with_additional_false(self): + """Test patternProperties with additionalProperties: false.""" + s1 = { + "type": "object", + "patternProperties": {"^allowed": {"type": "string"}}, + "additionalProperties": False, + } + s2 = {"type": "object", "patternProperties": {"^allowed": {"type": "string"}}} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_pattern_with_additional_schema(self): + """Test patternProperties with additionalProperties schema.""" + s1 = { + "type": "object", + "patternProperties": {"^num_": {"type": "integer"}}, + "additionalProperties": {"type": "string"}, + } + s2 = {"type": "object"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_additional_properties_compatibility(self): + """Test additionalProperties restricts unmatched keys.""" + s1 = { + "type": "object", + "properties": {"email": {"type": "string"}, "emaik": {"type": "string"}}, + "additionalProperties": {"type": "boolean"}, + } + s2 = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "patternProperties": {"emai": {"type": "string", "minLength": 10}}, + } + with self.subTest(): + self.assertFalse(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + +class TestMultiplePatternProperties(unittest.TestCase): + """Test multiple patternProperties in same schema.""" + + def test_multiple_disjoint_patterns(self): + """Test multiple non-overlapping patterns.""" + s1 = { + "type": "object", + "patternProperties": { + "^str_": {"type": "string"}, + "^num_": {"type": "number"}, + }, + } + s2 = {"type": "object"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_subset_and_superset_patterns(self): + """Test one pattern is subset of another in same schema.""" + s1 = { + "type": "object", + "patternProperties": { + "test": {"type": "integer"}, + "^test_strict$": {"type": "integer", "minimum": 0}, + }, + } + s2 = {"type": "object", "patternProperties": {"test": {"type": "integer"}}} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_multiple_patterns_same_type(self): + """Test multiple patterns with same value type.""" + s1 = { + "type": "object", + "patternProperties": { + "^data_": {"type": "string"}, + "info": {"type": "string"}, + }, + } + s2 = {"type": "object", "patternProperties": {"data": {"type": "string"}}} + with self.subTest(): + self.assertFalse(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_subset_and_superset_patterns(self): + """Test one pattern is subset of another in same schema.""" + s1 = { + "type": "object", + "patternProperties": { + "test": {"type": "integer"}, + "^test_strict$": {"type": "integer", "minimum": 0}, + }, + } + s2 = {"type": "object", "patternProperties": {"test": {"type": "integer"}}} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_multiple_overlapping_patterns(self): + """Test multiple overlapping patterns. + + WARNING: This test takes 30+ seconds due to expensive regex subset computation. + The pattern combination "^data" + "data_" triggers worst-case performance in greenery library. + + Note: Complex multi-pattern combinations may not produce intuitive subset relationships. + """ + s1 = { + "type": "object", + "patternProperties": { + "^data": {"type": "string"}, + "data_": {"type": "string"}, + }, + } + s2 = {"type": "object", "patternProperties": {"data": {"type": "string"}}} + with self.subTest(): + self.assertFalse(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + +class TestPatternPropertiesEdgeCases(unittest.TestCase): + """Test edge cases in patternProperties handling.""" + + def test_dot_star_pattern(self): + """Test .* pattern matches all keys.""" + s1 = {"type": "object", "patternProperties": {".*": {"type": "boolean"}}} + s2 = {"type": "object", "patternProperties": {"^.*$": {"type": "boolean"}}} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + def test_pattern_with_integer_constraints(self): + """Test pattern with constrained value type.""" + s1 = {"type": "object", "patternProperties": {"b.*b": {"type": "integer"}}} + s2 = { + "type": "object", + "patternProperties": {"^b(\\w)+b$": {"type": "integer", "minimum": 10}}, + } + with self.subTest(): + self.assertFalse(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_pattern_with_word_boundaries(self): + """Test pattern with word character class.""" + s1 = { + "type": "object", + "patternProperties": {"^[a-zA-Z]+$": {"type": "string"}}, + } + s2 = {"type": "object", "patternProperties": {"[a-z]": {"type": "string"}}} + with self.subTest(): + self.assertFalse(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_complex_regex_character_classes(self): + """Test complex regex with character classes. + + Note: When both schemas have single, finite-cardinality patterns, + the patternProperties logic treats them more permissively. + Both directions return TRUE despite regex subset relationship being one-way. + """ + s1 = { + "type": "object", + "patternProperties": {"^[a-z][A-Z][0-9]$": {"type": "null"}}, + } + s2 = {"type": "object", "patternProperties": {"^...$": {"type": "null"}}} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + def test_pattern_with_alternation(self): + """Test regex with alternation (|). + + Note: Similar to test_complex_regex_character_classes, both directions + return TRUE when patterns have finite cardinality. + """ + s1 = { + "type": "object", + "patternProperties": {"^(foo|bar)$": {"type": "string"}}, + } + s2 = { + "type": "object", + "patternProperties": {"^(foo|bar|baz)$": {"type": "string"}}, + } + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + def test_pattern_with_quantifiers(self): + """Test regex with quantifiers (+, *, ?). + + Note: Infinite-cardinality patterns (test+, test*) trigger the + "extra_patterns_on_rhs" logic, producing opposite results from + finite-cardinality patterns. + """ + s1 = {"type": "object", "patternProperties": {"^test+$": {"type": "number"}}} + s2 = {"type": "object", "patternProperties": {"^test*$": {"type": "number"}}} + with self.subTest(): + self.assertFalse(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + +class TestPatternPropertiesWithRequired(unittest.TestCase): + """Test patternProperties with required keyword.""" + + def test_required_matches_pattern(self): + """Test required keys that match patternProperties.""" + s1 = { + "type": "object", + "patternProperties": {"^id": {"type": "integer"}}, + "required": ["id_user"], + } + s2 = {"type": "object", "patternProperties": {"^id": {"type": "integer"}}} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_required_not_matching_pattern(self): + """Test required key not matching any pattern.""" + s1 = { + "type": "object", + "patternProperties": {"^num": {"type": "number"}}, + "required": ["name"], + "additionalProperties": {"type": "string"}, + } + s2 = {"type": "object"} + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_performance.py b/test/test_performance.py new file mode 100644 index 0000000..91192b2 --- /dev/null +++ b/test/test_performance.py @@ -0,0 +1,245 @@ +""" +Test suite for performance edge cases. + +This module tests performance-related scenarios including: +- Large schemas with many properties +- Deeply nested schemas +- Large enum values +- Large array constraints + +Note: These tests verify the library handles complex schemas without crashes, +not strict performance benchmarks. + +Created to ensure the library scales reasonably. +""" + +import unittest + +from jsonsubschema import isSubschema + + +class TestLargeSchemas(unittest.TestCase): + """Test handling of large schemas.""" + + def test_many_properties(self): + """Test object with many properties.""" + props1 = {f"prop{i}": {"type": "string"} for i in range(50)} + props2 = {f"prop{i}": {"type": "string"} for i in range(100)} + + s1 = {"type": "object", "properties": props1} + s2 = {"type": "object", "properties": props2} + + result = isSubschema(s1, s2) + self.assertIsInstance(result, bool) + + def test_many_required_fields(self): + """Test object with many required fields.""" + props = {f"field{i}": {"type": "integer"} for i in range(30)} + required1 = [f"field{i}" for i in range(20)] + required2 = [f"field{i}" for i in range(10)] + + s1 = {"type": "object", "properties": props, "required": required1} + s2 = {"type": "object", "properties": props, "required": required2} + + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_large_enum(self): + """Test enum with many values.""" + enum1 = list(range(100)) + enum2 = list(range(200)) + + s1 = {"type": "integer", "enum": enum1} + s2 = {"type": "integer", "enum": enum2} + + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_large_allOf(self): + """Test allOf with many schemas.""" + schemas = [{"type": "integer", "minimum": i, "maximum": 100} for i in range(10)] + s1 = {"allOf": schemas} + s2 = {"type": "integer"} + + result = isSubschema(s1, s2) + self.assertIsInstance(result, bool) + + def test_large_anyOf(self): + """Test anyOf with many schemas.""" + schemas = [{"type": "integer", "enum": [i]} for i in range(20)] + s1 = {"anyOf": schemas} + s2 = {"type": "integer"} + + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + +class TestDeepNesting(unittest.TestCase): + """Test deeply nested schemas.""" + + def test_deeply_nested_objects(self): + """Test deeply nested object schemas.""" + s1 = {"type": "object"} + s2 = {"type": "object"} + + for _ in range(10): + s1 = {"type": "object", "properties": {"nested": s1}} + s2 = {"type": "object", "properties": {"nested": s2}} + + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + def test_deeply_nested_arrays(self): + """Test deeply nested array schemas.""" + s1 = {"type": "integer"} + s2 = {"type": "integer"} + + for _ in range(10): + s1 = {"type": "array", "items": s1} + s2 = {"type": "array", "items": s2} + + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertTrue(isSubschema(s2, s1)) + + def test_deeply_nested_allOf(self): + """Test deeply nested allOf.""" + s = {"type": "integer"} + for _ in range(10): + s = {"allOf": [s]} + + result = isSubschema(s, {"type": "integer"}) + self.assertTrue(result) + + def test_deeply_nested_anyOf(self): + """Test deeply nested anyOf.""" + s = {"type": "integer"} + for _ in range(10): + s = {"anyOf": [s]} + + result = isSubschema(s, {"type": "integer"}) + self.assertTrue(result) + + +class TestComplexCombinations(unittest.TestCase): + """Test complex schema combinations.""" + + def test_mixed_deep_and_wide(self): + """Test schema that is both deep and wide.""" + base_props = { + "prop1": {"type": "string", "minLength": 1}, + "prop2": {"type": "integer", "minimum": 0}, + "prop3": {"type": "boolean"}, + } + + s1 = { + "type": "object", + "properties": base_props, + "required": ["prop1", "prop2"], + } + + nested_obj = {"type": "object", "properties": {"inner": s1}} + s2 = { + "type": "object", + "properties": {**base_props, "nested": nested_obj}, + } + + result = isSubschema(s1, s2) + self.assertIsInstance(result, bool) + + def test_multiple_allOf_anyOf_layers(self): + """Test multiple layers of allOf and anyOf.""" + s1 = { + "allOf": [ + {"type": "integer"}, + {"anyOf": [{"minimum": 0}, {"maximum": 100}]}, + {"allOf": [{"multipleOf": 2}]}, + ] + } + s2 = {"type": "integer"} + + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_oneOf_with_many_branches(self): + """Test oneOf with many branches.""" + branches = [ + {"type": "integer", "minimum": i * 10, "maximum": (i + 1) * 10} + for i in range(10) + ] + s1 = {"oneOf": branches} + s2 = {"type": "integer"} + + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + +class TestLargeConstraintRanges(unittest.TestCase): + """Test schemas with large constraint values.""" + + def test_large_number_range(self): + """Test number with large range.""" + s1 = {"type": "integer", "minimum": 0, "maximum": 1000000} + s2 = {"type": "integer", "minimum": -1000000, "maximum": 2000000} + + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_large_string_length(self): + """Test string with large length constraints.""" + s1 = {"type": "string", "minLength": 100, "maxLength": 1000} + s2 = {"type": "string", "minLength": 0, "maxLength": 10000} + + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_large_array_size(self): + """Test array with large size constraints.""" + s1 = { + "type": "array", + "items": {"type": "integer"}, + "minItems": 50, + "maxItems": 100, + } + s2 = { + "type": "array", + "items": {"type": "integer"}, + "minItems": 0, + "maxItems": 200, + } + + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + def test_large_multipleOf(self): + """Test number with large multipleOf.""" + s1 = {"type": "integer", "multipleOf": 1000} + s2 = {"type": "integer", "multipleOf": 500} + + with self.subTest(): + self.assertTrue(isSubschema(s1, s2)) + with self.subTest(): + self.assertFalse(isSubschema(s2, s1)) + + +if __name__ == "__main__": + unittest.main()