From 13b7adfe498c19d4bcb9de72f560159520bf4763 Mon Sep 17 00:00:00 2001 From: MrLYC Date: Sun, 18 Jan 2026 16:25:22 +0800 Subject: [PATCH 1/8] test: add comprehensive test suite coverage Signed-off-by: MrLYC --- test/test_cli.py | 209 ++++++++++++++++ test/test_edge_cases.py | 313 ++++++++++++++++++++++++ test/test_exceptions.py | 210 ++++++++++++++++ test/test_nontrivial.py | 355 +++++++++++++++++++++++++++ test/test_number_float.py | 267 ++++++++++++++++++++ test/test_pattern_properties.py | 419 ++++++++++++++++++++++++++++++++ test/test_performance.py | 245 +++++++++++++++++++ 7 files changed, 2018 insertions(+) create mode 100644 test/test_cli.py create mode 100644 test/test_edge_cases.py create mode 100644 test/test_exceptions.py create mode 100644 test/test_nontrivial.py create mode 100644 test/test_number_float.py create mode 100644 test/test_pattern_properties.py create mode 100644 test/test_performance.py 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_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_exceptions.py b/test/test_exceptions.py new file mode 100644 index 0000000..0f8b846 --- /dev/null +++ b/test/test_exceptions.py @@ -0,0 +1,210 @@ +""" +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(self): + """Negating an array schema with minItems should raise exception""" + s1 = {"type": "integer"} + s2 = {"not": {"type": "array", "minItems": 1}} + + with self.assertRaises(UnsupportedNegatedArray) as cm: + isSubschema(s1, s2) + + def test_negation_of_array_with_maxitems(self): + """Negating an array schema with maxItems should raise exception""" + s1 = {"type": "boolean"} + s2 = {"not": {"type": "array", "maxItems": 5}} + + with self.assertRaises(UnsupportedNegatedArray) as cm: + 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(self): + """Negating an object schema with minProperties should raise exception""" + s1 = {"type": "null"} + s2 = {"not": {"type": "object", "minProperties": 1}} + + with self.assertRaises(UnsupportedNegatedObject) as cm: + isSubschema(s1, s2) + + def test_negation_of_object_with_maxproperties(self): + """Negating an object schema with maxProperties should raise exception""" + s1 = {"type": "array"} + s2 = {"not": {"type": "object", "maxProperties": 10}} + + with self.assertRaises(UnsupportedNegatedObject) as cm: + 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(self): + """Enum with array type should raise exception during canonicalization""" + s1 = {"type": "array", "enum": [[1, 2], [3, 4, 5]]} + s2 = {"type": "array"} + + with self.assertRaises(UnsupportedEnumCanonicalization) as cm: + isSubschema(s1, s2) + + self.assertIn("Canonicalizing an enum schema", str(cm.exception)) + + def test_object_enum_canonicalization(self): + """Enum with object type should raise exception during canonicalization""" + s1 = {"type": "object", "enum": [{"name": "Alice"}, {"name": "Bob", "age": 30}]} + s2 = {"type": "object"} + + with self.assertRaises(UnsupportedEnumCanonicalization) as cm: + isSubschema(s1, s2) + + 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_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_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() From bcdeb2b64b683db73f5c0da2ce507a33a9939552 Mon Sep 17 00:00:00 2001 From: MrLYC Date: Sun, 18 Jan 2026 17:59:18 +0800 Subject: [PATCH 2/8] feat: add subschema check with failure reasons Signed-off-by: MrLYC --- jsonsubschema/__init__.py | 10 ++-- jsonsubschema/_explain.py | 108 ++++++++++++++++++++++++++++++++++++++ jsonsubschema/_utils.py | 66 ++++++++++++++--------- jsonsubschema/api.py | 51 ++++++++++++------ test/test_explain.py | 101 +++++++++++++++++++++++++++++++++++ 5 files changed, 290 insertions(+), 46 deletions(-) create mode 100644 jsonsubschema/_explain.py create mode 100644 test/test_explain.py diff --git a/jsonsubschema/__init__.py b/jsonsubschema/__init__.py index 753ab67..0b40a39 100644 --- a/jsonsubschema/__init__.py +++ b/jsonsubschema/__init__.py @@ -1,13 +1,13 @@ -''' +""" 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 @@ -18,3 +18,7 @@ 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/_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..93ad6b9 100644 --- a/jsonsubschema/_utils.py +++ b/jsonsubschema/_utils.py @@ -1,8 +1,7 @@ -''' +""" Created on May 24, 2019 @author: Andrew Habib -''' - +""" import copy import fractions @@ -88,7 +87,7 @@ def get_valid_enum_vals(enum, s): # try: # return sorted(vals) # except TypeError: - # return list(vals) + # return list(vals) return vals @@ -109,6 +108,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)): @@ -125,18 +132,19 @@ def print_db(*args): 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 @@ -180,8 +188,8 @@ 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() @@ -218,9 +226,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}' @@ -232,7 +240,9 @@ def complement_of_string_pattern(s): 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 +261,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 +332,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 +360,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..c355358 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,60 @@ 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 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/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() From c33fe92cbbb849e94f1217be997a542ff52812ed Mon Sep 17 00:00:00 2001 From: MrLYC Date: Fri, 26 Jun 2026 08:22:39 +0800 Subject: [PATCH 3/8] Fix multipleOf handling in integer/number negation Previously, JSONTypeInteger.neg() and JSONTypeNumber.neg() ignored multipleOf constraints entirely (marked as TODO). This caused incorrect results when negating schemas like {type:integer, min:0, max:10, multipleOf:3}. For bounded ranges, the negation now correctly computes gap ranges between consecutive multiples, producing precise complement representations. For unbounded ranges with multipleOf, the limitation is documented. Adds 6 test cases covering bounded multipleOf negation for both integer and number types, including acceptance and rejection scenarios. Signed-off-by: MrLYC --- jsonsubschema/_checkers.py | 130 ++++++++++++++++++++++++++++++++----- test/test_numeric.py | 45 +++++++++++++ 2 files changed, 159 insertions(+), 16 deletions(-) diff --git a/jsonsubschema/_checkers.py b/jsonsubschema/_checkers.py index 4eda4ff..7b5c777 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 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 From 9eb0e9d1ea293edae0ca55f127c498830fefea1d Mon Sep 17 00:00:00 2001 From: MrLYC Date: Fri, 26 Jun 2026 08:26:40 +0800 Subject: [PATCH 4/8] Implement array/object enum pushdown canonicalization Previously, array and object type enums raised UnsupportedEnumCanonicalization. Now they are decomposed into anyOf of precise schemas per the paper's approach: - Array enum values become schemas with exact items, minItems=maxItems - Object enum values become schemas with exact properties, required, additionalProperties=false - Inner enum values are recursively canonicalized to reach primitive types Updates tests that previously expected exceptions to verify correct behavior. Signed-off-by: MrLYC --- jsonsubschema/_canonicalization.py | 50 ++++++++++++++++++++++++++++-- test/test_const.py | 18 +++++------ test/test_enum.py | 38 +++++++++++++---------- test/test_exceptions.py | 18 +++++------ 4 files changed, 85 insertions(+), 39 deletions(-) diff --git a/jsonsubschema/_canonicalization.py b/jsonsubschema/_canonicalization.py index 4633de5..99d6ef1 100644 --- a/jsonsubschema/_canonicalization.py +++ b/jsonsubschema/_canonicalization.py @@ -310,9 +310,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): 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_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 index 0f8b846..240f9b2 100644 --- a/test/test_exceptions.py +++ b/test/test_exceptions.py @@ -155,23 +155,21 @@ def test_negation_of_plain_object_succeeds(self): class TestUnsupportedEnumCanonicalization(unittest.TestCase): """Test cases for UnsupportedEnumCanonicalization exception""" - def test_array_enum_canonicalization(self): - """Enum with array type should raise exception during canonicalization""" + 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"} - with self.assertRaises(UnsupportedEnumCanonicalization) as cm: - isSubschema(s1, s2) - - self.assertIn("Canonicalizing an enum schema", str(cm.exception)) + result = isSubschema(s1, s2) + self.assertTrue(result) - def test_object_enum_canonicalization(self): - """Enum with object type should raise exception during canonicalization""" + 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"} - with self.assertRaises(UnsupportedEnumCanonicalization) as cm: - isSubschema(s1, s2) + result = isSubschema(s1, s2) + self.assertTrue(result) def test_integer_enum_succeeds(self): """Enum with integer type should succeed (supported)""" From e236ed822d130c163083f782e854ceeab36e48f9 Mon Sep 17 00:00:00 2001 From: MrLYC Date: Fri, 26 Jun 2026 08:29:13 +0800 Subject: [PATCH 5/8] Add LRU cache for greenery regex parse operations Wrap greenery's parse() with functools.lru_cache(maxsize=1024) to avoid redundant FSM construction for repeated patterns. This benefits schemas with overlapping string patterns and recursive subtype checks where the same regex is parsed multiple times. All regex utility functions (regex_meet, regex_isSubset, regex_matches_string, complement_of_string_pattern) and one call site in _checkers.py now use the cached variant. Signed-off-by: MrLYC --- jsonsubschema/_checkers.py | 2 +- jsonsubschema/_utils.py | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/jsonsubschema/_checkers.py b/jsonsubschema/_checkers.py index 7b5c777..f9c380e 100644 --- a/jsonsubschema/_checkers.py +++ b/jsonsubschema/_checkers.py @@ -1481,7 +1481,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 diff --git a/jsonsubschema/_utils.py b/jsonsubschema/_utils.py index 93ad6b9..b63d0a2 100644 --- a/jsonsubschema/_utils.py +++ b/jsonsubschema/_utils.py @@ -5,6 +5,7 @@ import copy import fractions +import functools import math import numbers import re @@ -131,6 +132,12 @@ 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 @@ -170,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 @@ -191,8 +198,8 @@ def regex_isSubset(s1, s2): """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() @@ -207,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): @@ -236,7 +243,7 @@ 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): From 059f3e4efe5fe78b1b4acbe380a953430a8b2257 Mon Sep 17 00:00:00 2001 From: MrLYC Date: Fri, 26 Jun 2026 08:34:26 +0800 Subject: [PATCH 6/8] Support negation of array/object schemas with size constraints Previously, any array or object schema with constraints raised UnsupportedNegatedArray/Object. Now schemas with only minItems/maxItems (array) or minProperties/maxProperties (object) can be negated by computing complement ranges. For example, not({array, minItems:3}) produces anyOf([non-array types, {array, maxItems:2}]). Schemas with items, additionalItems, uniqueItems, properties, etc. still raise exceptions as these require more complex complement logic. Signed-off-by: MrLYC --- jsonsubschema/_checkers.py | 64 ++++++++++++++++++++++++++++---------- test/test_array.py | 30 +++++++++++++++++- test/test_exceptions.py | 36 ++++++++++----------- 3 files changed, 93 insertions(+), 37 deletions(-) diff --git a/jsonsubschema/_checkers.py b/jsonsubschema/_checkers.py index f9c380e..c73112a 100644 --- a/jsonsubschema/_checkers.py +++ b/jsonsubschema/_checkers.py @@ -1221,16 +1221,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): @@ -1557,16 +1572,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/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_exceptions.py b/test/test_exceptions.py index 240f9b2..21a50a9 100644 --- a/test/test_exceptions.py +++ b/test/test_exceptions.py @@ -65,21 +65,19 @@ def test_negation_of_array_with_items(self): self.assertIn("Array negation", str(cm.exception)) - def test_negation_of_array_with_minitems(self): - """Negating an array schema with minItems should raise 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)) - with self.assertRaises(UnsupportedNegatedArray) as cm: - isSubschema(s1, s2) - - def test_negation_of_array_with_maxitems(self): - """Negating an array schema with maxItems should raise exception""" + 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}} - - with self.assertRaises(UnsupportedNegatedArray) as cm: - isSubschema(s1, s2) + # 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""" @@ -127,21 +125,19 @@ def test_negation_of_object_with_additionalproperties(self): with self.assertRaises(UnsupportedNegatedObject) as cm: isSubschema(s1, s2) - def test_negation_of_object_with_minproperties(self): - """Negating an object schema with minProperties should raise exception""" + 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)) - with self.assertRaises(UnsupportedNegatedObject) as cm: - isSubschema(s1, s2) - - def test_negation_of_object_with_maxproperties(self): - """Negating an object schema with maxProperties should raise exception""" + 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}} - - with self.assertRaises(UnsupportedNegatedObject) as cm: - isSubschema(s1, s2) + # 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""" From 7b432421b74a044e9f501dd48041bbf39bf9a6b3 Mon Sep 17 00:00:00 2001 From: MrLYC Date: Fri, 26 Jun 2026 08:36:50 +0800 Subject: [PATCH 7/8] Add schemaDiff API for schema compatibility analysis New schemaDiff(s1, s2) function returns the compatibility relationship between two schemas: 'equivalent', 'backward_compatible', 'forward_compatible', 'breaking', or 'unknown'. This enables the schema evolution bug detection scenario described in the paper (Section 5.2.1, Snowplow). Also adds --diff CLI flag. Includes 8 test cases covering all result types plus the Washington Post API evolution example from the paper. Signed-off-by: MrLYC --- jsonsubschema/__init__.py | 1 + jsonsubschema/api.py | 41 +++++++++++++++++++++++ jsonsubschema/cli.py | 13 +++++--- test/test_diff.py | 70 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 test/test_diff.py diff --git a/jsonsubschema/__init__.py b/jsonsubschema/__init__.py index 0b40a39..860f087 100644 --- a/jsonsubschema/__init__.py +++ b/jsonsubschema/__init__.py @@ -13,6 +13,7 @@ meetSchemas = api.meet joinSchemas = api.join isEquivalent = api.isEquivalent +schemaDiff = api.schemaDiff canonicalizeSchema = _canonicalization.canonicalize_schema diff --git a/jsonsubschema/api.py b/jsonsubschema/api.py index c355358..f0f2d9b 100644 --- a/jsonsubschema/api.py +++ b/jsonsubschema/api.py @@ -69,6 +69,47 @@ def isEquivalent(s1, s2): 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. 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_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") From 60a2d26ca67952fdb68d3a9f193ab2edec7d023f Mon Sep 17 00:00:00 2001 From: MrLYC Date: Fri, 26 Jun 2026 08:42:46 +0800 Subject: [PATCH 8/8] Add Draft-07 support for if/then/else and contains keywords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit if/then/else is canonicalized to equivalent anyOf+allOf form: {if:C, then:T, else:E} → {anyOf: [{allOf:[C,T]}, {allOf:[{not:C},E]}]} contains is added as a new array keyword with subtype checking: If RHS has contains, LHS must also guarantee the constraint. Adds 9 test cases covering both features including edge cases (missing else, combined with type constraints, tuple items). Signed-off-by: MrLYC --- jsonsubschema/_canonicalization.py | 34 +++++++++++ jsonsubschema/_checkers.py | 20 +++++++ jsonsubschema/_constants.py | 6 +- test/test_draft07.py | 96 ++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 test/test_draft07.py diff --git a/jsonsubschema/_canonicalization.py b/jsonsubschema/_canonicalization.py index 99d6ef1..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()) @@ -385,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 c73112a..26f7aaa 100644 --- a/jsonsubschema/_checkers.py +++ b/jsonsubschema/_checkers.py @@ -947,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): @@ -1110,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_): 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/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))