Skip to content

Commit e99aa7f

Browse files
authored
v0.39-finalize-dumper-for-v1 (#239)
* updates to dumpers.py * update dump from tuple * v1: update load/dump logic for `NamedTuple` * v1: update load/dump logic for `NamedTuple` * v1: update load/dump logic for `NamedTuple` * v1: update load/dump logic for `NamedTuple` * v1: update load/dump logic for `TypedDict` * v1: in `load_to_str`, we no longer implicitly convert `None` to empty `str` ('') * Accordingly, add `Meta.v1_coerce_none_to_empty_str` as an option to retain this behavior * Clean up unused methods in utils/type_conv.py * minor code cleanup * minor code cleanup * code cleanup and add leaf handling (for optional subclass check on leaf types) * add `v1_leaf_handling` * v1: finalize `dump_from_union` * fix bug in nested Unions due to cache key mismatch * update docs * fix * add tests for coverage * fix * Update HISTORY.rst * fix for 3.9 * fix docstrings * fix typing in serial_json.pyi
1 parent 7b79825 commit e99aa7f

23 files changed

+1132
-604
lines changed

HISTORY.rst

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

5+
0.39.0 (2026-01-01)
6+
-------------------
7+
8+
**v1 Improvements & Fixes**
9+
10+
* Optimized v1 dump and encode logic for recursive types. Dumpers now return **JSON-compatible values only** (``dict``, ``list``, ``tuple``, and scalar primitives), improving correctness and performance for deeply nested structures.
11+
12+
* Fixed an issue where nested index assignments were not fully preserved during dump/encode. Index paths such as ``x[0][1]`` are now retained correctly instead of being truncated to ``x[0]``.
13+
14+
* Fixed a bug in v1 nested ``Union`` handling where internal collisions could occur. Union resolution now incorporates a **hash with a salt derived from the Union arguments**, ensuring stable behavior for nested Unions.
15+
16+
**Configuration**
17+
18+
* Added ``Meta.v1_namedtuple_as_dict`` (*default*: ``False``)
19+
- When enabled, named tuples are dumped as dictionaries instead of positional tuples.
20+
21+
* Added ``Meta.v1_coerce_none_to_empty_str`` (*default*: ``False``)
22+
- When enabled, ``None`` values are coerced to empty strings for ``str`` fields during dump/encode.
23+
24+
* Added ``Meta.v1_leaf_handling`` (*default*: ``'exact'``)
25+
- Controls how leaf values are handled during serialization.
26+
27+
**Internal Changes**
28+
29+
* Renamed internal codegen variable ``tp`` to ``t`` for clarity and consistency. This is an internal refactor with no user-facing impact.
30+
531
0.38.2 (2025-12-27)
632
-------------------
733

@@ -40,8 +66,10 @@ New v1 features:
4066
- Environment precedence is now configurable and explicit
4167
- Support for nested ``EnvWizard`` dataclasses
4268
- New aliasing model:
69+
4370
- ``v1_field_to_env_load`` (load-only)
4471
- ``v1_field_to_alias_dump`` (dump-only)
72+
4573
- Added ``Env(...)`` and ``Alias(env=...)`` helpers for field-level env configuration
4674
- Added ``v1_pre_decoder`` to decode JSON or delimited strings into ``dict`` / ``list``
4775
- Cached secrets and dotenv paths for improved performance
@@ -64,10 +92,12 @@ Internal Changes and Fixes
6492
- Improved Windows timezone handling via ``tz`` extra (``tzdata`` / ``ZoneInfo``)
6593
- Improved caching behavior for ``Union`` loaders
6694
- Fixed multiple codegen and caching edge cases:
95+
6796
- ``to_dict`` caching on subclasses
6897
- empty dataclass dumpers
6998
- ``kw_only`` field handling
7099
- FunctionBuilder globals merging
100+
71101
- Added extensive v1 test coverage (90%+)
72102
- No breaking changes without explicit v1 opt-in
73103

README.rst

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
transforms, and support for nested dataclasses. ``DataclassWizard``
5050
also auto-applies ``@dataclass`` to subclasses.
5151

52-
.. important::
52+
.. tip::
5353

5454
A new **v1 engine** is available as an opt-in, offering explicit
5555
environment precedence, nested dataclass support, and improved performance.
@@ -1384,6 +1384,22 @@ What's New in v1.0
13841384
print(MyModel(my_field="value").to_dict())
13851385
# Output: {'my_field': 'value'}
13861386
1387+
- **String Coercion Change (None Handling)**
1388+
1389+
Starting with **v1.0**, ``None`` values for fields annotated as ``str`` are
1390+
converted using ``str(None)`` (i.e. ``'None'``) instead of being silently
1391+
coerced to the empty string.
1392+
1393+
``Optional[str]`` fields continue to preserve ``None`` by default.
1394+
1395+
To restore the previous behavior and coerce ``None`` to ``''``, set:
1396+
1397+
.. code-block:: python3
1398+
1399+
class _(Meta):
1400+
v1 = True
1401+
v1_coerce_none_to_empty_str = True
1402+
13871403
- **Default __str__() Behavior Change**
13881404

13891405
Starting with **v1.0.0**, we no longer pretty-print the serialized JSON value with keys in ``camelCase``.

dataclass_wizard/bases.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,43 @@ class AbstractMeta(metaclass=ABCOrAndMeta):
423423
# deserialization.
424424
v1_assume_naive_datetime_tz: ClassVar[tzinfo | None] = None
425425

426+
# Controls how `typing.NamedTuple` and `collections.namedtuple`
427+
# fields are loaded and serialized.
428+
#
429+
# - False (DEFAULT): load from list/tuple and serialize
430+
# as a positional list.
431+
# - True: load from mapping and serialize as a dict
432+
# keyed by field name.
433+
#
434+
# In strict mode, inputs that do not match the selected mode
435+
# raise TypeError.
436+
#
437+
# Note:
438+
# This option enforces strict shape matching for performance reasons.
439+
v1_namedtuple_as_dict: bool = None
440+
441+
# If True (default: False), ``None`` is coerced to an empty string (``""``)
442+
# when loading ``str`` fields.
443+
#
444+
# When False, ``None`` is coerced using ``str(value)``, so ``None`` becomes
445+
# the literal string ``'None'`` for ``str`` fields.
446+
#
447+
# For ``Optional[str]`` fields, ``None`` is preserved by default.
448+
v1_coerce_none_to_empty_str: bool = None
449+
450+
# Controls how leaf (non-recursive) types are detected during serialization.
451+
#
452+
# - "exact" (DEFAULT): only exact built-in leaf types are treated as leaf values.
453+
# - "issubclass": subclasses of leaf types are also treated as leaf values.
454+
#
455+
# Leaf types are returned without recursive traversal. Bytes are still
456+
# handled separately according to their serialization rules.
457+
#
458+
# Note:
459+
# The default "exact" mode avoids treating third-party scalar-like
460+
# objects (e.g. NumPy scalars) as built-in leaf types.
461+
v1_leaf_handling: Literal['exact', 'issubclass'] = None
462+
426463
# noinspection PyMethodParameters
427464
@cached_class_property
428465
def all_fields(cls) -> FrozenKeys:
@@ -712,6 +749,43 @@ class AbstractEnvMeta(metaclass=ABCOrAndMeta):
712749
# deserialization.
713750
v1_assume_naive_datetime_tz: ClassVar[tzinfo | None] = None
714751

752+
# Controls how `typing.NamedTuple` and `collections.namedtuple`
753+
# fields are loaded and serialized.
754+
#
755+
# - False (DEFAULT): load from list/tuple and serialize
756+
# as a positional list.
757+
# - True: load from mapping and serialize as a dict
758+
# keyed by field name.
759+
#
760+
# In strict mode, inputs that do not match the selected mode
761+
# raise TypeError.
762+
#
763+
# Note:
764+
# This option enforces strict shape matching for performance reasons.
765+
v1_namedtuple_as_dict: bool = None
766+
767+
# If True (default: False), ``None`` is coerced to an empty string (``""``)
768+
# when loading ``str`` fields.
769+
#
770+
# When False, ``None`` is coerced using ``str(value)``, so ``None`` becomes
771+
# the literal string ``'None'`` for ``str`` fields.
772+
#
773+
# For ``Optional[str]`` fields, ``None`` is preserved by default.
774+
v1_coerce_none_to_empty_str: bool = None
775+
776+
# Controls how leaf (non-recursive) types are detected during serialization.
777+
#
778+
# - "exact" (DEFAULT): only exact built-in leaf types are treated as leaf values.
779+
# - "issubclass": subclasses of leaf types are also treated as leaf values.
780+
#
781+
# Leaf types are returned without recursive traversal. Bytes are still
782+
# handled separately according to their serialization rules.
783+
#
784+
# Note:
785+
# The default "exact" mode avoids treating third-party scalar-like
786+
# objects (e.g. NumPy scalars) as built-in leaf types.
787+
v1_leaf_handling: Literal['exact', 'issubclass'] = None
788+
715789
# noinspection PyMethodParameters
716790
@cached_class_property
717791
def all_fields(cls) -> FrozenKeys:

dataclass_wizard/bases_meta.pyi

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,10 @@ def LoadMeta(*,
9292
v1_case: KeyCase | str | None = MISSING,
9393
v1_field_to_alias: Mapping[str, str | Sequence[str]] = MISSING,
9494
v1_on_unknown_key: KeyAction | str | None = KeyAction.IGNORE,
95-
v1_unsafe_parse_dataclass_in_union: bool = MISSING) -> T | META:
95+
v1_unsafe_parse_dataclass_in_union: bool = MISSING,
96+
v1_namedtuple_as_dict: bool = MISSING,
97+
v1_coerce_none_to_empty_str: bool = MISSING,
98+
v1_leaf_handling: Literal['exact', 'issubclass'] = MISSING) -> T | META:
9699
...
97100

98101

@@ -114,7 +117,9 @@ def DumpMeta(*,
114117
v1_case: KeyCase | str | None = MISSING,
115118
v1_field_to_alias: Mapping[str, str | Sequence[str]] = MISSING,
116119
v1_dump_date_time_as: V1DateTimeTo | str = MISSING,
117-
v1_assume_naive_datetime_tz: tzinfo | None = MISSING) -> T | META:
120+
v1_assume_naive_datetime_tz: tzinfo | None = MISSING,
121+
v1_namedtuple_as_dict: bool = MISSING,
122+
v1_leaf_handling: Literal['exact', 'issubclass'] = MISSING) -> T | META:
118123
...
119124

120125

@@ -148,5 +153,8 @@ def EnvMeta(*, debug_enabled: 'bool | int | str' = MISSING,
148153
# v1_on_unknown_key: KeyAction | str | None = KeyAction.IGNORE,
149154
v1_unsafe_parse_dataclass_in_union: bool = MISSING,
150155
v1_dump_date_time_as: V1DateTimeTo | str = MISSING,
151-
v1_assume_naive_datetime_tz: tzinfo | None = MISSING) -> META:
156+
v1_assume_naive_datetime_tz: tzinfo | None = MISSING,
157+
v1_namedtuple_as_dict: bool = MISSING,
158+
v1_coerce_none_to_empty_str: bool = MISSING,
159+
v1_leaf_handling: Literal['exact', 'issubclass'] = MISSING) -> META:
152160
...

dataclass_wizard/errors.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from abc import ABC, abstractmethod
2-
from dataclasses import Field, MISSING
2+
from dataclasses import Field, MISSING, is_dataclass
33
from typing import (Any, Type, Dict, Tuple, ClassVar,
44
Optional, Union, Iterable, Callable, Collection, Sequence)
55

@@ -285,7 +285,8 @@ def message(self) -> str:
285285
# see https://github.com/rnag/dataclass-wizard/issues/54 for more info
286286

287287
normalized_json_keys = [normalize(key) for key in obj]
288-
if next((f for f in self.missing_fields if normalize(f) in normalized_json_keys), None):
288+
if (is_dataclass(self.parent_cls) and
289+
next((f for f in self.missing_fields if normalize(f) in normalized_json_keys), None)):
289290
from .enums import LetterCase
290291
from .v1.enums import KeyCase
291292
from .loader_selection import get_loader

dataclass_wizard/serial_json.pyi

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,7 @@ class SerializerHookMixin(Protocol):
6868
...
6969

7070

71-
class JSONPyWizard(JSONSerializable, SerializerHookMixin):
72-
"""Helper for JSONWizard that ensures dumping to JSON keeps keys as-is."""
73-
74-
75-
class JSONSerializable(DataclassWizard, SerializerHookMixin): ...
76-
77-
78-
@dataclass_transform()
79-
class DataclassWizard(AbstractJSONWizard, SerializerHookMixin):
71+
class JSONWizardImpl(AbstractJSONWizard, SerializerHookMixin):
8072
"""
8173
Mixin class to allow a `dataclass` sub-class to be easily converted
8274
to and from JSON.
@@ -201,9 +193,22 @@ class DataclassWizard(AbstractJSONWizard, SerializerHookMixin):
201193
...
202194

203195

196+
@dataclass_transform()
197+
class DataclassWizard(JSONWizardImpl):
198+
...
199+
200+
201+
class JSONPyWizard(JSONWizardImpl):
202+
"""Helper for JSONWizard that ensures dumping to JSON keeps keys as-is."""
203+
204+
205+
class JSONSerializable(JSONWizardImpl): ...
206+
207+
204208
def _str_fn() -> Callable[[W], str]:
205209
"""
206210
Converts the dataclass instance to a *prettified* JSON string
207211
representation, when the `str()` method is invoked.
208212
"""
209213
...
214+

dataclass_wizard/utils/function_builder.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from dataclasses import MISSING
2+
from typing import Any
23

34
from ..log import LOG
45

@@ -67,7 +68,7 @@ def function(self, name: str, args: list, return_type=MISSING,
6768
def _with_new_block(self,
6869
name: str,
6970
condition: 'str | None' = None,
70-
comment: str = '') -> 'FunctionBuilder':
71+
comment: Any = '') -> 'FunctionBuilder':
7172
"""Creates a new block. Used with a context manager (with)."""
7273
indent = ' ' * self.indent_level
7374

@@ -97,7 +98,7 @@ def for_(self, condition: str) -> 'FunctionBuilder':
9798
"""
9899
return self._with_new_block('for', condition)
99100

100-
def if_(self, condition: str, comment: str = '') -> 'FunctionBuilder':
101+
def if_(self, condition: str, comment: Any = '') -> 'FunctionBuilder':
101102
"""Equivalent to the `if` statement in Python.
102103
103104
Sample Usage:

dataclass_wizard/utils/json_util.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,7 @@ def default(self, o: Any) -> Any:
5151

5252

5353
def safe_dumps(o, cls=SafeEncoder, **kwargs):
54-
return dumps(o, cls=cls, **kwargs)
54+
try:
55+
return dumps(o, cls=cls, **kwargs)
56+
except TypeError:
57+
return o

0 commit comments

Comments
 (0)