Skip to content

Commit 3069bef

Browse files
authored
V0.30.1 - bugfix tag key is field (#150)
* Fix: dataclasses in `Union` when `Meta.tag_key` is a dataclass field * Fix: dataclasses in `Union` when `Meta.tag_key` is a dataclass field * Update HISTORY.rst
1 parent 3e9dc93 commit 3069bef

File tree

5 files changed

+83
-28
lines changed

5 files changed

+83
-28
lines changed

HISTORY.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22
History
33
=======
44

5+
0.30.1 (2024-11-25)
6+
-------------------
7+
8+
**Bugfixes**
9+
10+
* Resolved inconsistent behavior with dataclasses in ``Union`` when ``Meta`` :attr:`tag_key`
11+
is also defined as a dataclass field (:issue:`148`).
12+
513
0.30.0 (2024-11-25)
614
-------------------
715

dataclass_wizard/class_helper.pyi

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ from .bases import META
77
from .models import Condition
88
from .type_def import ExplicitNullType, T
99
from .utils.dict_helper import DictWithLowerStore
10+
from .utils.object_path import PathType
1011

1112

1213
# A cached mapping of dataclass to the list of fields, as returned by
@@ -31,21 +32,18 @@ CLASS_TO_DUMPER: dict[type, type[AbstractDumper]] = {}
3132

3233
# A cached mapping of a dataclass to each of its case-insensitive field names
3334
# and load hook.
34-
FIELD_NAME_TO_LOAD_PARSER: dict[
35-
type, DictWithLowerStore[str, AbstractParser]] = {}
35+
FIELD_NAME_TO_LOAD_PARSER: dict[type, DictWithLowerStore[str, AbstractParser]] = {}
3636

3737
# Since the dump process doesn't use Parsers currently, we use a sentinel
3838
# mapping to confirm if we need to setup the dump config for a dataclass
3939
# on an initial run.
4040
IS_DUMP_CONFIG_SETUP: dict[type, bool] = {}
4141

4242
# A cached mapping, per dataclass, of JSON field to instance field name
43-
JSON_FIELD_TO_DATACLASS_FIELD: dict[
44-
type, dict[str, str | ExplicitNullType]] = defaultdict(dict)
43+
JSON_FIELD_TO_DATACLASS_FIELD: dict[type, dict[str, str | ExplicitNullType]] = defaultdict(dict)
4544

4645
# A cached mapping, per dataclass, of instance field name to JSON path
47-
DATACLASS_FIELD_TO_JSON_PATH: dict[
48-
type, dict[str, list[str | int | bool | float]]] = defaultdict(dict)
46+
DATACLASS_FIELD_TO_JSON_PATH: dict[type, dict[str, PathType]] = defaultdict(dict)
4947

5048
# A cached mapping, per dataclass, of instance field name to JSON field
5149
DATACLASS_FIELD_TO_JSON_FIELD: dict[type, dict[str, str]] = defaultdict(dict)
@@ -56,22 +54,20 @@ DATACLASS_FIELD_TO_SKIP_IF: dict[type, dict[str, Condition]] = defaultdict(dict)
5654
# A mapping of dataclass name to its Meta initializer (defined in
5755
# :class:`bases.BaseJSONWizardMeta`), which is only set when the
5856
# :class:`JSONSerializable.Meta` is sub-classed.
59-
META_INITIALIZER: dict[
60-
str, Callable[[type[W]], None]] = {}
61-
57+
META_INITIALIZER: dict[str, Callable[[type[W]], None]] = {}
6258

6359
# Mapping of dataclass to its Meta inner class, which will only be set when
6460
# the :class:`JSONSerializable.Meta` is sub-classed.
6561
_META: dict[type, META] = {}
6662

6763

68-
def dataclass_to_loader(cls: type):
64+
def dataclass_to_loader(cls: type) -> type[AbstractLoader]:
6965
"""
7066
Returns the loader for a dataclass.
7167
"""
7268

7369

74-
def dataclass_to_dumper(cls: type):
70+
def dataclass_to_dumper(cls: type) -> type[AbstractDumper]:
7571
"""
7672
Returns the dumper for a dataclass.
7773
"""
@@ -89,13 +85,13 @@ def set_class_dumper(cls: type, dumper: type[AbstractDumper]):
8985
"""
9086

9187

92-
def json_field_to_dataclass_field(cls: type):
88+
def json_field_to_dataclass_field(cls: type) -> dict[str, str | ExplicitNullType]:
9389
"""
9490
Returns a mapping of JSON field to dataclass field.
9591
"""
9692

9793

98-
def dataclass_field_to_json_path(cls: type):
94+
def dataclass_field_to_json_path(cls: type) -> dict[str, PathType]:
9995
"""
10096
Returns a mapping of dataclass field to JSON path.
10197
"""
@@ -151,7 +147,7 @@ def _setup_load_config_for_cls(cls_loader: type[AbstractLoader],
151147
"""
152148

153149

154-
def setup_dump_config_for_cls_if_needed(cls: type):
150+
def setup_dump_config_for_cls_if_needed(cls: type) -> None:
155151
"""
156152
This function processes a class `cls` on an initial run, and sets up the
157153
dump process for `cls` by iterating over each dataclass field. For each
@@ -172,7 +168,7 @@ def setup_dump_config_for_cls_if_needed(cls: type):
172168
"""
173169

174170

175-
def call_meta_initializer_if_needed(cls: type[W]):
171+
def call_meta_initializer_if_needed(cls: type[W]) -> None:
176172
"""
177173
Calls the Meta initializer when the inner :class:`Meta` is sub-classed.
178174
"""
@@ -186,31 +182,31 @@ def get_meta(cls: type) -> META:
186182
"""
187183

188184

189-
def dataclass_fields(cls) -> tuple[Field, ...]:
185+
def dataclass_fields(cls: type) -> tuple[Field, ...]:
190186
"""
191187
Cache the `dataclasses.fields()` call for each class, as overall that
192188
ends up around 5x faster than making a fresh call each time.
193189
194190
"""
195191

196192

197-
def dataclass_init_fields(cls) -> tuple[Field, ...]:
193+
def dataclass_init_fields(cls: type) -> tuple[Field, ...]:
198194
"""Get only the dataclass fields that would be passed into the constructor."""
199195

200196

201-
def dataclass_field_names(cls) -> tuple[str, ...]:
197+
def dataclass_field_names(cls: type) -> tuple[str, ...]:
202198
"""Get the names of all dataclass fields"""
203199

204200

205-
def dataclass_field_to_default(cls) -> dict[str, Any]:
201+
def dataclass_field_to_default(cls: type) -> dict[str, Any]:
206202
"""Get default values for the (optional) dataclass fields."""
207203

208204

209-
def is_builtin_class(cls):
205+
def is_builtin_class(cls: type) -> bool:
210206
"""Check if a class is a builtin in Python."""
211207

212208

213-
def is_builtin(o: Any):
209+
def is_builtin(o: Any) -> bool:
214210
"""Check if an object/singleton/class is a builtin in Python."""
215211

216212

@@ -227,7 +223,7 @@ def get_class_name(class_or_instance) -> str:
227223
"""Return the fully qualified name of a class."""
228224

229225

230-
def get_outer_class_name(inner_cls, default=None, raise_=True):
226+
def get_outer_class_name(inner_cls, default=None, raise_: bool = True) -> str:
231227
"""
232228
Attempt to return the fully qualified name of the outer (enclosing) class,
233229
given a reference to the inner class.
@@ -239,11 +235,11 @@ def get_outer_class_name(inner_cls, default=None, raise_=True):
239235
"""
240236

241237

242-
def get_class(obj):
238+
def get_class(obj: Any) -> type:
243239
"""Get the class for an object `obj`"""
244240

245241

246-
def is_subclass(obj, base_cls: type) -> bool:
242+
def is_subclass(obj: Any, base_cls: type) -> bool:
247243
"""Check if `obj` is a sub-class of `base_cls`"""
248244

249245

dataclass_wizard/dumpers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,8 @@ def dump_func_for_dataclass(cls: Type[T],
431431
show_deprecation_warning(_pre_dict, reason)
432432

433433
_locals['__pre_dict__'] = _pre_dict
434+
435+
# Call the optional hook that runs before we process the dataclass
434436
fn_gen.add_line('__pre_dict__(o)')
435437

436438
# Initialize result list to hold field mappings

dataclass_wizard/loaders.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -643,7 +643,11 @@ def load_func_for_dataclass(
643643
# Fix for using `auto_assign_tags` and `raise_on_unknown_json_key` together
644644
# See https://github.com/rnag/dataclass-wizard/issues/137
645645
has_tag_assigned = meta.tag is not None
646-
if has_tag_assigned:
646+
if (has_tag_assigned and
647+
# Ensure `tag_key` isn't a dataclass field before assigning an
648+
# `ExplicitNull`, as assigning it directly can cause issues.
649+
# See https://github.com/rnag/dataclass-wizard/issues/148
650+
meta.tag_key not in field_to_parser):
647651
json_to_field[meta.tag_key] = ExplicitNull
648652

649653
_locals = {
@@ -743,6 +747,7 @@ def load_func_for_dataclass(
743747
fn_gen.add_line("field = json_to_field[json_key] = ExplicitNull")
744748
fn_gen.add_line("LOG.warning('JSON field %r missing from dataclass schema, "
745749
"class=%r, parsed field=%r',json_key,cls,py_field)")
750+
746751
# Raise an error here (if needed)
747752
if meta.raise_on_unknown_json_key:
748753
_globals['UnknownJSONKey'] = UnknownJSONKey
@@ -759,6 +764,11 @@ def load_func_for_dataclass(
759764
with fn_gen.except_(ParseError, 'e'):
760765
# We run into a parsing error while loading the field value;
761766
# Add additional info on the Exception object before re-raising it.
767+
#
768+
# First confirm these values are not already set by an
769+
# inner dataclass. If so, it likely makes it easier to
770+
# debug the cause. Note that this should already be
771+
# handled by the `setter` methods.
762772
fn_gen.add_line("e.class_name, e.field_name, e.json_object = cls, field, o")
763773
fn_gen.add_line("raise")
764774

@@ -772,9 +782,11 @@ def load_func_for_dataclass(
772782
fn_gen.add_line(line)
773783

774784
with fn_gen.except_(TypeError):
775-
# If the object `o` is None, then raise an error with the relevant info included.
785+
# If the object `o` is None, then raise an error with
786+
# the relevant info included.
776787
with fn_gen.if_('o is None'):
777788
fn_gen.add_line("raise MissingData(cls) from None")
789+
778790
# Check if the object `o` is some other type than what we expect -
779791
# for example, we could be passed in a `list` type instead.
780792
with fn_gen.if_('not isinstance(o, dict)'):
@@ -784,17 +796,20 @@ def load_func_for_dataclass(
784796
# Else, just re-raise the error.
785797
fn_gen.add_line("raise")
786798

787-
# Now pass the arguments to the constructor method, and return the new dataclass instance.
788-
# If there are any missing fields, we raise them here.
789799
if has_catch_all:
790800
if catch_all_field.endswith('?'): # Default value
791801
with fn_gen.if_('catch_all'):
792802
fn_gen.add_line(f'init_kwargs[{catch_all_field.rstrip("?")!r}] = catch_all')
793803
else:
794804
fn_gen.add_line(f'init_kwargs[{catch_all_field!r}] = catch_all')
795805

806+
# Now pass the arguments to the constructor method, and return
807+
# the new dataclass instance. If there are any missing fields,
808+
# we raise them here.
809+
796810
with fn_gen.try_():
797811
fn_gen.add_line("return cls(**init_kwargs)")
812+
798813
with fn_gen.except_(TypeError, 'e'):
799814
fn_gen.add_line("raise MissingFields(e, o, cls, init_kwargs, cls_fields) from None")
800815

tests/unit/test_load.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2441,3 +2441,37 @@ class Example(JSONWizard):
24412441

24422442
# Attempt to serialize an instance, which should raise the error.
24432443
Example(my_field=3).to_dict()
2444+
2445+
2446+
def test_dataclass_in_union_when_tag_key_is_field():
2447+
"""
2448+
Test case for dataclasses in `Union` when the `Meta.tag_key` is a dataclass field.
2449+
"""
2450+
@dataclass
2451+
class DataType(JSONWizard):
2452+
id: int
2453+
type: str
2454+
2455+
@dataclass
2456+
class XML(DataType):
2457+
class _(JSONWizard.Meta):
2458+
tag = "xml"
2459+
2460+
field_type_1: str
2461+
2462+
@dataclass
2463+
class HTML(DataType):
2464+
class _(JSONWizard.Meta):
2465+
tag = "html"
2466+
2467+
field_type_2: str
2468+
2469+
@dataclass
2470+
class Result(JSONWizard):
2471+
class _(JSONWizard.Meta):
2472+
tag_key = "type"
2473+
2474+
data: Union[XML, HTML]
2475+
2476+
t1 = Result.from_dict({"data": {"id": 1, "type": "xml", "field_type_1": "value"}})
2477+
assert t1 == Result(data=XML(id=1, type='xml', field_type_1='value'))

0 commit comments

Comments
 (0)