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
30 changes: 30 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@
History
=======

0.39.0 (2026-01-01)
-------------------

**v1 Improvements & Fixes**

* 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.

* 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]``.

* 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.

**Configuration**

* Added ``Meta.v1_namedtuple_as_dict`` (*default*: ``False``)
- When enabled, named tuples are dumped as dictionaries instead of positional tuples.

* Added ``Meta.v1_coerce_none_to_empty_str`` (*default*: ``False``)
- When enabled, ``None`` values are coerced to empty strings for ``str`` fields during dump/encode.

* Added ``Meta.v1_leaf_handling`` (*default*: ``'exact'``)
- Controls how leaf values are handled during serialization.

**Internal Changes**

* Renamed internal codegen variable ``tp`` to ``t`` for clarity and consistency. This is an internal refactor with no user-facing impact.

0.38.2 (2025-12-27)
-------------------

Expand Down Expand Up @@ -40,8 +66,10 @@ New v1 features:
- Environment precedence is now configurable and explicit
- Support for nested ``EnvWizard`` dataclasses
- New aliasing model:

- ``v1_field_to_env_load`` (load-only)
- ``v1_field_to_alias_dump`` (dump-only)

- Added ``Env(...)`` and ``Alias(env=...)`` helpers for field-level env configuration
- Added ``v1_pre_decoder`` to decode JSON or delimited strings into ``dict`` / ``list``
- Cached secrets and dotenv paths for improved performance
Expand All @@ -64,10 +92,12 @@ Internal Changes and Fixes
- Improved Windows timezone handling via ``tz`` extra (``tzdata`` / ``ZoneInfo``)
- Improved caching behavior for ``Union`` loaders
- Fixed multiple codegen and caching edge cases:

- ``to_dict`` caching on subclasses
- empty dataclass dumpers
- ``kw_only`` field handling
- FunctionBuilder globals merging

- Added extensive v1 test coverage (90%+)
- No breaking changes without explicit v1 opt-in

Expand Down
18 changes: 17 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
transforms, and support for nested dataclasses. ``DataclassWizard``
also auto-applies ``@dataclass`` to subclasses.

.. important::
.. tip::

A new **v1 engine** is available as an opt-in, offering explicit
environment precedence, nested dataclass support, and improved performance.
Expand Down Expand Up @@ -1384,6 +1384,22 @@ What's New in v1.0
print(MyModel(my_field="value").to_dict())
# Output: {'my_field': 'value'}

- **String Coercion Change (None Handling)**

Starting with **v1.0**, ``None`` values for fields annotated as ``str`` are
converted using ``str(None)`` (i.e. ``'None'``) instead of being silently
coerced to the empty string.

``Optional[str]`` fields continue to preserve ``None`` by default.

To restore the previous behavior and coerce ``None`` to ``''``, set:

.. code-block:: python3

class _(Meta):
v1 = True
v1_coerce_none_to_empty_str = True

- **Default __str__() Behavior Change**

Starting with **v1.0.0**, we no longer pretty-print the serialized JSON value with keys in ``camelCase``.
Expand Down
74 changes: 74 additions & 0 deletions dataclass_wizard/bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,43 @@ class AbstractMeta(metaclass=ABCOrAndMeta):
# deserialization.
v1_assume_naive_datetime_tz: ClassVar[tzinfo | None] = None

# Controls how `typing.NamedTuple` and `collections.namedtuple`
# fields are loaded and serialized.
#
# - False (DEFAULT): load from list/tuple and serialize
# as a positional list.
# - True: load from mapping and serialize as a dict
# keyed by field name.
#
# In strict mode, inputs that do not match the selected mode
# raise TypeError.
#
# Note:
# This option enforces strict shape matching for performance reasons.
v1_namedtuple_as_dict: bool = None

# If True (default: False), ``None`` is coerced to an empty string (``""``)
# when loading ``str`` fields.
#
# When False, ``None`` is coerced using ``str(value)``, so ``None`` becomes
# the literal string ``'None'`` for ``str`` fields.
#
# For ``Optional[str]`` fields, ``None`` is preserved by default.
v1_coerce_none_to_empty_str: bool = None

# Controls how leaf (non-recursive) types are detected during serialization.
#
# - "exact" (DEFAULT): only exact built-in leaf types are treated as leaf values.
# - "issubclass": subclasses of leaf types are also treated as leaf values.
#
# Leaf types are returned without recursive traversal. Bytes are still
# handled separately according to their serialization rules.
#
# Note:
# The default "exact" mode avoids treating third-party scalar-like
# objects (e.g. NumPy scalars) as built-in leaf types.
v1_leaf_handling: Literal['exact', 'issubclass'] = None

# noinspection PyMethodParameters
@cached_class_property
def all_fields(cls) -> FrozenKeys:
Expand Down Expand Up @@ -712,6 +749,43 @@ class AbstractEnvMeta(metaclass=ABCOrAndMeta):
# deserialization.
v1_assume_naive_datetime_tz: ClassVar[tzinfo | None] = None

# Controls how `typing.NamedTuple` and `collections.namedtuple`
# fields are loaded and serialized.
#
# - False (DEFAULT): load from list/tuple and serialize
# as a positional list.
# - True: load from mapping and serialize as a dict
# keyed by field name.
#
# In strict mode, inputs that do not match the selected mode
# raise TypeError.
#
# Note:
# This option enforces strict shape matching for performance reasons.
v1_namedtuple_as_dict: bool = None

# If True (default: False), ``None`` is coerced to an empty string (``""``)
# when loading ``str`` fields.
#
# When False, ``None`` is coerced using ``str(value)``, so ``None`` becomes
# the literal string ``'None'`` for ``str`` fields.
#
# For ``Optional[str]`` fields, ``None`` is preserved by default.
v1_coerce_none_to_empty_str: bool = None

# Controls how leaf (non-recursive) types are detected during serialization.
#
# - "exact" (DEFAULT): only exact built-in leaf types are treated as leaf values.
# - "issubclass": subclasses of leaf types are also treated as leaf values.
#
# Leaf types are returned without recursive traversal. Bytes are still
# handled separately according to their serialization rules.
#
# Note:
# The default "exact" mode avoids treating third-party scalar-like
# objects (e.g. NumPy scalars) as built-in leaf types.
v1_leaf_handling: Literal['exact', 'issubclass'] = None

# noinspection PyMethodParameters
@cached_class_property
def all_fields(cls) -> FrozenKeys:
Expand Down
14 changes: 11 additions & 3 deletions dataclass_wizard/bases_meta.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@ def LoadMeta(*,
v1_case: KeyCase | str | None = MISSING,
v1_field_to_alias: Mapping[str, str | Sequence[str]] = MISSING,
v1_on_unknown_key: KeyAction | str | None = KeyAction.IGNORE,
v1_unsafe_parse_dataclass_in_union: bool = MISSING) -> T | META:
v1_unsafe_parse_dataclass_in_union: bool = MISSING,
v1_namedtuple_as_dict: bool = MISSING,
v1_coerce_none_to_empty_str: bool = MISSING,
v1_leaf_handling: Literal['exact', 'issubclass'] = MISSING) -> T | META:
...


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


Expand Down Expand Up @@ -148,5 +153,8 @@ def EnvMeta(*, debug_enabled: 'bool | int | str' = MISSING,
# v1_on_unknown_key: KeyAction | str | None = KeyAction.IGNORE,
v1_unsafe_parse_dataclass_in_union: bool = MISSING,
v1_dump_date_time_as: V1DateTimeTo | str = MISSING,
v1_assume_naive_datetime_tz: tzinfo | None = MISSING) -> META:
v1_assume_naive_datetime_tz: tzinfo | None = MISSING,
v1_namedtuple_as_dict: bool = MISSING,
v1_coerce_none_to_empty_str: bool = MISSING,
v1_leaf_handling: Literal['exact', 'issubclass'] = MISSING) -> META:
...
5 changes: 3 additions & 2 deletions dataclass_wizard/errors.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod
from dataclasses import Field, MISSING
from dataclasses import Field, MISSING, is_dataclass
from typing import (Any, Type, Dict, Tuple, ClassVar,
Optional, Union, Iterable, Callable, Collection, Sequence)

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

normalized_json_keys = [normalize(key) for key in obj]
if next((f for f in self.missing_fields if normalize(f) in normalized_json_keys), None):
if (is_dataclass(self.parent_cls) and
next((f for f in self.missing_fields if normalize(f) in normalized_json_keys), None)):
from .enums import LetterCase
from .v1.enums import KeyCase
from .loader_selection import get_loader
Expand Down
23 changes: 14 additions & 9 deletions dataclass_wizard/serial_json.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,7 @@ class SerializerHookMixin(Protocol):
...


class JSONPyWizard(JSONSerializable, SerializerHookMixin):
"""Helper for JSONWizard that ensures dumping to JSON keeps keys as-is."""


class JSONSerializable(DataclassWizard, SerializerHookMixin): ...


@dataclass_transform()
class DataclassWizard(AbstractJSONWizard, SerializerHookMixin):
class JSONWizardImpl(AbstractJSONWizard, SerializerHookMixin):
"""
Mixin class to allow a `dataclass` sub-class to be easily converted
to and from JSON.
Expand Down Expand Up @@ -201,9 +193,22 @@ class DataclassWizard(AbstractJSONWizard, SerializerHookMixin):
...


@dataclass_transform()
class DataclassWizard(JSONWizardImpl):
...


class JSONPyWizard(JSONWizardImpl):
"""Helper for JSONWizard that ensures dumping to JSON keeps keys as-is."""


class JSONSerializable(JSONWizardImpl): ...


def _str_fn() -> Callable[[W], str]:
"""
Converts the dataclass instance to a *prettified* JSON string
representation, when the `str()` method is invoked.
"""
...

5 changes: 3 additions & 2 deletions dataclass_wizard/utils/function_builder.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from dataclasses import MISSING
from typing import Any

from ..log import LOG

Expand Down Expand Up @@ -67,7 +68,7 @@ def function(self, name: str, args: list, return_type=MISSING,
def _with_new_block(self,
name: str,
condition: 'str | None' = None,
comment: str = '') -> 'FunctionBuilder':
comment: Any = '') -> 'FunctionBuilder':
"""Creates a new block. Used with a context manager (with)."""
indent = ' ' * self.indent_level

Expand Down Expand Up @@ -97,7 +98,7 @@ def for_(self, condition: str) -> 'FunctionBuilder':
"""
return self._with_new_block('for', condition)

def if_(self, condition: str, comment: str = '') -> 'FunctionBuilder':
def if_(self, condition: str, comment: Any = '') -> 'FunctionBuilder':
"""Equivalent to the `if` statement in Python.

Sample Usage:
Expand Down
5 changes: 4 additions & 1 deletion dataclass_wizard/utils/json_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,7 @@ def default(self, o: Any) -> Any:


def safe_dumps(o, cls=SafeEncoder, **kwargs):
return dumps(o, cls=cls, **kwargs)
try:
return dumps(o, cls=cls, **kwargs)
except TypeError:
return o
Loading
Loading