From 1473870aa28bc993b378da0714badb30469c651a Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Sun, 26 Jul 2026 17:46:37 +0200 Subject: [PATCH] Fixed compositions returning wrong results when fast_fail is off anyOf, oneOf, not, if/then/else, contains and propertyNames only try their subschemas out and decide on the raised JsonSchemaValueException. With fast_fail=False nothing is raised, so the except branch is dead and the errors of a rejected attempt leak into the result - valid data was rejected. Subschemas of these keywords are now generated in raising mode, and the except also catches JsonSchemaValuesException, which is what a subschema behind a $ref raises from its own function. That $ref call is now merged into the caller's errors as well instead of aborting the rest of the validation. --- CHANGELOG.txt | 5 ++ fastjsonschema/draft04.py | 17 +++--- fastjsonschema/draft06.py | 32 +++++----- fastjsonschema/draft07.py | 16 ++--- fastjsonschema/generator.py | 28 ++++++++- tests/test_fast_fail.py | 118 +++++++++++++++++++++++++++++++++++- 6 files changed, 185 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 254e737..9eeea5d 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,8 @@ +=== 2.22.1 (UNRELEASED) + +* Fixed wrong results of anyOf, oneOf, not, if/then/else, contains and propertyNames when `fast_fail` is off +* Fixed collecting errors from subschemas behind `$ref` when `fast_fail` is off + === 2.22.0 (2026-07-25) * Fixed IPv4 validation (rejecting leading zeros) diff --git a/fastjsonschema/draft04.py b/fastjsonschema/draft04.py index b0db0be..2ecf6d2 100644 --- a/fastjsonschema/draft04.py +++ b/fastjsonschema/draft04.py @@ -2,7 +2,7 @@ import re from .exceptions import JsonSchemaDefinitionException -from .generator import CodeGenerator, enforce_list +from .generator import VALIDATION_EXCEPTIONS, CodeGenerator, enforce_list JSON_TYPE_TO_PYTHON_TYPE = { @@ -196,9 +196,10 @@ def generate_any_of(self): # When we know it's passing (at least once), we do not need to do another expensive try-except. with self.l('if not {variable}_any_of_count{count}:', count=count, optimize=False): with self.l('try:', optimize=False): - self.generate_func_code_block(definition_item, self._variable, self._variable_name, clear_variables=True) + with self.trial_validation(): + self.generate_func_code_block(definition_item, self._variable, self._variable_name, clear_variables=True) self.l('{variable}_any_of_count{count} += 1', count=count) - self.l('except JsonSchemaValueException: pass') + self.l('except {}: pass', VALIDATION_EXCEPTIONS) with self.l('if not {variable}_any_of_count{count}:', count=count, optimize=False): self.exc('{name} cannot be validated by any definition', rule='anyOf') @@ -226,9 +227,10 @@ def generate_one_of(self): # When we know it's failing (one of means exactly once), we do not need to do another expensive try-except. with self.l('if {variable}_one_of_count{count} < 2:', count=count, optimize=False): with self.l('try:', optimize=False): - self.generate_func_code_block(definition_item, self._variable, self._variable_name, clear_variables=True) + with self.trial_validation(): + self.generate_func_code_block(definition_item, self._variable, self._variable_name, clear_variables=True) self.l('{variable}_one_of_count{count} += 1', count=count) - self.l('except JsonSchemaValueException: pass') + self.l('except {}: pass', VALIDATION_EXCEPTIONS) with self.l('if {variable}_one_of_count{count} != 1:', count=count): dynamic = '" (" + str({variable}_one_of_count{}) + " matches found)"' @@ -257,10 +259,11 @@ def generate_not(self): else: with self.l('try:', optimize=False): code_len = len(self._code) - self.generate_func_code_block(not_definition, self._variable, self._variable_name, clear_variables=True) + with self.trial_validation(): + self.generate_func_code_block(not_definition, self._variable, self._variable_name, clear_variables=True) if len(self._code) == code_len: self.l('pass') - self.l('except JsonSchemaValueException: pass') + self.l('except {}: pass', VALIDATION_EXCEPTIONS) with self.l('else:'): self.exc('{name} must NOT match a disallowed definition', rule='not') diff --git a/fastjsonschema/draft06.py b/fastjsonschema/draft06.py index 1e71f0f..0deae92 100644 --- a/fastjsonschema/draft06.py +++ b/fastjsonschema/draft06.py @@ -1,7 +1,7 @@ import decimal from .draft04 import CodeGeneratorDraft04, JSON_TYPE_TO_PYTHON_TYPE from .exceptions import JsonSchemaDefinitionException -from .generator import enforce_list +from .generator import VALIDATION_EXCEPTIONS, enforce_list class CodeGeneratorDraft06(CodeGeneratorDraft04): @@ -127,15 +127,16 @@ def generate_property_names(self): with self.l('for {variable}_key in {variable}:'): with self.l('try:'): code_len = len(self._code) - self.generate_func_code_block( - property_names_definition, - '{}_key'.format(self._variable), - self._variable_name, - clear_variables=True, - ) + with self.trial_validation(): + self.generate_func_code_block( + property_names_definition, + '{}_key'.format(self._variable), + self._variable_name, + clear_variables=True, + ) if len(self._code) == code_len: self.l('pass') - with self.l('except JsonSchemaValueException:'): + with self.l('except {}:', VALIDATION_EXCEPTIONS): self.l('{variable}_property_names = False') with self.l('if not {variable}_property_names:'): self.exc('{name} must be named by propertyName definition', rule='propertyNames') @@ -167,15 +168,16 @@ def generate_contains(self): self.l('{variable}_contains = False') with self.l('for {variable}_key in {variable}:'): with self.l('try:'): - self.generate_func_code_block( - contains_definition, - '{}_key'.format(self._variable), - self._variable_name, - clear_variables=True, - ) + with self.trial_validation(): + self.generate_func_code_block( + contains_definition, + '{}_key'.format(self._variable), + self._variable_name, + clear_variables=True, + ) self.l('{variable}_contains = True') self.l('break') - self.l('except JsonSchemaValueException: pass') + self.l('except {}: pass', VALIDATION_EXCEPTIONS) with self.l('if not {variable}_contains:'): self.exc('{name} must contain one of contains definition', rule='contains') diff --git a/fastjsonschema/draft07.py b/fastjsonschema/draft07.py index 685956e..4c6f303 100644 --- a/fastjsonschema/draft07.py +++ b/fastjsonschema/draft07.py @@ -1,4 +1,5 @@ from .draft06 import CodeGeneratorDraft06 +from .generator import VALIDATION_EXCEPTIONS class CodeGeneratorDraft07(CodeGeneratorDraft06): @@ -58,15 +59,16 @@ def generate_if_then_else(self): """ with self.l('try:', optimize=False): code_len = len(self._code) - self.generate_func_code_block( - self._definition['if'], - self._variable, - self._variable_name, - clear_variables=True - ) + with self.trial_validation(): + self.generate_func_code_block( + self._definition['if'], + self._variable, + self._variable_name, + clear_variables=True + ) if len(self._code) == code_len: self.l('pass') - with self.l('except JsonSchemaValueException:'): + with self.l('except {}:', VALIDATION_EXCEPTIONS): if 'else' in self._definition: code_len = len(self._code) self.generate_func_code_block( diff --git a/fastjsonschema/generator.py b/fastjsonschema/generator.py index 7858774..cd9a622 100644 --- a/fastjsonschema/generator.py +++ b/fastjsonschema/generator.py @@ -1,4 +1,5 @@ from collections import OrderedDict +from contextlib import contextmanager from decimal import Decimal import re @@ -6,6 +7,10 @@ from .indent import indent from .ref_resolver import RefResolver +# Both mean "this subschema did not match": a subschema behind a $ref becomes its own +# function, which reports through JsonSchemaValuesException while errors are collected. +VALIDATION_EXCEPTIONS = '(JsonSchemaValueException, JsonSchemaValuesException)' + def enforce_list(variable): if isinstance(variable, list): @@ -172,6 +177,19 @@ def generate_func_code_block(self, definition, variable, variable_name, clear_va return count + @contextmanager + def trial_validation(self): + """ + Subschemas of anyOf, oneOf, not, if, contains and propertyNames are only tried out. + Their failure is control flow for the surrounding ``try``, not an error to report, + so they have to raise even when ``fast_fail`` is off. + """ + fast_fail, self._fast_fail = self._fast_fail, True + try: + yield + finally: + self._fast_fail = fast_fail + def _generate_func_code_block(self, definition): if not isinstance(definition, dict): raise JsonSchemaDefinitionException("definition must be an object") @@ -214,7 +232,15 @@ def generate_ref(self): name_arg = '(name_prefix or "data") + "{}"'.format(path) if '{' in name_arg: name_arg = name_arg + '.format(**locals())' - self.l('{}({variable}, custom_formats, {name_arg})', name, name_arg=name_arg) + if self._fast_fail: + self.l('{}({variable}, custom_formats, {name_arg})', name, name_arg=name_arg) + else: + # The referenced function collects into its own list, so merge it into ours + # instead of letting it abort the validation of the rest of the document. + with self.l('try:', optimize=False): + self.l('{}({variable}, custom_formats, {name_arg})', name, name_arg=name_arg) + with self.l('except JsonSchemaValuesException as e:'): + self.l('errors.extend(e.errors)') # pylint: disable=invalid-name diff --git a/tests/test_fast_fail.py b/tests/test_fast_fail.py index 195cc47..d037133 100644 --- a/tests/test_fast_fail.py +++ b/tests/test_fast_fail.py @@ -1,6 +1,11 @@ import pytest -from fastjsonschema import JsonSchemaValueException, JsonSchemaValuesException, compile +from fastjsonschema import ( + JsonSchemaValueException, + JsonSchemaValuesException, + compile, + compile_to_code, +) def test_fast_fail(): @@ -45,3 +50,114 @@ def test_captures_all_errors(): assert len(exc_info.value.errors) == 2 assert exc_info.value.errors[0].message == 'data.string must be string' assert exc_info.value.errors[1].message == 'data.number must be number' + + +INT_OR_STRING = { + 'definitions': {'int': {'type': 'integer'}}, + 'anyOf': [{'$ref': '#/definitions/int'}, {'type': 'string'}], +} +IF_THEN_ELSE = {'if': {'type': 'integer'}, 'then': {'minimum': 10}, 'else': {'type': 'string'}} + + +# anyOf, oneOf, not, if, contains and propertyNames only try their subschemas out and catch +# the failure, so they used to be decided by an exception that fast_fail=False never raises. +@pytest.mark.parametrize('definition, value, is_valid', [ + ({'anyOf': [{'type': 'integer'}, {'type': 'string'}]}, 'abc', True), + ({'anyOf': [{'type': 'integer'}, {'type': 'string'}]}, 1.5, False), + ({'oneOf': [{'type': 'integer'}, {'minimum': 2}]}, 1, True), + ({'oneOf': [{'type': 'integer'}, {'minimum': 2}]}, 2.5, True), + ({'oneOf': [{'type': 'integer'}, {'minimum': 2}]}, 3, False), + ({'not': {'type': 'integer'}}, 'abc', True), + ({'not': {'type': 'integer'}}, 1, False), + (IF_THEN_ELSE, 'abc', True), + (IF_THEN_ELSE, 42, True), + (IF_THEN_ELSE, 1, False), + (IF_THEN_ELSE, 1.5, False), + ({'contains': {'type': 'number'}}, ['abc', 1], True), + ({'contains': {'type': 'number'}}, ['abc'], False), + ({'propertyNames': {'maxLength': 3}}, {'foo': 1}, True), + ({'propertyNames': {'maxLength': 3}}, {'foobar': 1}, False), + # Nested, so an inner attempt must not decide the outer one. + ({'anyOf': [{'oneOf': [{'type': 'integer'}]}, {'type': 'string'}]}, 'abc', True), + ({'not': {'anyOf': [{'type': 'integer'}, {'type': 'string'}]}}, 1.5, True), + ({'not': {'anyOf': [{'type': 'integer'}, {'type': 'string'}]}}, 'abc', False), + # A subschema behind $ref becomes its own function reporting through JsonSchemaValuesException. + (INT_OR_STRING, 'abc', True), + (INT_OR_STRING, 42, True), + (INT_OR_STRING, 1.5, False), + (dict(IF_THEN_ELSE, **{'definitions': {'int': {'type': 'integer'}}, 'if': {'$ref': '#/definitions/int'}}), 'abc', True), + ({'definitions': {'int': {'type': 'integer'}}, 'not': {'$ref': '#/definitions/int'}}, 'abc', True), + ({'definitions': {'int': {'type': 'integer'}}, 'not': {'$ref': '#/definitions/int'}}, 1, False), + ({'definitions': {'num': {'type': 'number'}}, 'contains': {'$ref': '#/definitions/num'}}, ['abc', 1], True), +]) +def test_fast_fail_does_not_change_the_verdict(definition, value, is_valid): + for fast_fail in (True, False): + validator = compile(dict(definition), fast_fail=fast_fail) + if is_valid: + assert validator(value) == value + else: + with pytest.raises((JsonSchemaValueException, JsonSchemaValuesException)): + validator(value) + + +def test_rejected_attempt_does_not_leak_its_errors(): + validator = compile({ + 'type': 'object', + 'properties': { + 'value': {'anyOf': [{'type': 'integer'}, {'type': 'string'}]}, + 'number': {'type': 'number'}, + }, + }, fast_fail=False) + + with pytest.raises(JsonSchemaValuesException) as exc_info: + validator({'value': 'abc', 'number': 'a'}) + assert [error.message for error in exc_info.value.errors] == ['data.number must be number'] + + +def test_failing_composition_reports_its_own_error(): + validator = compile({ + 'type': 'object', + 'properties': { + 'value': {'anyOf': [{'type': 'integer'}, {'type': 'string'}]}, + 'number': {'type': 'number'}, + }, + }, fast_fail=False) + + with pytest.raises(JsonSchemaValuesException) as exc_info: + validator({'value': 1.5, 'number': 'a'}) + assert [error.message for error in exc_info.value.errors] == [ + 'data.value cannot be validated by any definition', + 'data.number must be number', + ] + + +def test_captures_errors_behind_ref(): + validator = compile({ + 'type': 'object', + 'definitions': {'int': {'type': 'integer'}}, + 'properties': { + 'a': {'$ref': '#/definitions/int'}, + 'b': {'type': 'string'}, + }, + }, fast_fail=False) + + with pytest.raises(JsonSchemaValuesException) as exc_info: + validator({'a': 'abc', 'b': 1}) + assert [error.message for error in exc_info.value.errors] == [ + 'data.a must be integer', + 'data.b must be string', + ] + + +@pytest.mark.parametrize('value, is_valid', [('abc', True), (42, True), (1.5, False)]) +def test_generated_code_verdict(tmp_path, monkeypatch, value, is_valid): + with open(tmp_path / 'schema_fast_fail.py', 'w') as f: + f.write(compile_to_code(dict(INT_OR_STRING), fast_fail=False)) + with monkeypatch.context() as m: + m.syspath_prepend(tmp_path) + from schema_fast_fail import validate + if is_valid: + assert validate(value) == value + else: + with pytest.raises(JsonSchemaValuesException): + validate(value)