Skip to content

Commit 0a845d0

Browse files
committed
Also take into account the count of subschema errors for relevance.
When sibling subschemas fail, pick the one which produced fewer errors. Closes: #1002
1 parent a8f4349 commit 0a845d0

3 files changed

Lines changed: 164 additions & 56 deletions

File tree

‎CHANGELOG.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ v4.27.0
44
* Improve ``best_match`` for ``anyOf`` / ``oneOf`` errors: the most relevant error is now found within each subschema separately before the deepest of those is picked (#1257, #1300).
55
In particular an applicator with a single subschema now produces the same best match as the bare subschema would.
66
``relevance`` no longer considers an error's position amongst its siblings.
7+
When subschemas fail equally deeply, the one which produced fewer errors is now preferred (#1002).
78
* Accessing an index with no errors on an ``ErrorTree`` no longer causes that index to appear in the tree (#1328).
89
Subtrees also properly propagate lookup errors which were previously missing when accessing indices not in the subtree.
910

‎jsonschema/exceptions.py‎

Lines changed: 56 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,10 @@
33
"""
44
from __future__ import annotations
55

6-
from collections import deque
7-
from operator import itemgetter
6+
from collections import defaultdict, deque
87
from pprint import pformat
98
from textwrap import dedent, indent
10-
from typing import TYPE_CHECKING, Any, ClassVar
11-
import heapq
9+
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar
1210
import re
1311
import warnings
1412

@@ -18,7 +16,13 @@
1816
from jsonschema import _utils
1917

2018
if TYPE_CHECKING:
21-
from collections.abc import Iterable, Mapping, MutableMapping, Sequence
19+
from collections.abc import (
20+
Callable,
21+
Iterable,
22+
Mapping,
23+
MutableMapping,
24+
Sequence,
25+
)
2226

2327
from jsonschema import _types
2428

@@ -198,6 +202,9 @@ def _matches_type(self) -> bool:
198202
)
199203

200204

205+
_E = TypeVar("_E", bound=_Error)
206+
207+
201208
class ValidationError(_Error):
202209
"""
203210
An instance was invalid under a provided schema.
@@ -468,9 +475,10 @@ def best_match(errors, key=relevance):
468475
469476
If the resulting match is either :kw:`oneOf` or :kw:`anyOf`, the
470477
*opposite* assumption is made -- i.e. the deepest error is picked
471-
among the most relevant errors in each separate subschema,
472-
since these keywords only need to match once, and any other errors
473-
may not be relevant.
478+
among the most relevant errors in each separate subschema (preferring
479+
subschemas which produced fewer errors when tied), since these
480+
keywords only need to match once, and any other errors may not be
481+
relevant.
474482
475483
Arguments:
476484
errors (collections.abc.Iterable):
@@ -498,55 +506,57 @@ def best_match(errors, key=relevance):
498506
set of inputs from version to version if better heuristics are added.
499507
500508
"""
501-
most_relevant = None
502-
for error in errors:
503-
most_relevant = _more_relevant(most_relevant, (key(error), error))
504-
if most_relevant is None:
509+
_, best = _most_relevant(errors, key=key)
510+
if best is None:
505511
return
506-
_, best = most_relevant
507512

508513
while best.context:
509-
# Find the most relevant error within each separate subschema,
510-
# computing each error's key exactly once along the way.
511-
best_in_subschemas: dict[Any, tuple[Any, ValidationError]] = {}
514+
# Group the errors by the subschema which produced them.
515+
by_subschema: dict[Any, list[_Error]] = defaultdict(list)
512516
for error in best.context:
513517
index = error.schema_path[0] if error.schema_path else None
514-
best_in_subschemas[index] = _more_relevant(
515-
best_in_subschemas.get(index),
516-
(key(error), error),
517-
)
518-
519-
# Calculate the minimum via nsmallest, because we don't recurse if
520-
# all nested errors have the same relevance (i.e. if min == max == all)
521-
smallest = heapq.nsmallest(
522-
2,
523-
best_in_subschemas.values(),
524-
key=itemgetter(0),
525-
)
526-
if len(smallest) == 2 and smallest[0][0] == smallest[1][0]: # noqa: PLR2004
527-
return best
528-
_, best = smallest[0]
518+
by_subschema[index].append(error)
519+
520+
# Rank each subschema by how deep its most relevant error is,
521+
# and amongst those equally deep, by how few errors it produced
522+
# (i.e. how close it was to being valid). Lower ranks are better.
523+
best_rank, best_in_subschema, tied = None, None, False
524+
for errors_in_subschema in by_subschema.values():
525+
error_key, error = _most_relevant(errors_in_subschema, key=key)
526+
rank = error_key, len(errors_in_subschema)
527+
if best_rank is None or rank < best_rank:
528+
best_rank, best_in_subschema, tied = rank, error, False
529+
elif rank == best_rank:
530+
tied = True
531+
532+
# If multiple subschemas rank equally we can't tell which was
533+
# intended, so we stop here rather than descend into one of them.
534+
if tied:
535+
break
536+
best = best_in_subschema
529537
return best
530538

531539

532-
def _more_relevant(
533-
previous: tuple[Any, ValidationError] | None,
534-
candidate: tuple[Any, ValidationError],
535-
) -> tuple[Any, ValidationError]:
540+
def _most_relevant(
541+
errors: Iterable[_E],
542+
key: Callable[[_E], Any],
543+
) -> tuple[Any, _E | None]:
536544
"""
537-
Pick the more relevant of two ``(key, error)`` pairs.
545+
Find the most relevant error along with its key, computing each key once.
538546
539547
Equally relevant errors are settled by picking the one which appears
540548
earlier in the instance, which makes the choice independent of the
541549
order in which the errors happened to be produced.
550+
551+
Returns ``(None, None)`` if there were no errors.
542552
"""
543-
if previous is None:
544-
return candidate
545-
previous_key, previous_error = previous
546-
candidate_key, candidate_error = candidate
547-
if candidate_key > previous_key or (
548-
candidate_key == previous_key
549-
and candidate_error.path < previous_error.path
550-
):
551-
return candidate
552-
return previous
553+
best_key, best = None, None
554+
for error in errors:
555+
error_key = key(error)
556+
if (
557+
best is None
558+
or error_key > best_key
559+
or (error_key == best_key and error.path < best.path)
560+
):
561+
best_key, best = error_key, error
562+
return best_key, best

‎jsonschema/tests/test_exceptions.py‎

Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,35 @@ def test_earlier_sibling_errors_are_better_matches(self):
4545
best = self.best_match_of(instance=[12, 12, 12], schema=schema)
4646
self.assertEqual(list(best.path), [0])
4747

48+
def test_earlier_sibling_errors_within_a_subschema_are_better_matches(
49+
self,
50+
):
51+
"""
52+
The same holds within an anyOf subschema, even though its errors are
53+
always provided in the order they were produced in.
54+
"""
55+
56+
schema = {"anyOf": [{"items": {"const": 37}}]}
57+
error = next(_LATEST_VERSION(schema).iter_errors([12, 12, 12]))
58+
error.context.reverse()
59+
best = exceptions.best_match([error])
60+
self.assertEqual(list(best.path), [0])
61+
62+
def test_hand_constructed_errors_with_context(self):
63+
"""
64+
Errors constructed by hand need not have any schema path at all.
65+
"""
66+
67+
error = exceptions.ValidationError(
68+
"parent",
69+
context=[
70+
exceptions.ValidationError("child", path=["foo"]),
71+
exceptions.ValidationError("grandchild", path=["foo", "bar"]),
72+
],
73+
)
74+
best = exceptions.best_match([error])
75+
self.assertEqual(best.message, "child")
76+
4877
def test_oneOf_and_anyOf_are_weak_matches(self):
4978
"""
5079
A property you *must* match is probably better than one you have to
@@ -160,10 +189,13 @@ def test_no_anyOf_traversal_for_two_properties_sibling_errors(self):
160189
best = self.best_match_of(instance={"foo": 1, "bar": 1}, schema=schema)
161190
self.assertEqual(best.validator, "anyOf")
162191

163-
def test_no_anyOf_traversal_for_two_items_sibling_errors(self):
192+
def test_anyOf_traversal_prefers_subschemas_with_fewer_errors(self):
164193
"""
165-
We don't traverse into an anyOf if all of its context errors
166-
seem to be equally "wrong" against the instance.
194+
When subschemas fail at the same depth, we traverse into the one
195+
which produced fewer errors, since it was closer to being valid.
196+
197+
Here the first subschema rejects every item, whereas the second
198+
rejects only the non-integer one.
167199
"""
168200

169201
schema = {
@@ -188,9 +220,40 @@ def test_no_anyOf_traversal_for_two_items_sibling_errors(self):
188220
},
189221
}
190222
best = self.best_match_of(instance=["not an int", 0], schema=schema)
191-
self.assertEqual(best.validator, "anyOf")
223+
self.assertEqual(best.validator, "type")
224+
self.assertEqual(best.instance, "not an int")
192225
best = self.best_match_of(instance=[0, "not an int"], schema=schema)
193-
self.assertEqual(best.validator, "anyOf")
226+
self.assertEqual(best.validator, "type")
227+
self.assertEqual(best.instance, "not an int")
228+
229+
def test_anyOf_traversal_for_partially_matching_subschemas(self):
230+
"""
231+
When one subschema matches more of the instance than another,
232+
we traverse into it, and report its remaining error.
233+
234+
See #1002.
235+
"""
236+
237+
schema = {
238+
"anyOf": [
239+
{
240+
"properties": {
241+
"version": {"const": 1},
242+
"description": {"type": "string"},
243+
},
244+
},
245+
{
246+
"properties": {
247+
"version": {"const": 2},
248+
"description": {"type": "string"},
249+
},
250+
},
251+
],
252+
}
253+
instance = {"version": 1, "description": 0}
254+
best = self.best_match_of(instance=instance, schema=schema)
255+
self.assertEqual(best.validator, "type")
256+
self.assertEqual(list(best.path), ["description"])
194257

195258
def test_anyOf_traversal_for_single_equally_relevant_error(self):
196259
"""
@@ -337,10 +400,13 @@ def test_no_oneOf_traversal_for_two_properties_sibling_errors(self):
337400
best = self.best_match_of(instance={"foo": 1, "bar": 1}, schema=schema)
338401
self.assertEqual(best.validator, "oneOf")
339402

340-
def test_no_oneOf_traversal_for_two_items_sibling_errors(self):
403+
def test_oneOf_traversal_prefers_subschemas_with_fewer_errors(self):
341404
"""
342-
We don't traverse into an anyOf if all of its context errors
343-
seem to be equally "wrong" against the instance.
405+
When subschemas fail at the same depth, we traverse into the one
406+
which produced fewer errors, since it was closer to being valid.
407+
408+
Here the first subschema rejects every item, whereas the second
409+
rejects only the non-integer one.
344410
"""
345411

346412
schema = {
@@ -365,9 +431,40 @@ def test_no_oneOf_traversal_for_two_items_sibling_errors(self):
365431
},
366432
}
367433
best = self.best_match_of(instance=["not an int", 0], schema=schema)
368-
self.assertEqual(best.validator, "oneOf")
434+
self.assertEqual(best.validator, "type")
435+
self.assertEqual(best.instance, "not an int")
369436
best = self.best_match_of(instance=[0, "not an int"], schema=schema)
370-
self.assertEqual(best.validator, "oneOf")
437+
self.assertEqual(best.validator, "type")
438+
self.assertEqual(best.instance, "not an int")
439+
440+
def test_oneOf_traversal_for_partially_matching_subschemas(self):
441+
"""
442+
When one subschema matches more of the instance than another,
443+
we traverse into it, and report its remaining error.
444+
445+
See #1002.
446+
"""
447+
448+
schema = {
449+
"oneOf": [
450+
{
451+
"properties": {
452+
"version": {"const": 1},
453+
"description": {"type": "string"},
454+
},
455+
},
456+
{
457+
"properties": {
458+
"version": {"const": 2},
459+
"description": {"type": "string"},
460+
},
461+
},
462+
],
463+
}
464+
instance = {"version": 1, "description": 0}
465+
best = self.best_match_of(instance=instance, schema=schema)
466+
self.assertEqual(best.validator, "type")
467+
self.assertEqual(list(best.path), ["description"])
371468

372469
def test_oneOf_traversal_for_single_equally_relevant_error(self):
373470
"""

0 commit comments

Comments
 (0)