Skip to content
Open
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
8 changes: 7 additions & 1 deletion dataclass_wizard/_dumpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -931,7 +931,13 @@ def dump_func_for_dataclass(
skip_defaults = True if meta.skip_defaults else False
skip_if = True if field_to_skip_if or skip_if_condition else False

catch_all_name: str | None = field_to_alias.pop(CATCH_ALL, None)
# Note: use `.get()` rather than `.pop()` here -- `field_to_alias` is a
# direct reference to a dict cached per-class (not a copy), so popping
# from it would permanently strip the `CATCH_ALL` marker from the shared
# cache the first time this runs, breaking any later codegen pass for
# this same class in a different structural context (e.g. nested vs.
# top-level). See: reuse-across-contexts regression test.
catch_all_name: str | None = field_to_alias.get(CATCH_ALL)
has_catch_all = catch_all_name is not None

if has_catch_all:
Expand Down
8 changes: 7 additions & 1 deletion dataclass_wizard/_loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -1240,7 +1240,13 @@ def load_func_for_dataclass(

on_unknown_key = meta.on_unknown_key

catch_all_field: str | None = field_to_aliases.pop(CATCH_ALL, None)
# Note: use `.get()` rather than `.pop()` here -- `field_to_aliases` is a
# direct reference to a dict cached per-class (not a copy), so popping
# from it would permanently strip the `CATCH_ALL` marker from the shared
# cache the first time this runs, breaking any later codegen pass for
# this same class in a different structural context (e.g. nested vs.
# top-level). See: reuse-across-contexts regression test.
catch_all_field: str | None = field_to_aliases.get(CATCH_ALL)
has_catch_all = catch_all_field is not None

if has_catch_all:
Expand Down
48 changes: 48 additions & 0 deletions tests/unit/test_loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -2806,6 +2806,54 @@ class _(JSONWizard.Meta):
assert opt == Options(my_extras={}, the_email='x@y.com')


def test_catch_all_reused_across_structural_contexts():
"""
A `CatchAll`-bearing dataclass must load/dump correctly regardless of how
many different "structural contexts" it's used in across a process: as
the direct top-level target of `from_dict`/`to_dict`, and as a field
nested inside one or more different enclosing classes.

Regression test for a bug where `load_func_for_dataclass` /
`dump_func_for_dataclass` popped the `CATCH_ALL` marker off of the
per-class alias dict returned by
`resolve_dataclass_field_to_alias_for_load` /
`resolve_dataclass_field_to_alias_for_dump` -- since that dict is a
direct (not copied) reference to a cache shared across all codegen
passes for the class, the first pass would permanently strip the
marker, and any later codegen pass for the same class (e.g. nested
inside a different enclosing class) would then treat it as having no
`CatchAll` field at all, causing the field's `NewType` annotation to
reach `issubclass()` and raise `TypeError`.
"""
@dataclass
class Leaf(JSONWizard):
name: str
extra_data: CatchAll = field(default_factory=dict)

@dataclass
class WrapperA(JSONWizard):
value: Leaf

@dataclass
class WrapperB(JSONWizard):
other: Leaf

# First use: direct top-level target.
leaf = Leaf.from_dict({'name': 'a', 'unrecognized': 'x'})
assert leaf == Leaf(name='a', extra_data={'unrecognized': 'x'})
assert leaf.to_dict() == {'name': 'a', 'unrecognized': 'x'}

# Second use: nested inside a first enclosing class.
wa = WrapperA.from_dict({'value': {'name': 'b', 'unrecognized': 'y'}})
assert wa == WrapperA(value=Leaf(name='b', extra_data={'unrecognized': 'y'}))
assert wa.to_dict() == {'value': {'name': 'b', 'unrecognized': 'y'}}

# Third use: nested inside a second, different enclosing class.
wb = WrapperB.from_dict({'other': {'name': 'c', 'unrecognized': 'z'}})
assert wb == WrapperB(other=Leaf(name='c', extra_data={'unrecognized': 'z'}))
assert wb.to_dict() == {'other': {'name': 'c', 'unrecognized': 'z'}}


def test_from_dict_with_nested_object_alias_path():
"""
Specifying a custom mapping of "nested" alias to dataclass field,
Expand Down