From 0de8a73cf093673820115acf4ac4093762a2b0e6 Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Fri, 3 Nov 2023 15:46:42 -0700 Subject: [PATCH] Add parsing stack trace (#11) --- src/implicitdict/__init__.py | 44 +++++++++++++--- tests/test_stacktrace.py | 97 ++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 tests/test_stacktrace.py diff --git a/src/implicitdict/__init__.py b/src/implicitdict/__init__.py index 9e13680..d4aaa6c 100644 --- a/src/implicitdict/__init__.py +++ b/src/implicitdict/__init__.py @@ -1,5 +1,6 @@ import inspect from dataclasses import dataclass +import re import arrow import datetime @@ -12,6 +13,21 @@ _DICT_FIELDS = set(dir({})) _KEY_FIELDS_INFO = '_fields_info' +_PARSING_ERRORS = (ValueError, TypeError) + + +def _bubble_up_parse_error(child: Union[ValueError, TypeError], field: str) -> Union[ValueError, TypeError]: + location_regex = r'^At ([A-Za-z0-9_.[\]]*):((?:.|[\n\r])*)$' + m = re.search(location_regex, str(child)) + if m: + suffix = m.group(1) + if suffix.startswith("["): + location = field + suffix + else: + location = f"{field}.{suffix}" + return type(child)(f"At {location}:{m.group(2)}") + else: + return type(child)(f"At {field}: {str(child)}") class ImplicitDict(dict): @@ -78,13 +94,16 @@ def __init__(self, **kwargs): @classmethod def parse(cls, source: Dict, parse_type: Type): if not isinstance(source, dict): - raise ValueError('Expected to find dictionary data to populate {} object but instead found {} type'.format(parse_type.__name__, type(source).__name__)) + raise ValueError(f'Expected to find dictionary data to populate {parse_type.__name__} object but instead found {type(source).__name__} type') kwargs = {} hints = get_type_hints(parse_type) for key, value in source.items(): if key in hints: # This entry has an explicit type - kwargs[key] = _parse_value(value, hints[key]) + try: + kwargs[key] = _parse_value(value, hints[key]) + except _PARSING_ERRORS as e: + raise _bubble_up_parse_error(e, key) else: # This entry's type isn't specified kwargs[key] = value @@ -165,12 +184,25 @@ def _parse_value(value, value_type: Type): if "not iterable" in str(e): raise ValueError(f"Cannot parse non-iterable value '{value}' of type '{type(value).__name__}' into list type '{value_type}'") raise - return [_parse_value(v, arg_types[0]) for v in value_list] + result = [] + for i, v in enumerate(value_list): + try: + result.append(_parse_value(v, arg_types[0])) + except _PARSING_ERRORS as e: + raise _bubble_up_parse_error(e, f"[{i}]") + return result elif generic_type is dict: # value is a dict of some kind - return {k if arg_types[0] is str else _parse_value(k, arg_types[0]): _parse_value(v, arg_types[1]) - for k, v in value.items()} + result = {} + for k, v in value.items(): + parsed_key = k if arg_types[0] is str else _parse_value(k, arg_types[0]) + try: + parsed_value = _parse_value(v, arg_types[1]) + except _PARSING_ERRORS as e: + raise _bubble_up_parse_error(e, k) + result[parsed_key] = parsed_value + return result elif generic_type is Union and len(arg_types) == 2 and arg_types[1] is type(None): # Type is an Optional declaration @@ -188,7 +220,7 @@ def _parse_value(value, value_type: Type): return value else: - raise NotImplementedError('Automatic parsing of {} type is not yet implemented'.format(value_type)) + raise ValueError(f'Automatic parsing of {value_type} type is not yet implemented') elif issubclass(value_type, ImplicitDict): # value is an ImplicitDict diff --git a/tests/test_stacktrace.py b/tests/test_stacktrace.py new file mode 100644 index 0000000..4bb0d30 --- /dev/null +++ b/tests/test_stacktrace.py @@ -0,0 +1,97 @@ +from __future__ import annotations +import json +from typing import Optional, List + +from implicitdict import ImplicitDict + +import pytest + + +# This object must be defined with future annotations as Python 3.8 will not resolve string-based forward references correctly +class MassiveNestingData(ImplicitDict): + children: Optional[List[MassiveNestingData]] + foo: str + bar: int = 0 + + @staticmethod + def example_value(): + return ImplicitDict.parse( + { + "foo": "1a", + "children": [ + { + "foo": "1a 2a" + }, + { + "foo": "1a 2b", + "children": [ + { + "foo": "1a 2b 3", + "children": [ + { + "foo": "1a 2b 3 4a", + "bar": 123 + }, + { + "foo": "1a 2b 3 4b", + "bar": 456 + }, + { + "foo": "1a 2b 3 4c", + "bar": 789, + "children": [] + } + ] + } + ] + } + ] + }, + MassiveNestingData + ) + + +def _get_correct_value() -> MassiveNestingData: + return json.loads(json.dumps(MassiveNestingData.example_value())) + + +def test_stacktrace(): + obj_dict = _get_correct_value() + obj_dict["bar"] = "wrong kind of value" + with pytest.raises(ValueError, match=r"^At bar:"): + ImplicitDict.parse(obj_dict, MassiveNestingData) + + obj_dict = _get_correct_value() + obj_dict["bar"] = [] + with pytest.raises(TypeError, match=r"^At bar:"): + ImplicitDict.parse(obj_dict, MassiveNestingData) + + obj_dict = _get_correct_value() + obj_dict["bar"] = {} + with pytest.raises(TypeError, match=r"^At bar:"): + ImplicitDict.parse(obj_dict, MassiveNestingData) + + obj_dict = _get_correct_value() + obj_dict["children"] = "this gets treated as a list" + with pytest.raises(ValueError, match=r"^At children\[0]:"): + ImplicitDict.parse(obj_dict, MassiveNestingData) + + obj_dict = _get_correct_value() + obj_dict["children"] = 0 + with pytest.raises(ValueError, match=r"^At children:"): + ImplicitDict.parse(obj_dict, MassiveNestingData) + + obj_dict = _get_correct_value() + obj_dict["children"][0]["bar"] = "wrong kind of value" + with pytest.raises(ValueError, match=r"^At children\[0].bar:"): + ImplicitDict.parse(obj_dict, MassiveNestingData) + + obj_dict = _get_correct_value() + obj_dict["children"][1]["children"] = False + with pytest.raises(ValueError, match=r"^At children\[1].children:"): + ImplicitDict.parse(obj_dict, MassiveNestingData) + + obj_dict = _get_correct_value() + obj_dict["children"][1]["children"][0]["children"][2]["children"] = 2 + with pytest.raises(ValueError, match=r"^At children\[1].children\[0].children\[2].children:"): + ImplicitDict.parse(obj_dict, MassiveNestingData)