Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@
History
=======

0.29.2 (2024-11-24)
-------------------

**Bugfixes**

* Fixed issue with using :attr:`Meta.auto_assign_tags` and :attr:`Meta.raise_on_unknown_json_key` together (:issue:`137`).
* Fixed :attr:`JSONWizard.debug` to prevent overwriting existing class meta.
* Resolved issue where both :attr:`auto_assign_tags` and :type:`CatchAll` resulted in the tag key being incorrectly saved in :type:`CatchAll`.
* Fixed issue when :type:`CatchAll` field was specified with a default value but serialized with :attr:`skip_defaults=False`.
* Improved performance in :class:`UnionParser`: ensured that :func:`get_parser` is called only once per annotated type.
* Added test case(s) to confirm intended behavior.

0.29.1 (2024-11-23)
-------------------

Expand Down
5 changes: 4 additions & 1 deletion dataclass_wizard/dumpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,10 @@ def dump_func_for_dataclass(cls: Type[T],
f' paths{key_part} = asdict(o.{field},dict_factory,hooks,config,cls_to_asdict)')

elif has_catch_all and catch_all_field == field:
field_assignments.append(f"if not {skip_field}:")
if field in field_to_default:
field_assignments.append(f"if o.{field} != {default_value} and not {skip_field}:")
else:
field_assignments.append(f"if not {skip_field}:")
field_assignments.append(f" for k, v in o.{field}.items():")
field_assignments.append(" result.append((k,"
"asdict(v,dict_factory,hooks,config,cls_to_asdict)))")
Expand Down
15 changes: 13 additions & 2 deletions dataclass_wizard/loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,12 @@ def load_func_for_dataclass(
catch_all_field = json_to_field.get(CATCH_ALL)
has_catch_all = catch_all_field is not None

# Fix for using `auto_assign_tags` and `raise_on_unknown_json_key` together
# See https://github.com/rnag/dataclass-wizard/issues/137
has_tag_assigned = meta.tag is not None
if has_tag_assigned:
json_to_field[meta.tag_key] = ExplicitNull

_locals = {
'cls': cls,
'py_case': cls_loader.transform_json_field,
Expand Down Expand Up @@ -757,8 +763,13 @@ def load_func_for_dataclass(
fn_gen.add_line("raise")

if has_catch_all:
with fn_gen.else_():
fn_gen.add_line('catch_all[json_key] = o[json_key]')
line = 'catch_all[json_key] = o[json_key]'
if has_tag_assigned:
with fn_gen.elif_(f'json_key != {meta.tag_key!r}'):
fn_gen.add_line(line)
else:
with fn_gen.else_():
fn_gen.add_line(line)

with fn_gen.except_(TypeError):
# If the object `o` is None, then raise an error with the relevant info included.
Expand Down
57 changes: 30 additions & 27 deletions dataclass_wizard/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,36 +238,39 @@ def __post_init__(self, cls: Type,
self.tag_key = TAG
auto_assign_tags = False

# noinspection PyUnboundLocalVariable
self.parsers = tuple(
parser
for t in self.base_type
if t is not NoneType
and isinstance(parser := get_parser(t, cls, extras), AbstractParser))

parsers_list = []
self.tag_to_parser = {}

for t in self.base_type:
t = eval_forward_ref_if_needed(t, cls)
if is_dataclass(t):
meta = get_meta(t)
tag = meta.tag
if not tag and (auto_assign_tags or meta.auto_assign_tags):
cls_name = t.__name__
tag = cls_name
# We don't want to mutate the base Meta class here
if meta is AbstractMeta:
from .bases_meta import BaseJSONWizardMeta
cls_dict = {'__slots__': (), 'tag': tag}
# noinspection PyTypeChecker
meta: type[M] = type(cls_name + 'Meta', (BaseJSONWizardMeta, ), cls_dict)
_META[t] = meta
else:
meta.tag = cls_name
if tag:
# TODO see if we can use a mapping of dataclass type to
# load func (maybe one passed in to __post_init__),
# rather than generating one on the fly like this.
self.tag_to_parser[tag] = get_parser(t, cls, extras)
if t is not NoneType:
parser = get_parser(t, cls, extras)

if isinstance(parser, AbstractParser):
parsers_list.append(parser)

elif is_dataclass(t):
meta = get_meta(t)
tag = meta.tag
if not tag and (auto_assign_tags or meta.auto_assign_tags):
cls_name = t.__name__
tag = cls_name
# We don't want to mutate the base Meta class here
if meta is AbstractMeta:
from .bases_meta import BaseJSONWizardMeta
cls_dict = {'__slots__': (), 'tag': tag}
# noinspection PyTypeChecker
meta: type[M] = type(cls_name + 'Meta', (BaseJSONWizardMeta, ), cls_dict)
_META[t] = meta
else:
meta.tag = cls_name
if tag:
# TODO see if we can use a mapping of dataclass type to
# load func (maybe one passed in to __post_init__),
# rather than generating one on the fly like this.
self.tag_to_parser[tag] = parser

self.parsers = tuple(parsers_list)

def __contains__(self, item):
"""Check if parser is expected to handle the specified item type."""
Expand Down
9 changes: 7 additions & 2 deletions dataclass_wizard/serial_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
import logging

from .abstractions import AbstractJSONWizard
from .bases import AbstractMeta
from .bases_meta import BaseJSONWizardMeta, LoadMeta
from .class_helper import call_meta_initializer_if_needed
from .class_helper import call_meta_initializer_if_needed, get_meta
from .dumpers import asdict
from .loaders import fromdict, fromlist
# noinspection PyProtectedMember
Expand Down Expand Up @@ -69,7 +70,11 @@ def __init_subclass__(cls, str=True, debug=False):
# minimum logging level for logs by this library
min_level = default_lvl if isinstance(debug, bool) else debug
# set `debug_enabled` flag for the class's Meta
LoadMeta(debug_enabled=min_level).bind_to(cls)
cls_meta = get_meta(cls)
if cls_meta is not AbstractMeta:
cls_meta.debug_enabled = min_level
else:
LoadMeta(debug_enabled=min_level).bind_to(cls)


def _str_fn():
Expand Down
16 changes: 16 additions & 0 deletions dataclass_wizard/utils/function_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,22 @@ def if_(self, condition: str) -> 'FunctionBuilder':
"""
return self._with_new_block('if', condition)

def elif_(self, condition: str) -> 'FunctionBuilder':
"""Equivalent to the `elif` statement in Python.

Sample Usage:

>>> with FunctionBuilder().elif_('something is True'):
>>> ...

Will generate the following code:

>>> elif something is True:
>>> ...

"""
return self._with_new_block('elif', condition)

def else_(self) -> 'FunctionBuilder':
"""Equivalent to the `else` statement in Python.

Expand Down
136 changes: 136 additions & 0 deletions tests/unit/test_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -1901,6 +1901,68 @@ class MyData(TOMLWizard):
def test_catch_all_with_default():
"""'Catch All' support with a default field value."""

@dataclass
class MyData(JSONWizard):
my_str: str
my_float: float
extra_data: CatchAll = False

# Case 1: Extra Data is provided

input_dict = {
'my_str': "test",
'my_float': 3.14,
'my_other_str': "test!",
'my_bool': True
}

# Load from TOML string
data = MyData.from_dict(input_dict)

assert data.extra_data == {'my_other_str': 'test!', 'my_bool': True}

# Save to TOML file
output_dict = data.to_dict()

assert output_dict == {
"myStr": "test",
"myFloat": 3.14,
"my_other_str": "test!",
"my_bool": True
}

new_data = MyData.from_dict(output_dict)

assert new_data.extra_data == {'my_other_str': 'test!', 'my_bool': True}

# Case 2: Extra Data is not provided

input_dict = {
'my_str': "test",
'my_float': 3.14,
}

# Load from TOML string
data = MyData.from_dict(input_dict)

assert data.extra_data is False

# Save to TOML file
output_dict = data.to_dict()

assert output_dict == {
"myStr": "test",
"myFloat": 3.14,
}

new_data = MyData.from_dict(output_dict)

assert new_data.extra_data is False


def test_catch_all_with_skip_defaults():
"""'Catch All' support with a default field value and `skip_defaults`."""

@dataclass
class MyData(JSONWizard):
class _(JSONWizard.Meta):
Expand Down Expand Up @@ -2156,3 +2218,77 @@ class _(JSONWizard.Meta):
}
},
}


def test_auto_assign_tags_and_raise_on_unknown_json_key():

@dataclass
class A:
mynumber: int

@dataclass
class B:
mystring: str

@dataclass
class Container(JSONWizard):
obj2: Union[A, B]

class _(JSONWizard.Meta):
auto_assign_tags = True
raise_on_unknown_json_key = True

c = Container(obj2=B("bar"))

output_dict = c.to_dict()

assert output_dict == {
"obj2": {
"mystring": "bar",
"__tag__": "B"
}
}

assert c == Container.from_dict(output_dict)


def test_auto_assign_tags_and_catch_all():
"""Using both `auto_assign_tags` and `CatchAll` does not save tag key in `CatchAll`."""
@dataclass
class A:
mynumber: int
extra: CatchAll = None

@dataclass
class B:
mystring: str
extra: CatchAll = None

@dataclass
class Container(JSONWizard, debug=False):
obj2: Union[A, B]
extra: CatchAll = None

class _(JSONWizard.Meta):
auto_assign_tags = True
tag_key = 'type'

c = Container(obj2=B("bar"))

output_dict = c.to_dict()

assert output_dict == {
"obj2": {
"mystring": "bar",
"type": "B"
}
}

c2 = Container.from_dict(output_dict)
assert c2 == c == Container(obj2=B(mystring='bar', extra=None), extra=None)

assert c2.to_dict() == {
"obj2": {
"mystring": "bar", "type": "B"
}
}
Loading