From aaf207751b0ca393e8354a63629b0e66c68c8f91 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Mon, 16 Dec 2024 22:36:20 -0500 Subject: [PATCH] checkin changes so far --- dataclass_wizard/abstractions.py | 5 --- dataclass_wizard/bases.py | 45 ++++++++++++++++------- dataclass_wizard/bases_meta.py | 5 ++- dataclass_wizard/class_helper.py | 44 ++++++++++++++++++---- dataclass_wizard/dumpers.py | 4 ++ dataclass_wizard/utils/object_path.py | 10 ++--- dataclass_wizard/v1/loaders.py | 53 ++++++++++----------------- dataclass_wizard/v1/models.py | 45 ++++++++++++++++++----- dataclass_wizard/v1/models.pyi | 8 ++-- tests/unit/v1/test_loaders.py | 32 ++++++++-------- 10 files changed, 159 insertions(+), 92 deletions(-) diff --git a/dataclass_wizard/abstractions.py b/dataclass_wizard/abstractions.py index 850343f0..4ac7940b 100644 --- a/dataclass_wizard/abstractions.py +++ b/dataclass_wizard/abstractions.py @@ -285,11 +285,6 @@ def default_load_to(tp: TypeInfo, extras: Extras) -> str: def load_after_type_check(tp: TypeInfo, extras: Extras) -> str: """ Generate code to load an object after confirming its type. - - :param type_str: The type annotation of the field as a string. - :param i: Index of the value being processed. - :param extras: Additional context or dependencies for code generation. - :raises ParseError: If the object type is not as expected. """ @staticmethod diff --git a/dataclass_wizard/bases.py b/dataclass_wizard/bases.py index e2df1c06..e7c38381 100644 --- a/dataclass_wizard/bases.py +++ b/dataclass_wizard/bases.py @@ -1,13 +1,12 @@ from abc import ABCMeta, abstractmethod -from collections.abc import Sequence -from typing import Callable, Type, Dict, Optional, ClassVar, Union, TypeVar, Sequence, Literal +from typing import Callable, Type, Dict, Optional, ClassVar, Union, TypeVar from .constants import TAG from .decorators import cached_class_property -from .models import Condition from .enums import DateTimeTo, LetterCase, LetterCasePriority -from .v1.enums import KeyAction, KeyCase +from .models import Condition from .type_def import FrozenKeys, EnvFileType +from .v1.enums import KeyAction, KeyCase # Create a generic variable that can be 'AbstractMeta', or any subclass. @@ -47,10 +46,12 @@ def __or__(cls: META, other: META) -> META: # defined on the abstract class. Use `other` instead, which # *will* be a concrete subclass of `AbstractMeta`. src = other + # noinspection PyTypeChecker for k in src.fields_to_merge: if k in other_dict: base_dict[k] = other_dict[k] else: + # noinspection PyTypeChecker for k in src.fields_to_merge: if k in src_dict: base_dict[k] = src_dict[k] @@ -71,6 +72,7 @@ def __or__(cls: META, other: META) -> META: # In a reversed MRO, the inheritance tree looks like this: # |___ object -> AbstractMeta -> BaseJSONWizardMeta -> ... # So here, we want to choose the third-to-last class in the list. + # noinspection PyUnresolvedReferences src = src.__mro__[-3] # noinspection PyTypeChecker @@ -89,6 +91,7 @@ def __and__(cls: META, other: META) -> META: other_dict = other.__dict__ # Set meta attributes here. + # noinspection PyTypeChecker for k in cls.all_fields: if k in other_dict: setattr(cls, k, other_dict[k]) @@ -115,20 +118,19 @@ class AbstractMeta(metaclass=ABCOrAndMeta): # Class attribute which enables us to detect a `JSONWizard.Meta` subclass. __is_inner_meta__ = False - # True to enable Debug mode for additional (more verbose) log output. + # Enable Debug mode for more verbose log output. # - # The value can also be a `str` or `int` which specifies - # the minimum level for logs in this library to show up. + # This setting can be a `bool`, `int`, or `str`: + # - `True` enables debug mode with default verbosity. + # - A `str` or `int` specifies the minimum log level (e.g., 'DEBUG', 10). # - # For example, a message is logged whenever an unknown JSON key is - # encountered when `from_dict` or `from_json` is called. + # Debug mode provides additional helpful log messages, including: + # - Logging unknown JSON keys encountered during `from_dict` or `from_json`. + # - Detailed error messages for invalid types during unmarshalling. # - # This also results in more helpful messages during error handling, which - # can be useful when debugging the cause when values are an invalid type - # (i.e. they don't match the annotation for the field) when unmarshalling - # a JSON object to a dataclass instance. + # Note: Enabling Debug mode may have a minor performance impact. # - # Note there is a minor performance impact when DEBUG mode is enabled. + # @deprecated and will be removed in V1 - Use `v1_debug` instead. debug_enabled: ClassVar['bool | int | str'] = False # When enabled, a specified Meta config for the main dataclass (i.e. the @@ -226,6 +228,19 @@ class AbstractMeta(metaclass=ABCOrAndMeta): # Defaults to False. v1: ClassVar[bool] = False + # Enable Debug mode for more verbose log output. + # + # This setting can be a `bool`, `int`, or `str`: + # - `True` enables debug mode with default verbosity. + # - A `str` or `int` specifies the minimum log level (e.g., 'DEBUG', 10). + # + # Debug mode provides additional helpful log messages, including: + # - Logging unknown JSON keys encountered during `from_dict` or `from_json`. + # - Detailed error messages for invalid types during unmarshalling. + # + # Note: Enabling Debug mode may have a minor performance impact. + v1_debug: ClassVar['bool | int | str'] = False + # Specifies the letter case used to match JSON keys when mapping them # to dataclass fields. # @@ -397,11 +412,13 @@ class AbstractEnvMeta(metaclass=ABCOrAndMeta): # the :func:`dataclasses.field`) in the serialization process. skip_defaults_if: ClassVar[Condition] = None + # noinspection PyMethodParameters @cached_class_property def all_fields(cls) -> FrozenKeys: """Return a list of all class attributes""" return frozenset(AbstractEnvMeta.__annotations__) + # noinspection PyMethodParameters @cached_class_property def fields_to_merge(cls) -> FrozenKeys: """Return a list of class attributes, minus `__special_attrs__`""" diff --git a/dataclass_wizard/bases_meta.py b/dataclass_wizard/bases_meta.py index 57c493fa..3a63e801 100644 --- a/dataclass_wizard/bases_meta.py +++ b/dataclass_wizard/bases_meta.py @@ -128,7 +128,10 @@ def bind_to(cls, dataclass: type, create=True, is_default=True, base_cls=base_loader, v1=cls.v1) cls_dumper = get_dumper(dataclass, create=create) - if cls.debug_enabled: + if cls.v1_debug: + _enable_debug_mode_if_needed(cls_loader, cls.v1_debug) + + elif cls.debug_enabled: _enable_debug_mode_if_needed(cls_loader, cls.debug_enabled) if cls.json_key_to_field is not None: diff --git a/dataclass_wizard/class_helper.py b/dataclass_wizard/class_helper.py index 11aca956..3cf74ac5 100644 --- a/dataclass_wizard/class_helper.py +++ b/dataclass_wizard/class_helper.py @@ -329,14 +329,44 @@ def _process_field(name: str, dump_dataclass_field_to_alias): """Process a :class:`Field` for a dataclass field.""" - if f.path is not None: - if set_paths: - dataclass_field_to_path[name] = f.path + if not f.dump_alias is ExplicitNull: + dump_dataclass_field_to_alias[f.name] = ExplicitNull + else: - if f.load_alias is not None: - load_dataclass_field_to_alias[name] = f.load_alias - if f.dump_alias is not None: - dump_dataclass_field_to_alias[name] = f.dump_alias + # if not f.json.dump: + # field_to_alias[f.name] = ExplicitNull + # elif f.json.all: + # keys = f.json.keys + # if f.json.path: + # if set_paths: + # field_to_path[f.name] = keys + # field_to_alias[f.name] = '' + # else: + # field_to_alias[f.name] = keys[0] + + + if f.path is not None: + if set_paths: + dataclass_field_to_path[name] = f.path + # TODO: I forgot why this is needed >.> + dump_dataclass_field_to_alias[name] = '' + + else: + if f.load_alias is not None: + load_dataclass_field_to_alias[name] = f.load_alias + if f.dump_alias is not None: + dump_dataclass_field_to_alias[name] = f.dump_alias + + # if not f.json.dump: + # field_to_alias[f.name] = ExplicitNull + # elif f.json.all: + # keys = f.json.keys + # if f.json.path: + # if set_paths: + # field_to_path[f.name] = keys + # field_to_alias[f.name] = '' + # else: + # field_to_alias[f.name] = keys[0] def _setup_v1_load_config_for_cls( diff --git a/dataclass_wizard/dumpers.py b/dataclass_wizard/dumpers.py index 4b9c4fa8..0919ec21 100644 --- a/dataclass_wizard/dumpers.py +++ b/dataclass_wizard/dumpers.py @@ -27,6 +27,7 @@ dataclass_to_dumper, set_class_dumper, CLASS_TO_DUMP_FUNC, setup_dump_config_for_cls_if_needed, get_meta, dataclass_field_to_load_parser, dataclass_field_to_json_path, is_builtin, dataclass_field_to_skip_if, + v1_dataclass_field_to_alias, ) from .constants import _DUMP_HOOKS, TAG, CATCH_ALL from .decorators import _alias @@ -288,6 +289,9 @@ def dump_func_for_dataclass(cls: Type[T], # sub-classes from `DumpMixIn`, these hooks could be customized. hooks = cls_dumper.__DUMP_HOOKS__ + # TODO this is temporary + if meta.v1: + _ = v1_dataclass_field_to_alias(cls) # Set up the initial dump config for the dataclass. setup_dump_config_for_cls_if_needed(cls) diff --git a/dataclass_wizard/utils/object_path.py b/dataclass_wizard/utils/object_path.py index 9be8c8d0..095437a4 100644 --- a/dataclass_wizard/utils/object_path.py +++ b/dataclass_wizard/utils/object_path.py @@ -3,7 +3,7 @@ from ..errors import ParseError -def safe_get(data, path, default=MISSING): +def safe_get(data, path, default=MISSING, raise_=True): current_data = data p = path # to avoid "unbound local variable" warnings @@ -20,7 +20,7 @@ def safe_get(data, path, default=MISSING): # AttributeError - # raised when `data` is an invalid type, such as a `None` except (IndexError, KeyError, AttributeError) as e: - if default is MISSING: + if raise_ and default is MISSING: raise _format_err(e, current_data, path, p) from None return default @@ -28,12 +28,12 @@ def safe_get(data, path, default=MISSING): # raised when `data` is a `list`, but we try to use it like a `dict` except TypeError: e = TypeError('Invalid path') - raise _format_err(e, current_data, path, p) from None + raise _format_err(e, current_data, path, p, True) from None -def _format_err(e, current_data, path, current_path): +def _format_err(e, current_data, path, current_path, invalid_path=False): return ParseError( - e, current_data, None, + e, current_data, dict if invalid_path else None, path=' => '.join(repr(p) for p in path), current_path=repr(current_path), ) diff --git a/dataclass_wizard/v1/loaders.py b/dataclass_wizard/v1/loaders.py index 2438a9c7..16d0b2e6 100644 --- a/dataclass_wizard/v1/loaders.py +++ b/dataclass_wizard/v1/loaders.py @@ -199,7 +199,11 @@ def load_to_tuple(cls, tp: TypeInfo, extras: Extras): # Check if the `Tuple` appears in the variadic form # i.e. Tuple[str, ...] - is_variadic = args and args[-1] is ... + if args: + is_variadic = args[-1] is ... + else: + args = (Any, ...) + is_variadic = True if is_variadic: # Parser that handles the variadic form of :class:`Tuple`'s, @@ -915,8 +919,7 @@ def load_func_for_dataclass( # raise RecursiveClassError(cls) from None field_to_path = dataclass_field_to_json_path(cls) - num_paths = len(field_to_path) - has_json_paths = True if num_paths else False + has_alias_paths = True if field_to_path else False # Fix for using `auto_assign_tags` and `raise_on_unknown_json_key` together # See https://github.com/rnag/dataclass-wizard/issues/137 @@ -962,11 +965,8 @@ def load_func_for_dataclass( else: should_raise = should_warn = None - if has_json_paths: - # loop_over_o = num_paths != len(cls_init_fields) + if has_alias_paths: _locals['safe_get'] = safe_get - # else: - # loop_over_o = True # Initialize the FuncBuilder fn_gen = FunctionBuilder() @@ -996,26 +996,6 @@ def load_func_for_dataclass( if pre_assign: fn_gen.add_line('i = 0') - if has_json_paths: - - with fn_gen.try_(): - for field, path in field_to_path.items(): - if field in field_to_default: - default_value = f'_default_{field}' - _locals[default_value] = field_to_default[field] - extra_args = f', {default_value}' - else: - extra_args = '' - fn_gen.add_line(f'field={field!r}; init_kwargs[field] = field_to_parser[field](safe_get(o, {path!r}{extra_args}))') - - - # TODO raise some useful message like (ex. on IndexError): - # Field "my_str" of type tuple[float, str] in A2 has invalid value ['123'] - - with fn_gen.except_(Exception, 'e', ParseError): - fn_gen.add_line('re_raise(e, cls, o, fields, field, v1)') - - vars_for_fields = [] if cls_init_fields: @@ -1035,6 +1015,18 @@ def load_func_for_dataclass( and (key := field_to_alias.get(name)) is not None and name != key): f_assign = f'field={name!r}; key={key!r}; {val}=o.get(key, MISSING)' + + elif (has_alias_paths + and (path := field_to_path.get(name)) is not None): + + if name in field_to_default: + f_assign = f'field={name!r}; {val}=safe_get(o, {path!r}, MISSING, False)' + else: + f_assign = f'field={name!r}; {val}=safe_get(o, {path!r})' + + # TODO raise some useful message like (ex. on IndexError): + # Field "my_str" of type tuple[float, str] in A2 has invalid value ['123'] + elif key_case is None: field_to_alias[name] = name f_assign = f'field={name!r}; {val}=o.get(field, MISSING)' @@ -1047,11 +1039,6 @@ def load_func_for_dataclass( string = generate_field_code(cls_loader, new_extras, f, i) if name in field_to_default: - # default = default_val = field_to_default[name] - # FIXME might need to update default value logic - # if not is_builtin(default): - # default = f'_dflt{i}' - # _locals[default] = default_val fn_gen.add_line(f_assign) with fn_gen.if_(f'{val} is not MISSING'): @@ -1061,8 +1048,8 @@ def load_func_for_dataclass( # TODO confirm this is ok # vars_for_fields.append(f'{name}={var}') vars_for_fields.append(var) - fn_gen.add_line(f_assign) + with fn_gen.if_(f'{val} is not MISSING'): fn_gen.add_line(f'{pre_assign}{var} = {string}') diff --git a/dataclass_wizard/v1/models.py b/dataclass_wizard/v1/models.py index 19c67617..ab2c1cdd 100644 --- a/dataclass_wizard/v1/models.py +++ b/dataclass_wizard/v1/models.py @@ -3,7 +3,7 @@ from ..constants import PY310_OR_ABOVE from ..log import LOG -from ..type_def import DefFactory +from ..type_def import DefFactory, ExplicitNull # noinspection PyProtectedMember from ..utils.object_path import split_object_path from ..utils.typing_compat import get_origin_v2, PyNotRequired @@ -221,17 +221,29 @@ def Alias(all=None, *, hash, compare, metadata, kw_only) # noinspection PyPep8Naming,PyShadowingBuiltins - def AliasPath(keys=None, *, + def AliasPath(all=None, *, + load=None, + dump=None, default=MISSING, default_factory=MISSING, init=True, repr=True, hash=None, compare=True, metadata=None, kw_only=False): - if isinstance(keys, str): - keys = split_object_path(keys) + if load is not None: + all = load + load = None + dump = ExplicitNull - return Field(None, None, keys, default, default_factory, init, repr, + if dump is not None: + all = dump + dump = None + load = ExplicitNull + + if isinstance(all, str): + all = split_object_path(all) + + return Field(load, dump, all, default, default_factory, init, repr, hash, compare, metadata, kw_only) @@ -278,17 +290,32 @@ def Alias(all=None, *, hash, compare, metadata) # noinspection PyPep8Naming,PyShadowingBuiltins - def AliasPath(keys=None, *, + def AliasPath(all=None, *, + load=None, + dump=None, default=MISSING, default_factory=MISSING, init=True, repr=True, hash=None, compare=True, metadata=None): - if isinstance(keys, str): - keys = split_object_path(keys) + if load is not None: + all = load + load = None + dump = ExplicitNull + + if dump is not None: + all = dump + dump = None + load = ExplicitNull + + if isinstance(all, str): + all = split_object_path(all) + + if isinstance(all, str): + all = split_object_path(all) - return Field(None, None, keys, default, default_factory, init, repr, + return Field(load, dump, all, default, default_factory, init, repr, hash, compare, metadata) diff --git a/dataclass_wizard/v1/models.pyi b/dataclass_wizard/v1/models.pyi index b66da8c3..0346d5c3 100644 --- a/dataclass_wizard/v1/models.pyi +++ b/dataclass_wizard/v1/models.pyi @@ -66,7 +66,9 @@ class Extras(TypedDict): # noinspection PyPep8Naming -def AliasPath(keys: PathType | str | None = None, *, +def AliasPath(all: PathType | str | None = None, *, + load : PathType | str | None = None, + dump : PathType | str | None = None, default=MISSING, default_factory: Callable[[], MISSING] = MISSING, init=True, repr=True, @@ -83,12 +85,12 @@ def AliasPath(keys: PathType | str | None = None, *, or nested paths. For example, passing "myField" will not match "myfield" in JSON, and vice versa. - `keys` represents one or more nested JSON keys (as strings or a collection of strings) + `all` represents one or more nested JSON keys (as strings or a collection of strings) to associate with the dataclass field. The keys can include paths like `a.b.c` or even more complex nested paths such as `a["nested"]["key"]`. Arguments: - keys (_STR_COLLECTION): The JSON key(s) or nested path(s) to associate with the dataclass field. + all (_STR_COLLECTION): The JSON key(s) or nested path(s) to associate with the dataclass field. default (Any): The default value for the field. Mutually exclusive with `default_factory`. default_factory (Callable[[], Any]): A callable to generate the default value. Mutually exclusive with `default`. diff --git a/tests/unit/v1/test_loaders.py b/tests/unit/v1/test_loaders.py index 1c2feeb4..2ae56183 100644 --- a/tests/unit/v1/test_loaders.py +++ b/tests/unit/v1/test_loaders.py @@ -21,6 +21,7 @@ ParseError, MissingFields, UnknownKeysError, MissingData, InvalidConditionError ) from dataclass_wizard.models import _PatternBase +from dataclass_wizard.v1 import * from ..conftest import MyUUIDSubclass from ...conftest import * @@ -764,8 +765,8 @@ class _(JSONWizard.Meta): def test_from_dict_key_transform_with_json_field(): """ - Specifying a custom mapping of JSON key to dataclass field, via the - `json_field` helper function. + Specifying a custom mapping of alias key to dataclass field, via the + `Alias` helper function. """ @dataclass @@ -1407,7 +1408,6 @@ class _(JSONWizard.Meta): assert result.my_tuple == expected -@pytest.mark.xfail(reason='Need to add support in v1') @pytest.mark.parametrize( 'input,expectation,expected', [ @@ -2417,7 +2417,6 @@ class _(JSONWizard.Meta): assert opt == Options(my_extras={}, email='x@y.org') -@pytest.mark.xfail(reason='TODO add support in v1') def test_from_dict_with_nested_object_key_path(): """ Specifying a custom mapping of "nested" JSON key to dataclass field, @@ -2425,13 +2424,13 @@ def test_from_dict_with_nested_object_key_path(): """ @dataclass - class A(JSONWizard, debug=True): - class _(JSONWizard.Meta): + class A(JSONPyWizard, debug=True): + class _(JSONPyWizard.Meta): v1 = True an_int: int - a_bool: Annotated[bool, KeyPath('x.y.z.0')] - my_str: str = path_field(['a', 'b', 'c', -1], default='xyz') + a_bool: Annotated[bool, AliasPath('x.y.z.0')] + my_str: str = AliasPath(['a', 'b', 'c', -1], default='xyz') # Failures @@ -2478,7 +2477,7 @@ class _(JSONWizard.Meta): 'c': { -1: '7' } } }, - 'anInt': 3 + 'an_int': 3 } a = A.from_dict(d) @@ -2504,11 +2503,10 @@ class _(JSONWizard.Meta): 'c': { -1: 'xyz' } } }, - 'anInt': 5 + 'an_int': 5 } -@pytest.mark.xfail(reason='TODO add support in v1') def test_from_dict_with_nested_object_key_path_with_skip_defaults(): """ Specifying a custom mapping of "nested" JSON key to dataclass field, @@ -2523,10 +2521,14 @@ class _(JSONWizard.Meta): v1 = True skip_defaults = True - an_int: Annotated[int, KeyPath('my."test value"[here!][0]')] - a_bool: Annotated[bool, KeyPath('x.y.z.-1', all=False)] - my_str: Annotated[str, KeyPath(['a', 'b', 'c', -1], dump=False)] = 'xyz1' - other_bool: bool = path_field('x.y."z z"', default=True) + an_int: Annotated[int, AliasPath('my."test value"[here!][0]')] + # a_bool: Annotated[bool, AliasPath('x.y.z.-1', all=False)] + # my_str: Annotated[str, AliasPath(['a', 'b', 'c', -1], dump=False)] = 'xyz1' + + a_bool: Annotated[bool, AliasPath('x.y.z.-1')] + my_str: Annotated[str, AliasPath(['a', 'b', 'c', -1])] = 'xyz1' + + other_bool: bool = AliasPath('x.y."z z"', default=True) # Failures