From 3183582e6aa948163aac71a4a81910f64471d4a1 Mon Sep 17 00:00:00 2001 From: Antoni Szych Date: Mon, 11 Dec 2023 19:31:53 +0100 Subject: [PATCH 1/4] fix: allow path to be a list of strings as well as integers --- voluptuous/error.py | 16 +++++++++++----- voluptuous/humanize.py | 5 +---- voluptuous/validators.py | 4 ++-- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/voluptuous/error.py b/voluptuous/error.py index 2789880..7ff016f 100644 --- a/voluptuous/error.py +++ b/voluptuous/error.py @@ -19,7 +19,13 @@ class Invalid(Error): """ - def __init__(self, message: str, path: typing.Optional[typing.List[str]] = None, error_message: typing.Optional[str] = None, error_type: typing.Optional[str] = None) -> None: + def __init__( + self, + message: str, + path: typing.Optional[typing.List[typing.Union[str, int]]] = None, + error_message: typing.Optional[str] = None, + error_type: typing.Optional[str] = None + ) -> None: Error.__init__(self, message) self._path = path or [] self._error_message = error_message or message @@ -30,7 +36,7 @@ def msg(self) -> str: return self.args[0] @property - def path(self) -> typing.List[str]: + def path(self) -> typing.List[typing.Union[str, int]]: return self._path @property @@ -45,7 +51,7 @@ def __str__(self) -> str: output += ' for ' + self.error_type return output + path - def prepend(self, path: typing.List[str]) -> None: + def prepend(self, path: typing.List[typing.Union[str, int]]) -> None: self._path = path + self.path @@ -61,7 +67,7 @@ def msg(self) -> str: return self.errors[0].msg @property - def path(self) -> typing.List[str]: + def path(self) -> typing.List[typing.Union[str, int]]: return self.errors[0].path @property @@ -74,7 +80,7 @@ def add(self, error: Invalid) -> None: def __str__(self) -> str: return str(self.errors[0]) - def prepend(self, path: typing.List[str]) -> None: + def prepend(self, path: typing.List[typing.Union[str, int]]) -> None: for error in self.errors: error.prepend(path) diff --git a/voluptuous/humanize.py b/voluptuous/humanize.py index 734f367..7553832 100644 --- a/voluptuous/humanize.py +++ b/voluptuous/humanize.py @@ -7,10 +7,7 @@ MAX_VALIDATION_ERROR_ITEM_LENGTH = 500 -IndexT = typing.TypeVar("IndexT") - - -def _nested_getitem(data: typing.Dict[IndexT, typing.Any], path: typing.List[IndexT]) -> typing.Optional[typing.Any]: +def _nested_getitem(data: typing.Any, path: typing.List[typing.Union[str, int]]) -> typing.Optional[typing.Any]: for item_index in path: try: data = data[item_index] diff --git a/voluptuous/validators.py b/voluptuous/validators.py index 776adb8..8ad8e1d 100644 --- a/voluptuous/validators.py +++ b/voluptuous/validators.py @@ -224,7 +224,7 @@ def __voluptuous_compile__(self, schema: Schema) -> typing.Callable: schema.required = old_required return self._run - def _run(self, path: typing.List[str], value): + def _run(self, path: typing.List[typing.Union[str, int]], value): if self.discriminant is not None: self._compiled = [ self.schema._compile(v) @@ -243,7 +243,7 @@ def __repr__(self): self.msg ) - def _exec(self, funcs: typing.Iterable, v, path: typing.Optional[typing.List[str]] = None): + def _exec(self, funcs: typing.Iterable, v, path: typing.Optional[typing.List[typing.Union[str, int]]] = None): raise NotImplementedError() From 9ab0599aa8184facc60a247dda6a803917573206 Mon Sep 17 00:00:00 2001 From: Antoni Szych Date: Tue, 12 Dec 2023 16:01:57 +0100 Subject: [PATCH 2/4] use typing.Hashable instead of str|int --- voluptuous/error.py | 10 +++++----- voluptuous/humanize.py | 2 +- voluptuous/validators.py | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/voluptuous/error.py b/voluptuous/error.py index 7ff016f..992ba0d 100644 --- a/voluptuous/error.py +++ b/voluptuous/error.py @@ -22,7 +22,7 @@ class Invalid(Error): def __init__( self, message: str, - path: typing.Optional[typing.List[typing.Union[str, int]]] = None, + path: typing.Optional[typing.List[typing.Hashable]] = None, error_message: typing.Optional[str] = None, error_type: typing.Optional[str] = None ) -> None: @@ -36,7 +36,7 @@ def msg(self) -> str: return self.args[0] @property - def path(self) -> typing.List[typing.Union[str, int]]: + def path(self) -> typing.List[typing.Hashable]: return self._path @property @@ -51,7 +51,7 @@ def __str__(self) -> str: output += ' for ' + self.error_type return output + path - def prepend(self, path: typing.List[typing.Union[str, int]]) -> None: + def prepend(self, path: typing.List[typing.Hashable]) -> None: self._path = path + self.path @@ -67,7 +67,7 @@ def msg(self) -> str: return self.errors[0].msg @property - def path(self) -> typing.List[typing.Union[str, int]]: + def path(self) -> typing.List[typing.Hashable]: return self.errors[0].path @property @@ -80,7 +80,7 @@ def add(self, error: Invalid) -> None: def __str__(self) -> str: return str(self.errors[0]) - def prepend(self, path: typing.List[typing.Union[str, int]]) -> None: + def prepend(self, path: typing.List[typing.Hashable]) -> None: for error in self.errors: error.prepend(path) diff --git a/voluptuous/humanize.py b/voluptuous/humanize.py index 7553832..923f3d1 100644 --- a/voluptuous/humanize.py +++ b/voluptuous/humanize.py @@ -7,7 +7,7 @@ MAX_VALIDATION_ERROR_ITEM_LENGTH = 500 -def _nested_getitem(data: typing.Any, path: typing.List[typing.Union[str, int]]) -> typing.Optional[typing.Any]: +def _nested_getitem(data: typing.Any, path: typing.List[typing.Hashable]) -> typing.Optional[typing.Any]: for item_index in path: try: data = data[item_index] diff --git a/voluptuous/validators.py b/voluptuous/validators.py index 8ad8e1d..6c02813 100644 --- a/voluptuous/validators.py +++ b/voluptuous/validators.py @@ -224,7 +224,7 @@ def __voluptuous_compile__(self, schema: Schema) -> typing.Callable: schema.required = old_required return self._run - def _run(self, path: typing.List[typing.Union[str, int]], value): + def _run(self, path: typing.List[typing.Hashable], value): if self.discriminant is not None: self._compiled = [ self.schema._compile(v) @@ -243,7 +243,7 @@ def __repr__(self): self.msg ) - def _exec(self, funcs: typing.Iterable, v, path: typing.Optional[typing.List[typing.Union[str, int]]] = None): + def _exec(self, funcs: typing.Iterable, v, path: typing.Optional[typing.List[typing.Hashable]] = None): raise NotImplementedError() From 28c08458b0aa8c82039aa2553aca15732cf49887 Mon Sep 17 00:00:00 2001 From: Antoni Szych Date: Wed, 13 Dec 2023 14:13:07 +0100 Subject: [PATCH 3/4] add tests --- voluptuous/tests/tests.py | 63 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/voluptuous/tests/tests.py b/voluptuous/tests/tests.py index abca5f8..c849962 100644 --- a/voluptuous/tests/tests.py +++ b/voluptuous/tests/tests.py @@ -1328,6 +1328,69 @@ def test_match_error_has_path(): else: assert False, "Did not raise MatchInvalid" +def test_path_with_string(): + """Most common dict use with strings as keys""" + s = Schema({'string_key': int}) + + with pytest.raises(MultipleInvalid) as ctx: + s({'string_key': 'str'}) + assert ctx.value.errors[0].path == ['string_key'] + +def test_path_with_list_index(): + """Position of the offending list index included in path as int""" + s = Schema({'string_key': [int]}) + + with pytest.raises(MultipleInvalid) as ctx: + s({'string_key': [123, 'should be int']}) + assert ctx.value.errors[0].path == ['string_key', 1] + +def test_path_with_tuple_index(): + """Position of the offending tuple index included in path as int""" + s = Schema({'string_key': (int,)}) + + with pytest.raises(MultipleInvalid) as ctx: + s({'string_key': (123, 'should be int')}) + assert ctx.value.errors[0].path == ['string_key', 1] + +def test_path_with_integer_dict_key(): + """Not obvious case with dict having not strings, but integers as keys""" + s = Schema({1337: int}) + + with pytest.raises(MultipleInvalid) as ctx: + s({1337: 'should be int'}) + assert ctx.value.errors[0].path == [1337] + +def test_path_with_float_dict_key(): + """Not obvious case with dict having not strings, but floats as keys""" + s = Schema({13.37: int}) + + with pytest.raises(MultipleInvalid) as ctx: + s({13.37: 'should be int'}) + assert ctx.value.errors[0].path == [13.37] + +def test_path_with_tuple_dict_key(): + """Not obvious case with dict having not strings, but tuples as keys""" + s = Schema({('fancy', 'key'): int}) + + with pytest.raises(MultipleInvalid) as ctx: + s({('fancy', 'key'): 'should be int'}) + assert ctx.value.errors[0].path == [('fancy', 'key')] + +def test_path_with_arbitrary_hashable_dict_key(): + """Not obvious case with dict having not strings, but arbitrary hashable objects as keys""" + + class HashableObjectWhichWillBeKeyInDict: + def __hash__(self): + return 1337 # dummy hash, used only for illustration + + s = Schema({HashableObjectWhichWillBeKeyInDict: [int]}) + + hashable_obj_provided_in_input = HashableObjectWhichWillBeKeyInDict() + + with pytest.raises(MultipleInvalid) as ctx: + s({hashable_obj_provided_in_input: [0, 1, 'should be int']}) + assert ctx.value.errors[0].path == [hashable_obj_provided_in_input, 2] + def test_self_any(): schema = Schema({"number": int, From 922ff886a5db66fb2933c1afb752ca50902242a3 Mon Sep 17 00:00:00 2001 From: Antoni Szych Date: Wed, 13 Dec 2023 14:14:44 +0100 Subject: [PATCH 4/4] make flake8 happy --- voluptuous/tests/tests.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/voluptuous/tests/tests.py b/voluptuous/tests/tests.py index c849962..e2c5a66 100644 --- a/voluptuous/tests/tests.py +++ b/voluptuous/tests/tests.py @@ -1328,6 +1328,7 @@ def test_match_error_has_path(): else: assert False, "Did not raise MatchInvalid" + def test_path_with_string(): """Most common dict use with strings as keys""" s = Schema({'string_key': int}) @@ -1336,6 +1337,7 @@ def test_path_with_string(): s({'string_key': 'str'}) assert ctx.value.errors[0].path == ['string_key'] + def test_path_with_list_index(): """Position of the offending list index included in path as int""" s = Schema({'string_key': [int]}) @@ -1344,6 +1346,7 @@ def test_path_with_list_index(): s({'string_key': [123, 'should be int']}) assert ctx.value.errors[0].path == ['string_key', 1] + def test_path_with_tuple_index(): """Position of the offending tuple index included in path as int""" s = Schema({'string_key': (int,)}) @@ -1352,6 +1355,7 @@ def test_path_with_tuple_index(): s({'string_key': (123, 'should be int')}) assert ctx.value.errors[0].path == ['string_key', 1] + def test_path_with_integer_dict_key(): """Not obvious case with dict having not strings, but integers as keys""" s = Schema({1337: int}) @@ -1360,6 +1364,7 @@ def test_path_with_integer_dict_key(): s({1337: 'should be int'}) assert ctx.value.errors[0].path == [1337] + def test_path_with_float_dict_key(): """Not obvious case with dict having not strings, but floats as keys""" s = Schema({13.37: int}) @@ -1368,6 +1373,7 @@ def test_path_with_float_dict_key(): s({13.37: 'should be int'}) assert ctx.value.errors[0].path == [13.37] + def test_path_with_tuple_dict_key(): """Not obvious case with dict having not strings, but tuples as keys""" s = Schema({('fancy', 'key'): int}) @@ -1376,6 +1382,7 @@ def test_path_with_tuple_dict_key(): s({('fancy', 'key'): 'should be int'}) assert ctx.value.errors[0].path == [('fancy', 'key')] + def test_path_with_arbitrary_hashable_dict_key(): """Not obvious case with dict having not strings, but arbitrary hashable objects as keys"""