Source code for dataclass_wizard.bases_meta

"""
Ideally should be in the `bases` module, however we'll run into a Circular
Import scenario if we move it there, since the `loaders` and `dumpers` modules
both import directly from `bases`.

"""
from __future__ import annotations

import logging
import warnings
from datetime import datetime, date
from typing import Mapping

from .bases import AbstractMeta, META, AbstractEnvMeta
from .class_helper import (
    META_INITIALIZER, _META, get_meta,
    get_outer_class_name, get_class_name, create_new_class,
    json_field_to_dataclass_field, dataclass_field_to_json_field,
    field_to_env_var,
    DATACLASS_FIELD_TO_ALIAS_FOR_LOAD,
    DATACLASS_FIELD_TO_ENV_FOR_LOAD,
    DATACLASS_FIELD_TO_ALIAS_FOR_DUMP,
)
from .decorators import try_with_load
from .enums import DateTimeTo, LetterCase, LetterCasePriority
from .errors import ParseError, show_deprecation_warning
from .loader_selection import get_dumper, get_loader
from .log import LOG
from .type_def import E
from .utils.type_conv import date_to_timestamp, as_enum

ALLOWED_MODES = ('runtime', 'v1_codegen')

# global flag to determine if debug mode was ever enabled
_debug_was_enabled = False


[docs] def register_type(cls, tp, *, load=None, dump=None, mode=None) -> None: meta = get_meta(cls) if meta.v1: if load is None: load = tp if dump is None: dump = str if (load_hook := meta.v1_type_to_load_hook) is None: meta.v1_type_to_load_hook = load_hook = {} if (dump_hook := meta.v1_type_to_dump_hook) is None: meta.v1_type_to_dump_hook = dump_hook = {} load_hook[tp] = (mode if mode else _infer_mode(load), load) dump_hook[tp] = (mode if mode else _infer_mode(dump), dump) else: from .dumpers import DumpMixin from .loaders import LoadMixin dumper = get_dumper(cls, base_cls=DumpMixin) loader = get_loader(cls, base_cls=LoadMixin) # default hooks load = tp if load is None else load dump = str if dump is None else dump # adapt to what v0 expects load = _adapt_to_arity(load, loader.HOOK_ARITY) dump = _adapt_to_arity(dump, dumper.HOOK_ARITY) dumper.register_dump_hook(tp, dump) loader.register_load_hook(tp, load)
# use `debug_enabled` for log level if it's a str or int. def _enable_debug_mode_if_needed(v1, cls_loader, possible_lvl): global _debug_was_enabled if not _debug_was_enabled: _debug_was_enabled = True # use `debug_enabled` for log level if it's a str or int. default_lvl = logging.DEBUG # minimum logging level for logs by this library. min_level = default_lvl if isinstance(possible_lvl, bool) else possible_lvl # set the logging level of this library's logger. LOG.setLevel(min_level) LOG.info('DEBUG Mode is enabled') # Decorate all hooks so they format more helpful messages # on error. if not v1: load_hooks = cls_loader.__LOAD_HOOKS__ for typ in load_hooks: load_hooks[typ] = try_with_load(load_hooks[typ]) def _as_enum_safe(cls: type, name: str, base_type: type[E]) -> 'E | None': """ Attempt to return the value for class attribute :attr:`attr_name` as a :type:`base_type`. :raises ParseError: If we are unable to convert the value of the class attribute to an Enum of type `base_type`. """ try: return as_enum(getattr(cls, name), base_type) except ParseError as e: # We run into a parsing error while loading the enum; Add # additional info on the Exception object before re-raising it e.class_name = get_class_name(cls) e.field_name = name raise def _arity(hook) -> int: # Python function / method code = getattr(hook, "__code__", None) if code is not None: # reject *args/**kwargs if you want strictness if code.co_flags & 0x04 or code.co_flags & 0x08: return -1 return code.co_argcount # Classes / C-callables (e.g., IPv4Address) don't expose __code__. # Treat as "callable(value)" i.e., 1-arg constructor. return 1 def _adapt_to_arity(fn, target_arity: int): src = _arity(fn) if src == -1: # If they already accept *args/**kwargs, it will work everywhere. return fn if src == target_arity: return fn # Common case: user gives 1-arg callable but backend passes extra info if src == 1 and target_arity > 1: def wrapper(x, *rest): return fn(x) return wrapper # Less common: user gives 2-arg (v1 codegen) but v0 expects 1 # You can reject this unless you have a sane mapping. raise TypeError( f"Hook {getattr(fn, '__name__', fn)!r} has {src} args, " f"but backend expects {target_arity}." ) def _infer_mode(hook) -> str: code = getattr(hook, '__code__', None) if code is None: return 'runtime' # types/builtins co_flags = code.co_flags if co_flags & 0x04 or co_flags & 0x08: raise TypeError('hooks must not use *args/**kwargs') argc = code.co_argcount if argc == 1: return 'runtime' if argc == 2: return 'v1_codegen' raise TypeError('hook must accept 1 arg (runtime) or 2 args (TypeInfo, Extras)') def _normalize_hooks(hooks: Mapping | None) -> None: if not hooks: return for tp, hook in hooks.items(): if isinstance(hook, tuple): if len(hook) != 2: raise ValueError(f"hook tuple must be (mode, hook), got {hook!r}") from None mode, fn = hook if mode not in ALLOWED_MODES: raise ValueError( f"mode must be 'runtime' or 'v1_codegen' (got {mode!r})" ) from None else: mode = _infer_mode(hook) # noinspection PyUnresolvedReferences hooks[tp] = mode, hook
[docs] class BaseJSONWizardMeta(AbstractMeta): """ Superclass definition for the `JSONWizard.Meta` inner class. See the implementation of the :class:`AbstractMeta` class for the available config that can be set, as well as for descriptions on any implemented methods. """ __slots__ = () @classmethod def _init_subclass(cls): """ Hook that should ideally be run whenever the `Meta` class is sub-classed. """ outer_cls_name = get_outer_class_name(cls, raise_=False) # We can retrieve the outer class name using `__qualname__`, but it's # not easy to find the class definition itself. The simplest way seems # to be to create a new callable (essentially a class method for the # outer class) which will later be called by the base enclosing class. # # Note that this relies on the observation that the # `__init_subclass__` method of any inner classes are run before the # one for the outer class. if outer_cls_name is not None: META_INITIALIZER[outer_cls_name] = cls.bind_to else: from .abstractions import AbstractJSONWizard # The `Meta` class is defined as an outer class. Emit a warning # here, just so we can ensure awareness of this special case. LOG.warning('The %r class is not declared as an Inner Class, so ' 'these are global settings that will apply to all ' 'JSONSerializable sub-classes.', get_class_name(cls)) # Copy over global defaults to the :class:`AbstractMeta` for attr in AbstractMeta.fields_to_merge: setattr(AbstractMeta, attr, getattr(cls, attr, None)) if cls.json_key_to_field: AbstractMeta.json_key_to_field = cls.json_key_to_field if cls.v1_field_to_alias: AbstractMeta.v1_field_to_alias = cls.v1_field_to_alias if cls.v1_field_to_alias_dump: AbstractMeta.v1_field_to_alias_dump = cls.v1_field_to_alias_dump if cls.v1_field_to_alias_load: AbstractMeta.v1_field_to_alias_load = cls.v1_field_to_alias_load # Create a new class of `Type[W]`, and then pass `create=False` so # that we don't create new loader / dumper for the class. new_cls = create_new_class(cls, (AbstractJSONWizard, )) cls.bind_to(new_cls, create=False)
[docs] @classmethod def bind_to(cls, dataclass: type, create=True, is_default=True, base_loader=None, base_dumper=None): from .v1.enums import KeyAction, KeyCase, DateTimeTo as V1DateTimeTo meta = get_meta(dataclass) v1 = cls.v1 or meta.v1 cls_loader = get_loader(dataclass, create=create, base_cls=base_loader, v1=v1) cls_dumper = get_dumper(dataclass, create=create, base_cls=base_dumper, v1=v1) if cls.v1_debug: _enable_debug_mode_if_needed(v1, cls_loader, cls.v1_debug) elif cls.debug_enabled: show_deprecation_warning( 'debug_enabled', fmt="Deprecated Meta setting {name} ({reason}).", reason='Use `v1_debug` instead', ) _enable_debug_mode_if_needed(v1, cls_loader, cls.debug_enabled) if cls.json_key_to_field is not None: add_for_both = cls.json_key_to_field.pop('__all__', None) json_field_to_dataclass_field(dataclass).update( cls.json_key_to_field ) if add_for_both: dataclass_to_json_field = dataclass_field_to_json_field( dataclass) # We unfortunately can't use a dict comprehension approach, as # we don't know if there are multiple JSON keys mapped to a # single dataclass field. So to be safe, we should only set # the first JSON key mapped to each dataclass field. for json_key, field in cls.json_key_to_field.items(): if field not in dataclass_to_json_field: dataclass_to_json_field[field] = json_key if cls.v1_dump_date_time_as is not None: cls.v1_dump_date_time_as = _as_enum_safe(cls, 'v1_dump_date_time_as', V1DateTimeTo) if cls.marshal_date_time_as is not None: enum_val = _as_enum_safe(cls, 'marshal_date_time_as', DateTimeTo) if enum_val is DateTimeTo.TIMESTAMP: # Update dump hooks for the `datetime` and `date` types cls_dumper.dump_with_datetime = lambda o, *_: round(o.timestamp()) cls_dumper.dump_with_date = lambda o, *_: date_to_timestamp(o) cls_dumper.register_dump_hook( datetime, cls_dumper.dump_with_datetime) cls_dumper.register_dump_hook( date, cls_dumper.dump_with_date) elif enum_val is DateTimeTo.ISO_FORMAT: # noop; the default dump hook for `datetime` and `date` # already serializes using this approach. pass if cls.key_transform_with_load is not None: cls_loader.transform_json_field = _as_enum_safe( cls, 'key_transform_with_load', LetterCase) if (key_case := cls.v1_case) is not None: cls.v1_load_case = cls.v1_dump_case = key_case cls.v1_case = None if cls.v1_load_case is not None: cls_loader.transform_json_field = _as_enum_safe( cls, 'v1_load_case', KeyCase) if cls.v1_dump_case is not None: cls_dumper.transform_dataclass_field = _as_enum_safe( cls, 'v1_dump_case', KeyCase) if (field_to_alias := cls.v1_field_to_alias) is not None: cls.v1_field_to_alias_dump = { k: v if isinstance(v, str) else v[0] for k, v in field_to_alias.items() } cls.v1_field_to_alias_load = field_to_alias if (field_to_alias := cls.v1_field_to_alias_dump) is not None: DATACLASS_FIELD_TO_ALIAS_FOR_DUMP[dataclass].update(field_to_alias) if (field_to_alias := cls.v1_field_to_alias_load) is not None: DATACLASS_FIELD_TO_ALIAS_FOR_LOAD[dataclass].update({ k: (v, ) if isinstance(v, str) else v for k, v in field_to_alias.items() }) if cls.key_transform_with_dump is not None: cls_dumper.transform_dataclass_field = _as_enum_safe( cls, 'key_transform_with_dump', LetterCase) if cls.v1_on_unknown_key is not None: cls.v1_on_unknown_key = _as_enum_safe(cls, 'v1_on_unknown_key', KeyAction) _normalize_hooks(cls.v1_type_to_load_hook) _normalize_hooks(cls.v1_type_to_dump_hook) # Finally, if needed, save the meta config for the outer class. This # will allow us to access this config as part of the JSON load/dump # process if needed. if is_default: # Check if the dataclass already has a Meta config; if so, we need to # copy over special attributes so they don't get overwritten. if dataclass in _META: _META[dataclass] &= cls else: _META[dataclass] = cls
[docs] class BaseEnvWizardMeta(AbstractEnvMeta): """ Superclass definition for the `EnvWizard.Meta` inner class. See the implementation of the :class:`AbstractEnvMeta` class for the available config that can be set, as well as for descriptions on any implemented methods. """ __slots__ = () @classmethod def _init_subclass(cls): """ Hook that should ideally be run whenever the `Meta` class is sub-classed. """ outer_cls_name = get_outer_class_name(cls, raise_=False) if outer_cls_name is not None: META_INITIALIZER[outer_cls_name] = cls.bind_to else: from .abstractions import AbstractJSONWizard # The `Meta` class is defined as an outer class. Emit a warning # here, just so we can ensure awareness of this special case. LOG.warning('The %r class is not declared as an Inner Class, so ' 'these are global settings that will apply to all ' 'EnvWizard sub-classes.', get_class_name(cls)) # Copy over global defaults to the :class:`AbstractMeta` for attr in AbstractEnvMeta.fields_to_merge: setattr(AbstractEnvMeta, attr, getattr(cls, attr, None)) if cls.field_to_env_var: AbstractEnvMeta.field_to_env_var = cls.field_to_env_var if cls.v1_field_to_alias_dump: AbstractEnvMeta.v1_field_to_alias_dump = cls.v1_field_to_alias_dump if cls.v1_field_to_env_load: AbstractEnvMeta.v1_field_to_env_load = cls.v1_field_to_env_load # Create a new class of `Type[W]`, and then pass `create=False` so # that we don't create new loader / dumper for the class. new_cls = create_new_class(cls, (AbstractJSONWizard, )) cls.bind_to(new_cls, create=False)
[docs] @classmethod def bind_to(cls, env_class: type, create=True, is_default=True): from .v1.enums import KeyCase, EnvKeyStrategy, EnvPrecedence meta = get_meta(env_class) v1 = cls.v1 or meta.v1 cls_loader = get_loader( env_class, create=create, env=True, v1=v1) cls_dumper = get_dumper( env_class, create=create, v1=v1) if cls.v1_debug: _enable_debug_mode_if_needed(v1, cls_loader, cls.v1_debug) if cls.debug_enabled: _enable_debug_mode_if_needed(v1, cls_loader, cls.debug_enabled) if cls.field_to_env_var is not None: if v1: warnings.warn( '`field_to_env_var` is deprecated and will be removed in v1. ' 'Use `v1_field_to_env_load` instead.', FutureWarning, stacklevel=2, ) cls.v1_field_to_env_load = cls.field_to_env_var else: field_to_env_var(env_class).update( cls.field_to_env_var ) cls.key_lookup_with_load = _as_enum_safe( cls, 'key_lookup_with_load', LetterCasePriority) if v1: from . import EnvWizard as V0EnvWizard from .v1 import EnvWizard as V1EnvWizard if issubclass(env_class, V0EnvWizard) and not issubclass(env_class, V1EnvWizard): raise TypeError( f'{env_class.__qualname__} is using Meta(v1=True) but does ' 'not inherit from `dataclass_wizard.v1.EnvWizard`.\n\n' 'Fix:\n' ' from dataclass_wizard.v1 import EnvWizard' ) from None if cls.v1_load_case is not None: cls.v1_load_case = _as_enum_safe( cls, 'v1_load_case', EnvKeyStrategy) if cls.v1_env_precedence is not None: cls.v1_env_precedence = _as_enum_safe( cls, 'v1_env_precedence', EnvPrecedence) # TODO cls_dumper.transform_dataclass_field = _as_enum_safe( cls, 'v1_dump_case', KeyCase) if (field_to_alias := cls.v1_field_to_alias_dump) is not None: DATACLASS_FIELD_TO_ALIAS_FOR_DUMP[env_class].update(field_to_alias) if (field_to_env := cls.v1_field_to_env_load) is not None: DATACLASS_FIELD_TO_ENV_FOR_LOAD[env_class].update({ k: (v, ) if isinstance(v, str) else v for k, v in field_to_env.items() }) # set this attribute in case of nested dataclasses (which # uses codegen in `v1/loaders.py`) cls.v1_on_unknown_key = None # if cls.v1_on_unknown_key is not None: # cls.v1_on_unknown_key = _as_enum_safe(cls, 'v1_on_unknown_key', KeyAction) _normalize_hooks(cls.v1_type_to_load_hook) _normalize_hooks(cls.v1_type_to_dump_hook) else: cls_dumper.transform_dataclass_field = _as_enum_safe( cls, 'key_transform_with_dump', LetterCase) # Finally, if needed, save the meta config for the outer class. This # will allow us to access this config as part of the JSON load/dump # process if needed. if is_default: # Check if the dataclass already has a Meta config; if so, we need to # copy over special attributes so they don't get overwritten. if env_class in _META: _META[env_class] &= cls else: _META[env_class] = cls
# noinspection PyPep8Naming
[docs] def LoadMeta(**kwargs) -> META: """ Helper function to setup the ``Meta`` Config for the JSON load (de-serialization) process, which is intended for use alongside the ``fromdict`` helper function. For descriptions on what each of these params does, refer to the `Docs`_ below, or check out the :class:`AbstractMeta` definition (I want to avoid duplicating the descriptions for params here). Examples:: >>> LoadMeta(key_transform='CAMEL').bind_to(MyClass) >>> fromdict(MyClass, {"myStr": "value"}) .. _Docs: https://dcw.ritviknag.com/en/latest/common_use_cases/meta.html """ base_dict = kwargs | {'__slots__': ()} if (v := base_dict.pop('key_transform', None)) is not None: base_dict['key_transform_with_load'] = v if (v := base_dict.pop('v1_case', None)) is not None: base_dict['v1_load_case'] = v if (v := base_dict.pop('v1_field_to_alias', None)) is not None: base_dict['v1_field_to_alias_load'] = v if (v := base_dict.pop('v1_type_to_hook', None)) is not None: base_dict['v1_type_to_load_hook'] = v # Create a new subclass of :class:`AbstractMeta` # noinspection PyTypeChecker return type('Meta', (BaseJSONWizardMeta, ), base_dict)
# noinspection PyPep8Naming
[docs] def DumpMeta(**kwargs) -> META: """ Helper function to setup the ``Meta`` Config for the JSON dump (serialization) process, which is intended for use alongside the ``asdict`` helper function. For descriptions on what each of these params does, refer to the `Docs`_ below, or check out the :class:`AbstractMeta` definition (I want to avoid duplicating the descriptions for params here). Examples:: >>> DumpMeta(key_transform='CAMEL').bind_to(MyClass) >>> asdict(MyClass, {"myStr": "value"}) .. _Docs: https://dcw.ritviknag.com/en/latest/common_use_cases/meta.html """ # Set meta attributes here. base_dict = kwargs | {'__slots__': ()} if (v := base_dict.pop('key_transform', None)) is not None: base_dict['key_transform_with_dump'] = v if (v := base_dict.pop('v1_case', None)) is not None: base_dict['v1_dump_case'] = v if (v := base_dict.pop('v1_field_to_alias', None)) is not None: base_dict['v1_field_to_alias_dump'] = v if (v := base_dict.pop('v1_type_to_hook', None)) is not None: base_dict['v1_type_to_dump_hook'] = v # Create a new subclass of :class:`AbstractMeta` # noinspection PyTypeChecker return type('Meta', (BaseJSONWizardMeta, ), base_dict)
# noinspection PyPep8Naming
[docs] def EnvMeta(**kwargs) -> META: """ Helper function to setup the ``Meta`` Config for the EnvWizard. For descriptions on what each of these params does, refer to the `Docs`_ below, or check out the :class:`AbstractEnvMeta` definition (I want to avoid duplicating the descriptions for params here). Examples:: >>> EnvMeta(key_transform_with_dump='SNAKE').bind_to(MyClass) .. _Docs: https://dcw.ritviknag.com/en/latest/common_use_cases/meta.html """ # Set meta attributes here. base_dict = kwargs | {'__slots__': ()} # Create a new subclass of :class:`AbstractMeta` # noinspection PyTypeChecker return type('EnvMeta', (BaseEnvWizardMeta, ), base_dict)