Skip to content

Commit

Permalink
Add JSONPyWizard and some condition functions
Browse files Browse the repository at this point in the history
* Add `IS_TRUTHY` and `IS_FALSY` conditions
* Add `JSONPyWizard` to skip key transformation during serialization (dump)
  • Loading branch information
rnag committed Nov 25, 2024
1 parent f013a83 commit 420a91a
Show file tree
Hide file tree
Showing 9 changed files with 195 additions and 28 deletions.
60 changes: 48 additions & 12 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ Wizard Mixins

In addition to the ``JSONWizard``, here are a few extra Mixin_ classes that might prove quite convenient to use.

* `JSONPyWizard`_ -- Extends ``JSONWizard`` to disable default key transform on dump, ensuring that keys are not camel-cased during JSON serialization.
* `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.
Expand Down Expand Up @@ -1047,7 +1048,10 @@ Additionally, here is an example to demonstrate usage of both these approaches:
Conditional Field Skipping
~~~~~~~~~~~~~~~~~~~~~~~~~~

Dataclass Wizard now supports **conditional skipping** of fields during serialization using global settings, per-field annotations, or field wrappers.
.. admonition:: **Added in v0.30.0**

Dataclass Wizard now supports `conditional skipping`_ of fields during serialization using ``Meta`` settings,
per-field `annotations`_ using ``SkipIf()``, or `field`_ wrappers.

Quick Examples
##############
Expand All @@ -1065,7 +1069,7 @@ Quick Examples
@dataclass
class Example(JSONWizard):
class _(JSONWizard.Meta):
skip_if = IS_NOT(True) # Skip fields if not `True`.
skip_if = IS_NOT(True) # Skip fields if the value is not `True`
my_bool: bool
my_str: 'str | None'
Expand All @@ -1082,6 +1086,8 @@ Quick Examples

.. code-block:: python3
from __future__ import annotations # Can remove in PY 3.10+
from dataclasses import dataclass
from dataclass_wizard import JSONWizard, IS
Expand All @@ -1093,12 +1099,13 @@ Quick Examples
key_transform_with_dump = 'NONE'
skip_defaults_if = IS(None) # Skip default `None` values.
my_str: 'str | None' = None
str_with_no_default: str | None
my_str: str | None = None
my_bool: bool = False
print(Example(my_str=None).to_dict())
# Output: {'my_bool': False}
print(Example(str_with_no_default=None, my_str=None).to_dict())
#> {'str_with_no_default': None, 'my_bool': False}
3. **Per-Field Conditional Skipping**
Use type annotations or ``skip_if_field`` for fine-grained control:
Expand All @@ -1121,15 +1128,41 @@ Quick Examples
print(Example(my_str=None, other_str='').to_dict())
# Output: {}
Special Cases
#############
4. **Skip Fields Based on Truthy or Falsy Values**

Use the ``IS_TRUTHY`` and ``IS_FALSY`` helpers to conditionally skip fields based on their truthiness:

.. code-block:: python3
from dataclasses import dataclass, field
from dataclass_wizard import JSONWizard, IS_FALSY
@dataclass
class ExampleWithFalsy(JSONWizard):
class _(JSONWizard.Meta):
skip_if = IS_FALSY() # Skip fields if they evaluate as "falsy".
my_bool: bool
my_list: list = field(default_factory=list)
my_none: None = None
print(ExampleWithFalsy(my_bool=False, my_list=[], my_none=None).to_dict())
#> {}
.. note::

*Special Cases*

- **SkipIfNone**: Alias for ``SkipIf(IS(None))``, skips fields with a value of ``None``.
- **Condition Helpers**:

- **SkipIfNone**: Alias for ``SkipIf(IS(None))``, skips fields with a value of ``None``.
- **Condition Helpers**:
- ``IS``, ``IS_NOT``: Identity checks.
- ``EQ``, ``NE``, ``LT``, ``LE``, ``GT``, ``GE``: Comparison operators.
- ``IS_TRUTHY``, ``IS_FALSY``: Skip fields based on truthy or falsy values.
- Combine these for flexible serialization rules.

- ``IS``, ``IS_NOT``: Identity checks.
- ``EQ``, ``NE``, ``GT``, etc.: Comparison operators.
- Combine these for flexible serialization rules.
.. _conditional skipping: https://dataclass-wizard.readthedocs.io/en/latest/common_use_cases/serialization_options.html#skip-if-functionality

Field Properties
----------------
Expand Down Expand Up @@ -1176,6 +1209,7 @@ This package was created with Cookiecutter_ and the `rnag/cookiecutter-pypackage
.. _`rnag/cookiecutter-pypackage`: https://github.com/rnag/cookiecutter-pypackage
.. _`Contributing`: https://dataclass-wizard.readthedocs.io/en/latest/contributing.html
.. _`open an issue`: https://github.com/rnag/dataclass-wizard/issues
.. _`JSONPyWizard`: https://dataclass-wizard.readthedocs.io/en/latest/common_use_cases/wizard_mixins.html#jsonpywizard
.. _`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
Expand All @@ -1201,3 +1235,5 @@ This package was created with Cookiecutter_ and the `rnag/cookiecutter-pypackage
.. _Easier Debug Mode: https://dataclass-wizard.readthedocs.io/en/latest/common_use_cases/easier_debug_mode.html
.. _Handling Unknown JSON Keys: https://dataclass-wizard.readthedocs.io/en/latest/common_use_cases/handling_unknown_json_keys.html
.. _custom paths to access nested keys: https://dataclass-wizard.readthedocs.io/en/latest/common_use_cases/nested_key_paths.html
.. _field: https://docs.python.org/3/library/dataclasses.html#dataclasses.field
.. _annotations: https://docs.python.org/3/library/typing.html#typing.Annotated
10 changes: 5 additions & 5 deletions dataclass_wizard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
__all__ = [
# Base exports
'JSONSerializable',
'JSONPyWizard',
'JSONWizard',
'LoadMixin',
'DumpMixin',
Expand Down Expand Up @@ -108,6 +109,8 @@
'GE',
'IS',
'IS_NOT',
'IS_TRUTHY',
'IS_FALSY',
]

import logging
Expand All @@ -119,19 +122,16 @@
KeyPath, Container,
Pattern, DatePattern, TimePattern, DateTimePattern,
CatchAll, SkipIf, SkipIfNone,
EQ, NE, LT, LE, GT, GE, IS, IS_NOT)
EQ, NE, LT, LE, GT, GE, IS, IS_NOT, IS_TRUTHY, IS_FALSY)
from .property_wizard import property_wizard
from .serial_json import JSONSerializable
from .serial_json import JSONWizard, JSONPyWizard, JSONSerializable
from .wizard_mixins import JSONListWizard, JSONFileWizard, TOMLWizard, YAMLWizard


# 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())

# A handy alias in case it comes in useful to anyone :)
JSONWizard = JSONSerializable

# Setup the default type hooks to use when converting `str` (json) or a Python
# `dict` object to a `dataclass` instance.
setup_default_loader()
Expand Down
34 changes: 27 additions & 7 deletions dataclass_wizard/dumpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,19 +252,32 @@ class C:
return dump(o, dict_factory, exclude, **kwargs)


def finalize_skip_if(skip_if: Condition,
operand_1: str,
conditional: str):

if skip_if.t_or_f:
return operand_1 if skip_if.op == '+' else f'not {operand_1}'

return f'{operand_1} {conditional}'


def get_skip_if_condition(skip_if: Condition,
_locals: dict[str, Any],
field_name: str):
operand_2: str):

if skip_if is None:
return False

if skip_if.t_or_f: # Truthy or falsy condition, no operand
return True

if is_builtin(skip_if.val):
return str(skip_if)

# Update locals (as `val` is not a builtin)
_locals[field_name] = skip_if.val
return f'{skip_if.op} {field_name}'
_locals[operand_2] = skip_if.val
return f'{skip_if.op} {operand_2}'


def dump_func_for_dataclass(cls: Type[T],
Expand Down Expand Up @@ -414,8 +427,10 @@ def dump_func_for_dataclass(cls: Type[T],
)
if field in field_to_default:
if skip_defaults_if_condition:
_final_skip_if = finalize_skip_if(
meta.skip_defaults_if, f'o.{field}', skip_defaults_if_condition)
skip_default_assignments.append(
f"{skip_field} = {skip_field} or o.{field} {skip_defaults_if_condition}"
f"{skip_field} = {skip_field} or {_final_skip_if}"
)
else:
_locals[default_value] = field_to_default[field]
Expand All @@ -436,12 +451,17 @@ def dump_func_for_dataclass(cls: Type[T],
if json_field is not ExplicitNull:
# If field has an explicit `SkipIf` condition
if field in field_to_skip_if:
_skip_condition = field_to_skip_if[field]
_skip_if = get_skip_if_condition(
field_to_skip_if[field], _locals, skip_if_field)
field_assignments.append(f'if not ({skip_field} or o.{field} {_skip_if}):')
_skip_condition, _locals, skip_if_field)
_final_skip_if = finalize_skip_if(
_skip_condition, f'o.{field}', _skip_if)
field_assignments.append(f'if not ({skip_field} or {_final_skip_if}):')
# If Meta `skip_if` has a value
elif skip_if_condition:
field_assignments.append(f'if not ({skip_field} or o.{field} {skip_if_condition}):')
_final_skip_if = finalize_skip_if(
meta.skip_if, f'o.{field}', skip_if_condition)
field_assignments.append(f'if not ({skip_field} or {_final_skip_if}):')
# Else, proceed as normal
else:
field_assignments.append(f"if not {skip_field}:")
Expand Down
9 changes: 9 additions & 0 deletions dataclass_wizard/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,12 +337,14 @@ class Condition:
__slots__ = (
'op',
'val',
't_or_f',
'_wrapped',
)

def __init__(self, operator, value):
self.op = operator
self.val = value
self.t_or_f = operator in {'+', '!'}

def __str__(self):
return f"{self.op} {self.val!r}"
Expand All @@ -358,6 +360,8 @@ def evaluate(self, other) -> bool:
">=": lambda a, b: a >= b,
"is": lambda a, b: a is b,
"is not": lambda a, b: a is not b,
"+": lambda a, _: True if a else False,
"!": lambda a, _: not a,
}
return operators[self.op](other, self.val)

Expand All @@ -380,12 +384,17 @@ def GE(value): return Condition(">=", value)
def IS(value): return Condition("is", value)
# noinspection PyPep8Naming
def IS_NOT(value): return Condition("is not", value)
# noinspection PyPep8Naming
def IS_TRUTHY(): return Condition("+", None)
# noinspection PyPep8Naming
def IS_FALSY(): return Condition("!", None)


# noinspection PyPep8Naming
def SkipIf(condition):
condition._wrapped = True # Set a marker attribute
return condition


# Convenience alias, to skip serializing field if value is None
SkipIfNone = SkipIf(IS(None))
17 changes: 14 additions & 3 deletions dataclass_wizard/models.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -419,9 +419,10 @@ class Container(list[T]):

class Condition:

op: str
val: Any
_wrapped: bool
op: str # Operator
val: Any # Value
t_or_f: bool # Truthy or falsy
_wrapped: bool # True if wrapped in `SkipIf()`

def __init__(self, operator: str, value: Any):
...
Expand Down Expand Up @@ -474,6 +475,16 @@ def IS_NOT(value: Any) -> Condition:
"""Create a condition for non-identity (is not)."""


# noinspection PyPep8Naming
def IS_TRUTHY() -> Condition:
"""Create a "truthy" condition for evaluation (if <var>)."""


# noinspection PyPep8Naming
def IS_FALSY() -> Condition:
"""Create a "falsy" condition for evaluation (if not <var>)."""


# noinspection PyPep8Naming
def SkipIf(condition: Condition) -> Condition:
"""
Expand Down
15 changes: 14 additions & 1 deletion dataclass_wizard/serial_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from .abstractions import AbstractJSONWizard
from .bases import AbstractMeta
from .bases_meta import BaseJSONWizardMeta, LoadMeta
from .bases_meta import BaseJSONWizardMeta, LoadMeta, DumpMeta
from .class_helper import call_meta_initializer_if_needed, get_meta
from .dumpers import asdict
from .loaders import fromdict, fromlist
Expand Down Expand Up @@ -85,3 +85,16 @@ def _str_fn():
return _create_fn('__str__',
('self', ),
['return self.to_json(indent=2)'])


# A handy alias in case it comes in useful to anyone :)
JSONWizard = JSONSerializable


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

def __init_subclass__(cls, str=True, debug=False):
"""Bind child class to DumpMeta with no key transformation."""
super().__init_subclass__(str, debug)
DumpMeta(key_transform='NONE').bind_to(cls)
9 changes: 9 additions & 0 deletions dataclass_wizard/serial_json.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ class SerializerHookMixin(Protocol):
...


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

def __init_subclass__(cls,
str: bool = True,
debug: bool | str | int = False):
"""Bind child class to DumpMeta with no key transformation."""


class JSONSerializable(AbstractJSONWizard, SerializerHookMixin):
"""
Mixin class to allow a `dataclass` sub-class to be easily converted
Expand Down
22 changes: 22 additions & 0 deletions docs/common_use_cases/wizard_mixins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ Wizard Mixin Classes
In addition to the :class:`JSONWizard`, here a few extra Wizard Mixin
classes that might prove to be quite convenient to use.


Class: `JSONPyWizard`
~~~~~~~~~~~~~~~~~~~~~

A subclass of `JSONWizard` that disables the default key transformation behavior,
ensuring that keys are not transformed during JSON serialization (e.g., no ``camelCase`` transformation).

.. code-block:: python3
class JSONPyWizard(JSONWizard):
"""Helper for JSONWizard that ensures dumping to JSON keeps keys as-is."""
def __init_subclass__(cls, str=True, debug=False):
"""Bind child class to DumpMeta with no key transformation."""
super().__init_subclass__(str, debug)
DumpMeta(key_transform='NONE').bind_to(cls)
Use Case
--------

Use :class:`JSONPyWizard` when you want to prevent the automatic camelCase conversion of dictionary keys during serialization, keeping them in their original ``snake_case`` format.

:class:`JSONListWizard`
~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
Loading

0 comments on commit 420a91a

Please sign in to comment.