Skip to content

Commit

Permalink
Finalize load/dump hooks for pre-processing
Browse files Browse the repository at this point in the history
* Add following serializer hooks
  * `_pre_from_dict`
  * `_pre_dict`
* Deprecate `DumpMixin.__pre_as_dict__`
* Add `debug` param to `JSONWizard.__init_subclass__`
  • Loading branch information
rnag committed Nov 15, 2024
1 parent a4bcc86 commit 8725b0b
Show file tree
Hide file tree
Showing 7 changed files with 148 additions and 24 deletions.
2 changes: 2 additions & 0 deletions dataclass_wizard/abstractions.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -376,5 +376,7 @@ class AbstractDumper(ABC):
>>> 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.
"""
...
27 changes: 20 additions & 7 deletions dataclass_wizard/dumpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
)
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,
Expand Down Expand Up @@ -329,16 +330,28 @@ def dump_func_for_dataclass(cls: Type[T],

# Code for `cls_asdict`
with fn_gen.function('cls_asdict',
['obj:T',
['o:T',
'dict_factory=dict',
"exclude:'list[str]|None'=None",
f'skip_defaults:bool={meta.skip_defaults}'],
return_type='JSONObject'):

_pre_as_dict_method = getattr(cls_dumper, '__pre_as_dict__', None)
if _pre_as_dict_method is not None:
_locals['__pre_as_dict__'] = _pre_as_dict_method
fn_gen.add_line('__pre_as_dict__(obj)')
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 = []")
Expand All @@ -362,7 +375,7 @@ def dump_func_for_dataclass(cls: Type[T],
if field in field_to_default:
_locals[default_value] = field_to_default[field]
skip_default_assignments.append(
f"{skip_field} = {skip_field} or obj.{field} == {default_value}"
f"{skip_field} = {skip_field} or o.{field} == {default_value}"
)

# Get the resolved JSON field name
Expand All @@ -378,7 +391,7 @@ def dump_func_for_dataclass(cls: Type[T],
if json_field is not ExplicitNull:
field_assignments.append(f"if not {skip_field}:")
field_assignments.append(f" result.append(('{json_field}',"
f"asdict(obj.{field},dict_factory,hooks,config,cls_to_asdict)))")
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')
Expand Down
23 changes: 22 additions & 1 deletion dataclass_wizard/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions dataclass_wizard/loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,8 @@ def get_parser_for_annotation(cls, ann_type: Type[T],
)
else: # else, logic is same as normal
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,
Expand Down Expand Up @@ -649,6 +651,11 @@ def load_func_for_dataclass(

with fn_gen.function('cls_fromdict', ['o']):

_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.
fn_gen.add_line('init_kwargs = {}')
Expand Down
10 changes: 8 additions & 2 deletions dataclass_wizard/serial_json.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -96,19 +97,24 @@ 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.
call_meta_initializer_if_needed(cls)
# 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():
Expand Down
58 changes: 57 additions & 1 deletion dataclass_wizard/serial_json.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,60 @@ class JSONSerializable(AbstractJSONWizard):
# 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,
Expand Down Expand Up @@ -108,11 +162,13 @@ class JSONSerializable(AbstractJSONWizard):
...

# 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.
"""
...
45 changes: 32 additions & 13 deletions docs/advanced_usage/serializer_hooks.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,52 @@ 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_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**).

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()
Expand All @@ -44,9 +63,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}

0 comments on commit 8725b0b

Please sign in to comment.