diff --git a/jsonschema/exceptions.py b/jsonschema/exceptions.py index 2e5d4ca0..8d329690 100644 --- a/jsonschema/exceptions.py +++ b/jsonschema/exceptions.py @@ -321,12 +321,17 @@ class ErrorTree: def __init__(self, errors: Iterable[ValidationError] = ()): self.errors: MutableMapping[str, ValidationError] = {} - self._contents: Mapping[str, ErrorTree] = defaultdict(self.__class__) + self._contents: MutableMapping[str, ErrorTree] = defaultdict( + self.__class__, + ) for error in errors: container = self for element in error.path: - container = container[element] + # Populate `_contents` directly (bypassing `__getitem__`) + # so that constructing the tree is the only thing allowed + # to auto-vivify entries in it. + container = container._contents[element] container.errors[error.validator] = error container._instance = error.instance @@ -346,9 +351,14 @@ def __getitem__(self, index): by ``instance.__getitem__`` will be propagated (usually this is some subclass of `LookupError`. """ - if self._instance is not _unset and index not in self: + if index in self._contents: + return self._contents[index] + if self._instance is not _unset: self._instance[index] - return self._contents[index] + # `index` has no errors of its own -- return an empty tree for + # it without recording it in `_contents`, so that querying an + # error-free index does not change `__contains__`/`__iter__`. + return self.__class__() def __setitem__(self, index: str | int, value: ErrorTree): """ diff --git a/jsonschema/tests/test_exceptions.py b/jsonschema/tests/test_exceptions.py index 358b9242..a54744bf 100644 --- a/jsonschema/tests/test_exceptions.py +++ b/jsonschema/tests/test_exceptions.py @@ -476,6 +476,33 @@ def test_iter(self): tree = exceptions.ErrorTree([e1, e2]) self.assertEqual(set(tree), {"bar", "foobar"}) + def test_getitem_of_an_error_free_index_does_not_mutate_the_tree(self): + """ + Accessing a valid index that has no errors of its own returns + an (empty) subtree, but must not cause that index to start + being reported by `__contains__` / `__iter__` / `total_errors`, + since it never actually had any errors. + + See https://github.com/python-jsonschema/jsonschema/issues/1328 + """ + error = exceptions.ValidationError( + "a bar message", validator="foo", instance=["spam", "eggs"], + path=["bar", 0], + ) + tree = exceptions.ErrorTree([error])["bar"] + + self.assertEqual(list(tree), [0]) + self.assertIn(0, tree) + self.assertNotIn(1, tree) + self.assertEqual(tree.total_errors, 1) + + subtree = tree[1] + + self.assertIsInstance(subtree, exceptions.ErrorTree) + self.assertEqual(list(tree), [0]) + self.assertNotIn(1, tree) + self.assertEqual(tree.total_errors, 1) + def test_repr_single(self): error = exceptions.ValidationError( "1",