Skip to content

Commit

Permalink
update AliasPath to support multiple alias paths
Browse files Browse the repository at this point in the history
  • Loading branch information
rnag committed Jan 14, 2025
1 parent 1b8e5a3 commit b7f6ef3
Show file tree
Hide file tree
Showing 8 changed files with 199 additions and 33 deletions.
2 changes: 1 addition & 1 deletion dataclass_wizard/class_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ def _process_field(name: str,
if f.load_alias is not ExplicitNull:
load_dataclass_field_to_path[name] = f.path
if not f.skip and f.dump_alias is not ExplicitNull:
dump_dataclass_field_to_path[name] = f.path
dump_dataclass_field_to_path[name] = f.path[0]
# TODO I forget why this is needed :o
if f.skip:
dump_dataclass_field_to_alias[name] = ExplicitNull
Expand Down
2 changes: 1 addition & 1 deletion dataclass_wizard/class_helper.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ JSON_FIELD_TO_DATACLASS_FIELD: dict[type, dict[str, str | ExplicitNullType]] = d
DATACLASS_FIELD_TO_JSON_PATH: dict[type, dict[str, PathType]] = defaultdict(dict)

# V1: A cached mapping, per dataclass, of instance field name to JSON path
DATACLASS_FIELD_TO_ALIAS_PATH_FOR_LOAD: dict[type, dict[str, PathType]] = defaultdict(dict)
DATACLASS_FIELD_TO_ALIAS_PATH_FOR_LOAD: dict[type, dict[str, Sequence[PathType]]] = defaultdict(dict)

# V1: A cached mapping, per dataclass, of instance field name to JSON field
DATACLASS_FIELD_TO_ALIAS_FOR_LOAD: dict[type, dict[str, Sequence[str]]] = defaultdict(dict)
Expand Down
28 changes: 28 additions & 0 deletions dataclass_wizard/utils/object_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,34 @@ def safe_get(data, path, default=MISSING, raise_=True):
raise _format_err(e, current_data, path, p, True) from None


def v1_safe_get(data, path, raise_):
current_data = data
p = path # to avoid "unbound local variable" warnings

try:
for p in path:
current_data = current_data[p]

return current_data

# IndexError -
# raised when `data` is a `list`, and we access an index that is "out of bounds"
# KeyError -
# raised when `data` is a `dict`, and we access a key that is not present
# AttributeError -
# raised when `data` is an invalid type, such as a `None`
except (IndexError, KeyError, AttributeError) as e:
if raise_:
raise _format_err(e, current_data, path, p) from None
return MISSING

# TypeError -
# raised when `data` is a `list`, but we try to use it like a `dict`
except TypeError:
e = TypeError('Invalid path')
raise _format_err(e, current_data, path, p, True) from None


def _format_err(e, current_data, path, current_path, invalid_path=False):
return ParseError(
e, current_data, dict if invalid_path else None,
Expand Down
35 changes: 31 additions & 4 deletions dataclass_wizard/utils/object_path.pyi
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from dataclasses import MISSING
from typing import Any

from typing import Any, Sequence

type PathPart = str | int | float | bool
type PathType = list[PathPart]
type PathType = Sequence[PathPart]


def safe_get(data: dict | list, path: PathType, default=MISSING) -> Any:
def safe_get(data: dict | list,
path: PathType,
default=MISSING,
raise_: bool = True) -> Any:
"""
Retrieve a value from a nested structure safely.
Expand All @@ -18,6 +20,7 @@ def safe_get(data: dict | list, path: PathType, default=MISSING) -> Any:
path (Iterable): A sequence of keys or indices to follow.
default (Any): The value to return if the path cannot be fully traversed.
If not provided and an error occurs, the exception is re-raised.
raise_ (bool): True to raise an error on invalid path (default True).
Returns:
Any: The value at the specified path, or `default` if traversal fails.
Expand All @@ -29,6 +32,30 @@ def safe_get(data: dict | list, path: PathType, default=MISSING) -> Any:
...


def v1_safe_get(data: dict | list,
path: PathType,
raise_: bool) -> Any:
"""
Retrieve a value from a nested structure safely.
Traverses a nested structure (e.g., dictionaries or lists) following a sequence of keys or indices specified in `path`.
Handles missing keys, out-of-bounds indices, or invalid types gracefully.
Args:
data (Any): The nested structure to traverse.
path (Iterable): A sequence of keys or indices to follow.
raise_ (bool): True to raise an error on invalid path.
Returns:
Any: The value at the specified path, or `MISSING` if traversal fails.
Raises:
KeyError, IndexError, AttributeError, TypeError: If `default` is not provided
and an error occurs during traversal.
"""
...


def _format_err(e: Exception,
current_data: Any,
path: PathType,
Expand Down
41 changes: 28 additions & 13 deletions dataclass_wizard/v1/loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
# noinspection PyProtectedMember
from ..utils.dataclass_compat import _set_new_attribute
from ..utils.function_builder import FunctionBuilder
from ..utils.object_path import safe_get
from ..utils.object_path import v1_safe_get
from ..utils.string_conv import possible_json_keys
from ..utils.type_conv import (
as_datetime_v1, as_date_v1, as_time_v1,
Expand Down Expand Up @@ -1009,11 +1009,11 @@ def load_func_for_dataclass(
key_case: 'V1LetterCase | None' = cls_loader.transform_json_field
auto_key_case = key_case is KeyCase.AUTO

field_to_alias = v1_dataclass_field_to_alias(cls)
check_aliases = True if field_to_alias else False
field_to_aliases = v1_dataclass_field_to_alias(cls)
check_aliases = True if field_to_aliases else False

field_to_path = DATACLASS_FIELD_TO_ALIAS_PATH_FOR_LOAD[cls]
has_alias_paths = True if field_to_path else False
field_to_paths = DATACLASS_FIELD_TO_ALIAS_PATH_FOR_LOAD[cls]
has_alias_paths = True if field_to_paths else False

# Fix for using `auto_assign_tags` and `raise_on_unknown_json_key` together
# See https://github.com/rnag/dataclass-wizard/issues/137
Expand All @@ -1029,7 +1029,7 @@ def load_func_for_dataclass(

on_unknown_key = meta.v1_on_unknown_key

catch_all_field = field_to_alias.pop(CATCH_ALL, None)
catch_all_field = field_to_aliases.pop(CATCH_ALL, None)
has_catch_all = catch_all_field is not None

if has_catch_all:
Expand All @@ -1056,7 +1056,7 @@ def load_func_for_dataclass(
should_raise = should_warn = None

if has_alias_paths:
new_locals['safe_get'] = safe_get
new_locals['safe_get'] = v1_safe_get

with fn_gen.function(fn_name, ['o'], MISSING, new_locals):

Expand Down Expand Up @@ -1090,7 +1090,7 @@ def load_func_for_dataclass(
val_is_found = _val_is_found

if (check_aliases
and (_aliases := field_to_alias.get(name)) is not None):
and (_aliases := field_to_aliases.get(name)) is not None):

if len(_aliases) == 1:
alias = _aliases[0]
Expand All @@ -1109,12 +1109,27 @@ def load_func_for_dataclass(
val_is_found = '(' + '\n or '.join(condition) + ')'

elif (has_alias_paths
and (path := field_to_path.get(name)) is not None):
and (paths := field_to_paths.get(name)) is not None):

if has_default:
f_assign = f'field={name!r}; {val}=safe_get(o, {path!r}, MISSING, False)'
if len(paths) == 1:
path = paths[0]
# add the first part (top-level key) of the path
aliases.add(path[0])
f_assign = f'field={name!r}; {val}=safe_get(o, {path!r}, {not has_default})'
else:
f_assign = f'field={name!r}; {val}=safe_get(o, {path!r})'
f_assign = None
fn_gen.add_line(f'field={name!r}')
condition = []
last_idx = len(paths) - 1
for k, path in enumerate(paths):
# add the first part (top-level key) of each path
aliases.add(path[0])
if k == last_idx:
condition.append(f'({val} := safe_get(o, {path!r}, {not has_default})) is not MISSING')
else:
condition.append(f'({val} := safe_get(o, {path!r}, False)) is not MISSING')

val_is_found = '(' + '\n or '.join(condition) + ')'

# TODO raise some useful message like (ex. on IndexError):
# Field "my_str" of type tuple[float, str] in A2 has invalid value ['123']
Expand Down Expand Up @@ -1143,7 +1158,7 @@ def load_func_for_dataclass(
alias = key_case(name)
aliases.add(alias)
if alias != name:
field_to_alias[name] = (alias, )
field_to_aliases[name] = (alias, )

f_assign = f'field={name!r}; {val}=o.get({alias!r}, MISSING)'

Expand Down
21 changes: 14 additions & 7 deletions dataclass_wizard/v1/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ def Alias(*all,
hash, compare, metadata, kw_only)

# noinspection PyPep8Naming,PyShadowingBuiltins
def AliasPath(all=None, *,
def AliasPath(*all,
load=None,
dump=None,
skip=False,
Expand All @@ -457,7 +457,12 @@ def AliasPath(all=None, *,
load = ExplicitNull

if isinstance(all, str):
all = split_object_path(all)
all = (split_object_path(all), )
else:
all = tuple([
split_object_path(a) if isinstance(a, str) else a
for a in all
])

return Field(load, dump, skip, all, default, default_factory, init, repr,
hash, compare, metadata, kw_only)
Expand Down Expand Up @@ -514,7 +519,7 @@ def Alias(*all,
hash, compare, metadata)

# noinspection PyPep8Naming,PyShadowingBuiltins
def AliasPath(all=None, *,
def AliasPath(*all,
load=None,
dump=None,
skip=False,
Expand All @@ -535,10 +540,12 @@ def AliasPath(all=None, *,
load = ExplicitNull

if isinstance(all, str):
all = split_object_path(all)

if isinstance(all, str):
all = split_object_path(all)
all = (split_object_path(all), )
else:
all = tuple([
split_object_path(a) if isinstance(a, str) else a
for a in all
])

return Field(load, dump, skip, all, default, default_factory, init, repr,
hash, compare, metadata)
Expand Down
9 changes: 7 additions & 2 deletions dataclass_wizard/v1/models.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ class DateTimePattern(datetime, Generic[T]): ...


# noinspection PyPep8Naming
def AliasPath(all: PathType | str | None = None, *,
def AliasPath(*all: PathType | str,
load : PathType | str | None = None,
dump : PathType | str | None = None,
skip: bool = False,
Expand All @@ -135,7 +135,12 @@ def AliasPath(all: PathType | str | None = None, *,
or even more complex nested paths such as `a["nested"]["key"]`.
Arguments:
all (_STR_COLLECTION): The JSON key(s) or nested path(s) to associate with the dataclass field.
all (PathType | str): The nested path(s) to associate with the dataclass field.
load (PathType | str | None): * De-serialize / load *: The nested path(s)
to associate with the dataclass field.
dump (PathType | str | None): * Serialize / dump *: The nested path(s)
to associate with the dataclass field.
skip (bool): True to omit the dataclass field in serialization (dump).
default (Any): The default value for the field. Mutually exclusive with `default_factory`.
default_factory (Callable[[], Any]): A callable to generate the default value.
Mutually exclusive with `default`.
Expand Down
94 changes: 89 additions & 5 deletions tests/unit/v1/test_loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,6 @@ class _(JSONWizard.Meta):

e = exc_info.value

# TODO
assert e.unknown_keys == {'myBoolTest', 'MyInt', 'my_str'}
assert e.obj == d
assert e.fields == ['my_str', 'my_bool', 'my_sub']
Expand Down Expand Up @@ -1015,10 +1014,7 @@ class _(JSONWizard.Meta):
v1 = True

my_str: str = Alias('myCustomStr')
my_bool: bool = Alias('myTestBool')

# TODO: currently multiple aliases are not supported
# my_bool: bool = Alias(('my_json_bool', 'myTestBool'))
my_bool: bool = Alias('my_json_bool', 'myTestBool')

value = 'Testing'
d = {'myCustomStr': value, 'myTestBool': 'true'}
Expand Down Expand Up @@ -3093,6 +3089,94 @@ class _(JSONWizard.Meta):
'a': {'b': {'c': {0: 'test'}}},
}

def test_from_dict_with_multiple_nested_object_alias_paths():
"""Confirm `AliasPath` works for multiple nested paths."""

@dataclass
class MyClass(JSONWizard):

class _(JSONWizard.Meta):
v1 = True
v1_key_case = 'CAMEL'
key_transform_with_dump = 'PASCAL'
v1_on_unknown_key = 'RAISE'

my_str: 'str | None' = AliasPath('ace.in.hole.0[1]', 'bears.eat.b33ts')
is_active_tuple: tuple[bool, ...]
list_of_int: list[int] = AliasPath(load=('the-path.0', ('another-path', 'here', 0)), default_factory=list)
other_int: Annotated[int, AliasPath('this.Other."Int 1.23"')] = 2
dump_only: int = AliasPath(dump='1.2.3', default=123)

string = """
{
"ace": {"in": {"hole": [["test", "value"]]}},
"the-path": [["1", "2", 3]],
"isActiveTuple": ["true", false, 1]
}
"""

instance = MyClass.from_json(string)
assert instance == MyClass(my_str='value', is_active_tuple=(True, False, True), list_of_int=[1, 2, 3])
assert instance.to_dict() == {
'ace': {'in': {'hole': {0: {1: 'value'}}}},
'this': {'Other': {'Int 1.23': 2}},
1: {2: {3: 123}},
'IsActiveTuple': (True, False, True),
'ListOfInt': [1, 2, 3],
}

string = """
{
"bears": {"eat": {"b33ts": "Fact!"}},
"another-path": {"here": [["3", "2", 1]]},
"isActiveTuple": ["false", 1, 0],
"this": {"Other": {"Int 1.23": "321"}},
"dumpOnly": "789"
}
"""

instance = MyClass.from_json(string)

assert instance == MyClass(my_str='Fact!', is_active_tuple=(False, True, False), list_of_int=[3, 2, 1],
other_int=321, dump_only=789)
assert instance.to_dict() == {
'ace': {'in': {'hole': {0: {1: 'Fact!'}}}},
'this': {'Other': {'Int 1.23': 321}},
1: {2: {3: 789}},
'IsActiveTuple': (False, True, False),
'ListOfInt': [3, 2, 1]
}

string = """
{
"ace": {"in": {"hole": [["test", "14"]]}},
"isActiveTuple": ["off", 1, "on"]
}
"""

instance = MyClass.from_json(string)
assert instance == MyClass(my_str='14', is_active_tuple=(False, True, True))
assert instance.to_dict() == {
'ace': {'in': {'hole': {0: {1: '14'}}}},
'this': {'Other': {'Int 1.23': 2}},
'IsActiveTuple': (False, True, True),
1: {2: {3: 123}},
'ListOfInt': []
}

string = """
{
"my_str": "14",
"isActiveTuple": ["off", 1, "on"]
}
"""

with pytest.raises(ParseError) as e:
_ = MyClass.from_json(string)

assert e.value.kwargs['current_path'] == "'bears'"
assert e.value.kwargs['path'] == "'bears' => 'eat' => 'b33ts'"


def test_auto_assign_tags_and_raise_on_unknown_json_key():

Expand Down

0 comments on commit b7f6ef3

Please sign in to comment.