From a63798280d188834d59d22476bfc8a9556121977 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Mon, 30 Dec 2024 16:57:56 -0500 Subject: [PATCH] V0.34: support recursive types and end-to-end (E2E) support for `bytes` 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 --- .coveragerc | 4 + HISTORY.rst | 30 + Makefile | 21 +- README.rst | 155 +++- benchmarks/catch_all.png | Bin 50120 -> 50346 bytes benchmarks/complex.py | 23 +- benchmarks/nested.py | 14 +- dataclass_wizard/__init__.py | 5 +- dataclass_wizard/__version__.py | 2 +- dataclass_wizard/abstractions.py | 68 +- dataclass_wizard/abstractions.pyi | 79 +- dataclass_wizard/bases.py | 4 +- dataclass_wizard/bases_meta.py | 7 +- dataclass_wizard/bases_meta.pyi | 1 + dataclass_wizard/class_helper.py | 4 +- dataclass_wizard/class_helper.pyi | 3 +- dataclass_wizard/constants.py | 6 + dataclass_wizard/dumpers.py | 11 +- dataclass_wizard/errors.py | 18 +- dataclass_wizard/errors.pyi | 7 +- dataclass_wizard/loader_selection.py | 5 +- dataclass_wizard/log.py | 4 +- dataclass_wizard/serial_json.py | 4 +- dataclass_wizard/type_def.py | 17 +- dataclass_wizard/utils/function_builder.py | 14 +- dataclass_wizard/utils/type_conv.py | 121 ++- dataclass_wizard/utils/typing_compat.py | 1 + dataclass_wizard/v1/decorators.py | 133 +++ dataclass_wizard/v1/enums.py | 57 +- dataclass_wizard/v1/loaders.py | 839 +++++++++--------- dataclass_wizard/v1/models.py | 66 +- dataclass_wizard/v1/models.pyi | 20 +- dataclass_wizard/wizard_cli/schema.py | 7 +- docs/overview.rst | 127 ++- pytest.ini | 1 + recipe/meta.yaml | 2 +- tests/conftest.py | 16 +- tests/unit/test_dump.py | 21 + tests/unit/v1/test_loaders.py | 373 +++++++- .../v1/test_union_as_type_alias_recursive.py | 35 + 40 files changed, 1666 insertions(+), 659 deletions(-) create mode 100644 dataclass_wizard/v1/decorators.py create mode 100644 tests/unit/v1/test_union_as_type_alias_recursive.py diff --git a/.coveragerc b/.coveragerc index c91eece7..696f2b03 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/HISTORY.rst b/HISTORY.rst index 457019e1..f23bdd69 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -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) ------------------- diff --git a/Makefile b/Makefile index cd190805..0cd3a762 100644 --- a/Makefile +++ b/Makefile @@ -83,7 +83,7 @@ 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 @@ -91,6 +91,25 @@ dist: clean ## builds source and wheel package 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 diff --git a/README.rst b/README.rst index d3dd33a5..d7f9f2a8 100644 --- a/README.rst +++ b/README.rst @@ -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:: @@ -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/ @@ -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: @@ -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 @@ -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 `_ +- `PEP 695: Type Syntax `_ + +These examples illustrate the power of recursive types in simplifying complex data structures while leveraging the functionality of ``dataclass-wizard``. + Dataclasses in ``Union`` Types ------------------------------ diff --git a/benchmarks/catch_all.png b/benchmarks/catch_all.png index 7f4387960d35abcc2bc3a725361a00f67d0738a0..baf46d56d0de9a7ed69a032bee008ae0de878142 100644 GIT binary patch literal 50346 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 JH*w*w{{!(3qDlY& diff --git a/benchmarks/complex.py b/benchmarks/complex.py index 1295dbef..205330d9 100644 --- a/benchmarks/complex.py +++ b/benchmarks/complex.py @@ -88,6 +88,7 @@ class Name(NamedTuple): last: str salutation: Optional[str] = 'Mr.' + @dataclass class NameDataclass: first: str @@ -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()) @@ -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)) @@ -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)) diff --git a/benchmarks/nested.py b/benchmarks/nested.py index 6041e9ea..0487906a 100644 --- a/benchmarks/nested.py +++ b/benchmarks/nested.py @@ -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() diff --git a/dataclass_wizard/__init__.py b/dataclass_wizard/__init__.py index cddd4a1c..6fb8967b 100644 --- a/dataclass_wizard/__init__.py +++ b/dataclass_wizard/__init__.py @@ -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: @@ -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 @@ -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. diff --git a/dataclass_wizard/__version__.py b/dataclass_wizard/__version__.py index b5a5571b..0bfe44ac 100644 --- a/dataclass_wizard/__version__.py +++ b/dataclass_wizard/__version__.py @@ -5,7 +5,7 @@ __title__ = 'dataclass-wizard' __description__ = ('Lightning-fast JSON wizardry for Python dataclasses — ' - 'effortless serialization with no external tools required!') + 'effortless serialization right out of the box!') __url__ = 'https://github.com/rnag/dataclass-wizard' __version__ = '0.33.0' __author__ = 'Ritvik Nag' diff --git a/dataclass_wizard/abstractions.py b/dataclass_wizard/abstractions.py index 4ac7940b..75e1f6c7 100644 --- a/dataclass_wizard/abstractions.py +++ b/dataclass_wizard/abstractions.py @@ -9,7 +9,7 @@ from .bases import META from .models import Extras -from .v1.models import TypeInfo +from .v1.models import Extras as V1Extras, TypeInfo from .type_def import T, TT @@ -274,7 +274,7 @@ def transform_json_field(string: str) -> str: @staticmethod @abstractmethod - def default_load_to(tp: TypeInfo, extras: Extras) -> str: + def default_load_to(tp: TypeInfo, extras: V1Extras) -> str: """ Generate code for the default load function if no other types match. Generally, this will be a stub load method. @@ -282,35 +282,28 @@ def default_load_to(tp: TypeInfo, extras: Extras) -> str: @staticmethod @abstractmethod - def load_after_type_check(tp: TypeInfo, extras: Extras) -> str: - """ - Generate code to load an object after confirming its type. - """ - - @staticmethod - @abstractmethod - def load_to_str(tp: TypeInfo, extras: Extras) -> str: + def load_to_str(tp: TypeInfo, extras: V1Extras) -> str: """ Generate code to load a value into a string field. """ @staticmethod @abstractmethod - def load_to_int(tp: TypeInfo, extras: Extras) -> str: + def load_to_int(tp: TypeInfo, extras: V1Extras) -> str: """ Generate code to load a value into an integer field. """ @staticmethod @abstractmethod - def load_to_float(tp: TypeInfo, extras: Extras) -> str: + def load_to_float(tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': """ Generate code to load a value into a float field. """ @staticmethod @abstractmethod - def load_to_bool(_: str, extras: Extras) -> str: + def load_to_bool(_: str, extras: V1Extras) -> str: """ Generate code to load a value into a boolean field. Adds a helper function `as_bool` to the local context. @@ -318,28 +311,28 @@ def load_to_bool(_: str, extras: Extras) -> str: @staticmethod @abstractmethod - def load_to_bytes(tp: TypeInfo, extras: Extras) -> str: + def load_to_bytes(tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': """ Generate code to load a value into a bytes field. """ @staticmethod @abstractmethod - def load_to_bytearray(tp: TypeInfo, extras: Extras) -> str: + def load_to_bytearray(tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': """ Generate code to load a value into a bytearray field. """ @staticmethod @abstractmethod - def load_to_none(tp: TypeInfo, extras: Extras) -> str: + def load_to_none(tp: TypeInfo, extras: V1Extras) -> str: """ Generate code to load a value into a None. """ @staticmethod @abstractmethod - def load_to_literal(tp: TypeInfo, extras: Extras) -> 'str | TypeInfo': + def load_to_literal(tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': """ Generate code to confirm a value is equivalent to one of the provided literals. @@ -347,111 +340,118 @@ def load_to_literal(tp: TypeInfo, extras: Extras) -> 'str | TypeInfo': @classmethod @abstractmethod - def load_to_union(cls, tp: TypeInfo, extras: Extras) -> 'str | TypeInfo': + def load_to_union(cls, tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': """ Generate code to load a value into a `Union[X, Y, ...]` (one of [X, Y, ...] possible types) """ @staticmethod @abstractmethod - def load_to_enum(tp: TypeInfo, extras: Extras) -> str: + def load_to_enum(tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': """ Generate code to load a value into an Enum field. """ @staticmethod @abstractmethod - def load_to_uuid(tp: TypeInfo, extras: Extras) -> 'str | TypeInfo': + def load_to_uuid(tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': """ Generate code to load a value into a UUID field. """ @staticmethod @abstractmethod - def load_to_iterable(tp: TypeInfo, extras: Extras) -> 'str | TypeInfo': + def load_to_iterable(tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': """ Generate code to load a value into an iterable field (list, set, etc.). """ @staticmethod @abstractmethod - def load_to_tuple(tp: TypeInfo, extras: Extras) -> 'str | TypeInfo': + def load_to_tuple(tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': """ Generate code to load a value into a tuple field. """ @staticmethod @abstractmethod - def load_to_named_tuple(tp: TypeInfo, extras: Extras) -> 'str | TypeInfo': + def load_to_named_tuple(tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': """ Generate code to load a value into a named tuple field. """ @classmethod @abstractmethod - def load_to_named_tuple_untyped(cls, tp: TypeInfo, extras: Extras) -> 'str | TypeInfo': + def load_to_named_tuple_untyped(cls, tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': """ Generate code to load a value into an untyped named tuple. """ @staticmethod @abstractmethod - def load_to_dict(tp: TypeInfo, extras: Extras) -> 'str | TypeInfo': + def load_to_dict(tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': """ Generate code to load a value into a dictionary field. """ @staticmethod @abstractmethod - def load_to_defaultdict(tp: TypeInfo, extras: Extras) -> 'str | TypeInfo': + def load_to_defaultdict(tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': """ Generate code to load a value into a defaultdict field. """ @staticmethod @abstractmethod - def load_to_typed_dict(tp: TypeInfo, extras: Extras) -> 'str | TypeInfo': + def load_to_typed_dict(tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': """ Generate code to load a value into a typed dictionary field. """ @staticmethod @abstractmethod - def load_to_decimal(tp: TypeInfo, extras: Extras) -> str: + def load_to_decimal(tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': + """ + Generate code to load a value into a Decimal field. + """ + + @staticmethod + @abstractmethod + def load_to_path(tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': """ Generate code to load a value into a Decimal field. """ @staticmethod @abstractmethod - def load_to_datetime(tp: TypeInfo, extras: Extras) -> str: + def load_to_datetime(tp: TypeInfo, extras: V1Extras) -> str: """ Generate code to load a value into a datetime field. """ @staticmethod @abstractmethod - def load_to_time(tp: TypeInfo, extras: Extras) -> str: + def load_to_time(tp: TypeInfo, extras: V1Extras) -> str: """ Generate code to load a value into a time field. """ @staticmethod @abstractmethod - def load_to_date(tp: TypeInfo, extras: Extras) -> 'str | TypeInfo': + def load_to_date(tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': """ Generate code to load a value into a date field. """ @staticmethod @abstractmethod - def load_to_timedelta(tp: TypeInfo, extras: Extras) -> 'str | TypeInfo': + def load_to_timedelta(tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': """ Generate code to load a value into a timedelta field. """ @staticmethod - def load_to_dataclass(tp: TypeInfo, extras: Extras) -> 'str | TypeInfo': + def load_to_dataclass(tp: TypeInfo, extras: V1Extras) -> 'str | TypeInfo': """ Generate code to load a value into a `dataclass` type field. """ @@ -460,7 +460,7 @@ def load_to_dataclass(tp: TypeInfo, extras: Extras) -> 'str | TypeInfo': @abstractmethod def get_string_for_annotation(cls, tp: TypeInfo, - extras: Extras) -> 'str | TypeInfo': + extras: V1Extras) -> 'str | TypeInfo': """ Generate code to get the parser (dispatcher) for a given annotation type. diff --git a/dataclass_wizard/abstractions.pyi b/dataclass_wizard/abstractions.pyi index 4f52743c..091040d9 100644 --- a/dataclass_wizard/abstractions.pyi +++ b/dataclass_wizard/abstractions.pyi @@ -11,7 +11,8 @@ from typing import ( Text, Sequence, Iterable, Generic ) -from .models import Extras, TypeInfo +from .models import Extras +from .v1.models import Extras as V1Extras, TypeInfo from .type_def import ( DefFactory, FrozenKeys, ListOfJSONObject, JSONObject, Encoder, M, N, T, TT, NT, E, U, DD, LSQ @@ -443,7 +444,7 @@ class AbstractLoaderGenerator(ABC): @staticmethod @abstractmethod - def default_load_to(tp: TypeInfo, extras: Extras) -> str: + def default_load_to(tp: TypeInfo, extras: V1Extras) -> str: """ Generate code for the default load function if no other types match. Generally, this will be a stub load method. @@ -451,39 +452,28 @@ class AbstractLoaderGenerator(ABC): @staticmethod @abstractmethod - def load_after_type_check(tp: TypeInfo, extras: Extras) -> str: - """ - Generate code to load an object after confirming its type. - - :param tp: The type information (including annotation) of the field as a string. - :param extras: Additional context or dependencies for code generation. - :raises ParseError: If the object type is not as expected. - """ - - @staticmethod - @abstractmethod - def load_to_str(tp: TypeInfo, extras: Extras) -> str: + def load_to_str(tp: TypeInfo, extras: V1Extras) -> str: """ Generate code to load a value into a string field. """ @staticmethod @abstractmethod - def load_to_int(tp: TypeInfo, extras: Extras) -> str: + def load_to_int(tp: TypeInfo, extras: V1Extras) -> str: """ Generate code to load a value into an integer field. """ @staticmethod @abstractmethod - def load_to_float(tp: TypeInfo, extras: Extras) -> str: + def load_to_float(tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ Generate code to load a value into a float field. """ @staticmethod @abstractmethod - def load_to_bool(_: str, extras: Extras) -> str: + def load_to_bool(_: str, extras: V1Extras) -> str: """ Generate code to load a value into a boolean field. Adds a helper function `as_bool` to the local context. @@ -491,28 +481,28 @@ class AbstractLoaderGenerator(ABC): @staticmethod @abstractmethod - def load_to_bytes(tp: TypeInfo, extras: Extras) -> str: + def load_to_bytes(tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ Generate code to load a value into a bytes field. """ @staticmethod @abstractmethod - def load_to_bytearray(tp: TypeInfo, extras: Extras) -> str: + def load_to_bytearray(tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ Generate code to load a value into a bytearray field. """ @staticmethod @abstractmethod - def load_to_none(tp: TypeInfo, extras: Extras) -> str: + def load_to_none(tp: TypeInfo, extras: V1Extras) -> str: """ Generate code to load a value into a None. """ @staticmethod @abstractmethod - def load_to_literal(tp: TypeInfo, extras: Extras) -> str | TypeInfo: + def load_to_literal(tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ Generate code to confirm a value is equivalent to one of the provided literals. @@ -520,111 +510,118 @@ class AbstractLoaderGenerator(ABC): @classmethod @abstractmethod - def load_to_union(cls, tp: TypeInfo, extras: Extras) -> str | TypeInfo: + def load_to_union(cls, tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ Generate code to load a value into a `Union[X, Y, ...]` (one of [X, Y, ...] possible types) """ @staticmethod @abstractmethod - def load_to_enum(tp: TypeInfo, extras: Extras) -> str: + def load_to_enum(tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ Generate code to load a value into an Enum field. """ @staticmethod @abstractmethod - def load_to_uuid(tp: TypeInfo, extras: Extras) -> str | TypeInfo: + def load_to_uuid(tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ Generate code to load a value into a UUID field. """ @staticmethod @abstractmethod - def load_to_iterable(tp: TypeInfo, extras: Extras) -> str | TypeInfo: + def load_to_iterable(tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ Generate code to load a value into an iterable field (list, set, etc.). """ @staticmethod @abstractmethod - def load_to_tuple(tp: TypeInfo, extras: Extras) -> str | TypeInfo: + def load_to_tuple(tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ Generate code to load a value into a tuple field. """ @classmethod @abstractmethod - def load_to_named_tuple(cls, tp: TypeInfo, extras: Extras) -> str | TypeInfo: + def load_to_named_tuple(cls, tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ Generate code to load a value into a named tuple field. """ @classmethod @abstractmethod - def load_to_named_tuple_untyped(cls, tp: TypeInfo, extras: Extras) -> str | TypeInfo: + def load_to_named_tuple_untyped(cls, tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ Generate code to load a value into an untyped named tuple. """ @staticmethod @abstractmethod - def load_to_dict(tp: TypeInfo, extras: Extras) -> str | TypeInfo: + def load_to_dict(tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ Generate code to load a value into a dictionary field. """ @staticmethod @abstractmethod - def load_to_defaultdict(tp: TypeInfo, extras: Extras) -> str | TypeInfo: + def load_to_defaultdict(tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ Generate code to load a value into a defaultdict field. """ @staticmethod @abstractmethod - def load_to_typed_dict(tp: TypeInfo, extras: Extras) -> str | TypeInfo: + def load_to_typed_dict(tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ Generate code to load a value into a typed dictionary field. """ @staticmethod @abstractmethod - def load_to_decimal(tp: TypeInfo, extras: Extras) -> str | TypeInfo: + def load_to_decimal(tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ Generate code to load a value into a Decimal field. """ @staticmethod @abstractmethod - def load_to_datetime(tp: TypeInfo, extras: Extras) -> str | TypeInfo: + def load_to_path(tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ - Generate code to load a value into a datetime field. + Generate code to load a value into a Path field. """ @staticmethod @abstractmethod - def load_to_time(tp: TypeInfo, extras: Extras) -> str: + def load_to_date(tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ - Generate code to load a value into a time field. + Generate code to load a value into a date field. """ @staticmethod @abstractmethod - def load_to_date(tp: TypeInfo, extras: Extras) -> str | TypeInfo: + def load_to_datetime(tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ - Generate code to load a value into a date field. + Generate code to load a value into a datetime field. + """ + + @staticmethod + @abstractmethod + def load_to_time(tp: TypeInfo, extras: V1Extras) -> str: + """ + Generate code to load a value into a time field. """ @staticmethod @abstractmethod - def load_to_timedelta(tp: TypeInfo, extras: Extras) -> str | TypeInfo: + def load_to_timedelta(tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ Generate code to load a value into a timedelta field. """ @staticmethod - def load_to_dataclass(tp: TypeInfo, extras: Extras) -> str | TypeInfo: + def load_to_dataclass(tp: TypeInfo, extras: V1Extras) -> str | TypeInfo: """ Generate code to load a value into a `dataclass` type field. """ @@ -633,7 +630,7 @@ class AbstractLoaderGenerator(ABC): @abstractmethod def get_string_for_annotation(cls, tp: TypeInfo, - extras: Extras) -> str | TypeInfo: + extras: V1Extras) -> str | TypeInfo: """ Generate code to get the parser (dispatcher) for a given annotation type. diff --git a/dataclass_wizard/bases.py b/dataclass_wizard/bases.py index e7c38381..d556a1cc 100644 --- a/dataclass_wizard/bases.py +++ b/dataclass_wizard/bases.py @@ -154,7 +154,7 @@ class AbstractMeta(metaclass=ABCOrAndMeta): # one that does not have a known mapping to a dataclass field. # # The default is to only log a "warning" for such cases, which is visible - # when `debug_enabled` is true and logging is properly configured. + # when `v1_debug` is true and logging is properly configured. raise_on_unknown_json_key: ClassVar[bool] = False # A customized mapping of JSON keys to dataclass fields, that is used @@ -275,7 +275,7 @@ class AbstractMeta(metaclass=ABCOrAndMeta): # # Valid options are: # - `"ignore"` (default): Silently ignore unknown keys. - # - `"warn"`: Log a warning for each unknown key. Requires `debug_enabled` + # - `"warn"`: Log a warning for each unknown key. Requires `v1_debug` # to be `True` and properly configured logging. # - `"raise"`: Raise an `UnknownKeyError` for the first unknown key encountered. v1_on_unknown_key: ClassVar[KeyAction] = None diff --git a/dataclass_wizard/bases_meta.py b/dataclass_wizard/bases_meta.py index 3a63e801..5d79408e 100644 --- a/dataclass_wizard/bases_meta.py +++ b/dataclass_wizard/bases_meta.py @@ -20,7 +20,7 @@ from .enums import DateTimeTo, LetterCase, LetterCasePriority from .v1.enums import KeyAction, KeyCase from .environ.loaders import EnvLoader -from .errors import ParseError +from .errors import ParseError, show_deprecation_warning from .loader_selection import get_loader from .log import LOG from .type_def import E @@ -132,6 +132,11 @@ def bind_to(cls, dataclass: type, create=True, is_default=True, _enable_debug_mode_if_needed(cls_loader, cls.v1_debug) elif cls.debug_enabled: + show_deprecation_warning( + 'debug_enabled', + fmt="Deprecated Meta setting {name} ({reason}).", + reason='Use `v1_debug` instead', + ) _enable_debug_mode_if_needed(cls_loader, cls.debug_enabled) if cls.json_key_to_field is not None: diff --git a/dataclass_wizard/bases_meta.pyi b/dataclass_wizard/bases_meta.pyi index 968965ab..8b170ada 100644 --- a/dataclass_wizard/bases_meta.pyi +++ b/dataclass_wizard/bases_meta.pyi @@ -64,6 +64,7 @@ def LoadMeta(*, debug_enabled: 'bool | int | str' = MISSING, tag_key: str = TAG, auto_assign_tags: bool = MISSING, v1: bool = MISSING, + v1_debug: bool | int | str = False, v1_key_case: KeyCase | str | None = MISSING, v1_field_to_alias: dict[str, str] = MISSING, v1_on_unknown_key: KeyAction | str | None = KeyAction.IGNORE, diff --git a/dataclass_wizard/class_helper.py b/dataclass_wizard/class_helper.py index b751045f..acc1667f 100644 --- a/dataclass_wizard/class_helper.py +++ b/dataclass_wizard/class_helper.py @@ -2,7 +2,7 @@ from dataclasses import MISSING, fields from .bases import AbstractMeta -from .constants import CATCH_ALL +from .constants import CATCH_ALL, PACKAGE_NAME from .errors import InvalidConditionError from .models import JSONField, JSON, Extras, PatternedDT, CatchAll, Condition from .type_def import ExplicitNull @@ -421,7 +421,7 @@ def _setup_v1_load_config_for_cls( return load_dataclass_field_to_alias -def call_meta_initializer_if_needed(cls, package_name='dataclass_wizard'): +def call_meta_initializer_if_needed(cls, package_name=PACKAGE_NAME): """ Calls the Meta initializer when the inner :class:`Meta` is sub-classed. """ diff --git a/dataclass_wizard/class_helper.pyi b/dataclass_wizard/class_helper.pyi index 6c117418..0f5005a7 100644 --- a/dataclass_wizard/class_helper.pyi +++ b/dataclass_wizard/class_helper.pyi @@ -4,6 +4,7 @@ from typing import Any, Callable, Literal, overload from .abstractions import W, AbstractLoader, AbstractDumper, AbstractParser, E, AbstractLoaderGenerator from .bases import META, AbstractMeta +from .constants import PACKAGE_NAME from .models import Condition from .type_def import ExplicitNullType, T from .utils.dict_helper import DictWithLowerStore @@ -215,7 +216,7 @@ def _setup_v1_load_config_for_cls(cls: type): def call_meta_initializer_if_needed(cls: type[W | E], - package_name='dataclass_wizard') -> None: + package_name=PACKAGE_NAME) -> None: """ Calls the Meta initializer when the inner :class:`Meta` is sub-classed. """ diff --git a/dataclass_wizard/constants.py b/dataclass_wizard/constants.py index 682f181a..b681b163 100644 --- a/dataclass_wizard/constants.py +++ b/dataclass_wizard/constants.py @@ -2,6 +2,9 @@ import sys +# Package name +PACKAGE_NAME = 'dataclass_wizard' + # Library Log Level LOG_LEVEL = os.getenv('WIZARD_LOG_LEVEL', 'ERROR').upper() @@ -14,6 +17,9 @@ # Check if currently running Python 3.11 or higher PY311_OR_ABOVE = _PY_VERSION >= (3, 11) +# Check if currently running Python 3.12 or higher +PY312_OR_ABOVE = _PY_VERSION >= (3, 12) + # Check if currently running Python 3.13 or higher PY313_OR_ABOVE = _PY_VERSION >= (3, 13) diff --git a/dataclass_wizard/dumpers.py b/dataclass_wizard/dumpers.py index 0919ec21..c42cf183 100644 --- a/dataclass_wizard/dumpers.py +++ b/dataclass_wizard/dumpers.py @@ -8,6 +8,7 @@ See the end of this file for the original Apache license from this library. """ +from base64 import b64encode from collections import defaultdict, deque # noinspection PyProtectedMember,PyUnresolvedReferences from dataclasses import _is_dataclass_instance @@ -36,7 +37,7 @@ from .log import LOG from .models import get_skip_if_condition, finalize_skip_if from .type_def import ( - ExplicitNull, NoneType, JSONObject, + Buffer, ExplicitNull, NoneType, JSONObject, DD, LSQ, E, U, LT, NT, T ) from .utils.dict_helper import NestedDict @@ -77,6 +78,10 @@ def dump_with_null(o: None, *_): def dump_with_str(o: str, *_): return o + @staticmethod + def dump_with_bytes(o: Buffer, *_) -> str: + return b64encode(o).decode() + @staticmethod def dump_with_int(o: int, *_): return o @@ -159,8 +164,8 @@ def setup_default_dumper(cls=DumpMixin): cls.register_dump_hook(int, cls.dump_with_int) cls.register_dump_hook(float, cls.dump_with_float) cls.register_dump_hook(bool, cls.dump_with_bool) - cls.register_dump_hook(bytes, cls.default_dump_with) - cls.register_dump_hook(bytearray, cls.default_dump_with) + cls.register_dump_hook(bytes, cls.dump_with_bytes) + cls.register_dump_hook(bytearray, cls.dump_with_bytes) cls.register_dump_hook(NoneType, cls.dump_with_null) # Complex types cls.register_dump_hook(Enum, cls.dump_with_enum) diff --git a/dataclass_wizard/errors.py b/dataclass_wizard/errors.py index ecda39dc..809d811b 100644 --- a/dataclass_wizard/errors.py +++ b/dataclass_wizard/errors.py @@ -3,6 +3,7 @@ from typing import (Any, Type, Dict, Tuple, ClassVar, Optional, Union, Iterable, Callable, Collection, Sequence) +from .constants import PACKAGE_NAME from .utils.string_conv import normalize @@ -24,7 +25,7 @@ def type_name(obj: type) -> str: def show_deprecation_warning( - fn: Callable, + fn: 'Callable | str', reason: str, fmt: str = "Deprecated function {name} ({reason})." ) -> None: @@ -38,7 +39,7 @@ def show_deprecation_warning( import warnings warnings.simplefilter('always', DeprecationWarning) warnings.warn( - fmt.format(name=fn.__name__, reason=reason), + fmt.format(name=getattr(fn, '__name__', fn), reason=reason), category=DeprecationWarning, stacklevel=2, ) @@ -220,7 +221,7 @@ class MissingFields(JSONWizardError): ' Input JSON: {json_string}' '{e}') - def __init__(self, base_err: Exception, + def __init__(self, base_err: 'Exception | None', obj: JSONObject, cls: Type, cls_fields: Tuple[Field, ...], @@ -251,6 +252,7 @@ def __init__(self, base_err: Exception, self.kwargs = kwargs self.class_name: str = self.name(cls) self.parent_cls = cls + self.all_fields = cls_fields @property def message(self) -> str: @@ -262,10 +264,16 @@ def message(self) -> str: meta = get_meta(self.parent_cls) v1 = meta.v1 + if isinstance(self.obj, list): + keys = [f.name for f in self.all_fields] + obj = dict(zip(keys, self.obj)) + else: + obj = self.obj + # check if any field names match, and where the key transform could be the cause # see https://github.com/rnag/dataclass-wizard/issues/54 for more info - normalized_json_keys = [normalize(key) for key in self.obj] + 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): from .enums import LetterCase from .v1.enums import KeyCase @@ -424,7 +432,7 @@ class RecursiveClassError(JSONWizardError): _TEMPLATE = ('Failure parsing class `{cls}`. ' 'Consider updating the Meta config to enable ' 'the `recursive_classes` flag.\n\n' - 'Example with `dataclass_wizard.LoadMeta`:\n' + f'Example with `{PACKAGE_NAME}.LoadMeta`:\n' ' >>> LoadMeta(recursive_classes=True).bind_to({cls})\n\n' 'For more info, please see:\n' ' https://github.com/rnag/dataclass-wizard/issues/62') diff --git a/dataclass_wizard/errors.pyi b/dataclass_wizard/errors.pyi index db3e5d28..88f587e6 100644 --- a/dataclass_wizard/errors.pyi +++ b/dataclass_wizard/errors.pyi @@ -13,7 +13,7 @@ def type_name(obj: type) -> str: def show_deprecation_warning( - fn: Callable, + fn: Callable | str, reason: str, fmt: str = "Deprecated function {name} ({reason})." ) -> None: @@ -132,14 +132,15 @@ class MissingFields(JSONWizardError): obj: JSONObject fields: list[str] + all_fields: tuple[Field, ...] missing_fields: Collection[str] - base_error: Exception + base_error: Exception | None missing_keys: Collection[str] | None kwargs: dict[str, Any] class_name: str parent_cls: type - def __init__(self, base_err: Exception, + def __init__(self, base_err: Exception | None, obj: JSONObject, cls: type, cls_fields: tuple[Field, ...], diff --git a/dataclass_wizard/loader_selection.py b/dataclass_wizard/loader_selection.py index 296aaff2..eb0e20b3 100644 --- a/dataclass_wizard/loader_selection.py +++ b/dataclass_wizard/loader_selection.py @@ -48,13 +48,14 @@ def fromlist(cls: type[T], list_of_dict: list[JSONObject]) -> list[T]: def _get_load_fn_for_dataclass(cls: type[T], v1=None) -> Callable[[JSONObject], T]: + meta = get_meta(cls) if v1 is None: - v1 = getattr(get_meta(cls), 'v1', False) + v1 = getattr(meta, 'v1', False) if v1: from .v1.loaders import load_func_for_dataclass as V1_load_func_for_dataclass # noinspection PyTypeChecker - load = V1_load_func_for_dataclass(cls, {}) + load = V1_load_func_for_dataclass(cls) else: from .loaders import load_func_for_dataclass load = load_func_for_dataclass(cls) diff --git a/dataclass_wizard/log.py b/dataclass_wizard/log.py index e54f43f2..7a6b6ab7 100644 --- a/dataclass_wizard/log.py +++ b/dataclass_wizard/log.py @@ -1,7 +1,7 @@ from logging import getLogger -from .constants import LOG_LEVEL +from .constants import LOG_LEVEL, PACKAGE_NAME -LOG = getLogger('dataclass_wizard') +LOG = getLogger(PACKAGE_NAME) LOG.setLevel(LOG_LEVEL) diff --git a/dataclass_wizard/serial_json.py b/dataclass_wizard/serial_json.py index 53e6e9ab..e1834ead 100644 --- a/dataclass_wizard/serial_json.py +++ b/dataclass_wizard/serial_json.py @@ -83,8 +83,8 @@ def __init_subclass__(cls, str=True, debug=False, logging.basicConfig(level=default_lvl) # minimum logging level for logs by this library min_level = default_lvl if isinstance(debug, bool) else debug - # set `debug_enabled` flag for the class's Meta - load_meta_kwargs['debug_enabled'] = min_level + # set `v1_debug` flag for the class's Meta + load_meta_kwargs['v1_debug'] = min_level # Calls the Meta initializer when inner :class:`Meta` is sub-classed. call_meta_initializer_if_needed(cls) diff --git a/dataclass_wizard/type_def.py b/dataclass_wizard/type_def.py index dbbb45aa..815981ff 100644 --- a/dataclass_wizard/type_def.py +++ b/dataclass_wizard/type_def.py @@ -1,4 +1,5 @@ __all__ = [ + 'Buffer', 'PyForwardRef', 'PyProtocol', 'PyDeque', @@ -55,8 +56,7 @@ ) from uuid import UUID -from .constants import PY310_OR_ABOVE, PY311_OR_ABOVE, PY313_OR_ABOVE - +from .constants import PY310_OR_ABOVE, PY311_OR_ABOVE, PY313_OR_ABOVE, PY312_OR_ABOVE # The class of the `None` singleton, cached for re-usability if PY310_OR_ABOVE: @@ -153,22 +153,29 @@ # wrappers from `typing_extensions`. if PY313_OR_ABOVE: # pragma: no cover + from collections.abc import Buffer + from typing import (Required as PyRequired, NotRequired as PyNotRequired, ReadOnly as PyReadOnly, LiteralString as PyLiteralString, dataclass_transform) - elif PY311_OR_ABOVE: # pragma: no cover + if PY312_OR_ABOVE: + from collections.abc import Buffer + else: + from typing_extensions import Buffer + from typing import (Required as PyRequired, NotRequired as PyNotRequired, LiteralString as PyLiteralString, dataclass_transform) from typing_extensions import ReadOnly as PyReadOnly else: - from typing_extensions import (Required as PyRequired, + from typing_extensions import (Buffer, + Required as PyRequired, NotRequired as PyNotRequired, - ReadOnly as PyReadOnly, + ReadOnly as PyReadOnly, LiteralString as PyLiteralString, dataclass_transform) diff --git a/dataclass_wizard/utils/function_builder.py b/dataclass_wizard/utils/function_builder.py index 4de55a98..5477db0d 100644 --- a/dataclass_wizard/utils/function_builder.py +++ b/dataclass_wizard/utils/function_builder.py @@ -61,14 +61,18 @@ def function(self, name: str, args: list, return_type=MISSING, def _with_new_block(self, name: str, - condition: 'str | None' = None) -> 'FunctionBuilder': + condition: 'str | None' = None, + comment: str = '') -> 'FunctionBuilder': """Creates a new block. Used with a context manager (with).""" indent = ' ' * self.indent_level + if comment: + comment = f' # {comment}' + if condition is not None: - self.current_function["body"].append(f"{indent}{name} {condition}:") + self.current_function["body"].append(f"{indent}{name} {condition}:{comment}") else: - self.current_function["body"].append(f"{indent}{name}:") + self.current_function["body"].append(f"{indent}{name}:{comment}") return self @@ -88,7 +92,7 @@ def for_(self, condition: str) -> 'FunctionBuilder': """ return self._with_new_block('for', condition) - def if_(self, condition: str) -> 'FunctionBuilder': + def if_(self, condition: str, comment: str = '') -> 'FunctionBuilder': """Equivalent to the `if` statement in Python. Sample Usage: @@ -102,7 +106,7 @@ def if_(self, condition: str) -> 'FunctionBuilder': >>> ... """ - return self._with_new_block('if', condition) + return self._with_new_block('if', condition, comment) def elif_(self, condition: str) -> 'FunctionBuilder': """Equivalent to the `elif` statement in Python. diff --git a/dataclass_wizard/utils/type_conv.py b/dataclass_wizard/utils/type_conv.py index 561cbdc9..71e5edd0 100644 --- a/dataclass_wizard/utils/type_conv.py +++ b/dataclass_wizard/utils/type_conv.py @@ -4,27 +4,33 @@ 'as_list', 'as_dict', 'as_enum', + 'as_datetime_v1', + 'as_date_v1', + 'as_time_v1', 'as_datetime', 'as_date', 'as_time', 'as_timedelta', - 'date_to_timestamp'] + 'date_to_timestamp', + 'TRUTHY_VALUES', + ] import json -from datetime import datetime, time, date, timedelta, timezone +from collections.abc import Callable +from datetime import datetime, time, date, timedelta, timezone, tzinfo from numbers import Number -from typing import Union, Type, AnyStr, Optional, Iterable +from typing import Union, Type, AnyStr, Optional, Iterable, Any from ..errors import ParseError from ..lazy_imports import pytimeparse from ..type_def import E, N, NUMBERS - # What values are considered "truthy" when converting to a boolean type. # noinspection SpellCheckingInspection -_TRUTHY_VALUES = frozenset({'true', 't', 'yes', 'y', 'on', '1'}) +TRUTHY_VALUES = frozenset({'true', 't', 'yes', 'y', 'on', '1'}) +# TODO Remove: Unused in V1 def as_bool(o: Union[str, bool, N]): """ Return `o` if already a boolean, otherwise return the boolean value @@ -34,7 +40,7 @@ def as_bool(o: Union[str, bool, N]): return o if t is str: - return o.lower() in _TRUTHY_VALUES + return o.lower() in TRUTHY_VALUES return o == 1 @@ -97,6 +103,7 @@ def as_int(o: Union[str, int, float, bool, None], base_type=int, return default +# TODO Remove: Unused in V1 def as_str(o: Union[str, None], base_type=str): """ Return `o` if already a str, otherwise return the string value for `o`. @@ -199,6 +206,106 @@ def as_enum(o: Union[AnyStr, N], return None +def as_datetime_v1(o: Union[int, float, datetime], + __from_timestamp: Callable[[float, tzinfo], datetime], + __utc=timezone.utc): + """ + V1: Attempt to convert an object `o` to a :class:`datetime` object using the + below logic. + + * ``Number`` (int or float): Convert a numeric timestamp via the + built-in ``fromtimestamp`` method, and return a UTC datetime. + * ``base_type``: Return object `o` if it's already of this type. + + Note: It is assumed that `o` is not a ``str`` (in ISO format), as + de-serialization in ``v1`` already checks for this. + + Otherwise, if we're unable to convert the value of `o` to a + :class:`datetime` as expected, raise an error. + + """ + try: + # We can assume that `o` is a number, as generally this will be the + # case. + return __from_timestamp(o, __utc) + + except Exception: + # Note: the `__self__` attribute refers to the class bound + # to the class method `fromtimestamp`. + # + # See: https://stackoverflow.com/a/41258933/10237506 + # + # noinspection PyUnresolvedReferences + if o.__class__ is __from_timestamp.__self__: + return o + + # Check `type` explicitly, because `bool` is a sub-class of `int` + if o.__class__ not in NUMBERS: + raise TypeError(f'Unsupported type, value={o!r}, type={type(o)}') + + raise + + +def as_date_v1(o: Union[int, float, date], + __from_timestamp: Callable[[float], date]): + """ + V1: Attempt to convert an object `o` to a :class:`date` object using the + below logic. + + * ``Number`` (int or float): Convert a numeric timestamp via the + built-in ``fromtimestamp`` method, and return a date. + * ``base_type``: Return object `o` if it's already of this type. + + Note: It is assumed that `o` is not a ``str`` (in ISO format), as + de-serialization in ``v1`` already checks for this. + + Otherwise, if we're unable to convert the value of `o` to a + :class:`date` as expected, raise an error. + + """ + try: + # We can assume that `o` is a number, as generally this will be the + # case. + return __from_timestamp(o) + + except Exception: + # Note: the `__self__` attribute refers to the class bound + # to the class method `fromtimestamp`. + # + # See: https://stackoverflow.com/a/41258933/10237506 + # + # noinspection PyUnresolvedReferences + if o.__class__ is __from_timestamp.__self__: + return o + + # Check `type` explicitly, because `bool` is a sub-class of `int` + if o.__class__ not in NUMBERS: + raise TypeError(f'Unsupported type, value={o!r}, type={type(o)}') + + raise + + +def as_time_v1(o: Union[time, Any], base_type: type[time]): + """ + V1: Attempt to convert an object `o` to a :class:`time` object using the + below logic. + + * ``base_type``: Return object `o` if it's already of this type. + + Note: It is assumed that `o` is not a ``str`` (in ISO format), as + de-serialization in ``v1`` already checks for this. + + Otherwise, if we're unable to convert the value of `o` to a + :class:`time` as expected, raise an error. + + """ + if o.__class__ is base_type: + return o + + raise TypeError(f'Unsupported type, value={o!r}, type={type(o)}') + + +# TODO Remove: Unused in V1 def as_datetime(o: Union[str, Number, datetime], base_type=datetime, default=None, raise_=True): """ @@ -247,6 +354,7 @@ def as_datetime(o: Union[str, Number, datetime], return default +# TODO Remove: Unused in V1 def as_date(o: Union[str, Number, date], base_type=date, default=None, raise_=True): """ @@ -295,6 +403,7 @@ def as_date(o: Union[str, Number, date], return default +# TODO Remove: Unused in V1 def as_time(o: Union[str, time], base_type=time, default=None, raise_=True): """ Attempt to convert an object `o` to a :class:`time` object using the diff --git a/dataclass_wizard/utils/typing_compat.py b/dataclass_wizard/utils/typing_compat.py index 7a42e11a..26c3a976 100644 --- a/dataclass_wizard/utils/typing_compat.py +++ b/dataclass_wizard/utils/typing_compat.py @@ -4,6 +4,7 @@ __all__ = [ 'is_literal', + 'is_union', 'get_origin', 'get_origin_v2', 'is_typed_dict_type_qualifier', diff --git a/dataclass_wizard/v1/decorators.py b/dataclass_wizard/v1/decorators.py new file mode 100644 index 00000000..0db01037 --- /dev/null +++ b/dataclass_wizard/v1/decorators.py @@ -0,0 +1,133 @@ +from dataclasses import MISSING +from functools import wraps +from typing import Callable, Union + +from .models import Extras, TypeInfo +from ..utils.function_builder import FunctionBuilder + + +def setup_recursive_safe_function( + func: Callable = None, + *, + fn_name: Union[str, None] = None, + is_generic: bool = False, +) -> Callable: + """ + A decorator to ensure recursion safety and facilitate dynamic function generation + with `FunctionBuilder`, supporting both generic and non-generic types. + + The decorated function can define the logic for dynamically generated functions. + If `fn_name` is provided, the decorator assumes that the function generation + context (e.g., `with fn_gen.function(...)`) has already been handled externally + and will not apply it again. + + :param func: The function to decorate. If None, the decorator is applied with arguments. + :type func: Callable, optional + :param fn_name: A format string for dynamically generating function names, or None. + :type fn_name: str, optional + :param is_generic: Whether the function deals with generic types. + :type is_generic: bool, optional + :return: The decorated function with recursion safety and dynamic function generation. + :rtype: Callable + """ + + if func is None: + return lambda f: setup_recursive_safe_function( + f, fn_name=fn_name, is_generic=is_generic + ) + + def _wrapper_logic(tp: TypeInfo, extras: Extras, _cls=None) -> str: + """ + Shared logic for both class and regular methods. Ensures recursion safety + and integrates `FunctionBuilder` to dynamically create functions. + + :param tp: The type or generic type being processed. + :param extras: A context dictionary containing auxiliary information like + recursion guards and function builders. + :type extras: dict + :param _cls: The class context for class methods. Defaults to None. + :return: The generated function call expression as a string. + :rtype: str + """ + cls = tp.args if is_generic else tp.origin + recursion_guard = extras['recursion_guard'] + + if (_fn_name := recursion_guard.get(cls)) is None: + cls_name = extras['cls_name'] + tp_name = func.__name__.split('_', 2)[-1] + + # Generate the function name + if fn_name: + _fn_name = fn_name.format(cls_name=tp.name) + else: + _fn_name = ( + f'_load_{cls_name}_{tp_name}_{tp.field_i}' if is_generic + else f'_load_{cls_name}_{tp_name}_{tp.name}' + ) + + recursion_guard[cls] = _fn_name + + # Retrieve the main FunctionBuilder + main_fn_gen = extras['fn_gen'] + + # Prepare a new FunctionBuilder for this function + updated_extras = extras.copy() + updated_extras['locals'] = _locals = {'cls': cls} + updated_extras['fn_gen'] = new_fn_gen = FunctionBuilder() + + # Apply the decorated function logic + if fn_name: + # Assume `with fn_gen.function(...)` is already handled + func(_cls, tp, updated_extras) if _cls else func(tp, updated_extras) + else: + # Apply `with fn_gen.function(...)` explicitly + with new_fn_gen.function(_fn_name, ['v1'], MISSING, _locals): + func(_cls, tp, updated_extras) if _cls else func(tp, updated_extras) + + # Merge the new FunctionBuilder into the main one + main_fn_gen |= new_fn_gen + + return f'{_fn_name}({tp.v()})' + + # Determine if the function is a class method + # noinspection PyUnresolvedReferences + is_class_method = func.__code__.co_argcount == 3 + + if is_class_method: + def wrapper_class_method(_cls, tp, extras) -> str: + """ + Wrapper logic for class methods. Passes the class context to `_wrapper_logic`. + + :param _cls: The class instance. + :param tp: The type or generic type being processed. + :param extras: A context dictionary with auxiliary information. + :type extras: dict + :return: The generated function call expression as a string. + :rtype: str + """ + return _wrapper_logic(tp, extras, _cls) + + wrapper = wraps(func)(wrapper_class_method) + else: + wrapper = wraps(func)(_wrapper_logic) + + return wrapper + + +def setup_recursive_safe_function_for_generic(func: Callable) -> Callable: + """ + A helper decorator to handle generic types using + `setup_recursive_safe_function`. + + Parameters + ---------- + func : Callable + The function to be decorated, responsible for returning the + generated function name. + + Returns + ------- + Callable + A wrapped function ensuring recursion safety for generic types. + """ + return setup_recursive_safe_function(func, is_generic=True) diff --git a/dataclass_wizard/v1/enums.py b/dataclass_wizard/v1/enums.py index fd9e406a..ea4c4fc8 100644 --- a/dataclass_wizard/v1/enums.py +++ b/dataclass_wizard/v1/enums.py @@ -6,39 +6,48 @@ class KeyAction(Enum): """ - Defines the action to take when an unknown key is encountered during deserialization. + Specifies how to handle unknown keys encountered during deserialization. + + Actions: + - `IGNORE`: Skip unknown keys silently. + - `RAISE`: Raise an exception upon encountering the first unknown key. + - `WARN`: Log a warning for each unknown key. + + For capturing unknown keys (e.g., including them in a dataclass), use the `CatchAll` field. + More details: https://dataclass-wizard.readthedocs.io/en/latest/common_use_cases/handling_unknown_json_keys.html#capturing-unknown-keys-with-catchall """ IGNORE = 0 # Silently skip unknown keys. RAISE = 1 # Raise an exception for the first unknown key. WARN = 2 # Log a warning for each unknown key. - # INCLUDE = 3 class KeyCase(Enum): """ - By default, performs no conversion on strings. - ex: `MY_FIELD_NAME` -> `MY_FIELD_NAME` - + Defines transformations for string keys, commonly used for mapping JSON keys to dataclass fields. + + Key transformations: + - `CAMEL`: Converts snake_case to camelCase. + Example: `my_field_name` -> `myFieldName` + - `PASCAL`: Converts snake_case to PascalCase (UpperCamelCase). + Example: `my_field_name` -> `MyFieldName` + - `KEBAB`: Converts camelCase or snake_case to kebab-case. + Example: `myFieldName` -> `my-field-name` + - `SNAKE`: Converts camelCase to snake_case. + Example: `myFieldName` -> `my_field_name` + - `AUTO`: Automatically maps JSON keys to dataclass fields by + attempting all valid key casing transforms at runtime. + Example: `My-Field-Name` -> `my_field_name` (cached for future lookups) + + By default, no transformation is applied: + Example: `MY_FIELD_NAME` -> `MY_FIELD_NAME` """ - # Converts strings (generally in snake case) to camel case. - # ex: `my_field_name` -> `myFieldName` - CAMEL = C = FuncWrapper(to_camel_case) - - # Converts strings to "upper" camel case. - # ex: `my_field_name` -> `MyFieldName` - PASCAL = P = FuncWrapper(to_pascal_case) - # Converts strings (generally in camel or snake case) to lisp case. - # ex: `myFieldName` -> `my-field-name` - KEBAB = K = FuncWrapper(to_lisp_case) - # Converts strings (generally in camel case) to snake case. - # ex: `myFieldName` -> `my_field_name` - SNAKE = S = FuncWrapper(to_snake_case) - # Auto-maps JSON keys to dataclass fields. - # - # All valid key casing transforms are attempted at runtime, - # and the result is cached for subsequent lookups. - # ex: `My-Field-Name` -> `my_field_name` - AUTO = A = None + # Key casing options + CAMEL = C = FuncWrapper(to_camel_case) # Convert to `camelCase` + PASCAL = P = FuncWrapper(to_pascal_case) # Convert to `PascalCase` + KEBAB = K = FuncWrapper(to_lisp_case) # Convert to `kebab-case` + SNAKE = S = FuncWrapper(to_snake_case) # Convert to `snake_case` + AUTO = A = None # Attempt all valid casing transforms at runtime. def __call__(self, *args): + """Apply the key transformation.""" return self.value.f(*args) diff --git a/dataclass_wizard/v1/loaders.py b/dataclass_wizard/v1/loaders.py index 167bef67..560d330d 100644 --- a/dataclass_wizard/v1/loaders.py +++ b/dataclass_wizard/v1/loaders.py @@ -1,7 +1,8 @@ # TODO cleanup imports import collections.abc as abc -from base64 import decodebytes +import dataclasses +from base64 import b64decode from collections import defaultdict, deque from dataclasses import is_dataclass, MISSING, Field from datetime import datetime, time, date, timedelta @@ -12,26 +13,26 @@ from typing import ( Any, Type, Dict, List, Tuple, Iterable, Sequence, Union, NamedTupleMeta, - SupportsFloat, AnyStr, Text, Callable, Optional, cast, Literal, Annotated + SupportsFloat, AnyStr, Text, Callable, Optional, cast, Literal, Annotated, NamedTuple ) from uuid import UUID -from .models import TypeInfo +from .decorators import (setup_recursive_safe_function, + setup_recursive_safe_function_for_generic) +from .enums import KeyAction, KeyCase +from .models import Extras, TypeInfo from ..abstractions import AbstractLoaderGenerator -from ..bases import BaseLoadHook, AbstractMeta +from ..bases import BaseLoadHook, AbstractMeta, META from ..class_helper import ( - v1_dataclass_field_to_alias, json_field_to_dataclass_field, - CLASS_TO_LOAD_FUNC, dataclass_fields, get_meta, is_subclass_safe, DATACLASS_FIELD_TO_ALIAS_PATH_FOR_LOAD, + v1_dataclass_field_to_alias, CLASS_TO_LOAD_FUNC, dataclass_fields, get_meta, is_subclass_safe, + DATACLASS_FIELD_TO_ALIAS_PATH_FOR_LOAD, dataclass_init_fields, dataclass_field_to_default, create_meta, dataclass_init_field_names, ) -from ..constants import CATCH_ALL, TAG -from ..decorators import _identity -from .enums import KeyAction, KeyCase +from ..constants import CATCH_ALL, TAG, PY311_OR_ABOVE, PACKAGE_NAME from ..errors import (ParseError, MissingFields, UnknownKeysError, MissingData, JSONWizardError) from ..loader_selection import get_loader, fromdict from ..log import LOG -from ..models import Extras from ..type_def import ( DefFactory, NoneType, JSONObject, PyLiteralString, @@ -43,7 +44,8 @@ from ..utils.object_path import safe_get from ..utils.string_conv import to_json_key from ..utils.type_conv import ( - as_bool, as_datetime, as_date, as_time, as_int, as_timedelta, + as_datetime_v1, as_date_v1, as_time_v1, + as_int, as_timedelta, TRUTHY_VALUES, ) from ..utils.typing_compat import ( is_typed_dict, get_args, is_annotated, @@ -98,86 +100,81 @@ def __init_subclass__(cls, **kwargs): transform_json_field = None @staticmethod - @_identity def default_load_to(tp: TypeInfo, extras: Extras) -> str: # identity: o return tp.v() @staticmethod - def load_after_type_check(tp: TypeInfo, extras: Extras) -> str: - ... - # return f'{tp.v()} if instance({tp.v()}, {tp.t()}' + def load_to_str(tp: TypeInfo, extras: Extras) -> str: + tn = tp.type_name(extras) + o = tp.v() - # if isinstance(o, base_type): - # return o - # - # e = ValueError(f'data type is not a {base_type!s}') - # raise ParseError(e, o, base_type) + if tp.in_optional: # str(v) + return f'{tn}({o})' - @staticmethod - def load_to_str(tp: TypeInfo, extras: Extras) -> str: - # TODO skip None check if in Optional - # return f'{tp.name}({tp.v()})' - return f"'' if {(v := tp.v())} is None else {tp.name}({v})" + # '' if v is None else str(v) + default = "''" if tp.origin is str else f'{tn}()' + return f'{default} if {o} is None else {tn}({o})' @staticmethod def load_to_int(tp: TypeInfo, extras: Extras) -> str: - # TODO - extras['locals'].setdefault('as_int', as_int) + # alias: as_int + tn = tp.type_name(extras) + tp.ensure_in_locals(extras, as_int) - # TODO - return f"as_int({tp.v()}, {tp.name})" + return f"as_int({tp.v()}, {tn})" @staticmethod - def load_to_float(tp: TypeInfo, extras: Extras) -> str: - # alias: base_type(o) - return f'{tp.name}({tp.v()})' + def load_to_float(tp: TypeInfo, extras: Extras): + # alias: float(o) + return tp.wrap_builtin(float, tp.v(), extras) @staticmethod def load_to_bool(tp: TypeInfo, extras: Extras) -> str: - extras['locals'].setdefault('as_bool', as_bool) - return f"as_bool({tp.v()})" - # Uncomment for efficiency! - # extras['locals']['_T'] = _TRUTHY_VALUES - # return f'{tp.v()} if (t := type({tp.v()})) is bool else ({tp.v()}.lower() in _T if t is str else {tp.v()} == 1)' + o = tp.v() + tp.ensure_in_locals(extras, __TRUTHY=TRUTHY_VALUES) - @staticmethod - def load_to_bytes(tp: TypeInfo, extras: Extras) -> str: - extras['locals'].setdefault('decodebytes', decodebytes) - return f'decodebytes({tp.v()}.encode())' + return (f'{o}.lower() in __TRUTHY ' + f'if {o}.__class__ is str ' + f'else {o} == 1') @staticmethod - def load_to_bytearray(tp: TypeInfo, extras: Extras) -> str: - extras['locals'].setdefault('decodebytes', decodebytes) - return f'{tp.name}(decodebytes({tp.v()}.encode()))' + def load_to_bytes(tp: TypeInfo, extras: Extras): + tp.ensure_in_locals(extras, b64decode) + return f'b64decode({tp.v()})' + + @classmethod + def load_to_bytearray(cls, tp: TypeInfo, extras: Extras): + as_bytes = cls.load_to_bytes(tp, extras) + return tp.wrap_builtin(bytearray, as_bytes, extras) @staticmethod def load_to_none(tp: TypeInfo, extras: Extras) -> str: return 'None' @staticmethod - def load_to_enum(tp: TypeInfo, extras: Extras) -> str: - # alias: base_type(o) - return tp.v() + def load_to_enum(tp: TypeInfo, extras: Extras): + # alias: enum_cls(o) + return tp.wrap(tp.v(), extras) - # load_to_uuid = load_to_enum @staticmethod def load_to_uuid(tp: TypeInfo, extras: Extras): - # alias: base_type(o) - return tp.wrap_builtin(tp.v(), extras) + # alias: UUID(o) + return tp.wrap_builtin(UUID, tp.v(), extras) @classmethod def load_to_iterable(cls, tp: TypeInfo, extras: Extras): v, v_next, i_next = tp.v_and_next() gorg = tp.origin + # noinspection PyBroadException try: elem_type = tp.args[0] except: elem_type = Any string = cls.get_string_for_annotation( - tp.replace(origin=elem_type, i=i_next), extras) + tp.replace(origin=elem_type, i=i_next, index=None), extras) # TODO if issubclass(gorg, (set, frozenset)): @@ -202,37 +199,30 @@ def load_to_tuple(cls, tp: TypeInfo, extras: Extras): if args: is_variadic = args[-1] is ... else: + # Annotated without args, as simply `tuple` args = (Any, ...) is_variadic = True if is_variadic: - # Parser that handles the variadic form of :class:`Tuple`'s, - # i.e. ``Tuple[str, ...]`` + # Logic that handles the variadic form of :class:`Tuple`'s, + # i.e. ``Tuple[str, ...]`` # - # Per `PEP 484`_, only **one** required type is allowed before the - # ``Ellipsis``. That is, ``Tuple[int, ...]`` is valid whereas - # ``Tuple[int, str, ...]`` would be invalid. `See here`_ for more info. + # Per `PEP 484`_, only **one** required type is allowed before the + # ``Ellipsis``. That is, ``Tuple[int, ...]`` is valid whereas + # ``Tuple[int, str, ...]`` would be invalid. `See here`_ for more info. # - # .. _PEP 484: https://www.python.org/dev/peps/pep-0484/ - # .. _See here: https://github.com/python/typing/issues/180 + # .. _PEP 484: https://www.python.org/dev/peps/pep-0484/ + # .. _See here: https://github.com/python/typing/issues/180 v, v_next, i_next = tp.v_and_next() + # Given `Tuple[T, ...]`, we only need the generated string for `T` string = cls.get_string_for_annotation( - tp.replace(origin=args[0], i=i_next), extras) - - # A one-element tuple containing the parser for the first type - # argument. - # Given `Tuple[T, ...]`, we only need a parser for `T` - # self.first_elem_parser = get_parser(elem_types[0], cls, extras), - # Total count should be `Infinity` here, since the variadic form - # accepts any number of possible arguments. - # self.total_count: N = float('inf') - # self.required_count = 0 + tp.replace(origin=args[0], i=i_next, index=None), extras) result = f'[{string} for {v_next} in {v}]' + # Wrap because we need to create a tuple from list comprehension force_wrap = True - else: string = ', '.join([ cls.get_string_for_annotation( @@ -241,48 +231,72 @@ def load_to_tuple(cls, tp: TypeInfo, extras: Extras): for k, arg in enumerate(args)]) result = f'({string}, )' + force_wrap = False return tp.wrap(result, extras, force=force_wrap) @classmethod + @setup_recursive_safe_function def load_to_named_tuple(cls, tp: TypeInfo, extras: Extras): + fn_gen = extras['fn_gen'] + nt_tp = cast(NamedTuple, tp.origin) + + _locals = extras['locals'] + _locals['cls'] = nt_tp + _locals['msg'] = "`dict` input is not supported for NamedTuple, use a dataclass instead." + + req_field_to_assign = {} + field_assigns = [] + optional_fields = set(nt_tp._field_defaults) + has_optionals = True if optional_fields else False + only_optionals = has_optionals and len(optional_fields) == len(nt_tp.__annotations__) + num_fields = 0 - fn_gen = FunctionBuilder() - - extras_cp: Extras = extras.copy() - extras_cp['locals'] = _locals = { - 'msg': "`dict` input is not supported for NamedTuple, use a dataclass instead." - } + for field, field_tp in nt_tp.__annotations__.items(): + string = cls.get_string_for_annotation( + tp.replace(origin=field_tp, index=num_fields), extras) - fn_name = f'_load_{extras["cls_name"]}_nt_typed_{tp.name}' + if has_optionals and field in optional_fields: + field_assigns.append(string) + else: + req_field_to_assign[f'__{field}'] = string - field_names = [] - result_list = [] - num_fields = 0 - # TODO set __annotations__? - for x, y in tp.origin.__annotations__.items(): - result_list.append(cls.get_string_for_annotation( - tp.replace(origin=y, index=num_fields), extras_cp)) - field_names.append(x) num_fields += 1 - with fn_gen.function(fn_name, ['v1'], None, _locals): - fn_gen.add_line('fields = []') - with fn_gen.try_(): - for i, string in enumerate(result_list): - fn_gen.add_line(f'fields.append({string})') - with fn_gen.except_(IndexError): - fn_gen.add_line('pass') - with fn_gen.except_(KeyError): + params = ', '.join(req_field_to_assign) + + with fn_gen.try_(): + + for field, string in req_field_to_assign.items(): + fn_gen.add_line(f'{field} = {string}') + + if has_optionals: + opt_start = len(req_field_to_assign) + fn_gen.add_line(f'L = len(v1); has_opt = L > {opt_start}') + with fn_gen.if_(f'has_opt'): + fn_gen.add_line(f'fields = [{field_assigns.pop(0)}]') + for i, string in enumerate(field_assigns, start=opt_start + 1): + fn_gen.add_line(f'if L > {i}: fields.append({string})') + + if only_optionals: + fn_gen.add_line(f'return cls(*fields)') + else: + fn_gen.add_line(f'return cls({params}, *fields)') + + fn_gen.add_line(f'return cls({params})') + + with fn_gen.except_(Exception, 'e'): + with fn_gen.if_('(e_cls := e.__class__) is IndexError'): + # raise `MissingFields`, as required NamedTuple fields + # are not present in the input object `o`. + fn_gen.add_line("raise_missing_fields(locals(), v1, cls, None)") + with fn_gen.if_('e_cls is KeyError and type(v1) is dict'): # Input object is a `dict` # TODO should we support dict for namedtuple? fn_gen.add_line('raise TypeError(msg) from None') - fn_gen.add_line(f'return {tp.wrap("*fields", extras_cp, prefix="nt_")}') - - extras['fn_gen'] |= fn_gen - - return f'{fn_name}({tp.v()})' + # re-raise + fn_gen.add_line('raise e from None') @classmethod def load_to_named_tuple_untyped(cls, tp: TypeInfo, extras: Extras): @@ -297,10 +311,10 @@ def load_to_named_tuple_untyped(cls, tp: TypeInfo, extras: Extras): @classmethod def _build_dict_comp(cls, tp, v, i_next, k_next, v_next, kt, vt, extras): - tp_k_next = tp.replace(origin=kt, i=i_next, prefix='k') + tp_k_next = tp.replace(origin=kt, i=i_next, prefix='k', index=None) string_k = cls.get_string_for_annotation(tp_k_next, extras) - tp_v_next = tp.replace(origin=vt, i=i_next, prefix='v') + tp_v_next = tp.replace(origin=vt, i=i_next, prefix='v', index=None) string_v = cls.get_string_for_annotation(tp_v_next, extras) return f'{{{string_k}: {string_v} for {k_next}, {v_next} in {v}.items()}}' @@ -318,7 +332,6 @@ def load_to_dict(cls, tp: TypeInfo, extras: Extras): result = cls._build_dict_comp( tp, v, i_next, k_next, v_next, kt, vt, extras) - # TODO return tp.wrap(result, extras) @classmethod @@ -339,15 +352,12 @@ def load_to_defaultdict(cls, tp: TypeInfo, extras: Extras): return tp.wrap_dd(default_factory, result, extras) @classmethod + @setup_recursive_safe_function def load_to_typed_dict(cls, tp: TypeInfo, extras: Extras): - fn_gen = FunctionBuilder() + fn_gen = extras['fn_gen'] req_keys, opt_keys = get_keys_for_typed_dict(tp.origin) - - extras_cp: Extras = extras.copy() - extras_cp['locals'] = _locals = {} - - fn_name = f'_load_{extras["cls_name"]}_typeddict_{tp.name}' + # _locals = extras['locals'] result_list = [] # TODO set __annotations__? @@ -359,199 +369,185 @@ def load_to_typed_dict(cls, tp: TypeInfo, extras: Extras): field_name = repr(k) string = cls.get_string_for_annotation( tp.replace(origin=field_tp, - index=field_name), extras_cp) + index=field_name), extras) result_list.append(f'{field_name}: {string}') - with fn_gen.function(fn_name, ['v1'], None, _locals): - with fn_gen.try_(): - fn_gen.add_lines('result = {', - *(f' {r},' for r in result_list), - '}') - - # Set optional keys for the `TypedDict` (if they exist) - for k in opt_keys: - field_tp = annotations[k] - field_name = repr(k) - string = cls.get_string_for_annotation( - tp.replace(origin=field_tp, - i=2), extras_cp) - with fn_gen.if_(f'(v2 := v1.get({field_name}, MISSING)) is not MISSING'): - fn_gen.add_line(f'result[{field_name}] = {string}') - fn_gen.add_line('return result') - with fn_gen.except_(Exception, 'e'): - with fn_gen.if_('type(e) is KeyError'): - fn_gen.add_line('name = e.args[0]; e = KeyError(f"Missing required key: {name!r}")') - with fn_gen.elif_('not isinstance(v1, dict)'): - fn_gen.add_line('e = TypeError("Incorrect type for object")') - fn_gen.add_line('raise ParseError(e, v1, {}) from None') - - extras['fn_gen'] |= fn_gen - - return f'{fn_name}({tp.v()})' + with fn_gen.try_(): + fn_gen.add_lines('result = {', + *(f' {r},' for r in result_list), + '}') + + # Set optional keys for the `TypedDict` (if they exist) + for k in opt_keys: + field_tp = annotations[k] + field_name = repr(k) + string = cls.get_string_for_annotation( + tp.replace(origin=field_tp, i=2, index=None), extras) + with fn_gen.if_(f'(v2 := v1.get({field_name}, MISSING)) is not MISSING'): + fn_gen.add_line(f'result[{field_name}] = {string}') + fn_gen.add_line('return result') + + with fn_gen.except_(Exception, 'e'): + with fn_gen.if_('type(e) is KeyError'): + fn_gen.add_line('name = e.args[0]; e = KeyError(f"Missing required key: {name!r}")') + with fn_gen.elif_('not isinstance(v1, dict)'): + fn_gen.add_line('e = TypeError("Incorrect type for object")') + fn_gen.add_line('raise ParseError(e, v1, {}) from None') @classmethod + @setup_recursive_safe_function_for_generic def load_to_union(cls, tp: TypeInfo, extras: Extras): - fn_gen = FunctionBuilder() + fn_gen = extras['fn_gen'] config = extras['config'] + actual_cls = extras['cls'] tag_key = config.tag_key or TAG auto_assign_tags = config.auto_assign_tags - fields = f'fields_{tp.field_i}' - - extras_cp: Extras = extras.copy() - extras_cp['locals'] = _locals = { - fields: tp.args, - 'tag_key': tag_key, - } - - actual_cls = extras['cls'] - - fn_name = f'load_to_{extras["cls_name"]}_union_{tp.field_i}' - - # TODO handle dataclasses in union (tag) - - with fn_gen.function(fn_name, ['v1'], None, _locals): - - dataclass_tag_to_lines: dict[str, list] = {} - - type_checks = [] - try_parse_at_end = [] - - for possible_tp in tp.args: + i = tp.field_i + fields = f'fields_{i}' - possible_tp = eval_forward_ref_if_needed(possible_tp, actual_cls) + args = tp.args + in_optional = NoneType in args - tp_new = TypeInfo(possible_tp, field_i=tp.field_i) + _locals = extras['locals'] + _locals[fields] = args + _locals['tag_key'] = tag_key - if possible_tp is NoneType: - with fn_gen.if_('v1 is None'): - fn_gen.add_line('return None') - continue + dataclass_tag_to_lines: dict[str, list] = {} - if is_dataclass(possible_tp): - # we see a dataclass in `Union` declaration - meta = get_meta(possible_tp) - tag = meta.tag - assign_tags_to_cls = auto_assign_tags or meta.auto_assign_tags - cls_name = possible_tp.__name__ - - if assign_tags_to_cls and not tag: - tag = cls_name - # We don't want to mutate the base Meta class here - if meta is AbstractMeta: - create_meta(possible_tp, cls_name, tag=tag) - else: - meta.tag = cls_name + type_checks = [] + try_parse_at_end = [] - if tag: - string = cls.get_string_for_annotation(tp_new, extras_cp) + for possible_tp in args: - dataclass_tag_to_lines[tag] = [ - f'if tag == {tag!r}:', - f' return {string}' - ] - continue + possible_tp = eval_forward_ref_if_needed(possible_tp, actual_cls) - elif not config.v1_unsafe_parse_dataclass_in_union: - e = ValueError(f'Cannot parse dataclass types in a Union without one of the following `Meta` settings:\n\n' - ' * `auto_assign_tags = True`\n' - f' - Set on class `{extras["cls_name"]}`.\n\n' - f' * `tag = "{cls_name}"`\n' - f' - Set on class `{possible_tp.__qualname__}`.\n\n' - ' * `v1_unsafe_parse_dataclass_in_union = True`\n' - f' - Set on class `{extras["cls_name"]}`\n\n' - 'For more information, refer to:\n' - ' https://dataclass-wizard.readthedocs.io/en/latest/common_use_cases/dataclasses_in_union_types.html') - raise e from None + tp_new = TypeInfo(possible_tp, field_i=i) + tp_new.in_optional = in_optional - string = cls.get_string_for_annotation(tp_new, extras_cp) + if possible_tp is NoneType: + with fn_gen.if_('v1 is None'): + fn_gen.add_line('return None') + continue - try_parse_lines = [ - 'try:', - f' return {string}', - 'except Exception:', - ' pass', - ] + if is_dataclass(possible_tp): + # we see a dataclass in `Union` declaration + meta = get_meta(possible_tp) + tag = meta.tag + assign_tags_to_cls = auto_assign_tags or meta.auto_assign_tags + cls_name = possible_tp.__name__ - # TODO disable for dataclasses + if assign_tags_to_cls and not tag: + tag = cls_name + # We don't want to mutate the base Meta class here + if meta is AbstractMeta: + create_meta(possible_tp, cls_name, tag=tag) + else: + meta.tag = cls_name - if possible_tp in _SIMPLE_TYPES or is_subclass_safe(get_origin_v2(possible_tp), _SIMPLE_TYPES): - tn = tp_new.type_name(extras_cp) - type_checks.extend([ - f'if tp is {tn}:', - ' return v1' - ]) - list_to_add = try_parse_at_end - else: - list_to_add = type_checks + if tag: + string = cls.get_string_for_annotation(tp_new, extras) - list_to_add.extend(try_parse_lines) + dataclass_tag_to_lines[tag] = [ + f'if tag == {tag!r}:', + f' return {string}' + ] + continue - if dataclass_tag_to_lines: + elif not config.v1_unsafe_parse_dataclass_in_union: + e = ValueError(f'Cannot parse dataclass types in a Union without one of the following `Meta` settings:\n\n' + ' * `auto_assign_tags = True`\n' + f' - Set on class `{extras["cls_name"]}`.\n\n' + f' * `tag = "{cls_name}"`\n' + f' - Set on class `{possible_tp.__qualname__}`.\n\n' + ' * `v1_unsafe_parse_dataclass_in_union = True`\n' + f' - Set on class `{extras["cls_name"]}`\n\n' + 'For more information, refer to:\n' + ' https://dataclass-wizard.readthedocs.io/en/latest/common_use_cases/dataclasses_in_union_types.html') + raise e from None + + string = cls.get_string_for_annotation(tp_new, extras) + + try_parse_lines = [ + 'try:', + f' return {string}', + 'except Exception:', + ' pass', + ] + + # TODO disable for dataclasses + + if (possible_tp in _SIMPLE_TYPES + or is_subclass_safe( + get_origin_v2(possible_tp), _SIMPLE_TYPES)): + + tn = tp_new.type_name(extras) + type_checks.extend([ + f'if tp is {tn}:', + ' return v1' + ]) + list_to_add = try_parse_at_end + else: + list_to_add = type_checks - with fn_gen.try_(): - fn_gen.add_line(f'tag = v1[tag_key]') + list_to_add.extend(try_parse_lines) - with fn_gen.except_(Exception): - fn_gen.add_line('pass') + if dataclass_tag_to_lines: - with fn_gen.else_(): + with fn_gen.try_(): + fn_gen.add_line(f'tag = v1[tag_key]') - for lines in dataclass_tag_to_lines.values(): - fn_gen.add_lines(*lines) + with fn_gen.except_(Exception): + fn_gen.add_line('pass') - fn_gen.add_line( - "raise ParseError(" - "TypeError('Object with tag was not in any of Union types')," - f"v1,{fields}," - "input_tag=tag," - "tag_key=tag_key," - f"valid_tags={list(dataclass_tag_to_lines)})" - ) + with fn_gen.else_(): - fn_gen.add_line('tp = type(v1)') + for lines in dataclass_tag_to_lines.values(): + fn_gen.add_lines(*lines) - if type_checks: - fn_gen.add_lines(*type_checks) + fn_gen.add_line( + "raise ParseError(" + "TypeError('Object with tag was not in any of Union types')," + f"v1,{fields}," + "input_tag=tag," + "tag_key=tag_key," + f"valid_tags={list(dataclass_tag_to_lines)})" + ) - if try_parse_at_end: - fn_gen.add_lines(*try_parse_at_end) + fn_gen.add_line('tp = type(v1)') - # Invalid type for Union - fn_gen.add_line("raise ParseError(" - "TypeError('Object was not in any of Union types')," - f"v1,{fields}," - "tag_key=tag_key" - ")") + if type_checks: + fn_gen.add_lines(*type_checks) - extras['fn_gen'] |= fn_gen + if try_parse_at_end: + fn_gen.add_lines(*try_parse_at_end) - return f'{fn_name}({tp.v()})' + # Invalid type for Union + fn_gen.add_line("raise ParseError(" + "TypeError('Object was not in any of Union types')," + f"v1,{fields}," + "tag_key=tag_key" + ")") @staticmethod + @setup_recursive_safe_function_for_generic def load_to_literal(tp: TypeInfo, extras: Extras): - fn_gen = FunctionBuilder() + fn_gen = extras['fn_gen'] fields = f'fields_{tp.field_i}' - extras_cp: Extras = extras.copy() - extras_cp['locals'] = _locals = { - fields: frozenset(tp.args), - } + _locals = extras['locals'] + _locals[fields] = frozenset(tp.args) - fn_name = f'load_to_{extras["cls_name"]}_literal_{tp.field_i}' + with fn_gen.if_(f'{tp.v()} in {fields}', comment=repr(tp.args)): + fn_gen.add_line('return v1') - with fn_gen.function(fn_name, ['v1'], None, _locals): - - with fn_gen.if_(f'{tp.v()} in {fields}'): - fn_gen.add_line('return v1') - - # No such Literal with the value of `o` - fn_gen.add_line("e = ValueError('Value not in expected Literal values')") - fn_gen.add_line(f'raise ParseError(e, v1, {fields}, ' - f'allowed_values=list({fields}))') + # No such Literal with the value of `o` + fn_gen.add_line("e = ValueError('Value not in expected Literal values')") + fn_gen.add_line(f'raise ParseError(e, v1, {fields}, ' + f'allowed_values=list({fields}))') # TODO Checks for Literal equivalence, as mentioned here: # https://www.python.org/dev/peps/pep-0586/#equivalence-of-two-literals @@ -584,48 +580,93 @@ def load_to_literal(tp: TypeInfo, extras: Extras): # f'e, v1, {fields}, allowed_values=list({fields})' # f')') - extras['fn_gen'] |= fn_gen - - return f'{fn_name}({tp.v()})' - @staticmethod def load_to_decimal(tp: TypeInfo, extras: Extras): - s = f'str({tp.v()}) if isinstance({tp.v()}, float) else {tp.v()}' - return tp.wrap_builtin(s, extras) + o = tp.v() + s = f'str({o}) if {o}.__class__ is float else {o}' - # alias: base_type(o) - load_to_path = load_to_uuid + return tp.wrap_builtin(Decimal, s, extras) @staticmethod - def load_to_datetime(tp: TypeInfo, extras: Extras): - # alias: as_datetime - tp.ensure_in_locals(extras, as_datetime, datetime) - return f'as_datetime({tp.v()}, {tp.name})' + def load_to_path(tp: TypeInfo, extras: Extras): + # alias: Path(o) + return tp.wrap_builtin(Path, tp.v(), extras) + + @classmethod + def load_to_date(cls, tp: TypeInfo, extras: Extras): + return cls._load_to_date(tp, extras, date) + + @classmethod + def load_to_datetime(cls, tp: TypeInfo, extras: Extras): + return cls._load_to_date(tp, extras, datetime) @staticmethod def load_to_time(tp: TypeInfo, extras: Extras): - # alias: as_time - tp.ensure_in_locals(extras, as_time, time) - return f'as_time({tp.v()}, {tp.name})' + o = tp.v() + tn = tp.type_name(extras, bound=time) + tp_time = cast('type[time]', tp.origin) + + __fromisoformat = f'__{tn}_fromisoformat' + + tp.ensure_in_locals( + extras, + __as_time=as_time_v1, + **{__fromisoformat: tp_time.fromisoformat} + ) + + if PY311_OR_ABOVE: + _parse_iso_string = f'{__fromisoformat}({o})' + else: # pragma: no cover + _parse_iso_string = f"{__fromisoformat}({o}.replace('Z', '+00:00', 1))" + + return (f'{_parse_iso_string} if {o}.__class__ is str ' + f'else __as_time({o}, {tn})') @staticmethod - def load_to_date(tp: TypeInfo, extras: Extras): - # alias: as_date - tp.ensure_in_locals(extras, as_date, date) - return f'as_date({tp.v()}, {tp.name})' + def _load_to_date(tp: TypeInfo, extras: Extras, + cls: 'Union[type[date], type[datetime]]'): + o = tp.v() + tn = tp.type_name(extras, bound=cls) + tp_date_or_datetime = cast('type[date]', tp.origin) + + _fromisoformat = f'__{tn}_fromisoformat' + _fromtimestamp = f'__{tn}_fromtimestamp' + + name_to_func = { + _fromisoformat: tp_date_or_datetime.fromisoformat, + _fromtimestamp: tp_date_or_datetime.fromtimestamp, + } + + if cls is datetime: + _as_func = '__as_datetime' + name_to_func[_as_func] = as_datetime_v1 + else: + _as_func = '__as_date' + name_to_func[_as_func] = as_date_v1 + + tp.ensure_in_locals(extras, **name_to_func) + + if PY311_OR_ABOVE: + _parse_iso_string = f'{_fromisoformat}({o})' + else: # pragma: no cover + _parse_iso_string = f"{_fromisoformat}({o}.replace('Z', '+00:00', 1))" + + return (f'{_parse_iso_string} if {o}.__class__ is str ' + f'else {_as_func}({o}, {_fromtimestamp})') @staticmethod def load_to_timedelta(tp: TypeInfo, extras: Extras): # alias: as_timedelta - tp.ensure_in_locals(extras, as_timedelta, timedelta) - return f'as_timedelta({tp.v()}, {tp.name})' + tn = tp.type_name(extras, bound=timedelta) + tp.ensure_in_locals(extras, as_timedelta) + + return f'as_timedelta({tp.v()}, {tn})' @staticmethod + @setup_recursive_safe_function( + fn_name=f'__{PACKAGE_NAME}_from_dict_{{cls_name}}__') def load_to_dataclass(tp: TypeInfo, extras: Extras): - fn_name = load_func_for_dataclass( - tp.origin, extras, False) - - return f'{fn_name}({tp.v()})' + load_func_for_dataclass(tp.origin, extras) @classmethod def get_string_for_annotation(cls, @@ -641,7 +682,6 @@ def get_string_for_annotation(cls, name = getattr(origin, '__name__', origin) args = None - wrap = False if is_annotated(type_ann) or is_typed_dict_type_qualifier(origin): # Given `Required[T]` or `NotRequired[T]`, we only need `T` @@ -651,33 +691,23 @@ def get_string_for_annotation(cls, name = getattr(origin, '__name__', origin) # origin = type_ann.__args__[0] - # -> Union[x] - if is_union(origin): - args = get_args(type_ann) - - # Special case for Optional[x], which is actually Union[x, None] - if NoneType in args and len(args) == 2: - string = cls.get_string_for_annotation( - tp.replace(origin=args[0], args=None, name=None), extras) - return f'None if {tp.v()} is None else {string}' - - load_hook = cls.load_to_union - - # raise NotImplementedError('`Union` support is not yet fully implemented!') - - elif origin is Literal: - load_hook = cls.load_to_literal - args = get_args(type_ann) + # TypeAliasType: Type aliases are created through + # the `type` statement + if (value := getattr(origin, '__value__', None)) is not None: + type_ann = value + origin = get_origin_v2(type_ann) + name = getattr(origin, '__name__', origin) + # `LiteralString` enforces stricter rules at + # type-checking but behaves like `str` at runtime. # TODO maybe add `load_to_literal_string` - elif origin is PyLiteralString: + if origin is PyLiteralString: load_hook = cls.load_to_str origin = str name = 'str' # -> Atomic, immutable types which don't require # any iterative / recursive handling. - # TODO use subclass safe elif origin in _SIMPLE_TYPES or is_subclass_safe(origin, _SIMPLE_TYPES): load_hook = hooks.get(origin) @@ -688,21 +718,34 @@ def get_string_for_annotation(cls, except ValueError: args = Any, + # -> Union[x] + elif is_union(origin): + load_hook = cls.load_to_union + args = get_args(type_ann) + + # Special case for Optional[x], which is actually Union[x, None] + if len(args) == 2 and NoneType in args: + new_tp = tp.replace(origin=args[0], args=None, name=None) + new_tp.in_optional = True + + string = cls.get_string_for_annotation(new_tp, extras) + + return f'None if {tp.v()} is None else {string}' + + # -> Literal[X, Y, ...] + elif origin is Literal: + load_hook = cls.load_to_literal + args = get_args(type_ann) + # https://stackoverflow.com/questions/76520264/dataclasswizard-after-upgrading-to-python3-11-is-not-working-as-expected elif origin is Any: load_hook = cls.default_load_to - elif issubclass(origin, tuple) and hasattr(origin, '_fields'): + elif is_subclass_safe(origin, tuple) and hasattr(origin, '_fields'): if getattr(origin, '__annotations__', None): # Annotated as a `typing.NamedTuple` subtype load_hook = cls.load_to_named_tuple - - # load_hook = hooks.get(NamedTupleMeta) - # return NamedTupleParser( - # base_cls, extras, base_type, load_hook, - # cls.get_parser_for_annotation - # ) else: # Annotated as a `collections.namedtuple` subtype load_hook = cls.load_to_named_tuple_untyped @@ -716,6 +759,9 @@ def get_string_for_annotation(cls, # for the `cls` (base_type) load_hook = cls.load_to_dataclass + elif is_subclass_safe(origin, Enum): + load_hook = cls.load_to_enum + elif origin in (abc.Sequence, abc.MutableSequence, abc.Collection): if origin is abc.Sequence: load_hook = cls.load_to_tuple @@ -752,10 +798,7 @@ def get_string_for_annotation(cls, for t in hooks: if issubclass(origin, (t,)): load_hook = hooks[t] - wrap = True break - else: - wrap = False tp.origin = origin tp.args = args @@ -763,11 +806,6 @@ def get_string_for_annotation(cls, if load_hook is not None: result = load_hook(tp, extras) - # Only wrap result if not already wrapped - if wrap: - if (wrapped := getattr(result, '_wrapped', None)) is not None: - return wrapped - return tp.wrap(result, extras) return result # No matching hook is found for the type. @@ -791,10 +829,6 @@ def setup_default_loader(cls=LoadMixin): """ # TODO maybe `dict.update` might be better? - # Technically a complex type, however check this - # first, since `StrEnum` and `IntEnum` are subclasses - # of `str` and `int` - cls.register_load_hook(Enum, cls.load_to_enum) # Simple types cls.register_load_hook(str, cls.load_to_str) cls.register_load_hook(float, cls.load_to_float) @@ -810,9 +844,6 @@ def setup_default_loader(cls=LoadMixin): cls.register_load_hook(deque, cls.load_to_iterable) cls.register_load_hook(list, cls.load_to_iterable) cls.register_load_hook(tuple, cls.load_to_tuple) - # `typing` Generics - # cls.register_load_hook(Literal, cls.load_to_literal) - # noinspection PyTypeChecker cls.register_load_hook(defaultdict, cls.load_to_defaultdict) cls.register_load_hook(dict, cls.load_to_dict) cls.register_load_hook(Decimal, cls.load_to_decimal) @@ -824,38 +855,51 @@ def setup_default_loader(cls=LoadMixin): cls.register_load_hook(timedelta, cls.load_to_timedelta) -def add_to_missing_fields(missing_fields: 'list[str] | None', field: str): - if missing_fields is None: - missing_fields = [field] - else: - missing_fields.append(field) - return missing_fields +def check_and_raise_missing_fields( + _locals, o, cls, + fields: 'Union[tuple[Field, ...], None]'): + if fields is None: # named tuple + nt_tp = cast(NamedTuple, cls) + field_to_default = nt_tp._field_defaults -def check_and_raise_missing_fields( - _locals, o, cls, fields: tuple[Field, ...]): + fields = tuple([ + dataclasses.field( + default=field_to_default.get(field, MISSING), + ) + for field in cls.__annotations__]) - missing_fields = [f.name for f in fields - if f.init - and f'__{f.name}' not in _locals - and (f.default is MISSING - and f.default_factory is MISSING)] + for field, name in zip(fields, cls.__annotations__): + field.name = name - missing_keys = [v1_dataclass_field_to_alias(cls)[field] - for field in missing_fields] + missing_fields = [f for f in cls.__annotations__ + if f'__{f}' not in _locals + and f not in field_to_default] + + missing_keys = None + + else: + missing_fields = [f.name for f in fields + if f.init + and f'__{f.name}' not in _locals + and (f.default is MISSING + and f.default_factory is MISSING)] + + missing_keys = [v1_dataclass_field_to_alias(cls)[field] + for field in missing_fields] raise MissingFields( None, o, cls, fields, None, missing_fields, missing_keys ) from None + def load_func_for_dataclass( cls: type, - extras: Extras, - is_main_class: bool = True, + extras: 'Extras | None' = None, loader_cls=LoadMixin, base_meta_cls: type = AbstractMeta, -) -> Union[Callable[[JSONObject], T], str]: +) -> Optional[Callable[[JSONObject], T]]: # TODO dynamically generate for multiple nested classes at once @@ -872,52 +916,74 @@ def load_func_for_dataclass( # Get the loader for the class, or create a new one as needed. cls_loader = get_loader(cls, base_cls=loader_cls, v1=True) + cls_name = cls.__name__ + + fn_name = f'__{PACKAGE_NAME}_from_dict_{cls_name}__' + # Get the meta config for the class, or the default config otherwise. meta = get_meta(cls, base_meta_cls) - if is_main_class: # we are being run for the main dataclass + if extras is None: # we are being run for the main dataclass + is_main_class = True + # If the `recursive` flag is enabled and a Meta config is provided, # apply the Meta recursively to any nested classes. # # Else, just use the base `AbstractMeta`. - config = meta if meta.recursive else base_meta_cls + config: META = meta if meta.recursive else base_meta_cls + + # Initialize the FuncBuilder + fn_gen = FunctionBuilder() + + new_locals = { + 'cls': cls, + 'fields': fields, + } + + extras: Extras = { + 'config': config, + 'cls': cls, + 'cls_name': cls_name, + 'locals': new_locals, + 'recursion_guard': {cls: fn_name}, + 'fn_gen': fn_gen, + } _globals = { - 'add': add_to_missing_fields, - 're_raise': re_raise, + 'MISSING': MISSING, 'ParseError': ParseError, - # 'LOG': LOG, 'raise_missing_fields': check_and_raise_missing_fields, - 'MISSING': MISSING, + 're_raise': re_raise, } # we are being run for a nested dataclass else: + is_main_class = False + # config for nested dataclasses config = extras['config'] + # Initialize the FuncBuilder + fn_gen = extras['fn_gen'] + if config is not base_meta_cls: # we want to apply the meta config from the main dataclass # recursively. meta = meta | config meta.bind_to(cls, is_default=False) + new_locals = extras['locals'] + new_locals['fields'] = fields + + # TODO need a way to auto-magically do this + extras['cls'] = cls + extras['cls_name'] = cls_name + key_case: 'V1LetterCase | None' = cls_loader.transform_json_field field_to_alias = v1_dataclass_field_to_alias(cls) check_aliases = True if field_to_alias else False - # This contains a mapping of the original field name to the parser for its - # annotated type; the item lookup *can* be case-insensitive. - # try: - # field_to_parser = dataclass_field_to_load_parser(cls_loader, cls, config) - # except RecursionError: - # if meta.recursive_classes: - # # recursion-safe loader is already in use; something else must have gone wrong - # raise - # else: - # raise RecursiveClassError(cls) from None - field_to_path = DATACLASS_FIELD_TO_ALIAS_PATH_FOR_LOAD[cls] has_alias_paths = True if field_to_path else False @@ -933,14 +999,9 @@ def load_func_for_dataclass( else: expect_tag_as_unknown_key = False - _locals = { - 'cls': cls, - 'fields': fields, - } - if key_case is KeyCase.AUTO: - _locals['f2k'] = field_to_alias - _locals['to_key'] = to_json_key + new_locals['f2k'] = field_to_alias + new_locals['to_key'] = to_json_key on_unknown_key = meta.v1_on_unknown_key @@ -966,27 +1027,12 @@ def load_func_for_dataclass( should_raise = should_warn = None if has_alias_paths: - _locals['safe_get'] = safe_get + new_locals['safe_get'] = safe_get - # Initialize the FuncBuilder - fn_gen = FunctionBuilder() - - cls_name = cls.__name__ - # noinspection PyTypeChecker - new_extras: Extras = { - 'config': config, - 'locals': _locals, - 'cls': cls, - 'cls_name': cls_name, - 'fn_gen': fn_gen, - } - - fn_name = f'__dataclass_wizard_from_dict_{cls_name}__' - - with fn_gen.function(fn_name, ['o'], MISSING, _locals): + with fn_gen.function(fn_name, ['o'], MISSING, new_locals): if (_pre_from_dict := getattr(cls, '_pre_from_dict', None)) is not None: - _locals['__pre_from_dict__'] = _pre_from_dict + new_locals['__pre_from_dict__'] = _pre_from_dict fn_gen.add_line('o = __pre_from_dict__(o)') # Need to create a separate dictionary to copy over the constructor @@ -1036,7 +1082,7 @@ def load_func_for_dataclass( field_to_alias[name] = key = key_case(name) f_assign = f'field={name!r}; key={key!r}; {val}=o.get(key, MISSING)' - string = generate_field_code(cls_loader, new_extras, f, i) + string = generate_field_code(cls_loader, extras, f, i) if name in field_to_default: fn_gen.add_line(f_assign) @@ -1063,14 +1109,14 @@ def load_func_for_dataclass( # add an alias for the tag key, so we don't capture it field_to_alias['...'] = meta.tag_key - if 'f2k' in _locals: + if 'f2k' in new_locals: # If this is the case, then `AUTO` key transform mode is enabled # line = 'extra_keys = o.keys() - f2k.values()' aliases_var = 'f2k.values()' else: aliases_var = 'aliases' - _locals['aliases'] = set(field_to_alias.values()) + new_locals['aliases'] = set(field_to_alias.values()) catch_all_def = f'{{k: o[k] for k in o if k not in {aliases_var}}}' @@ -1087,22 +1133,22 @@ def load_func_for_dataclass( # add an alias for the tag key, so we don't raise an error when we see it field_to_alias['...'] = meta.tag_key - if 'f2k' in _locals: + if 'f2k' in new_locals: # If this is the case, then `AUTO` key transform mode is enabled line = 'extra_keys = o.keys() - f2k.values()' else: - _locals['aliases'] = set(field_to_alias.values()) + new_locals['aliases'] = set(field_to_alias.values()) line = 'extra_keys = set(o) - aliases' with fn_gen.if_('len(o) != i'): fn_gen.add_line(line) if should_raise: # Raise an error here (if needed) - _locals['UnknownKeysError'] = UnknownKeysError + new_locals['UnknownKeysError'] = UnknownKeysError fn_gen.add_line("raise UnknownKeysError(extra_keys, o, cls, fields) from None") elif should_warn: # Show a warning here - _locals['LOG'] = LOG + new_locals['LOG'] = LOG fn_gen.add_line(r"LOG.warning('Found %d unknown keys %r not mapped to the dataclass schema.\n" r" Class: %r\n Dataclass fields: %r', len(extra_keys), extra_keys, cls.__qualname__, [f.name for f in fields])") @@ -1137,20 +1183,16 @@ def load_func_for_dataclass( _set_new_attribute(cls, 'from_dict', cls_fromdict) _set_new_attribute( - cls, '__dataclass_wizard_from_dict__', cls_fromdict) + cls, f'__{PACKAGE_NAME}_from_dict__', cls_fromdict) LOG.debug( - "setattr(%s, '__dataclass_wizard_from_dict__', %s)", - cls_name, fn_name) + "setattr(%s, '__%s_from_dict__', %s)", + cls_name, PACKAGE_NAME, fn_name) # TODO in `v1`, we will use class attribute (set above) instead. CLASS_TO_LOAD_FUNC[cls] = cls_fromdict return cls_fromdict - # Update the FunctionBuilder - extras['fn_gen'] |= fn_gen - - return fn_name def generate_field_code(cls_loader: LoadMixin, extras: Extras, @@ -1165,8 +1207,11 @@ def generate_field_code(cls_loader: LoadMixin, TypeInfo(field_type, field_i=field_i), extras ) + # except Exception as e: + # re_raise(e, cls, None, dataclass_init_fields(cls), field, None) except ParseError as pe: pe.class_name = cls + # noinspection PyPropertyAccess pe.field_name = field.name raise pe from None diff --git a/dataclass_wizard/v1/models.py b/dataclass_wizard/v1/models.py index 3fd6378f..a5f18f03 100644 --- a/dataclass_wizard/v1/models.py +++ b/dataclass_wizard/v1/models.py @@ -1,12 +1,13 @@ +from collections import defaultdict from dataclasses import MISSING, Field as _Field from typing import Any, TypedDict from ..constants import PY310_OR_ABOVE from ..log import LOG -from ..type_def import DefFactory, ExplicitNull +from ..type_def import DefFactory, ExplicitNull, PyNotRequired # noinspection PyProtectedMember from ..utils.object_path import split_object_path -from ..utils.typing_compat import get_origin_v2, PyNotRequired +from ..utils.typing_compat import get_origin_v2 _BUILTIN_COLLECTION_TYPES = frozenset({ @@ -38,6 +39,9 @@ class TypeInfo: # optional attribute, that indicates if we should wrap the # assignment with `name` -- ex. `(1, 2)` -> `deque((1, 2))` '_wrapped', + # optional attribute, that indicates if we are currently in Optional, + # e.g. `typing.Optional[...]` *or* `typing.Union[T, ...*T2, None]` + '_in_opt', ) def __init__(self, origin, @@ -73,18 +77,33 @@ def replace(self, **changes): # noinspection PyArgumentList return TypeInfo(**current_values) + @property + def in_optional(self): + return getattr(self, '_in_opt', False) + + # noinspection PyUnresolvedReferences + @in_optional.setter + def in_optional(self, value): + # noinspection PyAttributeOutsideInit + self._in_opt = value + @staticmethod - def ensure_in_locals(extras, *types): + def ensure_in_locals(extras, *tps, **name_to_tp): locals = extras['locals'] - for tp in types: + + for tp in tps: locals.setdefault(tp.__name__, tp) - def type_name(self, extras): + for name, tp in name_to_tp.items(): + locals.setdefault(name, tp) + + def type_name(self, extras, bound=None): """Return type name as string (useful for `Union` type checks)""" if self.name is None: self.name = get_origin_v2(self.origin).__name__ - return self._wrap_inner(extras, force=True) + return self._wrap_inner( + extras, force=True, bound=bound) def v(self): return (f'{self.prefix}{self.i}' if (idx := self.index) is None @@ -99,8 +118,8 @@ def v_and_next_k_v(self): return self.v(), f'k{next_i}', f'v{next_i}', next_i def wrap_dd(self, default_factory: DefFactory, result: str, extras): - tn = self._wrap_inner(extras, is_builtin=True) - tn_df = self._wrap_inner(extras, default_factory, 'df_') + tn = self._wrap_inner(extras, is_builtin=True, bound=defaultdict) + tn_df = self._wrap_inner(extras, default_factory) result = f'{tn}({tn_df}, {result})' setattr(self, '_wrapped', result) return self @@ -112,15 +131,17 @@ def multi_wrap(self, extras, prefix='', *result, force=False): return result - def wrap(self, result: str, extras, force=False, prefix=''): - if (tn := self._wrap_inner(extras, prefix=prefix, force=force)) is not None: + def wrap(self, result: str, extras, force=False, prefix='', bound=None): + if (tn := self._wrap_inner( + extras, prefix=prefix, force=force, + bound=bound)) is not None: result = f'{tn}({result})' setattr(self, '_wrapped', result) return self - def wrap_builtin(self, result: str, extras): - tn = self._wrap_inner(extras, is_builtin=True) + def wrap_builtin(self, bound, result, extras): + tn = self._wrap_inner(extras, is_builtin=True, bound=bound) result = f'{tn}({result})' setattr(self, '_wrapped', result) @@ -130,27 +151,31 @@ def _wrap_inner(self, extras, tp=None, prefix='', is_builtin=False, - force=False) -> 'str | None': + force=False, + bound=None) -> 'str | None': if tp is None: tp = self.origin name = self.name - return_name = False + return_name = force else: name = tp.__name__ return_name = True - if force: - return_name = True + # This ensures we don't create a "unique" name + # if it's a non-subclass, e.g. ensures we end + # up with `date` instead of `date_123`. + if bound is not None: + is_builtin = tp is bound if tp not in _BUILTIN_COLLECTION_TYPES: - # TODO? - if is_builtin or (mod := tp.__module__) == 'collections': + if (mod := tp.__module__) == 'builtins': + tn = name + elif (is_builtin + or mod == 'collections'): tn = name LOG.debug(f'Ensuring %s=%s', tn, name) extras['locals'].setdefault(tn, tp) - elif mod == 'builtins': - tn = name else: tn = f'{prefix}{name}_{self.field_i}' LOG.debug(f'Adding %s=%s', tn, name) @@ -181,6 +206,7 @@ class Extras(TypedDict): fn_gen: 'FunctionBuilder' locals: dict[str, Any] pattern: PyNotRequired['PatternedDT'] + recursion_guard: dict[type, str] # Instances of Field are only ever created from within this module, diff --git a/dataclass_wizard/v1/models.pyi b/dataclass_wizard/v1/models.pyi index 1d57857e..df41453f 100644 --- a/dataclass_wizard/v1/models.pyi +++ b/dataclass_wizard/v1/models.pyi @@ -35,23 +35,32 @@ class TypeInfo: prefix: str = 'v' # index of assignment (ex. `2 -> v1[2]`, *or* a string `"key" -> v4["key"]`) index: int | None = None + # indicates if we are currently in Optional, + # e.g. `typing.Optional[...]` *or* `typing.Union[T, ...*T2, None]` + in_optional: bool = False def replace(self, **changes) -> TypeInfo: ... @staticmethod - def ensure_in_locals(extras: dict[str, Any], *types: Callable) -> None: ... - def type_name(self, extras: dict[str, Any]) -> str: ... + def ensure_in_locals(extras: Extras, *tps: Callable, **name_to_tp: Callable[..., Any]) -> None: ... + def type_name(self, extras: Extras, + *, bound: type | None = None) -> str: ... def v(self) -> str: ... def v_and_next(self) -> tuple[str, str, int]: ... def v_and_next_k_v(self) -> tuple[str, str, str, int]: ... def multi_wrap(self, extras, prefix='', *result, force=False) -> list[str]: ... - def wrap(self, result: str, extras: Extras, force=False, prefix='') -> Self: ... - def wrap_builtin(self, result: str, extras: Extras) -> Self: ... + def wrap(self, result: str, + extras: Extras, + force=False, + prefix='', + *, bound: type | None = None) -> Self: ... + def wrap_builtin(self, bound: type, result: str, extras: Extras) -> Self: ... def wrap_dd(self, default_factory: DefFactory, result: str, extras: Extras) -> Self: ... def _wrap_inner(self, extras: Extras, tp: type | DefFactory | None = None, prefix: str = '', is_builtin: bool = False, - force=False) -> str | None: ... + force=False, + bound: type | None = None) -> str | None: ... class Extras(TypedDict): """ @@ -63,6 +72,7 @@ class Extras(TypedDict): fn_gen: FunctionBuilder locals: dict[str, Any] pattern: NotRequired[PatternedDT] + recursion_guard: dict[type, str] # noinspection PyPep8Naming diff --git a/dataclass_wizard/wizard_cli/schema.py b/dataclass_wizard/wizard_cli/schema.py index be1dc50d..eb638ae9 100644 --- a/dataclass_wizard/wizard_cli/schema.py +++ b/dataclass_wizard/wizard_cli/schema.py @@ -68,11 +68,12 @@ ) from .. import property_wizard +from ..constants import PACKAGE_NAME from ..class_helper import get_class_name from ..type_def import PyDeque, JSONList, JSONObject, JSONValue, T from ..utils.string_conv import to_snake_case, to_pascal_case # noinspection PyProtectedMember -from ..utils.type_conv import _TRUTHY_VALUES +from ..utils.type_conv import TRUTHY_VALUES from ..utils.type_conv import as_datetime, as_date, as_time @@ -83,7 +84,7 @@ # Merge both the "truthy" and "falsy" values, so we can determine the criteria # under which a string can be considered as a boolean value. _FALSY_VALUES = {'false', 'f', 'no', 'n', 'off', '0'} -_BOOL_VALUES = _TRUTHY_VALUES | _FALSY_VALUES +_BOOL_VALUES = TRUTHY_VALUES | _FALSY_VALUES # Valid types for JSON contents; this can be either a list of any type, # or a dictionary with `string` keys and values of any type. @@ -830,7 +831,7 @@ def __or__(self, other): def get_lines(self) -> List[str]: if self.is_root: ModuleImporter.register_import_by_name( - 'dataclass_wizard', 'JSONWizard', level=2) + PACKAGE_NAME, 'JSONWizard', level=2) class_name = f'class {self.name}(JSONWizard):' else: class_name = f'class {self.name}:' diff --git a/docs/overview.rst b/docs/overview.rst index 480d6c56..6d271b1b 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -47,63 +47,103 @@ Supported Types ~~~~~~~~~~~~~~~ .. tip:: - See the below section on `Special Cases`_ for additional info - on the JSON load/dump process for special Python types. + See the section on `Special Cases`_ for additional information on how Dataclass Wizard handles JSON + load/dump for special Python types. -* Strings - - ``str`` - - ``bytes`` - - ``bytearray`` +Dataclass Wizard supports a wide range of Python types, making it easier to work with complex data structures. +This includes built-in types, collections, and more advanced type annotations. +The following types are supported: -* Numerics - - ``int`` - - ``float`` - - ``Decimal`` +- **Basic Types**: -* Booleans (``bool``) + - ``str`` + - ``int`` + - ``float`` + - ``bool`` + - ``None`` (`docs `_) -* Sequences (and their equivalents in the ``typing`` module) - - ``list`` - - ``deque`` - - ``tuple`` - - ``NamedTuple`` +- **Binary Types**: -* Sets (and their equivalents in the ``typing`` module) - - ``set`` - - ``frozenset`` + - ``bytes`` (`docs `_) + - ``bytearray`` (`docs `_) -* Mappings (and their equivalents in the ``typing`` module) - - ``dict`` - - ``defaultdict`` - - ``TypedDict`` - - ``OrderedDict`` +- **Decimal Type**: -* ``Enum`` subclasses + - ``Decimal`` (`docs `_) -* ``UUID`` +- **Pathlib**: -* *date* and *time* objects - - ``datetime`` - - ``date`` - - ``time`` - - ``timedelta`` + - ``Path`` (`docs `_) -* Special `typing primitives`_ from the ``typing`` module - - ``Any`` - - ``Union`` - Also supports `using dataclasses`_. - - ``Optional`` +- **Typed Collections**: + Typed collections are supported for structured data, including: -- `ABC Containers`_ in ``typing`` and ``collections.abc`` - - ``Collection`` -- instantiated as ``list`` - - ``MutableSequence`` -- mapped to ``list`` - - ``Sequence`` -- mapped to ``tuple`` + - ``TypedDict`` (`docs `_) + - ``NamedTuple`` (`docs `_) + - ``namedtuple`` (`docs `_) -* Recently introduced Generic types - - ``Annotated`` - - ``Literal`` +- **ABC Containers** (`docs `_): + - ``Sequence`` (`docs `_) -- instantiated as ``tuple`` + - ``MutableSequence`` (`docs `_) -- mapped to ``list`` + - ``Collection`` (`docs `_) -- instantiated as ``list`` -.. _typing primitives: https://docs.python.org/3/library/typing.html#special-typing-primitives +- **Type Annotations and Qualifiers**: + + - ``Required``, ``NotRequired``, ``ReadOnly`` (`docs `_) + - ``Annotated`` (`docs `_) + - ``Literal`` (`docs `_) + - ``LiteralString`` (`docs `_) + - ``Union`` (`docs `_) -- Also supports `using dataclasses`_. + - ``Optional`` (`docs `_) + - ``Any`` (`docs `_) + +- **Enum Types**: + + - ``Enum`` (`docs `_) + - ``StrEnum`` (`docs `_) + - ``IntEnum`` (`docs `_) + +- **Sets**: + + - ``set`` (`docs `_) + - ``frozenset`` (`docs `_) + +- **Mappings**: + + - ``dict`` (`docs `_) + - ``defaultdict`` (`docs `_) + - ``OrderedDict`` (`docs `_) + +- **Sequences**: + + - ``list`` (`docs `_) + - ``deque`` (`docs `_) + - ``tuple`` (`docs `_) + +- **UUID**: + + - ``UUID`` (`docs `_) + +- **Date and Time**: + + - ``datetime`` (`docs `_) + - ``date`` (`docs `_) + - ``time`` (`docs `_) + - ``timedelta`` (`docs `_) + +- **Nested Dataclasses**: Nested dataclasses are supported, allowing you to serialize and deserialize + nested data structures. + +Starting with **v0.34.0**, recursive and self-referential dataclasses are supported out of the box +when the ``v1`` option is enabled in the ``Meta`` setting (i.e., ``v1 = True``). This removes the +need for custom settings like ``recursive_classes`` and expands type support beyond what is +available in ``v0.x``. + +For more advanced functionality and additional types, enabling ``v1`` is recommended. It forms +the basis for more complex cases and will evolve into the standard model for Dataclass Wizard. + +For more info, see the `Field Guide to V1 Opt-in `_. Special Cases ------------- @@ -183,4 +223,3 @@ Special Cases .. _using dataclasses: https://dataclass-wizard.readthedocs.io/en/latest/common_use_cases/dataclasses_in_union_types.html .. _pytimeparse: https://pypi.org/project/pytimeparse/ -.. _ABC Containers: https://docs.python.org/3/library/typing.html#aliases-to-container-abcs-in-collections-abc diff --git a/pytest.ini b/pytest.ini index 8ec89833..60b6f751 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,5 @@ [pytest] +addopts = -s log_cli = 1 log_cli_format = %(name)s.%(module)s - [%(levelname)s] %(message)s log_cli_level = INFO diff --git a/recipe/meta.yaml b/recipe/meta.yaml index c1637054..9b4df2bf 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -65,7 +65,7 @@ about: # (even if the license doesn't require it) using the license_file entry. # See https://docs.conda.io/projects/conda-build/en/latest/resources/define-metadata.html#license-file license_file: LICENSE - summary: Lightning-fast JSON wizardry for Python dataclasses — effortless serialization with no external tools required! + summary: Lightning-fast JSON wizardry for Python dataclasses — effortless serialization right out of the box! # The remaining entries in this section are optional, but recommended. description: | The dataclass-wizard library provides a set of simple, yet diff --git a/tests/conftest.py b/tests/conftest.py index d54c8b1a..8c15ecf9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,10 +4,12 @@ 'data_file_path', 'PY310_OR_ABOVE', 'PY311_OR_ABOVE', + 'PY312_OR_ABOVE', 'TypedDict', # For compatibility with Python 3.9 and 3.10 'Required', - 'NotRequired' + 'NotRequired', + 'LiteralString', ] import sys @@ -27,6 +29,9 @@ # Check if we are running Python 3.11+ PY311_OR_ABOVE = sys.version_info[:2] >= (3, 11) +# Check if we are running Python 3.12+ +PY312_OR_ABOVE = sys.version_info[:2] >= (3, 12) + # Check if we are running Python 3.9 or 3.10 PY310_OR_EARLIER = not PY311_OR_ABOVE @@ -41,10 +46,19 @@ if PY311_OR_ABOVE: from typing import Required from typing import NotRequired + from typing import LiteralString else: from typing_extensions import Required from typing_extensions import NotRequired + from typing_extensions import LiteralString + +# Ignore test files if the Python version is below 3.12 +if not PY312_OR_ABOVE: + print("Python version is below 3.12. Ignoring test files.") + collect_ignore = [ + Path('unit', 'v1', 'test_union_as_type_alias_recursive.py').as_posix(), + ] def data_file_path(name: str) -> str: """Returns the full path to a test file.""" diff --git a/tests/unit/test_dump.py b/tests/unit/test_dump.py index 781ab7d2..ad9d4af0 100644 --- a/tests/unit/test_dump.py +++ b/tests/unit/test_dump.py @@ -1,5 +1,6 @@ import logging from abc import ABC +from base64 import b64decode from collections import deque, defaultdict from dataclasses import dataclass, field from datetime import datetime, timedelta @@ -508,3 +509,23 @@ class Config: assert fromdict(Config, config) == Config( tests={'test_a': Test(field='a'), 'test_b': Test(field='b')}) + + +def test_bytes_and_bytes_array_are_supported(): + """Confirm dump with `bytes` and `bytesarray` is supported.""" + + @dataclass + class Foo(JSONWizard): + b: bytes = None + barray: bytearray = None + s: str = None + + data = {'b': 'AAAA', 'barray': 'SGVsbG8sIFdvcmxkIQ==', 's': 'foobar'} + + # noinspection PyTypeChecker + foo = Foo(b=b64decode('AAAA'), + barray=bytearray(b'Hello, World!'), + s='foobar') + + # noinspection PyTypeChecker + assert foo.to_dict() == data diff --git a/tests/unit/v1/test_loaders.py b/tests/unit/v1/test_loaders.py index eeb974bf..2dc3d402 100644 --- a/tests/unit/v1/test_loaders.py +++ b/tests/unit/v1/test_loaders.py @@ -3,11 +3,15 @@ Note: I might refactor this into a separate `test_parsers.py` as time permits. """ +import enum import logging from abc import ABC +from base64 import b64decode from collections import namedtuple, defaultdict, deque from dataclasses import dataclass, field from datetime import datetime, date, time, timedelta +from decimal import Decimal +from pathlib import Path from typing import ( List, Optional, Union, Tuple, Dict, NamedTuple, DefaultDict, Set, FrozenSet, Annotated, Literal, Sequence, MutableSequence, Collection @@ -21,6 +25,7 @@ ParseError, MissingFields, UnknownKeysError, MissingData, InvalidConditionError ) from dataclass_wizard.models import _PatternBase +from dataclass_wizard.type_def import NoneType from dataclass_wizard.v1 import * from ..conftest import MyUUIDSubclass from ...conftest import * @@ -908,6 +913,94 @@ class _(JSONWizard.Meta): log.debug('Parsed object: %r', result) +def test_literal_recursive(): + """Test case for recursive or self-referential `typing.Literal` usage.""" + + L1 = Literal['A', 'B'] + L2 = Literal['C', 'D', L1] + L2_FINAL = Union[L1, L2] + L3 = Literal[Literal[Literal[1, 2, 3], "foo"], 5, None] # Literal[1, 2, 3, "foo", 5, None] + + @dataclass + class A(JSONWizard, debug=True): + class _(JSONWizard.Meta): + v1 = True + + test1: L1 + test2: L2_FINAL + test3: L3 + + a = A.from_dict({'test1': 'B', 'test2': 'D', 'test3': 'foo'}) + assert a == A(test1='B', test2='D', test3='foo') + + a = A.from_dict({'test1': 'A', 'test2': 'B', 'test3': None}) + assert a == A(test1='A', test2='B', test3=None) + + with pytest.raises(ParseError): + A.from_dict({'test1': 'C', 'test2': 'D', 'test3': 'foo'}) + + with pytest.raises(ParseError): + A.from_dict({'test1': 'A', 'test2': 'E', 'test3': 'foo'}) + + with pytest.raises(ParseError): + A.from_dict({'test1': 'A', 'test2': 'B', 'test3': 'None'}) + + +def test_union_recursive(): + """Recursive or self-referential `Union` types are supported.""" + JSON = Union[str, int, float, bool, dict[str, 'JSON'], list['JSON'], None] + + @dataclass + class MyClass(JSONWizard): + + class _(JSONWizard.Meta): + v1 = True + + x: str + y: JSON + + # Fix for local tests + globals().update(locals()) + + assert MyClass( + x="x", y={"x": [{"x": {"x": [{"x": ["x", 1, 1.0, True, None]}]}}]} + ).to_dict() == { + "x": "x", + "y": {"x": [{"x": {"x": [{"x": ["x", 1, 1.0, True, None]}]}}]}, + } + + assert MyClass.from_dict( + { + "x": "x", + "y": {"x": [{"x": {"x": [{"x": ["x", 1, 1.0, True, None]}]}}]}, + } + ) == MyClass( + x="x", y={"x": [{"x": {"x": [{"x": ["x", 1, 1.0, True, None]}]}}]} + ) + + +def test_multiple_union(): + """Test case for a dataclass with multiple `Union` fields.""" + + @dataclass + class A(JSONWizard): + + class _(JSONWizard.Meta): + v1 = True + + a: Union[int, float, list[str]] + b: Union[float, bool] + + a = A.from_dict({'a': '123', 'b': '456'}) + assert a == A(a=['1', '2', '3'], b=456.0) + + a = A.from_dict({'a': 123, 'b': 'True'}) + assert a == A(a=123, b=True) + + a = A.from_dict({'a': 3.21, 'b': '0'}) + assert a == A(a=3.21, b=0.0) + + @pytest.mark.parametrize( 'input,expected', [ @@ -1087,7 +1180,7 @@ class C: ('2020-01-02T01:02:03Z', does_not_raise()), ('2010-12-31 23:59:59-04:00', does_not_raise()), (123456789, does_not_raise()), - (True, pytest.raises(ParseError)), + (True, does_not_raise()), (datetime(2010, 12, 31, 23, 59, 59), does_not_raise()), ] ) @@ -1115,7 +1208,7 @@ class _(JSONWizard.Meta): ('2020-01-02', does_not_raise()), ('2010-12-31', does_not_raise()), (123456789, does_not_raise()), - (True, pytest.raises(ParseError)), + (True, does_not_raise()), (date(2010, 12, 31), does_not_raise()), ] ) @@ -1897,6 +1990,68 @@ class _(JSONWizard.Meta): assert result.my_typed_dict == expected +def test_typed_dict_recursive(): + """Test case for recursive or self-referential `TypedDict`s.""" + + class TD(TypedDict): + key_one: str + key_two: Union['TD', None] + key_three: NotRequired[dict[int, list['TD']]] + key_four: NotRequired[list['TD']] + + @dataclass + class MyContainer(JSONWizard, debug=True): + class _(JSONWizard.Meta): + v1 = True + + test1: TD + + # Fix for local test cases so the forward reference works + globals().update(locals()) + + d = { + 'test1': { + 'key_one': 'S1', + 'key_two': {'key_one': 'S2', 'key_two': None}, + 'key_three': { + '123': [ + {'key_one': 'S3', + 'key_two': {'key_one': 'S4', 'key_two': None}, + 'key_three': {}} + ] + }, + 'key_four': [ + {'key_one': 'test', + 'key_two': {'key_one': 'S5', + 'key_two': {'key_one': 'S6', 'key_two': None} + } + } + ] + } + } + a = MyContainer.from_dict(d) + print(repr(a)) + + assert a == MyContainer( + test1={'key_one': 'S1', + 'key_two': {'key_one': 'S2', 'key_two': None}, + 'key_three': {123: [{'key_one': 'S3', + 'key_two': {'key_one': 'S4', 'key_two': None}, + 'key_three': {}}]}, + 'key_four': [ + { + 'key_one': 'test', + 'key_two': { + 'key_one': 'S5', + 'key_two': { + 'key_one': 'S6', + 'key_two': None + } + } + } + ]}) + + @pytest.mark.parametrize( 'input,expectation,expected', [ @@ -1995,6 +2150,50 @@ class _(JSONWizard.Meta): assert result.my_nt == expected +def test_named_tuple_recursive(): + """Test case for recursive or self-referential `NamedTuple`s.""" + + class NT(NamedTuple): + field_one: str + field_two: Union['NT', None] + field_three: dict[int, list['NT']] = {} + field_four: list['NT'] = [] + + @dataclass + class MyContainer(JSONWizard, debug=True): + class _(JSONWizard.Meta): + v1 = True + + test1: NT + + # Fix for local test cases so the forward reference works + globals().update(locals()) + + d = { + 'test1': [ + 'S1', + ['S2', None], + { + '123': [ + ['S3', ['S4', None], {}] + ] + }, + [['test', ['S5', ['S6', None]]]] + ] + } + a = MyContainer.from_dict(d) + print(repr(a)) + + assert a == MyContainer( + test1=NT(field_one='S1', + field_two=NT('S2', None), + field_three={123: [NT('S3', NT('S4', None))]}, + field_four=[ + NT('test', NT('S5', NT('S6', None))) + ]) + ) + + @pytest.mark.parametrize( 'input,expectation,expected', [ @@ -2173,7 +2372,6 @@ class _(JSONSerializable.Meta): assert item.b is item.c is None -@pytest.mark.skip(reason='TODO add support in v1') def test_with_self_referential_dataclasses_1(): """ Test loading JSON data, when a dataclass model has cyclic @@ -2183,8 +2381,8 @@ def test_with_self_referential_dataclasses_1(): class A: a: Optional['A'] = None - # enable support for self-referential / recursive dataclasses - LoadMeta(v1=True, recursive_classes=True).bind_to(A) + # enable `v1` opt-in` + LoadMeta(v1=True).bind_to(A) # Fix for local test cases so the forward reference works globals().update(locals()) @@ -2195,7 +2393,6 @@ class A: assert a == A(a=A(a=A(a=None))) -@pytest.mark.skip(reason='TODO add support in v1') def test_with_self_referential_dataclasses_2(): """ Test loading JSON data, when a dataclass model has cyclic @@ -2205,8 +2402,6 @@ def test_with_self_referential_dataclasses_2(): class A(JSONWizard): class _(JSONWizard.Meta): v1 = True - # enable support for self-referential / recursive dataclasses - recursive_classes = True b: Optional['B'] = None @@ -3060,3 +3255,165 @@ class _(JSONWizard.Meta): with pytest.raises(TypeError, match=".*Test\.__init__\(\) missing 1 required positional argument: 'my_field'"): Test() + + +def test_bytes_and_bytes_array_are_supported(): + """Confirm `bytes` and `bytesarray` are supported.""" + + @dataclass + class Foo(JSONWizard): + class _(JSONWizard.Meta): + v1 = True + + b: bytes = None + barray: bytearray = None + s: str = None + + data = {'b': 'AAAA', 'barray': 'SGVsbG8sIFdvcmxkIQ==', 's': 'foobar'} + + foo = Foo.from_dict(data) + + # noinspection PyTypeChecker + assert foo == Foo(b=b64decode('AAAA'), + barray=bytearray(b'Hello, World!'), + s='foobar') + assert foo.to_dict() == data + + # Check data consistency + assert Foo.from_dict(foo.to_dict()).to_dict() == data + + +def test_literal_string(): + """Confirm `literal` strings (typing.LiteralString) are supported.""" + + @dataclass + class Test(JSONWizard): + class _(JSONWizard.Meta): + v1 = True + + s: LiteralString + + t = Test.from_dict({'s': 'value'}) + assert t.s == 'value' + assert Test.from_dict(t.to_dict()).s == 'value' + + +def test_decimal(): + """Confirm `Decimal` is supported.""" + + @dataclass + class Test(JSONWizard): + class _(JSONWizard.Meta): + v1 = True + + d1: Decimal + d2: Decimal + d3: Decimal + + d = {'d1': 123, + 'd2': 3.14, + 'd3': '42.7'} + + t = Test.from_dict(d) + + assert t.d1 == Decimal(123) + assert t.d2 == Decimal('3.14') + assert t.d3 == Decimal('42.7') + + assert t.to_dict() == { + 'd1': '123', + 'd2': '3.14', + 'd3': '42.7', + } + + +def test_path(): + """Confirm `Path` objects are supported.""" + + @dataclass + class Test(JSONWizard): + class _(JSONWizard.Meta): + v1 = True + + p: Path + + t = Test.from_dict({'p': 'a/b/c'}) + assert t.p == Path('a/b/c') + assert Test.from_dict(t.to_dict()).p == Path('a/b/c') + + +def test_none(): + """Confirm `None` type annotation is supported.""" + + @dataclass + class Test(JSONWizard): + class _(JSONWizard.Meta): + v1 = True + + x: NoneType + + t = Test.from_dict({'x': None}) + assert t.x is None + + t = Test.from_dict({'x': 'test'}) + assert t.x is None + + +def test_enum(): + """Confirm `Enum` objects are supported.""" + + class MyEnum(enum.Enum): + A = 'the A' + B = 'the B' + C = 'the C' + + @dataclass + class Test(JSONWizard): + class _(JSONWizard.Meta): + v1 = True + + e: MyEnum + + with pytest.raises(ParseError): + Test.from_dict({'e': 'the D'}) + + t = Test.from_dict({'e': 'the B'}) + assert t.e is MyEnum.B + assert Test.from_dict(t.to_dict()).e is MyEnum.B + + +@pytest.mark.skipif(not PY311_OR_ABOVE, reason='Requires Python 3.11 or higher') +def test_str_and_int_enum(): + """Confirm `StrEnum` objects are supported.""" + + class MyStrEnum(enum.StrEnum): + A = 'the A' + B = 'the B' + C = 'the C' + + class MyIntEnum(enum.IntEnum): + X = enum.auto() + Y = enum.auto() + Z = enum.auto() + + @dataclass + class Test(JSONPyWizard): + class _(JSONPyWizard.Meta): + v1 = True + + str_e: MyStrEnum + int_e: MyIntEnum + + with pytest.raises(ParseError): + Test.from_dict({'str_e': 'the D', 'int_e': 3}) + + with pytest.raises(ParseError): + Test.from_dict({'str_e': 'the C', 'int_e': 4}) + + t = Test.from_dict({'str_e': 'the B', 'int_e': 3}) + assert t.str_e is MyStrEnum.B + assert t.int_e is MyIntEnum.Z + + t2 = Test.from_dict(t.to_dict()) + assert t2.str_e is MyStrEnum.B + assert t2.int_e is MyIntEnum.Z diff --git a/tests/unit/v1/test_union_as_type_alias_recursive.py b/tests/unit/v1/test_union_as_type_alias_recursive.py new file mode 100644 index 00000000..80bf9e5f --- /dev/null +++ b/tests/unit/v1/test_union_as_type_alias_recursive.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass + +from dataclass_wizard import JSONWizard + + +# noinspection PyCompatibility +def test_union_as_type_alias_recursive(): + """ + Recursive or self-referential `Union` (defined as `TypeAlias`) + types are supported. + """ + 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]}]}}], + )