Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.txt
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
17 changes: 10 additions & 7 deletions fastjsonschema/draft04.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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)"'
Expand Down Expand Up @@ -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')

Expand Down
32 changes: 17 additions & 15 deletions fastjsonschema/draft06.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down
16 changes: 9 additions & 7 deletions fastjsonschema/draft07.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .draft06 import CodeGeneratorDraft06
from .generator import VALIDATION_EXCEPTIONS


class CodeGeneratorDraft07(CodeGeneratorDraft06):
Expand Down Expand Up @@ -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(
Expand Down
28 changes: 27 additions & 1 deletion fastjsonschema/generator.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
from collections import OrderedDict
from contextlib import contextmanager
from decimal import Decimal
import re

from .exceptions import JsonSchemaValueException, JsonSchemaValuesException, JsonSchemaDefinitionException
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):
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
118 changes: 117 additions & 1 deletion tests/test_fast_fail.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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)
Loading