From a6d3eafdddf49d8d7dec3bc772795a9add54ad25 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Fri, 15 Nov 2024 00:11:47 -0500 Subject: [PATCH] v0.28.0: Minor Performance and QoL Change: Dynamically `exec` Dataclass Load and Dump Functions (#141) * loaders.py: Use `CodeBuilder` to dynamically generate code * loaders.py: Use `CodeBuilder` to dynamically generate code * Add serial_json.pyi * dumpers.py: Optimize `cls_asdict` by dynamically generating it * minor change * minor optimizations * minor optimizations * minor optimizations * Fix * Minor changes * add `TOMLWizard` * add `TOMLWizard` * update docs for `TOMLWizard` * requirements-dev.txt: add explicit `[toml]` dependency * Fix tests * I was wrong; that's not needed * Finalize load/dump hooks for pre-processing * Add following serializer hooks * `_pre_from_dict` * `_pre_dict` * Deprecate `DumpMixin.__pre_as_dict__` * Add `debug` param to `JSONWizard.__init_subclass__` * Fix typo in docs * easier_debug_mode.rst: Add section * update docs * minor bump * Bump HISTORY.rst --- HISTORY.rst | 25 ++ README.rst | 2 + dataclass_wizard/__init__.py | 3 +- dataclass_wizard/abstractions.py | 23 +- dataclass_wizard/abstractions.pyi | 382 ++++++++++++++++++++ dataclass_wizard/class_helper.py | 5 + dataclass_wizard/dumpers.py | 213 ++++++----- dataclass_wizard/errors.py | 27 +- dataclass_wizard/lazy_imports.py | 11 + dataclass_wizard/loaders.py | 248 ++++++------- dataclass_wizard/parsers.py | 34 +- dataclass_wizard/serial_json.py | 10 +- dataclass_wizard/serial_json.pyi | 174 +++++++++ dataclass_wizard/type_def.py | 19 +- dataclass_wizard/utils/function_builder.py | 223 ++++++++++++ dataclass_wizard/wizard_mixins.py | 129 ++++++- docs/advanced_usage/serializer_hooks.rst | 50 ++- docs/advanced_usage/type_hooks.rst | 5 + docs/common_use_cases/easier_debug_mode.rst | 62 ++++ docs/common_use_cases/skip_the_str.rst | 4 + docs/common_use_cases/wizard_mixins.rst | 149 ++++++++ requirements-dev.txt | 5 + setup.py | 7 +- tests/unit/test_wizard_mixins.py | 101 +++++- 24 files changed, 1641 insertions(+), 270 deletions(-) create mode 100644 dataclass_wizard/abstractions.pyi create mode 100644 dataclass_wizard/serial_json.pyi create mode 100644 dataclass_wizard/utils/function_builder.py create mode 100644 docs/common_use_cases/easier_debug_mode.rst diff --git a/HISTORY.rst b/HISTORY.rst index 116b8139..e3cf9c8b 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,6 +2,31 @@ History ======= +0.28.0 (2024-11-15) +------------------- + +**Features and Improvements** + +* Added :class:`TOMLWizard`. +* Introduced new (pre-process) serializer hooks: + * :meth:`_pre_from_dict` + * :meth:`_pre_dict` +* Added ``debug`` parameter to :meth:`JSONWizard.__init_subclass__`. +* Added ``*.pyi`` stub files for better Type Hinting and Autocompletion in IDEs (e.g., PyCharm): + * :file:`abstractions.pyi` + * :file:`serial_json.pyi` +* Introduced utility class :class:`FunctionBuilder` to help build and dynamically ``exec`` a function. +* Documentation/tests on the new and updated features. + +**Changes** + +* The returned parser for a dataclass is now the original load/dump function itself (which takes a single argument) + rather than a :class:`Parser` instance. +* Minor optimization and quality-of-life improvement: dynamically ``exec`` dataclass load and dump functions. +* Improved performance: if a class defines a :meth:`from_dict` method - equivalent to :func:`fromdict` - and a :meth:`to_dict` method + - equivalent to :func:`asdict` - replace them with dynamically generated load/dump functions. +* Deprecated the pre-process hook :meth:`DumpMixin.__pre_as_dict__`. + 0.27.0 (2024-11-10) ------------------- diff --git a/README.rst b/README.rst index 1c5bed50..2f128cd7 100644 --- a/README.rst +++ b/README.rst @@ -111,6 +111,7 @@ In addition to the ``JSONWizard``, here are a few extra Mixin_ classes that migh * `JSONListWizard`_ -- Extends ``JSONWizard`` to return `Container`_ -- instead of *list* -- objects where possible. * `JSONFileWizard`_ -- Makes it easier to convert dataclass instances from/to JSON files on a local drive. +* `TOMLWizard`_ -- Provides support to convert dataclass instances to/from TOML. * `YAMLWizard`_ -- Provides support to convert dataclass instances to/from YAML, using the default ``PyYAML`` parser. @@ -961,6 +962,7 @@ This package was created with Cookiecutter_ and the `rnag/cookiecutter-pypackage .. _`open an issue`: https://github.com/rnag/dataclass-wizard/issues .. _`JSONListWizard`: https://dataclass-wizard.readthedocs.io/en/latest/common_use_cases/wizard_mixins.html#jsonlistwizard .. _`JSONFileWizard`: https://dataclass-wizard.readthedocs.io/en/latest/common_use_cases/wizard_mixins.html#jsonfilewizard +.. _`TOMLWizard`: https://dataclass-wizard.readthedocs.io/en/latest/common_use_cases/wizard_mixins.html#tomlwizard .. _`YAMLWizard`: https://dataclass-wizard.readthedocs.io/en/latest/common_use_cases/wizard_mixins.html#yamlwizard .. _`Container`: https://dataclass-wizard.readthedocs.io/en/latest/dataclass_wizard.html#dataclass_wizard.Container .. _`Supported Types`: https://dataclass-wizard.readthedocs.io/en/latest/overview.html#supported-types diff --git a/dataclass_wizard/__init__.py b/dataclass_wizard/__init__.py index 97d04b18..8a7d532d 100644 --- a/dataclass_wizard/__init__.py +++ b/dataclass_wizard/__init__.py @@ -78,6 +78,7 @@ # Wizard Mixins 'JSONListWizard', 'JSONFileWizard', + 'TOMLWizard', 'YAMLWizard', # Helper serializer functions + meta config 'fromlist', @@ -104,7 +105,7 @@ Pattern, DatePattern, TimePattern, DateTimePattern) from .property_wizard import property_wizard from .serial_json import JSONSerializable -from .wizard_mixins import JSONListWizard, JSONFileWizard, YAMLWizard +from .wizard_mixins import JSONListWizard, JSONFileWizard, TOMLWizard, YAMLWizard # Set up logging to ``/dev/null`` like a library is supposed to. diff --git a/dataclass_wizard/abstractions.py b/dataclass_wizard/abstractions.py index 1abad946..fa36c065 100644 --- a/dataclass_wizard/abstractions.py +++ b/dataclass_wizard/abstractions.py @@ -119,7 +119,7 @@ class AbstractParser(ABC, Generic[T, TT]): # This is usually the underlying base type of the annotation (for example, # for `List[str]` it will be `list`), though in some cases this will be # the annotation itself. - base_type: T + base_type: type[T] def __contains__(self, item) -> bool: """ @@ -356,24 +356,3 @@ def get_parser_for_annotation(cls, ann_type: Type[T], class AbstractDumper(ABC): __slots__ = () - - def __pre_as_dict__(self): - """ - Optional hook that runs before the dataclass instance is processed and - before it is converted to a dictionary object via :meth:`to_dict`. - - To override this, subclasses need to extend from :class:`DumpMixIn` - and implement this method. A simple example is shown below: - - >>> from dataclasses import dataclass - >>> from dataclass_wizard import JSONSerializable, DumpMixin - >>> - >>> - >>> @dataclass - >>> class MyClass(JSONSerializable, DumpMixin): - >>> my_str: str - >>> - >>> def __pre_as_dict__(self): - >>> self.my_str = self.my_str.swapcase() - - """ diff --git a/dataclass_wizard/abstractions.pyi b/dataclass_wizard/abstractions.pyi new file mode 100644 index 00000000..97e06c3d --- /dev/null +++ b/dataclass_wizard/abstractions.pyi @@ -0,0 +1,382 @@ +""" +Contains implementations for Abstract Base Classes +""" +import json +from abc import ABC, abstractmethod +from dataclasses import dataclass, InitVar +from datetime import datetime, time, date, timedelta +from decimal import Decimal +from typing import ( + Any, TypeVar, SupportsFloat, AnyStr, + Text, Sequence, Iterable, Generic +) + +from .models import Extras +from .type_def import ( + DefFactory, FrozenKeys, ListOfJSONObject, JSONObject, Encoder, + M, N, T, TT, NT, E, U, DD, LSQ +) + + +# Create a generic variable that can be 'AbstractJSONWizard', or any subclass. +W = TypeVar('W', bound='AbstractJSONWizard') + +FieldToParser = dict[str, 'AbstractParser'] + + +class AbstractJSONWizard(ABC): + """ + Abstract class that defines the methods a sub-class must implement at a + minimum to be considered a "true" JSON Wizard. + + In particular, these are the abstract methods which - if correctly + implemented - will allow a concrete sub-class (ideally a dataclass) to + be properly loaded from, and serialized to, JSON. + + """ + __slots__ = () + + @classmethod + @abstractmethod + def from_json(cls: type[W], string: AnyStr) -> W | list[W]: + """ + Converts a JSON `string` to an instance of the dataclass, or a list of + the dataclass instances. + """ + + @classmethod + @abstractmethod + def from_list(cls: type[W], o: ListOfJSONObject) -> list[W]: + """ + Converts a Python `list` object to a list of the dataclass instances. + """ + + @classmethod + @abstractmethod + def from_dict(cls: type[W], o: JSONObject) -> W: + """ + Converts a Python `dict` object to an instance of the dataclass. + """ + + @abstractmethod + def to_dict(self: W) -> JSONObject: + """ + Converts the dataclass instance to a Python dictionary object that is + JSON serializable. + """ + + @abstractmethod + def to_json(self: W, *, + encoder: Encoder = json.dumps, + indent=None, + **encoder_kwargs) -> AnyStr: + """ + Converts the dataclass instance to a JSON `string` representation. + """ + + @classmethod + @abstractmethod + def list_to_json(cls: type[W], + instances: list[W], + encoder: Encoder = json.dumps, + indent=None, + **encoder_kwargs) -> AnyStr: + """ + Converts a ``list`` of dataclass instances to a JSON `string` + representation. + """ + + +@dataclass +class AbstractParser(ABC, Generic[T, TT]): + """ + Abstract parsers, which will ideally act as dispatchers to route objects + to the `load` or `dump` hook methods responsible for transforming the + objects into the annotated type for the dataclass field for which value we + want to set. The error handling logic should ideally be implemented on the + Parser (dispatcher) side. + + There can be more complex Parsers, for example ones which will handle + ``typing.Union``, ``typing.Literal``, ``Dict``, and ``NamedTuple`` types. + There can even be nested Parsers, which will be useful for handling + collection and sequence types. + + """ + __slots__ = ('base_type', ) + + # This represents the class that contains the field that has an annotated + # type `base_type`. This is primarily useful for resolving `ForwardRef` + # types, where we need the globals of the class to resolve the underlying + # type of the reference. + cls: InitVar[type] + + # This represents an optional Meta config that was specified for the main + # dataclass. This is primarily useful to have so that we can merge this + # base Meta config with the one for each class, and then recursively + # apply the merged Meta config to any nested dataclasses. + extras: InitVar[Extras] + + # This is usually the underlying base type of the annotation (for example, + # for `list[str]` it will be `list`), though in some cases this will be + # the annotation itself. + base_type: type[T] + + def __contains__(self, item) -> bool: + """ + Return true if the Parser is expected to handle the specified item + type. Checks against the exact type instead of `isinstance` so we can + handle special cases like `bool`, which is a subclass of `int`. + """ + return type(item) is self.base_type + + @abstractmethod + def __call__(self, o: Any) -> TT: + """ + Parse object `o` + """ + + +class AbstractLoader(ABC): + """ + Abstract loader which defines the helper methods that can be used to load + an object `o` into an object of annotated (or concrete) type `base_type`. + + """ + __slots__ = () + + @staticmethod + @abstractmethod + def transform_json_field(string: str) -> str: + """ + Transform a JSON field name (which will typically be camel-cased) into + the conventional format for a dataclass field name (which will ideally + be snake-cased). + """ + + @staticmethod + @abstractmethod + def default_load_to(o: T, _: Any) -> T: + """ + Default load function if no other paths match. Generally, this will + be a stub load method. + """ + + @staticmethod + @abstractmethod + def load_after_type_check(o: Any, base_type: type[T]) -> T: + """ + Load an object `o`, after confirming that it is indeed of + type `base_type`. + + :raises ParseError: If the object is not of the expected type. + """ + + @staticmethod + @abstractmethod + def load_to_str(o: Text | N | None, base_type: type[str]) -> str: + """ + Load a string or numeric type into a new object of type `base_type` + (generally a sub-class of the :class:`str` type) + """ + + @staticmethod + @abstractmethod + def load_to_int(o: str | int | bool | None, base_type: type[N]) -> N: + """ + Load a string or int into a new object of type `base_type` + (generally a sub-class of the :class:`int` type) + """ + + @staticmethod + @abstractmethod + def load_to_float(o: SupportsFloat | str, base_type: type[N]) -> N: + """ + Load a string or float into a new object of type `base_type` + (generally a sub-class of the :class:`float` type) + """ + + @staticmethod + @abstractmethod + def load_to_bool(o: str | bool | N, _: type[bool]) -> bool: + """ + Load a bool, string, or an numeric value into a new object of type + `bool`. + + *Note*: `bool` cannot be sub-classed, so the `base_type` argument is + discarded in this case. + """ + + @staticmethod + @abstractmethod + def load_to_enum(o: AnyStr | N, base_type: type[E]) -> E: + """ + Load an object `o` into a new object of type `base_type` (generally a + sub-class of the :class:`Enum` type) + """ + + @staticmethod + @abstractmethod + def load_to_uuid(o: AnyStr | U, base_type: type[U]) -> U: + """ + Load an object `o` into a new object of type `base_type` (generally a + sub-class of the :class:`UUID` type) + """ + + @staticmethod + @abstractmethod + def load_to_iterable( + o: Iterable, base_type: type[LSQ], + elem_parser: AbstractParser) -> LSQ: + """ + Load a list, set, frozenset or deque into a new object of type + `base_type` (generally a list, set, frozenset, deque, or a sub-class + of one) + """ + + @staticmethod + @abstractmethod + def load_to_tuple( + o: list | tuple, base_type: type[tuple], + elem_parsers: Sequence[AbstractParser]) -> tuple: + """ + Load a list or tuple into a new object of type `base_type` (generally + a :class:`tuple` or a sub-class of one) + """ + + @staticmethod + @abstractmethod + def load_to_named_tuple( + o: dict | list | tuple, base_type: type[NT], + field_to_parser: FieldToParser, + field_parsers: list[AbstractParser]) -> NT: + """ + Load a dictionary, list, or tuple to a `NamedTuple` sub-class + """ + + @staticmethod + @abstractmethod + def load_to_named_tuple_untyped( + o: dict | list | tuple, base_type: type[NT], + dict_parser: AbstractParser, list_parser: AbstractParser) -> NT: + """ + Load a dictionary, list, or tuple to a (generally) un-typed + `collections.namedtuple` + """ + + @staticmethod + @abstractmethod + def load_to_dict( + o: dict, base_type: type[M], + key_parser: AbstractParser, + val_parser: AbstractParser) -> M: + """ + Load an object `o` into a new object of type `base_type` (generally a + :class:`dict` or a sub-class of one) + """ + + @staticmethod + @abstractmethod + def load_to_defaultdict( + o: dict, base_type: type[DD], + default_factory: DefFactory, + key_parser: AbstractParser, + val_parser: AbstractParser) -> DD: + """ + Load an object `o` into a new object of type `base_type` (generally a + :class:`collections.defaultdict` or a sub-class of one) + """ + + @staticmethod + @abstractmethod + def load_to_typed_dict( + o: dict, base_type: type[M], + key_to_parser: FieldToParser, + required_keys: FrozenKeys, + optional_keys: FrozenKeys) -> M: + """ + Load an object `o` annotated as a ``TypedDict`` sub-class into a new + object of type `base_type` (generally a :class:`dict` or a sub-class + of one) + """ + + @staticmethod + @abstractmethod + def load_to_decimal(o: N, base_type: type[Decimal]) -> Decimal: + """ + Load an object `o` into a new object of type `base_type` (generally a + :class:`Decimal` or a sub-class of one) + """ + + @staticmethod + @abstractmethod + def load_to_datetime( + o: str | N, base_type: type[datetime]) -> datetime: + """ + Load a string or number (int or float) into a new object of type + `base_type` (generally a :class:`datetime` or a sub-class of one) + """ + + @staticmethod + @abstractmethod + def load_to_time(o: str, base_type: type[time]) -> time: + """ + Load a string or number (int or float) into a new object of type + `base_type` (generally a :class:`time` or a sub-class of one) + """ + + @staticmethod + @abstractmethod + def load_to_date(o: str | N, base_type: type[date]) -> date: + """ + Load a string or number (int or float) into a new object of type + `base_type` (generally a :class:`date` or a sub-class of one) + """ + + @staticmethod + @abstractmethod + def load_to_timedelta( + o: str | N, base_type: type[timedelta]) -> timedelta: + """ + Load a string or number (int or float) into a new object of type + `base_type` (generally a :class:`timedelta` or a sub-class of one) + """ + + @classmethod + @abstractmethod + def get_parser_for_annotation(cls, ann_type: type[T], + base_cls: type = None, + extras: Extras = None) -> AbstractParser: + """ + Returns the Parser (dispatcher) for a given annotation type. + + `base_cls` is the original class object, this is useful when the + annotated type is a :class:`typing.ForwardRef` object + """ + + +class AbstractDumper(ABC): + __slots__ = () + + def __pre_as_dict__(self): + """ + Optional hook that runs before the dataclass instance is processed and + before it is converted to a dictionary object via :meth:`to_dict`. + + To override this, subclasses need to extend from :class:`DumpMixIn` + and implement this method. A simple example is shown below: + + >>> from dataclasses import dataclass + >>> from dataclass_wizard import JSONSerializable, DumpMixin + >>> + >>> + >>> @dataclass + >>> class MyClass(JSONSerializable, DumpMixin): + >>> my_str: str + >>> + >>> def __pre_as_dict__(self): + >>> self.my_str = self.my_str.swapcase() + + @deprecated since v0.28.0. Use `_pre_dict()` instead - no need + to subclass from DumpMixin. + """ + ... diff --git a/dataclass_wizard/class_helper.py b/dataclass_wizard/class_helper.py index 9a3cd6d9..30563c61 100644 --- a/dataclass_wizard/class_helper.py +++ b/dataclass_wizard/class_helper.py @@ -308,6 +308,11 @@ def dataclass_field_to_default(cls) -> Dict[str, Any]: return defaults +def is_builtin_class(cls): + """Check if a class is a builtin in Python.""" + return cls.__module__ == 'builtins' + + def create_new_class( class_or_instance, bases: Tuple[T, ...], suffix: Optional[str] = None, attr_dict=None) -> T: diff --git a/dataclass_wizard/dumpers.py b/dataclass_wizard/dumpers.py index 19402db6..fe8ea804 100644 --- a/dataclass_wizard/dumpers.py +++ b/dataclass_wizard/dumpers.py @@ -9,12 +9,13 @@ See the end of this file for the original Apache license from this library. """ from collections import defaultdict, deque -# noinspection PyProtectedMember +# noinspection PyProtectedMember,PyUnresolvedReferences from dataclasses import _is_dataclass_instance from datetime import datetime, time, date, timedelta from decimal import Decimal from enum import Enum -from typing import Type, List, Dict, Any, NamedTupleMeta, Optional, Callable +# noinspection PyProtectedMember,PyUnresolvedReferences +from typing import Type, List, Dict, Any, NamedTupleMeta, Optional, Callable, Collection from uuid import UUID from .abstractions import AbstractDumper @@ -29,11 +30,14 @@ ) from .constants import _DUMP_HOOKS, TAG from .decorators import _alias +from .errors import show_deprecation_warning from .log import LOG from .type_def import ( ExplicitNull, NoneType, JSONObject, DD, LSQ, E, U, LT, NT, T ) +from .utils.function_builder import FunctionBuilder +from .utils.dataclass_compat import _set_new_attribute from .utils.string_conv import to_camel_case @@ -199,9 +203,11 @@ def get_dumper(cls=None, create=True) -> Type[DumpMixin]: return set_class_dumper(cls, DumpMixin) -def asdict(obj: T, - *, cls=None, dict_factory=dict, - exclude: List[str] = None, **kwargs) -> JSONObject: +def asdict(o: T, + *, cls=None, + dict_factory=dict, + exclude: 'Collection[str] | None' = None, + **kwargs) -> JSONObject: # noinspection PyUnresolvedReferences """Return the fields of a dataclass instance as a new dictionary mapping field names to field values. @@ -234,14 +240,14 @@ class C: # if not _is_dataclass_instance(obj): # raise TypeError("asdict() should be called on dataclass instances") - cls = cls or type(obj) + cls = cls or type(o) try: dump = _CLASS_TO_DUMP_FUNC[cls] except KeyError: dump = dump_func_for_dataclass(cls) - return dump(obj, dict_factory, exclude, **kwargs) + return dump(o, dict_factory, exclude, **kwargs) def dump_func_for_dataclass(cls: Type[T], @@ -249,6 +255,8 @@ def dump_func_for_dataclass(cls: Type[T], nested_cls_to_dump_func: Dict[Type, Any] = None, ) -> Callable[[T, Any, Any, Any], JSONObject]: + # TODO dynamically generate for multiple nested classes at once + # Get the dumper for the class, or create a new one as needed. cls_dumper = get_dumper(cls) @@ -265,12 +273,12 @@ def dump_func_for_dataclass(cls: Type[T], if meta.recursive and meta is not AbstractMeta: config = meta - else: # we are being run for a nested dataclass - if config: - # we want to apply the meta config from the main dataclass - # recursively. - meta = meta | config - meta.bind_to(cls, is_default=False) + # we are being run for a nested dataclass + elif config: + # we want to apply the meta config from the main dataclass + # recursively. + meta = meta | config + meta.bind_to(cls, is_default=False) # This contains the dump hooks for the dataclass. If the class # sub-classes from `DumpMixIn`, these hooks could be customized. @@ -306,80 +314,119 @@ def dump_func_for_dataclass(cls: Type[T], # Tag key to populate when a dataclass is in a `Union` with other types. tag_key = meta.tag_key or TAG - def cls_asdict(obj: T, dict_factory=dict, - exclude: List[str] = None, - skip_defaults=meta.skip_defaults) -> JSONObject: - """ - Serialize a dataclass of type `cls` to a Python dictionary object. - """ - - # Call the optional hook that runs before we process the dataclass - cls_dumper.__pre_as_dict__(obj) - - # This a list that contains a mapping of each dataclass field to its - # serialized value. - result = [] - - # Loop over the dataclass fields - for field in field_names: - - # Get the resolved JSON field name - try: - json_field = dataclass_to_json_field[field] - - except KeyError: - # Normalize the dataclass field name (by default to camel - # case) - json_field = cls_dumper.transform_dataclass_field(field) - dataclass_to_json_field[field] = json_field - - # Exclude any dataclass fields that are explicitly ignored. - if json_field is ExplicitNull: - continue - if exclude and field in exclude: - continue - - # -- This line is *mostly* the same as in the original version -- - fv = getattr(obj, field) - - # Check if we need to strip defaults, and the field currently - # is assigned a default value. - # - # TODO: maybe it makes sense to move this logic to a separate - # function, as it might be slightly more performant. - if skip_defaults and field in field_to_default \ - and fv == field_to_default[field]: - continue - - value = _asdict_inner(fv, dict_factory, hooks, config, - nested_cls_to_dump_func) - - # -- This line is *mostly* the same as in the original version -- - result.append((json_field, value)) - - # -- This line is the same as in the original version -- - return dict_factory(result) - - def cls_asdict_with_tag(obj: T, dict_factory=dict, - exclude: List[str] = None, - **kwargs) -> JSONObject: - """ - Serialize a dataclass of type `cls` to a Python dictionary object. - Adds a tag field when `tag` field is passed in Meta. - """ - result = cls_asdict(obj, dict_factory, exclude, **kwargs) - result[tag_key] = meta.tag - - return result - - if meta.tag: - asdict_func = cls_asdict_with_tag - else: - asdict_func = cls_asdict + _locals = { + 'config': config, + 'asdict': _asdict_inner, + 'hooks': hooks, + 'cls_to_asdict': nested_cls_to_dump_func, + } + + _globals = { + 'T': T, + } + + # Initialize FuncBuilder + fn_gen = FunctionBuilder() + + # Code for `cls_asdict` + with fn_gen.function('cls_asdict', + ['o:T', + 'dict_factory=dict', + "exclude:'list[str]|None'=None", + f'skip_defaults:bool={meta.skip_defaults}'], + return_type='JSONObject'): + + if ( + _pre_dict := getattr(cls, '_pre_dict', None) + ) is not None: + # class defines a `_pre_dict()` + _locals['__pre_dict__'] = _pre_dict + fn_gen.add_line('__pre_dict__(o)') + elif ( + _pre_dict := getattr(cls_dumper, '__pre_as_dict__', None) + ) is not None: + # deprecated since v0.28.0 + # subclass of `DumpMixin` defines a `__pre_as_dict__()` + reason = "use `_pre_dict` instead - no need to subclass from DumpMixin" + show_deprecation_warning(_pre_dict, reason) + + _locals['__pre_dict__'] = _pre_dict + fn_gen.add_line('__pre_dict__(o)') + + # Initialize result list to hold field mappings + fn_gen.add_line("result = []") + + if field_names: + + skip_field_assignments = [] + exclude_assignments = [] + skip_default_assignments = [] + field_assignments = [] + + # Loop over the dataclass fields + for i, field in enumerate(field_names): + skip_field = f'_skip_{i}' + default_value = f'_default_{i}' + + skip_field_assignments.append(skip_field) + exclude_assignments.append( + f'{skip_field}={field!r} in exclude' + ) + if field in field_to_default: + _locals[default_value] = field_to_default[field] + skip_default_assignments.append( + f"{skip_field} = {skip_field} or o.{field} == {default_value}" + ) + + # Get the resolved JSON field name + try: + json_field = dataclass_to_json_field[field] + except KeyError: + # Normalize the dataclass field name (by default to camel + # case) + json_field = cls_dumper.transform_dataclass_field(field) + dataclass_to_json_field[field] = json_field + + # Exclude any dataclass fields that are explicitly ignored. + if json_field is not ExplicitNull: + field_assignments.append(f"if not {skip_field}:") + field_assignments.append(f" result.append(('{json_field}'," + f"asdict(o.{field},dict_factory,hooks,config,cls_to_asdict)))") + + with fn_gen.if_('exclude is None'): + fn_gen.add_line('='.join(skip_field_assignments) + '=False') + with fn_gen.else_(): + fn_gen.add_line(';'.join(exclude_assignments)) + + if skip_default_assignments: + with fn_gen.if_('skip_defaults'): + fn_gen.add_lines(*skip_default_assignments) + + fn_gen.add_lines(*field_assignments) + + # Return the final dictionary result + if meta.tag: + fn_gen.add_line("result = dict_factory(result)") + fn_gen.add_line(f"result[{tag_key!r}] = {meta.tag!r}") + # Return the result with the tag added + fn_gen.add_line("return result") + else: + fn_gen.add_line("return dict_factory(result)") + + # Compile the code into a dynamic string + functions = fn_gen.create_functions(locals=_locals, globals=_globals) + + cls_asdict = functions['cls_asdict'] + + asdict_func = cls_asdict # In any case, save the dump function for the class, so we don't need to # run this logic each time. if is_main_class: + # Check if the class has a `to_dict`, and it's + # equivalent to `asdict`. + if getattr(cls, 'to_dict', None) is asdict: + _set_new_attribute(cls, 'to_dict', asdict_func) _CLASS_TO_DUMP_FUNC[cls] = asdict_func else: nested_cls_to_dump_func[cls] = asdict_func diff --git a/dataclass_wizard/errors.py b/dataclass_wizard/errors.py index e6d98904..3ab151fd 100644 --- a/dataclass_wizard/errors.py +++ b/dataclass_wizard/errors.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from dataclasses import Field, MISSING from typing import (Any, Type, Dict, Tuple, ClassVar, - Optional, Union, Iterable) + Optional, Union, Iterable, Callable) from .utils.string_conv import normalize @@ -11,6 +11,27 @@ JSONObject = Dict[str, Any] +def show_deprecation_warning( + fn: Callable, + reason: str, + fmt: str = "Deprecated function {name} ({reason})." +) -> None: + """ + Display a deprecation warning for a given function. + + @param fn: Function which is deprecated. + @param reason: Reason for the deprecation. + @param fmt: Format string for the name/reason. + """ + import warnings + warnings.simplefilter('always', DeprecationWarning) + warnings.warn( + fmt.format(name=fn.__name__, reason=reason), + category=DeprecationWarning, + stacklevel=2, + ) + + class JSONWizardError(ABC, Exception): """ Base error class, for errors raised by this library. @@ -128,7 +149,7 @@ def __init__(self, base_err: Exception, obj: JSONObject, cls: Type, cls_kwargs: JSONObject, - cls_fields: Tuple[Field], **kwargs): + cls_fields: Tuple[Field, ...], **kwargs): super().__init__() @@ -199,7 +220,7 @@ def __init__(self, json_key: str, obj: JSONObject, cls: Type, - cls_fields: Tuple[Field], **kwargs): + cls_fields: Tuple[Field, ...], **kwargs): super().__init__() self.json_key = json_key diff --git a/dataclass_wizard/lazy_imports.py b/dataclass_wizard/lazy_imports.py index 8ff5a4ae..058a96d9 100644 --- a/dataclass_wizard/lazy_imports.py +++ b/dataclass_wizard/lazy_imports.py @@ -5,6 +5,7 @@ $ pip install dataclass-wizard[timedelta] """ +from .constants import PY311_OR_ABOVE from .utils.lazy_loader import LazyLoader @@ -13,3 +14,13 @@ # PyYAML: to add support for (de)serializing YAML data to dataclass instances yaml = LazyLoader(globals(), 'yaml', 'yaml', local_name='PyYAML') + +# Tomli -or- tomllib (PY 3.11+): to add support for (de)serializing TOML +# data to dataclass instances +if PY311_OR_ABOVE: + import tomllib as toml +else: + toml = LazyLoader(globals(), 'tomli', 'toml', local_name='tomli') + +# Tomli-W: to add support for serializing dataclass instances to TOML +toml_w = LazyLoader(globals(), 'tomli_w', 'toml', local_name='tomli-w') diff --git a/dataclass_wizard/loaders.py b/dataclass_wizard/loaders.py index 64bdc6b5..010ac47e 100644 --- a/dataclass_wizard/loaders.py +++ b/dataclass_wizard/loaders.py @@ -15,7 +15,7 @@ from .abstractions import AbstractLoader, AbstractParser, FieldToParser from .bases import BaseLoadHook, AbstractMeta, META from .class_helper import ( - get_class_name, create_new_class, + create_new_class, dataclass_to_loader, set_class_loader, dataclass_field_to_load_parser, json_field_to_dataclass_field, _CLASS_TO_LOAD_FUNC, dataclass_fields, get_meta, is_subclass_safe, @@ -32,6 +32,9 @@ PyRequired, PyNotRequired, M, N, T, E, U, DD, LSQ, NT ) +from .utils.function_builder import FunctionBuilder +# noinspection PyProtectedMember +from .utils.dataclass_compat import _set_new_attribute from .utils.string_conv import to_snake_case from .utils.type_conv import ( as_bool, as_str, as_datetime, as_date, as_time, as_int, as_timedelta @@ -246,7 +249,7 @@ def load_to_timedelta( @classmethod def get_parser_for_annotation(cls, ann_type: Type[T], base_cls: Type = None, - extras: Extras = None) -> AbstractParser: + extras: Extras = None) -> 'AbstractParser | Callable[[dict[str, Any]], T]': """Returns the Parser (dispatcher) for a given annotation type.""" hooks = cls.__LOAD_HOOKS__ ann_type = eval_forward_ref_if_needed(ann_type, base_cls) @@ -303,8 +306,10 @@ def get_parser_for_annotation(cls, ann_type: Type[T], base_cls, extras, base_type, hook=None ) else: # else, logic is same as normal - base_type: Type[T] - load_hook = load_func_for_dataclass( + base_type: 'type[T]' + # return a dynamically generated `fromdict` + # for the `cls` (base_type) + return load_func_for_dataclass( base_type, is_main_class=False, config=extras['config'] @@ -583,7 +588,13 @@ def fromlist(cls: Type[T], list_of_dict: List[JSONObject]) -> List[T]: def load_func_for_dataclass( cls: Type[T], is_main_class: bool = True, - config: Optional[META] = None) -> Callable[[JSONObject], T]: + config: Optional[META] = None, +) -> Callable[[JSONObject], T]: + + # TODO dynamically generate for multiple nested classes at once + + # Tuple describing the fields of this dataclass. + cls_fields = dataclass_fields(cls) # Get the loader for the class, or create a new one as needed. cls_loader = get_loader(cls) @@ -597,18 +608,18 @@ def load_func_for_dataclass( if meta.recursive and meta is not AbstractMeta: config = meta - else: # we are being run for a nested dataclass - if config: - # we want to apply the meta config from the main dataclass - # recursively. - meta = meta | config - meta.bind_to(cls, is_default=False) + # we are being run for a nested dataclass + elif config: + # we want to apply the meta config from the main dataclass + # recursively. + meta = meta | config + meta.bind_to(cls, is_default=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 as e: + except RecursionError: if meta.recursive_classes: # recursion-safe loader is already in use; something else must have gone wrong raise @@ -618,134 +629,129 @@ def load_func_for_dataclass( # A cached mapping of each key in a JSON or dictionary object to the # resolved dataclass field name; useful so we don't need to do a case # transformation (via regex) each time. - json_to_dataclass_field = json_field_to_dataclass_field(cls) + json_to_field = json_field_to_dataclass_field(cls) + + _locals = { + 'cls': cls, + 'py_case': cls_loader.transform_json_field, + 'field_to_parser': field_to_parser, + 'json_to_field': json_to_field, + 'ExplicitNull': ExplicitNull, + } + + _globals = { + 'cls_fields': cls_fields, + 'LOG': LOG, + 'MissingData': MissingData, + 'MissingFields': MissingFields, + } + + # Initialize the FuncBuilder + fn_gen = FunctionBuilder() + + with fn_gen.function('cls_fromdict', ['o']): - def cls_fromdict(o: JSONObject, *_): - """ - De-serialize a dictionary `o` to an instance of a dataclass `cls`. - """ + _pre_from_dict_method = getattr(cls, '_pre_from_dict', None) + if _pre_from_dict_method is not None: + _locals['__pre_from_dict__'] = _pre_from_dict_method + fn_gen.add_line('o = __pre_from_dict__(o)') # Need to create a separate dictionary to copy over the constructor # args, as we don't want to mutate the original dictionary object. - cls_kwargs = {} - + fn_gen.add_line('init_kwargs = {}') # This try-block is here in case the object `o` is None. - try: + with fn_gen.try_(): # Loop over the dictionary object - for json_key in o: + with fn_gen.for_('json_key in o'): - # Get the resolved dataclass field name - try: - field_name = json_to_dataclass_field[json_key] - # Exclude JSON keys that don't map to any fields. - if field_name is ExplicitNull: - continue + with fn_gen.try_(): + # Get the resolved dataclass field name + fn_gen.add_line("field = json_to_field[json_key]") - except KeyError: - try: - field_name = lookup_field_for_json_key(o, json_key) - except LookupError: - continue - - try: - # Note: pass the original cased field to the class - # constructor; don't use the lowercase result from - # `transform_json_field` - cls_kwargs[field_name] = field_to_parser[field_name]( - o[json_key]) - - except ParseError as e: - # We run into a parsing error while loading the field - # value; Add additional info on the Exception object - # before re-raising it. + with fn_gen.except_(KeyError): + fn_gen.add_line('# Lookup Field for JSON Key') + # Determines the dataclass field which a JSON key should map to. + # Note this logic only runs the initial time, i.e. the first time + # we encounter the key in a JSON object. # - # First confirm these values are not already set by an - # inner dataclass. If so, it likely makes it easier to - # debug the cause. Note that this should already be - # handled by the `setter` methods. - e.class_name = cls - e.field_name = field_name - e.json_object = o - raise - - except TypeError: - # If the object `o` is None, then raise an error with the relevant - # info included. - if o is None: - raise MissingData(cls) from None - + # :raises UnknownJSONKey: If there is no resolved field name for the + # JSON key, and`raise_on_unknown_json_key` is enabled in the Meta + # config for the class. + + # Short path: an identical-cased field name exists for the JSON key + with fn_gen.if_('json_key in field_to_parser'): + fn_gen.add_line("field = json_to_field[json_key] = json_key") + + with fn_gen.else_(): + # Transform JSON field name (typically camel-cased) to the + # snake-cased variant which is convention in Python. + fn_gen.add_line("py_field = py_case(json_key)") + + with fn_gen.try_(): + # Do a case-insensitive lookup of the dataclass field, and + # cache the mapping, so we have it for next time + fn_gen.add_line("field " + "= json_to_field[json_key] " + "= field_to_parser.get_key(py_field)") + + with fn_gen.except_(KeyError): + # Else, we see an unknown field in the dictionary object + fn_gen.add_line("field = json_to_field[json_key] = ExplicitNull") + fn_gen.add_line("LOG.warning('JSON field %r missing from dataclass schema, " + "class=%r, parsed field=%r',json_key,cls,py_field)") + # Raise an error here (if needed) + if meta.raise_on_unknown_json_key: + _globals['UnknownJSONKey'] = UnknownJSONKey + fn_gen.add_line("raise UnknownJSONKey(json_key, o, cls, cls_fields) from None") + + # Exclude JSON keys that don't map to any fields. + with fn_gen.if_('field is not ExplicitNull'): + + with fn_gen.try_(): + # Note: pass the original cased field to the class constructor; + # don't use the lowercase result from `py_case` + fn_gen.add_line("init_kwargs[field] = field_to_parser[field](o[json_key])") + + with fn_gen.except_(ParseError, 'e'): + # We run into a parsing error while loading the field value; + # Add additional info on the Exception object before re-raising it. + fn_gen.add_line("e.class_name, e.field_name, e.json_object = cls, field, o") + fn_gen.add_line("raise") + + with fn_gen.except_(TypeError): + # If the object `o` is None, then raise an error with the relevant info included. + with fn_gen.if_('o is None'): + fn_gen.add_line("raise MissingData(cls) from None") # Check if the object `o` is some other type than what we expect - # for example, we could be passed in a `list` type instead. - if not isinstance(o, dict): - e = TypeError('Incorrect type for field') - raise ParseError( - e, o, dict, cls, - desired_type=dict - ) from None - - # Else, just re-raise the error. - raise - - # Now pass the arguments to the constructor method, and return the new - # dataclass instance. If there are any missing fields, we raise them - # here. - - try: - return cls(**cls_kwargs) - - except TypeError as e: - raise MissingFields( - e, o, cls, cls_kwargs, dataclass_fields(cls) - ) from None - - def lookup_field_for_json_key(o: JSONObject, json_field: str): - """ - Determines the dataclass field which a JSON key should map to. Note - this only runs the initial time, i.e. the first time we encounter the - key in a JSON object. - - :raises LookupError: If there no resolved field name for the JSON key. - :raises UnknownJSONKey: If there is no resolved field name for the - JSON key, and`raise_on_unknown_json_key` is enabled in the Meta - config for the class. - """ - - # Short path: an identical-cased field name exists for the JSON key - if json_field in field_to_parser: - json_to_dataclass_field[json_field] = json_field - return json_field - - # Transform JSON field name (typically camel-cased) to the - # snake-cased variant which is convention in Python. - transformed_field = cls_loader.transform_json_field(json_field) - - try: - # Do a case-insensitive lookup of the dataclass field, and - # cache the mapping, so we have it for next time - field_name = field_to_parser.get_key(transformed_field) - json_to_dataclass_field[json_field] = field_name + with fn_gen.if_('not isinstance(o, dict)'): + fn_gen.add_line("e = TypeError('Incorrect type for field')") + fn_gen.add_line("raise ParseError(e, o, dict, cls, desired_type=dict) from None") - except KeyError: - # Else, we see an unknown field in the dictionary object - json_to_dataclass_field[json_field] = ExplicitNull - LOG.warning( - 'JSON field %r missing from dataclass schema, ' - 'class=%r, parsed field=%r', - json_field, get_class_name(cls), transformed_field) + # Else, just re-raise the error. + fn_gen.add_line("raise") - # Raise an error here (if needed) - if meta.raise_on_unknown_json_key: - cls_fields = dataclass_fields(cls) - e = UnknownJSONKey(json_field, o, cls, cls_fields) - raise e from None + # Now pass the arguments to the constructor method, and return the new dataclass instance. + # If there are any missing fields, we raise them here. + with fn_gen.try_(): + fn_gen.add_line("return cls(**init_kwargs)") + with fn_gen.except_(TypeError, 'e'): + fn_gen.add_line("raise MissingFields(e, o, cls, init_kwargs, cls_fields) from None") - raise LookupError + functions = fn_gen.create_functions( + locals=_locals, globals=_globals + ) - return field_name + cls_fromdict = functions['cls_fromdict'] # Save the load function for the main dataclass, so we don't need to run # this logic each time. if is_main_class: + # Check if the class has a `from_dict`, and it's + # a class method bound to `fromdict`. + if ((from_dict := getattr(cls, 'from_dict', None)) is not None + and getattr(from_dict, '__func__', None) is fromdict): + _set_new_attribute(cls, 'from_dict', cls_fromdict) _CLASS_TO_LOAD_FUNC[cls] = cls_fromdict return cls_fromdict diff --git a/dataclass_wizard/parsers.py b/dataclass_wizard/parsers.py index 22fee9f2..00bd11c6 100644 --- a/dataclass_wizard/parsers.py +++ b/dataclass_wizard/parsers.py @@ -65,10 +65,10 @@ def __call__(self, o: Any) -> T: @dataclass -class Parser(AbstractParser[Type[T], T]): +class Parser(AbstractParser[T, T]): __slots__ = ('hook', ) - hook: Callable[[Any, Type[T]], T] + hook: Callable[[Any, type[T]], T] def __call__(self, o: Any) -> T: return self.hook(o, self.base_type) @@ -113,10 +113,10 @@ def __call__(self, o: Any) -> T: @dataclass -class LiteralParser(AbstractParser[Type[M], M]): +class LiteralParser(AbstractParser[M, M]): __slots__ = ('value_to_type', ) - base_type: Type[M] + base_type: type[M] # noinspection PyDataclass def __post_init__(self, *_): @@ -139,7 +139,7 @@ def __call__(self, o: Any) -> M: """ try: - type_does_not_match = type(o) != self.value_to_type[o] + type_does_not_match = type(o) is not self.value_to_type[o] except KeyError: # No such Literal with the value of `o` @@ -192,7 +192,7 @@ def __call__(self, date_string: str) -> DT: @dataclass -class OptionalParser(AbstractParser[Type[T], Optional[T]]): +class OptionalParser(AbstractParser[T, Optional[T]]): __slots__ = ('parser', ) get_parser: InitVar[GetParserType] @@ -238,9 +238,12 @@ def __post_init__(self, cls: Type, self.tag_key = TAG auto_assign_tags = False + # noinspection PyUnboundLocalVariable self.parsers = tuple( - get_parser(t, cls, extras) for t in self.base_type - if t is not NoneType) + parser + for t in self.base_type + if t is not NoneType + and isinstance(parser := get_parser(t, cls, extras), AbstractParser)) self.tag_to_parser = {} for t in self.base_type: @@ -255,7 +258,8 @@ def __post_init__(self, cls: Type, if meta is AbstractMeta: from .bases_meta import BaseJSONWizardMeta cls_dict = {'__slots__': (), 'tag': tag} - meta = type(cls_name + 'Meta', (BaseJSONWizardMeta, ), cls_dict) + # noinspection PyTypeChecker + meta: type[M] = type(cls_name + 'Meta', (BaseJSONWizardMeta, ), cls_dict) _META[t] = meta else: meta.tag = cls_name @@ -465,14 +469,13 @@ def __call__(self, o: M) -> M: @dataclass -class NamedTupleParser(AbstractParser[Type[NT], NT]): +class NamedTupleParser(AbstractParser[tuple, NT]): __slots__ = ('hook', 'field_to_parser', 'field_parsers') - base_type: Type[NT] hook: Callable[ - [Any, Type[NT], Optional[FieldToParser], List[AbstractParser]], + [Any, type[tuple], Optional[FieldToParser], List[AbstractParser]], NT ] get_parser: InitVar[GetParserType] @@ -482,7 +485,7 @@ def __post_init__(self, cls: Type, get_parser: GetParserType): # Get the field annotations for the `NamedTuple` type - type_anns: Dict[str, Type[Any]] = self.base_type.__annotations__ + type_anns: Dict[str, type[T]] = self.base_type.__annotations__ self.field_to_parser: Optional[FieldToParser] = { f: get_parser(ftype, cls, extras) @@ -501,13 +504,12 @@ def __call__(self, o: Any) -> NT: @dataclass -class NamedTupleUntypedParser(AbstractParser[Type[NT], NT]): +class NamedTupleUntypedParser(AbstractParser[tuple, NT]): __slots__ = ('hook', 'dict_parser', 'list_parser') - base_type: Type[NT] - hook: Callable[[Any, Type[NT], AbstractParser, AbstractParser], NT] + hook: Callable[[Any, Type[tuple], AbstractParser, AbstractParser], NT] get_parser: InitVar[GetParserType] def __post_init__(self, cls: Type, diff --git a/dataclass_wizard/serial_json.py b/dataclass_wizard/serial_json.py index f309bf5b..432be71c 100644 --- a/dataclass_wizard/serial_json.py +++ b/dataclass_wizard/serial_json.py @@ -1,8 +1,9 @@ import json +import logging from typing import Type, List, Union, AnyStr from .abstractions import AbstractJSONWizard, W -from .bases_meta import BaseJSONWizardMeta +from .bases_meta import BaseJSONWizardMeta, LoadMeta from .class_helper import call_meta_initializer_if_needed from .decorators import _alias from .dumpers import asdict @@ -96,12 +97,14 @@ def list_to_json(cls: Type[W], return encoder(list_of_dict, **encoder_kwargs) # noinspection PyShadowingBuiltins - def __init_subclass__(cls, str=True): + def __init_subclass__(cls, str=True, debug=False): """ Checks for optional settings and flags that may be passed in by the sub-class, and calls the Meta initializer when :class:`Meta` is sub-classed. :param str: True to add a default `__str__` method to the subclass. + :param debug: True to enable debug mode and setup logging, so that + this library's DEBUG (and above) log messages are visible. """ super().__init_subclass__() # Calls the Meta initializer when inner :class:`Meta` is sub-classed. @@ -109,6 +112,9 @@ def __init_subclass__(cls, str=True): # Add a `__str__` method to the subclass, if needed if str: _set_new_attribute(cls, '__str__', _str_fn()) + if debug: + logging.basicConfig(level='DEBUG') + LoadMeta(debug_enabled=True).bind_to(cls) def _str_fn(): diff --git a/dataclass_wizard/serial_json.pyi b/dataclass_wizard/serial_json.pyi new file mode 100644 index 00000000..2f0cb1fc --- /dev/null +++ b/dataclass_wizard/serial_json.pyi @@ -0,0 +1,174 @@ +import json +from typing import Type, List, Union, AnyStr, Collection + +from .abstractions import AbstractJSONWizard, W +from .bases_meta import BaseJSONWizardMeta +from .type_def import Decoder, Encoder, JSONObject, ListOfJSONObject + + +# A handy alias in case it comes in useful to anyone :) +JSONWizard = JSONSerializable + + +class JSONSerializable(AbstractJSONWizard): + """ + Mixin class to allow a `dataclass` sub-class to be easily converted + to and from JSON. + + """ + __slots__ = () + + class Meta(BaseJSONWizardMeta): + """ + Inner meta class that can be extended by sub-classes for additional + customization with the JSON load / dump process. + """ + __slots__ = () + + # Class attribute to enable detection of the class type. + __is_inner_meta__ = True + + def __init_subclass__(cls): + # Set the `__init_subclass__` method here, so we can ensure it + # doesn't run for the `JSONSerializable.Meta` class. + ... + + @classmethod + def _pre_from_dict(cls: Type[W], o: JSONObject) -> JSONObject: + """ + Optional hook that runs before the dataclass instance is + loaded, and before it is converted from a dictionary object + via :meth:`from_dict`. + + To override this, subclasses need to implement this method. + A simple example is shown below: + + >>> from dataclasses import dataclass + >>> from dataclass_wizard import JSONWizard + >>> from dataclass_wizard.type_def import JSONObject + >>> + >>> + >>> @dataclass + >>> class MyClass(JSONWizard): + >>> a_bool: bool + >>> + >>> @classmethod + >>> def _pre_from_dict(cls, o: JSONObject) -> JSONObject: + >>> # o = o.copy() # Copying the `dict` object is optional + >>> o['a_bool'] = True # Add a new key/value pair + >>> return o + >>> + >>> c = MyClass.from_dict({}) + >>> assert c == MyClass(a_bool=True) + """ + ... + + def _pre_dict(self): + # noinspection PyDunderSlots, PyUnresolvedReferences + """ + Optional hook that runs before the dataclass instance is processed and + before it is converted to a dictionary object via :meth:`to_dict`. + + To override this, subclasses need to extend from :class:`DumpMixIn` + and implement this method. A simple example is shown below: + + >>> from dataclasses import dataclass + >>> from dataclass_wizard import JSONWizard + >>> + >>> + >>> @dataclass + >>> class MyClass(JSONWizard): + >>> my_str: str + >>> + >>> def _pre_dict(self): + >>> self.my_str = self.my_str.swapcase() + >>> + >>> assert MyClass('test').to_dict() == {'myStr': 'TEST'} + """ + ... + + @classmethod + def from_json(cls: Type[W], string: AnyStr, *, + decoder: Decoder = json.loads, + **decoder_kwargs) -> Union[W, List[W]]: + """ + Converts a JSON `string` to an instance of the dataclass, or a list of + the dataclass instances. + """ + ... + + @classmethod + def from_list(cls: Type[W], o: ListOfJSONObject) -> List[W]: + """ + Converts a Python `list` object to a list of the dataclass instances. + """ + # alias: fromlist(cls, o) + ... + + @classmethod + def from_dict(cls: Type[W], o: JSONObject) -> W: + """ + Converts a Python `dict` object to an instance of the dataclass. + """ + # alias: fromdict(cls, o) + ... + + def to_dict(self: W, + *, + dict_factory=dict, + exclude: Collection[str] | None = None, + skip_defaults: bool | None = None, + ) -> JSONObject: + """ + Converts the dataclass instance to a Python dictionary object that is + JSON serializable. + + Example usage: + + @dataclass + class C(JSONWizard): + x: int + y: int + z: bool = True + + c = C(1, 2, True) + assert c.to_dict(skip_defaults=True) == {'x': 1, 'y': 2} + + If given, 'dict_factory' will be used instead of built-in dict. + The function applies recursively to field values that are + dataclass instances. This will also look into built-in containers: + tuples, lists, and dicts. + """ + # alias: asdict(self) + ... + + def to_json(self: W, *, + encoder: Encoder = json.dumps, + **encoder_kwargs) -> AnyStr: + """ + Converts the dataclass instance to a JSON `string` representation. + """ + ... + + @classmethod + def list_to_json(cls: Type[W], + instances: List[W], + encoder: Encoder = json.dumps, + **encoder_kwargs) -> AnyStr: + """ + Converts a ``list`` of dataclass instances to a JSON `string` + representation. + """ + ... + + # noinspection PyShadowingBuiltins + def __init_subclass__(cls, str=True, debug=False): + """ + Checks for optional settings and flags that may be passed in by the + sub-class, and calls the Meta initializer when :class:`Meta` is sub-classed. + + :param str: True to add a default `__str__` method to the subclass. + :param debug: True to enable debug mode and setup logging, so that + this library's DEBUG (and above) log messages are visible. + """ + ... diff --git a/dataclass_wizard/type_def.py b/dataclass_wizard/type_def.py index 3b88d80c..db981f9d 100644 --- a/dataclass_wizard/type_def.py +++ b/dataclass_wizard/type_def.py @@ -16,6 +16,7 @@ 'JSONObject', 'ListOfJSONObject', 'JSONValue', + 'ParseFloat', 'Encoder', 'FileEncoder', 'Decoder', @@ -145,19 +146,29 @@ FREF = TypeVar('FREF', str, PyForwardRef) -# Create our own "nullish" type for explicit type assertions class ExplicitNullType: - __slots__ = () + __slots__ = () # Saves memory by preventing the creation of instance dictionaries + + _instance = None # Class-level instance variable for singleton control + + def __new__(cls): + if cls._instance is None: + cls._instance = super(ExplicitNullType, cls).__new__(cls) + return cls._instance def __bool__(self): return False def __repr__(self): - return self.__class__.__qualname__ + return '' +# Create the singleton instance ExplicitNull = ExplicitNullType() +# Type annotations +ParseFloat = Callable[[str], Any] + class Encoder(PyProtocol): """ @@ -166,6 +177,8 @@ class Encoder(PyProtocol): """ def __call__(self, obj: Union[JSONObject, JSONList], + /, + *args, **kwargs) -> AnyStr: ... diff --git a/dataclass_wizard/utils/function_builder.py b/dataclass_wizard/utils/function_builder.py new file mode 100644 index 00000000..3f5e4a64 --- /dev/null +++ b/dataclass_wizard/utils/function_builder.py @@ -0,0 +1,223 @@ +from dataclasses import MISSING + +from ..class_helper import is_builtin_class +from ..log import LOG + + +class FunctionBuilder: + __slots__ = ( + 'current_function', + 'functions', + 'globals', + 'indent_level', + 'namespace', + ) + + def __init__(self): + self.functions = {} + self.indent_level = 0 + self.globals = {} + self.namespace = {} + + def __enter__(self): + self.indent_level += 1 + + def __exit__(self, exc_type, exc_val, exc_tb): + indent_lvl = self.indent_level = self.indent_level - 1 + + if not indent_lvl: + self.finalize_function() + + def function(self, name: str, args: list, return_type=MISSING) -> 'FunctionBuilder': + """Start a new function definition with optional return type.""" + # noinspection PyAttributeOutsideInit + self.current_function = {"name": name, "args": args, "body": [], "return_type": return_type} + return self + + def _with_new_block(self, + name: str, + condition: 'str | None' = None) -> 'FunctionBuilder': + """Creates a new block. Used with a context manager (with).""" + indent = ' ' * self.indent_level + + if condition is not None: + self.current_function["body"].append(f"{indent}{name} {condition}:") + else: + self.current_function["body"].append(f"{indent}{name}:") + + return self + + def for_(self, condition: str) -> 'FunctionBuilder': + """Equivalent to the `for` statement in Python. + + Sample Usage: + + >>> with FunctionBuilder().for_('i in range(3)'): + >>> ... + + Will generate the following code: + + >>> for i in range(3): + >>> ... + + """ + return self._with_new_block('for', condition) + + def if_(self, condition: str) -> 'FunctionBuilder': + """Equivalent to the `if` statement in Python. + + Sample Usage: + + >>> with FunctionBuilder().if_('something is True'): + >>> ... + + Will generate the following code: + + >>> if something is True: + >>> ... + + """ + return self._with_new_block('if', condition) + + def else_(self) -> 'FunctionBuilder': + """Equivalent to the `else` statement in Python. + + Sample Usage: + + >>> with FunctionBuilder().else_(): + >>> ... + + Will generate the following code: + + >>> else: + >>> ... + + """ + return self._with_new_block('else') + + def try_(self) -> 'FunctionBuilder': + """Equivalent to the `try` block in Python. + + Sample Usage: + + >>> with FunctionBuilder().try_(): + >>> ... + + Will generate the following code: + + >>> try: + >>> ... + + """ + return self._with_new_block('try') + + def except_(self, + cls: type[Exception], + var_name: 'str | None' = None): + """Equivalent to the `except` block in Python. + + Sample Usage: + + >>> with FunctionBuilder().except_(TypeError, 'exc'): + >>> ... + + Will generate the following code: + + >>> except TypeError as exc: + >>> ... + + """ + cls_name = cls.__name__ + statement = f'{cls_name} as {var_name}' if var_name else cls_name + + if not is_builtin_class(cls): + self.globals[cls_name] = cls + + return self._with_new_block('except', statement) + + def add_line(self, line: str): + """Add a line to the current function's body with proper indentation.""" + indent = ' ' * self.indent_level + self.current_function["body"].append(f"{indent}{line}") + + def add_lines(self, *lines: str): + """Add lines to the current function's body with proper indentation.""" + indent = ' ' * self.indent_level + self.current_function["body"].extend( + [f"{indent}{line}" for line in lines] + ) + + def increase_indent(self): # pragma: no cover + """Increase indentation level for nested code.""" + self.indent_level += 1 + + def decrease_indent(self): # pragma: no cover + """Decrease indentation level.""" + if self.indent_level > 1: + self.indent_level -= 1 + + def finalize_function(self): + """Finalize the function code and add to the list of functions.""" + # Add the function body and don't re-add the function definition + func_code = '\n'.join(self.current_function["body"]) + self.functions[self.current_function["name"]] = ({"args": self.current_function["args"], + "return_type": self.current_function["return_type"], + "code": func_code}) + self.current_function = None # Reset current function + + def create_functions(self, *, globals=None, locals=None): + """Create functions by compiling the code.""" + # Note that we may mutate locals. Callers beware! + # The only callers are internal to this module, so no + # worries about external callers. + if locals is None: # pragma: no cover + locals = {} + + # Compute the text of the entire function. + # txt = f' def {name}({args}){return_annotation}:\n{body}' + + # Build the function code for all functions + # Free variables in exec are resolved in the global namespace. + # The global namespace we have is user-provided, so we can't modify it for + # our purposes. So we put the things we need into locals and introduce a + # scope to allow the function we're creating to close over them. + + name_to_func_code = {} + + for name, func in self.functions.items(): + args = ','.join(func['args']) + body = func['code'] + return_type = func['return_type'] + + return_annotation = '' + if return_type is not MISSING: + locals[f'__dataclass_{name}_return_type__'] = return_type + return_annotation = f'->__dataclass_{name}_return_type__' + + name_to_func_code[name] = f'def {name}({args}){return_annotation}:\n{body}' + + local_vars = ', '.join(locals.keys()) + + txt = '\n'.join([ + f"def __create_{name}_fn__({local_vars}):\n" + f" {code}\n" + f" return {name}" + for name, code in name_to_func_code.items() + ]) + + # Print the generated code for debugging + # logging.debug(f"Generated function code:\n{all_func_code}") + LOG.debug(f"Generated function code:\n{txt}") + + ns = {} + exec(txt, globals | self.globals, ns) + + final_ns = self.namespace = { + name: ns[f'__create_{name}_fn__'](**locals) + for name in name_to_func_code + } + + # Print namespace for debugging + LOG.debug(f"Namespace after function compilation: {self.namespace}") + + return final_ns diff --git a/dataclass_wizard/wizard_mixins.py b/dataclass_wizard/wizard_mixins.py index c6f1b35c..e57f7c72 100644 --- a/dataclass_wizard/wizard_mixins.py +++ b/dataclass_wizard/wizard_mixins.py @@ -3,6 +3,7 @@ """ __all__ = ['JSONListWizard', 'JSONFileWizard', + 'TOMLWizard', 'YAMLWizard'] import json @@ -13,12 +14,12 @@ from .class_helper import _META from .dumpers import asdict from .enums import LetterCase -from .lazy_imports import yaml +from .lazy_imports import toml, toml_w, yaml from .loaders import fromdict, fromlist from .models import Container from .serial_json import JSONSerializable from .type_def import (T, ListOfJSONObject, - Encoder, Decoder, FileDecoder, FileEncoder) + Encoder, Decoder, FileDecoder, FileEncoder, ParseFloat) class JSONListWizard(JSONSerializable, str=False): @@ -94,6 +95,130 @@ def to_json_file(self: T, file: str, mode: str = 'w', encoder(asdict(self), out_file, **encoder_kwargs) +class TOMLWizard: + # noinspection PyUnresolvedReferences + """ + A Mixin class that makes it easier to interact with TOML data. + + .. NOTE:: + By default, *NO* key transform is used in the TOML dump process. + In practice, this means that a `snake_case` field name in Python is saved + as `snake_case` to TOML; however, this can easily be customized without + the need to sub-class from :class:`JSONWizard`. + + For example: + + >>> @dataclass + >>> class MyClass(TOMLWizard, key_transform='CAMEL'): + >>> ... + + """ + def __init_subclass__(cls, key_transform=LetterCase.NONE): + """Allow easy setup of common config, such as key casing transform.""" + + # Only add the key transform if Meta config has not been specified + # for the dataclass. + if key_transform and cls not in _META: + DumpMeta(key_transform=key_transform).bind_to(cls) + + @classmethod + def from_toml(cls: Type[T], + string_or_stream: Union[AnyStr, BinaryIO], *, + decoder: Optional[Decoder] = None, + header: str = 'items', + parse_float: ParseFloat = float) -> Union[T, List[T]]: + """ + Converts a TOML `string` to an instance of the dataclass, or a list of + the dataclass instances. + + If ``header`` is provided and the corresponding value in the parsed + data is a ``list``, the return type is ``List[T]``. + """ + if decoder is None: # pragma: no cover + decoder = toml.loads + + o = decoder(string_or_stream, parse_float=parse_float) + + return (fromlist(cls, maybe_l) + if (maybe_l := o.get(header)) and isinstance(maybe_l, list) + else fromdict(cls, o)) + + @classmethod + def from_toml_file(cls: Type[T], file: str, *, + decoder: Optional[FileDecoder] = None, + header: str = 'items', + parse_float: ParseFloat = float) -> Union[T, List[T]]: + """ + Reads the contents of a TOML file and converts them + into an instance (or list of instances) of the dataclass. + + Similar to :meth:`from_toml`, it can return a list if ``header`` + is specified and points to a list in the TOML data. + """ + if decoder is None: # pragma: no cover + decoder = toml.load + + with open(file, 'rb') as in_file: + return cls.from_toml(in_file, + decoder=decoder, + header=header, + parse_float=parse_float) + + def to_toml(self: T, + /, + *encoder_args, + encoder: Optional[Encoder] = None, + multiline_strings: bool = False, + indent: int = 4) -> AnyStr: + """ + Converts a dataclass instance to a TOML `string`. + + Optional parameters include ``multiline_strings`` + for enabling/disabling multiline formatting of strings, + and ``indent`` for setting the indentation level. + """ + if encoder is None: # pragma: no cover + encoder = toml_w.dumps + + return encoder(asdict(self), *encoder_args, + multiline_strings=multiline_strings, + indent=indent) + + def to_toml_file(self: T, file: str, mode: str = 'wb', + encoder: Optional[FileEncoder] = None, + multiline_strings: bool = False, + indent: int = 4) -> None: + """ + Serializes a dataclass instance and writes it to a TOML file. + + By default, opens the file in "write binary" mode. + """ + if encoder is None: # pragma: no cover + encoder = toml_w.dump + + with open(file, mode) as out_file: + self.to_toml(out_file, encoder=encoder, + multiline_strings=multiline_strings, + indent=indent) + + @classmethod + def list_to_toml(cls: Type[T], + instances: List[T], + header: str = 'items', + encoder: Optional[Encoder] = None, + **encoder_kwargs) -> AnyStr: + """ + Serializes a ``list`` of dataclass instances into a TOML `string`, + grouped under a specified header. + """ + if encoder is None: + encoder = toml_w.dumps + + list_of_dict = [asdict(o, cls=cls) for o in instances] + + return encoder({header: list_of_dict}, **encoder_kwargs) + + class YAMLWizard: # noinspection PyUnresolvedReferences """ diff --git a/docs/advanced_usage/serializer_hooks.rst b/docs/advanced_usage/serializer_hooks.rst index 667e0a5d..f6f8fb24 100644 --- a/docs/advanced_usage/serializer_hooks.rst +++ b/docs/advanced_usage/serializer_hooks.rst @@ -1,41 +1,61 @@ Serializer Hooks ================ - Note: To customize the load or dump process for annotated types +.. note:: + To customize the load or dump process for annotated types instead of individual fields, please see the `Type - Hooks <#type-hooks>`__ section. + Hooks `__ section. You can optionally add hooks that are run before a JSON string or a Python ``dict`` object is loaded to a dataclass instance, or before the dataclass instance is converted back to a Python ``dict`` object. -To customize the load process, simply implement the ``__post_init__`` -method which will be run by the ``dataclass`` decorator. +To customize the load process: -To customize the dump process, simply extend from ``DumpMixin`` and -override the ``__pre_as_dict__`` method which will be called whenever -you invoke the ``to_dict`` or ``to_json`` methods. Please note that this -will pass in the original dataclass instance, so updating any values -will affect the fields of the underlying dataclass (**this might change -in a future revision**). +* To pre-process data before ``from_dict`` is called, simply + implement a ``_pre_from_dict`` method which will be called + whenever you invoke the ``from_dict`` or ``from_json`` methods. + Please note that this will pass in the original ``dict`` object, + so updating any values will affect data in the underlying ``dict`` + (**this might change in a future revision**). +* To post-process data, *after* a dataclass instance is de-serialized, + simply implement the ``__post_init__`` method which will be run + by the ``dataclass`` decorator. + +To customize the dump process, simply implement +a ``_pre_dict`` method which will be called +whenever you invoke the ``to_dict`` or ``to_json`` +methods. Please note that this will pass in the +original dataclass instance, so updating any values +will affect the fields of the underlying dataclass +(**this might change in a future revision**). A simple example to illustrate both approaches is shown below: .. code:: python3 from dataclasses import dataclass - from dataclass_wizard import JSONSerializable, DumpMixin + from dataclass_wizard import JSONWizard + from dataclass_wizard.type_def import JSONObject @dataclass - class MyClass(JSONSerializable, DumpMixin): + class MyClass(JSONWizard): my_str: str my_int: int + my_bool: bool = False def __post_init__(self): self.my_str = self.my_str.title() + self.my_int *= 2 + + @classmethod + def _pre_from_dict(cls, o: JSONObject) -> JSONObject: + # o = o.copy() # Copying the `dict` object is optional + o['my_bool'] = True # Adds a new key/value pair + return o - def __pre_as_dict__(self): + def _pre_dict(self): self.my_str = self.my_str.swapcase() @@ -44,9 +64,9 @@ A simple example to illustrate both approaches is shown below: c = MyClass.from_dict(data) print(repr(c)) # prints: - # MyClass(my_str='My String', my_int=10) + # MyClass(my_str='My String', my_int=20, my_bool=True) string = c.to_json() print(string) # prints: - # {"myStr": "mY sTRING", "myInt": 10} + # {"myStr": "mY sTRING", "myInt": 20, "myBool": true} diff --git a/docs/advanced_usage/type_hooks.rst b/docs/advanced_usage/type_hooks.rst index bc38db89..a0f67ca4 100644 --- a/docs/advanced_usage/type_hooks.rst +++ b/docs/advanced_usage/type_hooks.rst @@ -1,6 +1,11 @@ Type Hooks ========== +.. note:: + To customize the load or dump process for dataclass + fields instead of annotated types, please see the `Serializer + Hooks `__ section. + Sometimes you might want to customize the load and dump process for (annotated) variable types, rather than for specific dataclass fields. Type hooks are very useful and will let you do exactly that. diff --git a/docs/common_use_cases/easier_debug_mode.rst b/docs/common_use_cases/easier_debug_mode.rst new file mode 100644 index 00000000..48af9e97 --- /dev/null +++ b/docs/common_use_cases/easier_debug_mode.rst @@ -0,0 +1,62 @@ +Easier Debug Mode +================= + +While one way to see ``DEBUG`` level (or above) logs for this +library is to enable the ``debug_enabled`` flag in ``JSONWizard.Meta``, +this can sometimes be time-consuming, and still requires correct setup +of the ``logging`` module:: + + import logging + from dataclasses import dataclass + + from dataclass_wizard import JSONWizard + + + logging.basicConfig(level='DEBUG') + + + @dataclass + class MyClass(JSONWizard): + + class _(JSONWizard.Meta): + debug_enabled = True + +An easier approach, is to pass in ``debug=True`` as shown below: + + +.. code:: python3 + + from dataclasses import dataclass + + from dataclass_wizard import JSONWizard + + + @dataclass + class MyClass(JSONWizard, debug=True): + + class _(JSONWizard.Meta): + skip_defaults = True + key_transform_with_dump = 'PASCAL' + + my_bool: bool + my_int: int = 2 + + @classmethod + def _pre_from_dict(cls, o): + o['myBool'] = True + return o + + + # because of `debug=True` you should now see DEBUG + # (and above) logs from this library, such as below. + # + # DEBUG:dataclass_wizard:Generated function code: + # def cls_fromdict(o): + # DEBUG:dataclass_wizard:Generated function code: + # def cls_asdict(o:T,dict_factory=dict,...): + + c = MyClass.from_dict({'myBool': 'false'}) + print(c) + # { + # "MyBool": true + # } diff --git a/docs/common_use_cases/skip_the_str.rst b/docs/common_use_cases/skip_the_str.rst index f2c00131..dffb810b 100644 --- a/docs/common_use_cases/skip_the_str.rst +++ b/docs/common_use_cases/skip_the_str.rst @@ -1,6 +1,10 @@ Skip the :meth:`__str__` ======================== +.. note:: + It is now easier to view ``DEBUG``-level log messages from this library! Check out + the `Easier Debug Mode `__ section. + The ``JSONSerializable`` class implements a default ``__str__`` method if a sub-class doesn't already define this method. This method will format the dataclass diff --git a/docs/common_use_cases/wizard_mixins.rst b/docs/common_use_cases/wizard_mixins.rst index c9dbf0b9..909dfced 100644 --- a/docs/common_use_cases/wizard_mixins.rst +++ b/docs/common_use_cases/wizard_mixins.rst @@ -191,3 +191,152 @@ A (mostly) complete example of using the :class:`YAMLWizard` is as follows: # ... .. _PyYAML: https://pypi.org/project/PyYAML/ + +:class:`TOMLWizard` +~~~~~~~~~~~~~~~~~~~ + +.. admonition:: **Added in v0.28.0** + + The :class:`TOMLWizard` was introduced in version 0.28.0. + +The TOML Wizard provides an easy, convenient interface for converting ``dataclass`` instances to/from `TOML`_. This mixin enables simple loading, saving, and flexible serialization of TOML data, including support for custom key casing transforms. + +.. note:: + By default, *NO* key transform is used in the TOML dump process. This means that a `snake_case` field name in Python is saved as `snake_case` in TOML. However, this can be customized without subclassing from :class:`JSONWizard`, as below. + + >>> @dataclass + >>> class MyClass(TOMLWizard, key_transform='CAMEL'): + >>> ... + +Dependencies +------------ +- For reading TOML, `TOMLWizard` uses `Tomli`_ for Python 3.9 and 3.10, and the built-in `tomllib`_ for Python 3.11+. +- For writing TOML, `Tomli-W`_ is used across all Python versions. + +.. _TOML: https://toml.io/en/ +.. _Tomli: https://pypi.org/project/tomli/ +.. _Tomli-W: https://pypi.org/project/tomli-w/ +.. _tomllib: https://docs.python.org/3/library/tomllib.html + +Example +------- + +A (mostly) complete example of using the :class:`TOMLWizard` is as follows: + +.. code:: python3 + + from dataclasses import dataclass, field + from dataclass_wizard import TOMLWizard + + + @dataclass + class InnerData: + my_float: float + my_list: list[str] = field(default_factory=list) + + + @dataclass + class MyData(TOMLWizard): + my_str: str + my_dict: dict[str, int] = field(default_factory=dict) + inner_data: InnerData = field(default_factory=lambda: InnerData(3.14, ["hello", "world"])) + + + # TOML input string with nested tables and lists + toml_string = """ + my_str = 'example' + [my_dict] + key1 = 1 + key2 = '2' + + [inner_data] + my_float = 2.718 + my_list = ['apple', 'banana', 'cherry'] + """ + + # Load from TOML string + data = MyData.from_toml(toml_string) + + # Sample output of `data` after loading from TOML: + #> my_str = 'example' + #> my_dict = {'key1': 1, 'key2': 2} + #> inner_data = InnerData(my_float=2.718, my_list=['apple', 'banana', 'cherry']) + + # Save to TOML file + data.to_toml_file('data.toml') + + # Now read it back from the TOML file + new_data = MyData.from_toml_file('data.toml') + + # Assert we get back the same data + assert data == new_data, "Data read from TOML file does not match the original." + + # Create a list of dataclass instances + data_list = [data, new_data, MyData("another_example", {"key3": 3}, InnerData(1.618, ["one", "two"]))] + + # Serialize the list to a TOML string + toml_output = MyData.list_to_toml(data_list, header='testing') + + print(toml_output) + # [[testing]] + # my_str = "example" + # + # [testing.my_dict] + # key1 = 1 + # key2 = 2 + # + # [testing.inner_data] + # my_float = 2.718 + # my_list = [ + # "apple", + # "banana", + # "cherry", + # ] + # ... + +This approach provides a straightforward way to handle TOML data within Python dataclasses. + +Methods +------- + +.. method:: from_toml(cls, string_or_stream, *, decoder=None, header='items', parse_float=float) + + Parses a TOML `string` or stream and converts it into an instance (or list of instances) of the dataclass. If `header` is provided and the corresponding value in the parsed data is a list, the return type is `List[T]`. + + **Example usage:** + + >>> data_str = '''my_str = "test"\n[inner]\nmy_float = 1.2''' + >>> obj = MyClass.from_toml(data_str) + +.. method:: from_toml_file(cls, file, *, decoder=None, header='items', parse_float=float) + + Reads the contents of a TOML file and converts them into an instance (or list of instances) of the dataclass. Similar to :meth:`from_toml`, it can return a list if `header` is specified and points to a list in the TOML data. + + **Example usage:** + + >>> obj = MyClass.from_toml_file('config.toml') + +.. method:: to_toml(self, /, *encoder_args, encoder=None, multiline_strings=False, indent=4) + + Converts a dataclass instance to a TOML string. Optional parameters include `multiline_strings` for enabling/disabling multiline formatting of strings and `indent` for setting the indentation level. + + **Example usage:** + + >>> toml_str = obj.to_toml() + +.. method:: to_toml_file(self, file, mode='wb', encoder=None, multiline_strings=False, indent=4) + + Serializes a dataclass instance and writes it to a TOML file. By default, opens the file in "write binary" mode. + + **Example usage:** + + >>> obj.to_toml_file('output.toml') + +.. method:: list_to_toml(cls, instances, header='items', encoder=None, **encoder_kwargs) + + Serializes a list of dataclass instances into a TOML string, grouped under a specified `header`. + + **Example usage:** + + >>> obj_list = [MyClass(), MyClass(my_str="example")] + >>> toml_str = MyClass.list_to_toml(obj_list) diff --git a/requirements-dev.txt b/requirements-dev.txt index 3b1dd1ce..02a60ed7 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,6 +4,10 @@ flake8>=3 # pyup: ignore tox==4.23.2 # Extras pytimeparse==1.1.8 +# [toml] extra +tomli>=2,<3; python_version=="3.9" +tomli>=2,<3; python_version=="3.10" +tomli-w>=1,<2 # TODO I don't know if we need the below on CI coverage>=6.2 pip>=21.3.1 @@ -13,3 +17,4 @@ watchdog[watchmedo]==6.0.0 Sphinx==7.4.7; python_version == "3.9" Sphinx==8.1.3; python_version >= "3.10" twine==5.1.1 +dataclass-wizard[toml] diff --git a/setup.py b/setup.py index 11852f56..543fe110 100644 --- a/setup.py +++ b/setup.py @@ -87,7 +87,12 @@ tests_require=test_requirements, extras_require={ 'timedelta': ['pytimeparse>=1.1.7'], - 'yaml': ['PyYAML>=5.3'], + 'toml': [ + 'tomli>=2,<3; python_version=="3.9"', + 'tomli>=2,<3; python_version=="3.10"', + 'tomli-w>=1,<2' + ], + 'yaml': ['PyYAML>=6,<7'], 'dev': dev_requires + doc_requires + test_requirements, }, zip_safe=False diff --git a/tests/unit/test_wizard_mixins.py b/tests/unit/test_wizard_mixins.py index 9a0b123b..617f3106 100644 --- a/tests/unit/test_wizard_mixins.py +++ b/tests/unit/test_wizard_mixins.py @@ -1,3 +1,4 @@ +import io from dataclasses import dataclass from typing import List, Optional, Dict @@ -6,7 +7,7 @@ from dataclass_wizard import Container from dataclass_wizard.wizard_mixins import ( - JSONListWizard, JSONFileWizard, YAMLWizard + JSONListWizard, JSONFileWizard, TOMLWizard, YAMLWizard ) from .conftest import SampleClass @@ -176,3 +177,101 @@ class MyClass(YAMLWizard, key_transform=None): assert result == mock_return_val mock_encoder.assert_any_call([]) + + +@dataclass +class MyTOMLWizard(TOMLWizard): + my_str: str + inner: Optional['Inner'] = None + + +def test_toml_wizard_methods(mocker: MockerFixture): + """Test and cover the base methods in TOMLWizard.""" + toml_data = b"""\ +my_str = "test value" +[inner] +my_float = 1.2 +my_list = ["hello, world!", "123"] + """ + + # Mock open to return the TOML data as a string directly. + mock_open = mocker.patch("dataclass_wizard.wizard_mixins.open", mocker.mock_open(read_data=toml_data)) + + filename = 'my_file.toml' + + # Test reading from TOML file + obj = MyTOMLWizard.from_toml_file(filename) + + mock_open.assert_called_once_with(filename, 'rb') + mock_open.reset_mock() + + assert obj == MyTOMLWizard(my_str="test value", + inner=Inner(my_float=1.2, + my_list=["hello, world!", "123"])) + + # Test writing to TOML file + # Mock open for writing to the TOML file. + mock_open_write = mocker.mock_open() + mocker.patch("dataclass_wizard.wizard_mixins.open", mock_open_write) + + obj.to_toml_file(filename) + + mock_open_write.assert_called_once_with(filename, 'wb') + + +def test_toml_wizard_list_to_toml(): + """Test and cover the `list_to_toml` method in TOMLWizard.""" + @dataclass + class MyClass(TOMLWizard, key_transform='SNAKE'): + my_str: str + my_dict: Dict[str, str] + + toml_string = MyClass.list_to_toml([ + MyClass('42', {'111': 'hello', '222': 'world'}), + MyClass('testing!', {'333': 'this is a test.'}) + ]) + + print(toml_string) + + assert toml_string == """\ +items = [ + { my_str = "42", my_dict = { 111 = "hello", 222 = "world" } }, + { my_str = "testing!", my_dict = { 333 = "this is a test." } }, +] +""" + + +def test_toml_wizard_for_branch_coverage(mocker: MockerFixture): + """Test branching logic in TOMLWizard, mainly for code coverage purposes.""" + + # This is to cover the `if` condition in the `__init_subclass__` + @dataclass + class MyClass(TOMLWizard, key_transform=None): + ... + + # from_toml: To cover the case of passing in `decoder` + mock_return_val = {'my_str': 'test string'} + + mock_decoder = mocker.Mock() + mock_decoder.return_value = mock_return_val + + result = MyTOMLWizard.from_toml('my stream', decoder=mock_decoder) + + assert result == MyTOMLWizard('test string') + mock_decoder.assert_called_once() + + # to_toml: To cover the case of passing in `encoder` + mock_encoder = mocker.Mock() + mock_encoder.return_value = mock_return_val + + m = MyTOMLWizard('test string') + result = m.to_toml(encoder=mock_encoder) + + assert result == mock_return_val + mock_encoder.assert_called_once() + + # list_to_toml: To cover the case of passing in `encoder` + result = MyTOMLWizard.list_to_toml([], encoder=mock_encoder) + + assert result == mock_return_val + mock_encoder.assert_any_call({'items': []})