Skip to content

Commit

Permalink
Add parsing stack trace (#11)
Browse files Browse the repository at this point in the history
  • Loading branch information
BenjaminPelletier authored Nov 3, 2023
1 parent 4d0cba0 commit 0de8a73
Show file tree
Hide file tree
Showing 2 changed files with 135 additions and 6 deletions.
44 changes: 38 additions & 6 deletions src/implicitdict/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import inspect
from dataclasses import dataclass
import re

import arrow
import datetime
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
97 changes: 97 additions & 0 deletions tests/test_stacktrace.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit 0de8a73

Please sign in to comment.