Skip to content

Commit

Permalink
V0.34: support recursive types and end-to-end (E2E) support for `byte…
Browse files Browse the repository at this point in the history
…s` and `bytearray` (#171)

* `v1`: Support recursive types and bytes

* show deprecation warning for `debug_enabled`

* add test case for `bytes` and `bytearray`

* Performance fixes and updates

* `load_to_str`: Support subclass of `str`
* `load_to_str`: minor perf. fix, skip `None` check if in `Optional`
* `load_to_int`: Support subclass of `int`
* `load_to_float`: Support subclass of `float`
* `load_to_bool`: minor perf. fix, inline rather than call `as_bool`
* `load_to_bytearray`: Support subclass of `bytearray`
* `load_to_uuid`: Support subclass of `UUID`
* `load_to_decimal`: Support subclass of `Decimal`
* `load_to_path`: Support subclass of `Path`
* `load_to_timedelta`: Support subclass of `timedelta`
* `time`, `date`, `datetime`: perf. fix, inline `fromisoformat`
* Add test cases for `LiteralString`, `Decimal`, `Path`, `None`, `Enum`

* code cleanup and add tests for `StrEnum` and `IntEnum`

* minor code refactor

* don't need to register load hook for `Enum`

* code cleanup

* fix return type

* optimize `load_to_named_tuple`

* optimize named tuple de-ser, need to add tests

* change name to `recursive_guard`

* refactor code

* refactor code to use decorators.py

* code cleanup

* update benchmarks and tests

* add test for Literal recursive

* make "load to NamedTuple" recursive

* make "load to TypedDict" recursive

* move `dataclasses-json` to slower pakages

* fixing typing issues

* Support `TypeAliasType` and recursive `Union` in `load_to_union`

* Add support for `TypeAliasType` (e.g. `type` statement in Python 3.12+)
* Support recursive `Union` in `load_to_union`

* Update `make check` to replace version in `README.rst`

* move Python 3.12 specific tests to test_union_as_type_alias_recursive.py

* fix

* fix

* fix

* fix

* fix

* fix

* Add docs on "Recursive Types"

* Update docs on "Recursive Types"

* Merge sections together

* Update docs on "Supported Types"

* Update docs on "Supported Types"

* Update docs on "Supported Types"

* update HISTORY.rst

* update HISTORY.rst
  • Loading branch information
rnag authored Dec 30, 2024
1 parent 1511fdc commit a637982
Show file tree
Hide file tree
Showing 40 changed files with 1,666 additions and 659 deletions.
4 changes: 4 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ exclude_lines =
# Conditional code which is dependent on the OS, or `os.name`
if name == 'nt':

# This will exclude all lines starting with something like
# if PY311_OR_ABOVE: or if PY310_BETA:.
if PY\d+_\w+:

# Don't complain if tests don't hit defensive assertion code:
raise AssertionError
raise NotImplementedError
Expand Down
30 changes: 30 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@
History
=======

0.34.0 (2024-12-30)
-------------------

**Features and Improvements**

- **V1 Opt-in**
- Support for recursive types OOTB for the following Python types:
- ``NamedTuple``
- ``TypedDict``
- ``Union``
- ``Literal``
- Nested `dataclasses`
- `Type aliases`_ (introduced in Python 3.12+)
- Full support for ``bytes`` and ``bytearray`` in the de/serialization process (fixes :issue:`140`).
- Performance improvements: Optimized Load functions for ``bool``, ``NamedTuple``, ``datetime``, ``date``, and ``time``.
- Added support for `Type aliases`_ (via ``type`` statement in Python 3.12+).
- Improved logic in ``load_to_str`` to better check if it's within an ``Optional[...]`` type.
- Enhanced handling of sub-types in de/serialization (**TODO**: add test cases).
- Show deprecation warning for Meta setting ``debug_enabled`` (replaced by ``v1_debug``).

- Updated benchmarks for improved accuracy.

**Bugfixes**

- Fixed issue where code generation failed to correctly account for indexes, especially when nested collection types like ``dict`` were used within a ``NamedTuple``.
- ``make check`` now works out-of-the-box for validating ``README.rst`` and other RST files for PyPI deployment.
- :pr:`169`: Explicitly added ``utf-8`` encoding for ``setup.py`` to enable installation from source on Windows (shoutout to :user:`birkholz-cubert`!).

.. _Type aliases: https://docs.python.org/3/library/typing.html#type-aliases

0.33.0 (2024-12-17)
-------------------

Expand Down
21 changes: 20 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,33 @@ servedocs: docs ## compile the docs watching for changes
release: dist ## package and upload a release
twine upload dist/*

check: dist ## verify release before upload to PyPI
check: dist-local ## verify release before upload to PyPI
twine check dist/*

dist: clean ## builds source and wheel package
python setup.py sdist
python setup.py bdist_wheel
ls -l dist

dist-local: clean replace_version ## builds source and wheel package (for local testing)
python setup.py sdist
python setup.py bdist_wheel
ls -l dist
$(MAKE) revert_readme

replace_version: ## replace |version| in README.rst with the current version
cp README.rst README.rst.bak
python -c "import re; \
from pathlib import Path; \
version = re.search(r\"__version__\\s*=\\s*'(.+?)'\", Path('dataclass_wizard/__version__.py').read_text()).group(1); \
readme_path = Path('README.rst'); \
readme_content = readme_path.read_text(); \
readme_path.write_text(readme_content.replace('|version|', version)); \
print(f'Replaced version in {readme_path}: {version}')"

revert_readme: ## revert README.rst to its original state
mv README.rst.bak README.rst

install: clean ## install the package to the active Python's site-packages
python setup.py install

Expand Down
155 changes: 135 additions & 20 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ This library supports **Python 3.9+**. Support for Python 3.6 – 3.8 was
available in earlier releases but is no longer maintained, as those
versions no longer receive security updates.

For convenience, the table below outlines the last compatible version
For convenience, the table below outlines the last compatible release
of *Dataclass Wizard* for unsupported Python versions (3.6 – 3.8):

.. list-table::
Expand All @@ -153,15 +153,15 @@ of *Dataclass Wizard* for unsupported Python versions (3.6 – 3.8):
* - Python Version
- Last Version of ``dataclass-wizard``
- Python EOL
* - 3.6
* - 3.8
- 0.26.1_
- 2021-12-23
- 2024-10-07
* - 3.7
- 0.26.1_
- 2023-06-27
* - 3.8
* - 3.6
- 0.26.1_
- 2024-10-07
- 2021-12-23

.. _0.26.1: https://pypi.org/project/dataclass-wizard/0.26.1/
.. _PyPI: https://pypi.org/project/dataclass-wizard/
Expand Down Expand Up @@ -834,10 +834,10 @@ A brief example of the intended usage is shown below:
# serialization. In fact, it'll be faster than parsing the custom patterns!
assert class_obj == fromdict(MyClass, asdict(class_obj))
"Recursive" Dataclasses with Cyclic References
----------------------------------------------
Recursive Types and Dataclasses with Cyclic References
------------------------------------------------------

Prior to version `v0.27.0`, dataclasses with cyclic references
Prior to version **0.27.0**, dataclasses with cyclic references
or self-referential structures were not supported. This
limitation is shown in the following toy example:

Expand All @@ -851,28 +851,24 @@ limitation is shown in the following toy example:
a = A(a=A(a=A(a=A())))
This was a `longstanding issue`_.

New in ``v0.27.0``: The Dataclass Wizard now extends its support
to cyclic and self-referential dataclass models.
This was a `longstanding issue`_, but starting with ``v0.27.0``, Dataclass Wizard now supports
recursive dataclasses, including cyclic references.

The example below demonstrates recursive dataclasses with cyclic
dependencies, following the pattern ``A -> B -> A -> B``. For more details, see
the `Cyclic or "Recursive" Dataclasses`_ section in the documentation.
The example below demonstrates recursive
dataclasses with cyclic dependencies, following the pattern ``A -> B -> A -> B``.
For more details, see the `Cyclic or "Recursive" Dataclasses`_ section in the documentation.

.. code:: python3
from __future__ import annotations # This can be removed in Python 3.10+
from dataclasses import dataclass
from dataclass_wizard import JSONWizard
@dataclass
class A(JSONWizard):
class _(JSONWizard.Meta):
# enable support for self-referential / recursive dataclasses
# Enable support for self-referential / recursive dataclasses
recursive_classes = True
b: 'B | None' = None
Expand All @@ -882,13 +878,132 @@ the `Cyclic or "Recursive" Dataclasses`_ section in the documentation.
class B:
a: A | None = None
# confirm that `from_dict` with a recursive, self-referential
# Confirm that `from_dict` with a recursive, self-referential
# input `dict` works as expected.
a = A.from_dict({'b': {'a': {'b': {'a': None}}}})
assert a == A(b=B(a=A(b=B())))
Starting with version **0.34.0**, recursive types are supported *out of the box* (OOTB) with ``v1`` opt-in,
removing the need for any ``Meta`` settings like ``recursive_classes = True``.

This makes working with recursive dataclasses even easier and more streamlined. In addition, recursive types
are now supported for the following Python type constructs:

- NamedTuple_
- TypedDict_
- Union_
- Literal_
- Nested dataclasses_
- `Type aliases`_ (introduced in Python 3.12+)

.. _NamedTuple: https://docs.python.org/3/library/typing.html#typing.NamedTuple
.. _TypedDict: https://docs.python.org/3/library/typing.html#typing.TypedDict
.. _Union: https://docs.python.org/3/library/typing.html#typing.Union
.. _Literal: https://docs.python.org/3/library/typing.html#typing.Literal
.. _Type aliases: https://docs.python.org/3/library/typing.html#type-aliases

Example Usage
~~~~~~~~~~~~~

Recursive types allow handling complex nested data structures, such as deeply nested JSON objects or lists.
With ``v0.34.0`` of Dataclass Wizard, de/serializing these structures becomes seamless
and more intuitive.

Recursive ``Union``
###################

.. code-block:: python3
from dataclasses import dataclass
from dataclass_wizard import JSONWizard
# For Python 3.9, use this `Union` approach:
from typing_extensions import TypeAlias
JSON: TypeAlias = 'str | int | float | bool | dict[str, JSON] | list[JSON] | None'
# For Python 3.10 and above, use this simpler approach:
# JSON = str | int | float | bool | dict[str, 'JSON'] | list['JSON'] | None
# For Python 3.12+, you can use the `type` statement:
# type JSON = str | int | float | bool | dict[str, JSON] | list[JSON] | None
@dataclass
class MyTestClass(JSONWizard):
class _(JSONWizard.Meta):
v1 = True
name: str
meta: str
msg: JSON
x = MyTestClass.from_dict(
{
"name": "name",
"meta": "meta",
"msg": [{"x": {"x": [{"x": ["x", 1, 1.0, True, None]}]}}],
}
)
assert x == MyTestClass(
name="name",
meta="meta",
msg=[{"x": {"x": [{"x": ["x", 1, 1.0, True, None]}]}}],
)
.. note::
The ``type`` statement in Python 3.12+ simplifies type alias definitions by avoiding string annotations for recursive references.

Recursive ``Union`` with Nested ``dataclasses``
###############################################

.. code-block:: python3
from dataclasses import dataclass, field
from dataclass_wizard import JSONWizard
@dataclass
class A(JSONWizard):
class _(JSONWizard.Meta):
v1 = True
value: int
nested: 'B'
next: 'A | None' = None
@dataclass
class B:
items: list[A] = field(default_factory=list)
x = A.from_dict(
{
"value": 1,
"next": {"value": 2, "next": None, "nested": {}},
"nested": {"items": [{"value": 3, "nested": {}}]},
}
)
assert x == A(
value=1,
next=A(value=2, next=None, nested=B(items=[])),
nested=B(items=[A(value=3, nested=B())]),
)
.. note::
Nested ``dataclasses`` are particularly useful for representing hierarchical structures, such as trees or graphs, in a readable and maintainable way.

Official References
~~~~~~~~~~~~~~~~~~~

For more information, see:

- `Typing in Python <https://docs.python.org/3/library/typing.html>`_
- `PEP 695: Type Syntax <https://peps.python.org/pep-0695/>`_

These examples illustrate the power of recursive types in simplifying complex data structures while leveraging the functionality of ``dataclass-wizard``.

Dataclasses in ``Union`` Types
------------------------------

Expand Down
Binary file modified benchmarks/catch_all.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 12 additions & 11 deletions benchmarks/complex.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ class Name(NamedTuple):
last: str
salutation: Optional[str] = 'Mr.'


@dataclass
class NameDataclass:
first: str
Expand Down Expand Up @@ -218,14 +219,14 @@ def test_load(request, data, data_2, data_dacite, n):
"""
[ RESULTS ON MAC OS X ]
benchmarks.complex.complex - [INFO] dataclass-wizard 0.373847
benchmarks.complex.complex - [INFO] dataclass-factory 0.777164
benchmarks.complex.complex - [INFO] dataclasses-json 28.177022
benchmarks.complex.complex - [INFO] dacite 6.619898
benchmarks.complex.complex - [INFO] mashumaro 0.351623
benchmarks.complex.complex - [INFO] pydantic 0.563395
benchmarks.complex.complex - [INFO] jsons 30.564242
benchmarks.complex.complex - [INFO] jsons (strict) 35.122489
benchmarks.complex.complex - [INFO] dataclass-wizard 0.325364
benchmarks.complex.complex - [INFO] dataclass-factory 0.773195
benchmarks.complex.complex - [INFO] dataclasses-json 28.435088
benchmarks.complex.complex - [INFO] dacite 6.287875
benchmarks.complex.complex - [INFO] mashumaro 0.344701
benchmarks.complex.complex - [INFO] pydantic 0.547749
benchmarks.complex.complex - [INFO] jsons 29.978993
benchmarks.complex.complex - [INFO] jsons (strict) 34.052532
"""
g = globals().copy()
g.update(locals())
Expand All @@ -236,9 +237,6 @@ def test_load(request, data, data_2, data_dacite, n):
log.info('dataclass-factory %f',
timeit('factory.load(data_2, MyClass)', globals=g, number=n))

log.info('dataclasses-json %f',
timeit('MyClassDJ.from_dict(data_2)', globals=g, number=n))

log.info('dacite %f',
timeit('dacite_from_dict(MyClassDacite, data_dacite, config=dacite_cfg)',
globals=g, number=n))
Expand All @@ -264,6 +262,9 @@ def test_load(request, data, data_2, data_dacite, n):
if not request.config.getoption("--all"):
pytest.skip("Skipping benchmarks for the rest by default, unless --all is specified.")

log.info('dataclasses-json %f',
timeit('MyClassDJ.from_dict(data_2)', globals=g, number=n))

log.info('jsons %f',
timeit('MyClassJsons.load(data)', globals=g, number=n))

Expand Down
14 changes: 7 additions & 7 deletions benchmarks/nested.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,13 +214,13 @@ def test_load(request, data, n):
"""
[ RESULTS ON MAC OS X ]
benchmarks.nested.nested - [INFO] dataclass-wizard 0.135700
benchmarks.nested.nested - [INFO] dataclass-factory 0.412265
benchmarks.nested.nested - [INFO] dataclasses-json 11.448704
benchmarks.nested.nested - [INFO] mashumaro 0.150680
benchmarks.nested.nested - [INFO] pydantic 0.328947
benchmarks.nested.nested - [INFO] jsons 25.052287
benchmarks.nested.nested - [INFO] jsons (strict) 43.233567
benchmarks.nested.nested - [INFO] dataclass-wizard 0.130734
benchmarks.nested.nested - [INFO] dataclass-factory 0.404371
benchmarks.nested.nested - [INFO] dataclasses-json 11.315233
benchmarks.nested.nested - [INFO] mashumaro 0.158986
benchmarks.nested.nested - [INFO] pydantic 0.330295
benchmarks.nested.nested - [INFO] jsons 25.084872
benchmarks.nested.nested - [INFO] jsons (strict) 28.306646
"""
g = globals().copy()
Expand Down
5 changes: 3 additions & 2 deletions dataclass_wizard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
~~~~~~~~~~~~~~~~
Lightning-fast JSON wizardry for Python dataclasses — effortless
serialization with no external tools required!
serialization right out of the box!
Sample Usage:
Expand Down Expand Up @@ -119,6 +119,7 @@
import logging

from .bases_meta import LoadMeta, DumpMeta, EnvMeta
from .constants import PACKAGE_NAME
from .dumpers import DumpMixin, setup_default_dumper, asdict
from .loaders import LoadMixin, setup_default_loader
from .loader_selection import fromlist, fromdict
Expand All @@ -135,7 +136,7 @@

# Set up logging to ``/dev/null`` like a library is supposed to.
# http://docs.python.org/3.3/howto/logging.html#configuring-logging-for-a-library
logging.getLogger('dataclass_wizard').addHandler(logging.NullHandler())
logging.getLogger(PACKAGE_NAME).addHandler(logging.NullHandler())

# Setup the default type hooks to use when converting `str` (json) or a Python
# `dict` object to a `dataclass` instance.
Expand Down
Loading

0 comments on commit a637982

Please sign in to comment.