Source code for dataclass_wizard.models

import json
# noinspection PyProtectedMember
from dataclasses import MISSING, Field, _create_fn
from datetime import date, datetime, time
from typing import (cast, Collection, Callable,
                    Optional, List, Union, Type)

from .bases import META
from .constants import PY310_OR_ABOVE
from .decorators import cached_property
from .type_def import T, DT, Encoder, PyTypedDict, FileEncoder
from .utils.type_conv import as_datetime, as_time, as_date


# Type for a string or a collection of strings.
_STR_COLLECTION = Union[str, Collection[str]]

# A date, time, datetime sub type, or None.
DT_OR_NONE = Optional[DT]


[docs] class Extras(PyTypedDict): """ "Extra" config that can be used in the load / dump process. """ config: META # noinspection PyTypedDict pattern: '_PatternedDT'
[docs] def json_key(*keys: str, all=False, dump=True): """ Represents a mapping of one or more JSON key names for a dataclass field. This is only in *addition* to the default key transform; for example, a JSON key appearing as "myField", "MyField" or "my-field" will already map to a dataclass field "my_field" by default (assuming the key transform converts to snake case). The mapping to each JSON key name is case-sensitive, so passing "myfield" will not match a "myField" key in a JSON string or a Python dict object. :param keys: A list of one of more JSON keys to associate with the dataclass field. :param all: True to also associate the reverse mapping, i.e. from dataclass field to JSON key. If multiple JSON keys are passed in, it uses the first one provided in this case. This mapping is then used when `to_dict` or `to_json` is called, instead of the default key transform. :param dump: False to skip this field in the serialization process to JSON. By default, this field and its value is included. """ return JSON(*keys, all=all, dump=dump)
[docs] def json_field(keys: _STR_COLLECTION, *, all=False, dump=True, default=MISSING, default_factory=MISSING, init=True, repr=True, hash=None, compare=True, metadata=None): """ This is a helper function that sets the same defaults for keyword arguments as the ``dataclasses.field`` function. It can be thought of as an alias to ``dataclasses.field(...)``, but one which also represents a mapping of one or more JSON key names to a dataclass field. This is only in *addition* to the default key transform; for example, a JSON key appearing as "myField", "MyField" or "my-field" will already map to a dataclass field "my_field" by default (assuming the key transform converts to snake case). The mapping to each JSON key name is case-sensitive, so passing "myfield" will not match a "myField" key in a JSON string or a Python dict object. `keys` is a string, or a collection (list, tuple, etc.) of strings. It represents one of more JSON keys to associate with the dataclass field. When `all` is passed as True (default is False), it will also associate the reverse mapping, i.e. from dataclass field to JSON key. If multiple JSON keys are passed in, it uses the first one provided in this case. This mapping is then used when ``to_dict`` or ``to_json`` is called, instead of the default key transform. When `dump` is passed as False (default is True), this field will be skipped, or excluded, in the serialization process to JSON. """ if default is not MISSING and default_factory is not MISSING: raise ValueError('cannot specify both default and default_factory') return JSONField(keys, all, dump, default, default_factory, init, repr, hash, compare, metadata)
[docs] class JSON: """ Represents one or more mappings of JSON keys. See the docs on the :func:`json_key` function for more info. """ __slots__ = ('keys', 'all', 'dump') def __init__(self, *keys: str, all=False, dump=True): self.keys = keys self.all = all self.dump = dump
[docs] class JSONField(Field): """ Alias to a :class:`dataclasses.Field`, but one which also represents a mapping of one or more JSON key names to a dataclass field. See the docs on the :func:`json_field` function for more info. """ __slots__ = ('json', ) # In Python 3.10, dataclasses adds a new parameter to the :class:`Field` # constructor: `kw_only` # # Ref: https://docs.python.org/3.10/library/dataclasses.html#dataclasses.dataclass if PY310_OR_ABOVE: # pragma: no cover def __init__(self, keys: _STR_COLLECTION, all: bool, dump: bool, default, default_factory, init, repr, hash, compare, metadata): super().__init__(default, default_factory, init, repr, hash, compare, metadata, False) if isinstance(keys, str): keys = (keys, ) self.json = JSON(*keys, all=all, dump=dump) else: # pragma: no cover def __init__(self, keys: _STR_COLLECTION, all: bool, dump: bool, default, default_factory, init, repr, hash, compare, metadata): super().__init__(default, default_factory, init, repr, hash, compare, metadata) if isinstance(keys, str): keys = (keys, ) self.json = JSON(*keys, all=all, dump=dump)
# noinspection PyPep8Naming
[docs] def Pattern(pattern: str): """ Represents a pattern (i.e. format string) for a date / time / datetime type or subtype. For example, a custom pattern like below:: %d, %b, %Y %H:%M:%S.%f A sample usage of ``Pattern``, using a subclass of :class:`time`:: time_field: Annotated[List[MyTime], Pattern('%I:%M %p')] :param pattern: A format string to be passed in to `datetime.strptime` """ return _PatternedDT(pattern)
class _PatternBase: """Base "subscriptable" pattern for date/time/datetime.""" __slots__ = () def __class_getitem__(cls, pattern: str): return _PatternedDT(pattern, cast(DT, cls.__base__)) __getitem__ = __class_getitem__
[docs] class DatePattern(date, _PatternBase): """ An annotated type representing a date pattern (i.e. format string). Upon de-serialization, the resolved type will be a :class:`date` instead. See the docs on :func:`Pattern` for more info. """ __slots__ = ()
[docs] class TimePattern(time, _PatternBase): """ An annotated type representing a time pattern (i.e. format string). Upon de-serialization, the resolved type will be a :class:`time` instead. See the docs on :func:`Pattern` for more info. """ __slots__ = ()
[docs] class DateTimePattern(datetime, _PatternBase): """ An annotated type representing a datetime pattern (i.e. format string). Upon de-serialization, the resolved type will be a :class:`datetime` instead. See the docs on :func:`Pattern` for more info. """ __slots__ = ()
class _PatternedDT: """ Base class for pattern matching using :meth:`datetime.strptime` when loading (de-serializing) a string to a date / time / datetime object. """ # `cls` is the date/time/datetime type or subclass. # `pattern` is the format string to pass in to `datetime.strptime`. __slots__ = ('cls', 'pattern') def __init__(self, pattern: str, cls: DT_OR_NONE = None): self.cls = cls self.pattern = pattern def get_transform_func(self) -> Callable[[str], DT]: """ Build an return a load function which takes a `date_string` as an argument, and returns a new object of type :attr:`cls`. We try to parse the input string to a `cls` object in the following order: - In case it's an ISO-8601 format string, or a numeric timestamp, we first parse with the default load function (ex. as_datetime). We parse strings using the builtin :meth:`fromisoformat` method, as this is much faster than :meth:`datetime.strptime` - see link below for more details. - Next, we parse with :meth:`datetime.strptime` by passing in the :attr:`pattern` to match against. If the pattern is invalid, the method raises a ValueError, which is is re-raised by our `Parser` implementation. Ref: https://stackoverflow.com/questions/13468126/a-faster-strptime :raises ValueError: If the input date string does not match the pre-defined pattern. """ cls = self.cls # Parse with `fromisoformat` first, because its *much* faster than # `datetime.strptime` - see linked article above for more details. body_lines = [ 'dt = default_load_func(date_string, cls, raise_=False)', 'if dt is not None:', ' return dt', 'dt = datetime.strptime(date_string, pattern)', ] locals_ns = {'datetime': datetime, 'pattern': self.pattern, 'cls': cls} if cls is datetime: default_load_func = as_datetime body_lines.append('return dt') elif cls is date: default_load_func = as_date body_lines.append('return dt.date()') elif cls is time: default_load_func = as_time # temp fix for Python 3.11+, since `time.fromisoformat` is updated # to support more formats, such as "-" and "+" in strings. if '-' in self.pattern or '+' in self.pattern: body_lines = ['try:', ' return datetime.strptime(date_string, pattern).time()', 'except (ValueError, TypeError):', ' dt = default_load_func(date_string, cls, raise_=False)', ' if dt is not None:', ' return dt'] else: body_lines.append('return dt.time()') elif issubclass(cls, datetime): default_load_func = as_datetime locals_ns['datetime'] = cls body_lines.append('return dt') elif issubclass(cls, date): default_load_func = as_date body_lines.append('return cls(dt.year, dt.month, dt.day)') elif issubclass(cls, time): default_load_func = as_time # temp fix for Python 3.11+, since `time.fromisoformat` is updated # to support more formats, such as "-" and "+" in strings. if '-' in self.pattern or '+' in self.pattern: body_lines = ['try:', ' dt = datetime.strptime(date_string, pattern).time()', 'except (ValueError, TypeError):', ' dt = default_load_func(date_string, cls, raise_=False)', ' if dt is not None:', ' return dt'] body_lines.append('return cls(dt.hour, dt.minute, dt.second, ' 'dt.microsecond, fold=dt.fold)') else: raise TypeError(f'Annotation for `Pattern` is of invalid type ' f'({cls}). Expected a type or subtype of: ' f'{DT.__constraints__}') locals_ns['default_load_func'] = default_load_func # TODO This approach unfortunately won't work in Python 3.6. To fix # it, we'll need to pass `globals` instead of `locals` here. return _create_fn('pattern_to_dt', ('date_string', ), body_lines, locals=locals_ns, return_type=DT) def __repr__(self): repr_val = [f'{k}={getattr(self, k)!r}' for k in self.__slots__] return f'{self.__class__.__name__}({", ".join(repr_val)})'
[docs] class Container(List[T]): """Convenience wrapper around a collection of dataclass instances. For all intents and purposes, this should behave exactly as a `list` object. Usage: >>> from dataclass_wizard import Container, fromlist >>> from dataclasses import make_dataclass >>> >>> A = make_dataclass('A', [('f1', str), ('f2', int)]) >>> list_of_a = fromlist(A, [{'f1': 'hello', 'f2': 1}, {'f1': 'world', 'f2': 2}]) >>> c = Container[A](list_of_a) >>> print(c.prettify()) """ # TODO: Uncomment once we drop support for Python 3.6 # __slots__ = ('__dict__', # '__orig_class__') @cached_property def __model__(self) -> Type[T]: """ Given a declaration like Container[T], this returns the subscripted value of the generic type T. """ try: # noinspection PyUnresolvedReferences return self.__orig_class__.__args__[0] except AttributeError: cls_name = self.__class__.__qualname__ msg = (f'A {cls_name} object needs to be instantiated with ' f'a generic type T.\n\n' 'Example:\n' f' my_list = {cls_name}[T](...)') raise TypeError(msg) from None def __str__(self): """ Control the value displayed when ``print(self)`` is called. """ import pprint return pprint.pformat(self)
[docs] def prettify(self, encoder: Encoder = json.dumps, ensure_ascii=False, **encoder_kwargs) -> str: """ Convert the list of instances to a *prettified* JSON string. """ return self.to_json( indent=2, encoder=encoder, ensure_ascii=ensure_ascii, **encoder_kwargs )
[docs] def to_json(self, encoder: Encoder = json.dumps, **encoder_kwargs) -> str: """ Convert the list of instances to a JSON string. """ from .dumpers import asdict cls = self.__model__ list_of_dict = [asdict(o, cls=cls) for o in self] return encoder(list_of_dict, **encoder_kwargs)
[docs] def to_json_file(self, file: str, mode: str = 'w', encoder: FileEncoder = json.dump, **encoder_kwargs) -> None: """ Serializes the list of instances and writes it to a JSON file. """ from .dumpers import asdict cls = self.__model__ list_of_dict = [asdict(o, cls=cls) for o in self] with open(file, mode) as out_file: encoder(list_of_dict, out_file, **encoder_kwargs)