From 420a91a0474e506ead96cfdeae0defc3743133a8 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Mon, 25 Nov 2024 12:13:19 -0500 Subject: [PATCH] Add `JSONPyWizard` and some condition functions * Add `IS_TRUTHY` and `IS_FALSY` conditions * Add `JSONPyWizard` to skip key transformation during serialization (dump) --- README.rst | 60 ++++++++++++++++++++----- dataclass_wizard/__init__.py | 10 ++--- dataclass_wizard/dumpers.py | 34 +++++++++++--- dataclass_wizard/models.py | 9 ++++ dataclass_wizard/models.pyi | 17 +++++-- dataclass_wizard/serial_json.py | 15 ++++++- dataclass_wizard/serial_json.pyi | 9 ++++ docs/common_use_cases/wizard_mixins.rst | 22 +++++++++ tests/unit/test_load.py | 47 +++++++++++++++++++ 9 files changed, 195 insertions(+), 28 deletions(-) diff --git a/README.rst b/README.rst index 27f847db..b9906336 100644 --- a/README.rst +++ b/README.rst @@ -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. @@ -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 ############## @@ -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' @@ -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 @@ -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: @@ -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 ---------------- @@ -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 @@ -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 diff --git a/dataclass_wizard/__init__.py b/dataclass_wizard/__init__.py index 40836ff0..f972c5de 100644 --- a/dataclass_wizard/__init__.py +++ b/dataclass_wizard/__init__.py @@ -71,6 +71,7 @@ __all__ = [ # Base exports 'JSONSerializable', + 'JSONPyWizard', 'JSONWizard', 'LoadMixin', 'DumpMixin', @@ -108,6 +109,8 @@ 'GE', 'IS', 'IS_NOT', + 'IS_TRUTHY', + 'IS_FALSY', ] import logging @@ -119,9 +122,9 @@ 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 @@ -129,9 +132,6 @@ # 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() diff --git a/dataclass_wizard/dumpers.py b/dataclass_wizard/dumpers.py index e79c4a14..80e0f9e7 100644 --- a/dataclass_wizard/dumpers.py +++ b/dataclass_wizard/dumpers.py @@ -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], @@ -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] @@ -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}:") diff --git a/dataclass_wizard/models.py b/dataclass_wizard/models.py index edd33f32..81699457 100644 --- a/dataclass_wizard/models.py +++ b/dataclass_wizard/models.py @@ -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}" @@ -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) @@ -380,6 +384,10 @@ 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 @@ -387,5 +395,6 @@ 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)) diff --git a/dataclass_wizard/models.pyi b/dataclass_wizard/models.pyi index bb27df60..974974b5 100644 --- a/dataclass_wizard/models.pyi +++ b/dataclass_wizard/models.pyi @@ -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): ... @@ -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 ).""" + + +# noinspection PyPep8Naming +def IS_FALSY() -> Condition: + """Create a "falsy" condition for evaluation (if not ).""" + + # noinspection PyPep8Naming def SkipIf(condition: Condition) -> Condition: """ diff --git a/dataclass_wizard/serial_json.py b/dataclass_wizard/serial_json.py index 47d408e9..c88ed0ce 100644 --- a/dataclass_wizard/serial_json.py +++ b/dataclass_wizard/serial_json.py @@ -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 @@ -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) diff --git a/dataclass_wizard/serial_json.pyi b/dataclass_wizard/serial_json.pyi index ddaecf56..8d00bce6 100644 --- a/dataclass_wizard/serial_json.pyi +++ b/dataclass_wizard/serial_json.pyi @@ -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 diff --git a/docs/common_use_cases/wizard_mixins.rst b/docs/common_use_cases/wizard_mixins.rst index 909dfced..3fc45506 100644 --- a/docs/common_use_cases/wizard_mixins.rst +++ b/docs/common_use_cases/wizard_mixins.rst @@ -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` ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/unit/test_load.py b/tests/unit/test_load.py index a786209b..f4fb220d 100644 --- a/tests/unit/test_load.py +++ b/tests/unit/test_load.py @@ -2379,3 +2379,50 @@ class _(JSONWizard.Meta): ex = Example('None', other_str='test', third_str='None', my_bool=None, other_bool=True) assert ex.to_dict() == {'my_str': 'None', 'other_str': 'test', 'third_str': 'None', 'my_bool': None} + + +def test_is_truthy_and_is_falsy_conditions(): + """ + Test both IS_TRUTHY and IS_FALSY conditions within a single test case. + """ + + # Define the Example class within the test case and apply the conditions + @dataclass + class Example(JSONPyWizard): + my_str: Annotated['str | None', SkipIf(IS_TRUTHY())] # Skip if truthy + my_bool: bool = skip_if_field(IS_FALSY()) # Skip if falsy + my_int: Annotated['int | None', SkipIf(IS_FALSY())] = None # Skip if falsy + + # Test IS_TRUTHY condition (field will be skipped if truthy) + obj = Example(my_str="Hello", my_bool=True, my_int=5) + assert obj.to_dict() == {'my_bool': True, 'my_int': 5} # `my_str` is skipped because it is truthy + + # Test IS_FALSY condition (field will be skipped if falsy) + obj = Example(my_str=None, my_bool=False, my_int=0) + assert obj.to_dict() == {'my_str': None} # `my_str` is None (falsy), so it is not skipped + + # Test a mix of truthy and falsy values + obj = Example(my_str="Not None", my_bool=True, my_int=None) + assert obj.to_dict() == {'my_bool': True} # `my_str` is truthy, so it is skipped, `my_int` is falsy and skipped + + # Test with both IS_TRUTHY and IS_FALSY applied (both `my_bool` and `my_in + + +def test_skip_if_truthy_or_falsy(): + """ + Test skip if condition is truthy or falsy for individual fields. + """ + + # Use of SkipIf with IS_TRUTHY + @dataclass + class SkipExample(JSONWizard): + my_str: Annotated['str | None', SkipIf(IS_TRUTHY())] + my_bool: bool = skip_if_field(IS_FALSY()) + + # Test with truthy `my_str` and falsy `my_bool` should be skipped + obj = SkipExample(my_str="Test", my_bool=False) + assert obj.to_dict() == {} + + # Test with truthy `my_str` and `my_bool` should include the field + obj = SkipExample(my_str="", my_bool=True) + assert obj.to_dict() == {'myStr': '', 'myBool': True}