diff --git a/HISTORY.rst b/HISTORY.rst index e078c85c..077ff49a 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,6 +2,42 @@ History ======= +0.25.0 (2024-11-03) +------------------- + +**Features and Improvements** + +* Add support for `pathlib.Path`_. Thanks to :user:`assafge` in :pr:`79`. + +.. _pathlib.Path: https://docs.python.org/3/library/pathlib.html#basic-use + +0.24.1 (2024-11-03) +------------------- + +* Resolve ``mypy`` typing issues. Thanks to :user:`AdiNar` in :pr:`64`. + +0.24.0 (2024-11-03) +------------------- + +**Features and Improvements** + +* :pr:`125`: add support for ``typing.Required``, ``NotRequired`` + +**Bugfixes** + +* Fixed by :pr:`125`: Annotating ``TypedDict`` field with one of ``Required`` or ``NotRequired`` wrappers introduced in Python 3.11, no longer raises a ``TypeError`` + -- credits to :user:`claui`. + +0.23.0 (2024-09-18) +------------------- + +* :pr:`94`: Allows the ability to define keys in JSON/dataclass + that do not undergo transformation -- credits to :user:`cquick01`. + + * ``LetterCase.NONE`` - Performs no conversion on strings. + + * ex: `MY_FIELD_NAME` -> `MY_FIELD_NAME` + 0.22.3 (2024-01-29) ------------------- diff --git a/dataclass_wizard/__version__.py b/dataclass_wizard/__version__.py index e56e887a..0ae0a5aa 100644 --- a/dataclass_wizard/__version__.py +++ b/dataclass_wizard/__version__.py @@ -7,7 +7,7 @@ 'with initial values. Construct a dataclass schema with ' \ 'JSON input.' __url__ = 'https://github.com/rnag/dataclass-wizard' -__version__ = '0.22.3' +__version__ = '0.25.0' __author__ = 'Ritvik Nag' __author_email__ = 'rv.kvetch@gmail.com' __license__ = 'Apache 2.0' diff --git a/dataclass_wizard/abstractions.py b/dataclass_wizard/abstractions.py index 2b266930..1abad946 100644 --- a/dataclass_wizard/abstractions.py +++ b/dataclass_wizard/abstractions.py @@ -8,13 +8,13 @@ from decimal import Decimal from typing import ( Any, Type, TypeVar, Union, List, Tuple, Dict, SupportsFloat, AnyStr, - Text, Sequence, Iterable + Text, Sequence, Iterable, Generic ) from .models import Extras from .type_def import ( DefFactory, FrozenKeys, ListOfJSONObject, JSONObject, Encoder, - M, N, T, NT, E, U, DD, LSQ + M, N, T, TT, NT, E, U, DD, LSQ ) @@ -88,7 +88,7 @@ def list_to_json(cls: Type[W], @dataclass -class AbstractParser(ABC): +class AbstractParser(ABC, Generic[T, TT]): """ Abstract parsers, which will ideally act as dispatchers to route objects to the `load` or `dump` hook methods responsible for transforming the @@ -119,7 +119,7 @@ class AbstractParser(ABC): # This is usually the underlying base type of the annotation (for example, # for `List[str]` it will be `list`), though in some cases this will be # the annotation itself. - base_type: Type[T] + base_type: T def __contains__(self, item) -> bool: """ @@ -130,7 +130,7 @@ def __contains__(self, item) -> bool: return type(item) is self.base_type @abstractmethod - def __call__(self, o: Any): + def __call__(self, o: Any) -> TT: """ Parse object `o` """ diff --git a/dataclass_wizard/bases.py b/dataclass_wizard/bases.py index aa476001..b151d855 100644 --- a/dataclass_wizard/bases.py +++ b/dataclass_wizard/bases.py @@ -8,10 +8,10 @@ # Create a generic variable that can be 'AbstractMeta', or any subclass. -M = TypeVar('M', bound='AbstractMeta') -# Use `Type` here explicitly, because we will never have an `M` object. -M = Type[M] -META = M # alias, since `M` is already defined in another module +# Full word as `M` is already defined in another module +META_ = TypeVar('META_', bound='AbstractMeta') +# Use `Type` here explicitly, because we will never have an `META_` object. +META = Type[META_] class ABCOrAndMeta(ABCMeta): @@ -24,7 +24,7 @@ class ABCOrAndMeta(ABCMeta): - https://stackoverflow.com/a/57351066/10237506 """ - def __or__(cls: M, other: M) -> M: + def __or__(cls: META, other: META) -> META: """ Merge two Meta configs. Priority will be given to the source config present in `cls`, e.g. the first operand in the '|' expression. @@ -73,7 +73,7 @@ def __or__(cls: M, other: M) -> M: # noinspection PyTypeChecker return type(new_cls_name, (src, ), base_dict) - def __and__(cls: M, other: M) -> M: + def __and__(cls: META, other: META) -> META: """ Merge the `other` Meta config into the first one, i.e. `cls`. This operation does not create a new class, but instead it modifies the diff --git a/dataclass_wizard/bases_meta.py b/dataclass_wizard/bases_meta.py index 7ba48c50..52352e6c 100644 --- a/dataclass_wizard/bases_meta.py +++ b/dataclass_wizard/bases_meta.py @@ -8,7 +8,7 @@ from typing import Type, Optional, Dict, Union from .abstractions import AbstractJSONWizard -from .bases import AbstractMeta, M +from .bases import AbstractMeta, META from .class_helper import ( _META_INITIALIZER, _META, get_outer_class_name, get_class_name, create_new_class, @@ -176,7 +176,7 @@ def LoadMeta(*, debug_enabled: bool = False, raise_on_unknown_json_key: bool = False, json_key_to_field: Dict[str, str] = None, key_transform: Union[LetterCase, str] = None, - tag: str = None) -> M: + tag: str = None) -> META: """ Helper function to setup the ``Meta`` Config for the JSON load (de-serialization) process, which is intended for use alongside the @@ -216,7 +216,7 @@ def DumpMeta(*, debug_enabled: bool = False, marshal_date_time_as: Union[DateTimeTo, str] = None, key_transform: Union[LetterCase, str] = None, tag: str = None, - skip_defaults: bool = False) -> M: + skip_defaults: bool = False) -> META: """ Helper function to setup the ``Meta`` Config for the JSON dump (serialization) process, which is intended for use alongside the diff --git a/dataclass_wizard/class_helper.py b/dataclass_wizard/class_helper.py index f236831a..c4f70937 100644 --- a/dataclass_wizard/class_helper.py +++ b/dataclass_wizard/class_helper.py @@ -3,7 +3,7 @@ from typing import Dict, Tuple, Type, Union, Callable, Optional, Any from .abstractions import W, AbstractLoader, AbstractDumper, AbstractParser -from .bases import M, AbstractMeta +from .bases import AbstractMeta, META from .models import JSONField, JSON, Extras, _PatternedDT from .type_def import ExplicitNull, ExplicitNullType, T from .utils.dict_helper import DictWithLowerStore @@ -14,7 +14,7 @@ # A cached mapping of dataclass to the list of fields, as returned by # `dataclasses.fields()`. -_FIELDS: Dict[Type, Tuple[Field]] = {} +_FIELDS: Dict[Type, Tuple[Field, ...]] = {} # Mapping of main dataclass to its `load` function. _CLASS_TO_LOAD_FUNC: Dict[Type, Any] = {} @@ -56,7 +56,7 @@ # Mapping of dataclass to its Meta inner class, which will only be set when # the :class:`JSONSerializable.Meta` is sub-classed. -_META: Dict[Type, M] = {} +_META: Dict[Type, META] = {} def dataclass_to_loader(cls): @@ -111,7 +111,7 @@ def dataclass_field_to_json_field(cls): def dataclass_field_to_load_parser( cls_loader: Type[AbstractLoader], cls: Type, - config: M, + config: META, save: bool = True) -> 'DictWithLowerStore[str, AbstractParser]': """ Returns a mapping of each lower-cased field name to its annotated type. @@ -124,7 +124,7 @@ def dataclass_field_to_load_parser( def _setup_load_config_for_cls(cls_loader: Type[AbstractLoader], cls: Type, - config: M, + config: META, save: bool = True ) -> 'DictWithLowerStore[str, AbstractParser]': """ @@ -267,7 +267,7 @@ def call_meta_initializer_if_needed(cls: Type[W]): _META_INITIALIZER[cls_name](cls) -def get_meta(cls: Type) -> M: +def get_meta(cls: Type) -> META: """ Retrieves the Meta config for the :class:`AbstractJSONWizard` subclass. @@ -276,7 +276,7 @@ def get_meta(cls: Type) -> M: return _META.get(cls, AbstractMeta) -def dataclass_fields(cls) -> Tuple[Field]: +def dataclass_fields(cls) -> Tuple[Field, ...]: """ Cache the `dataclasses.fields()` call for each class, as overall that ends up around 5x faster than making a fresh call each time. @@ -288,7 +288,7 @@ def dataclass_fields(cls) -> Tuple[Field]: return _FIELDS[cls] -def dataclass_init_fields(cls) -> Tuple[Field]: +def dataclass_init_fields(cls) -> Tuple[Field, ...]: """Get only the dataclass fields that would be passed into the constructor.""" return tuple(f for f in dataclass_fields(cls) if f.init) diff --git a/dataclass_wizard/constants.py b/dataclass_wizard/constants.py index 2c60a877..3e0623d3 100644 --- a/dataclass_wizard/constants.py +++ b/dataclass_wizard/constants.py @@ -23,6 +23,9 @@ # Check if currently running Python 3.10 or higher PY310_OR_ABOVE = _PY_VERSION >= (3, 10) +# Check if currently running Python 3.11 or higher +PY311_OR_ABOVE = _PY_VERSION >= (3, 11) + # The name of the dictionary object that contains `load` hooks for each # object type. Also used to check if a class is a :class:`BaseLoadHook` _LOAD_HOOKS = '__LOAD_HOOKS__' diff --git a/dataclass_wizard/enums.py b/dataclass_wizard/enums.py index 2d2c455a..803cbcd3 100644 --- a/dataclass_wizard/enums.py +++ b/dataclass_wizard/enums.py @@ -27,7 +27,7 @@ class LetterCase(Enum): # Converts strings (generally in camel case) to snake case. # ex: `myFieldName` -> `my_field_name` SNAKE = FuncWrapper(to_snake_case) - # Perfoms no conversion on strings. + # Performs no conversion on strings. # ex: `MY_FIELD_NAME` -> `MY_FIELD_NAME` NONE = FuncWrapper(lambda s: s) diff --git a/dataclass_wizard/errors.py b/dataclass_wizard/errors.py index 1d4db5e5..ba2b39f2 100644 --- a/dataclass_wizard/errors.py +++ b/dataclass_wizard/errors.py @@ -41,7 +41,7 @@ class ParseError(JSONWizardError): def __init__(self, base_err: Exception, obj: Any, - ann_type: Union[Type, Iterable], + ann_type: Optional[Union[Type, Iterable]], _default_class: Optional[type] = None, _field_name: Optional[str] = None, _json_object: Any = None, diff --git a/dataclass_wizard/loaders.py b/dataclass_wizard/loaders.py index e1bcc360..3407d45f 100644 --- a/dataclass_wizard/loaders.py +++ b/dataclass_wizard/loaders.py @@ -3,6 +3,7 @@ from datetime import datetime, time, date, timedelta from decimal import Decimal from enum import Enum +from pathlib import Path from typing import ( Any, Type, Dict, List, Tuple, Iterable, Sequence, Union, NamedTupleMeta, SupportsFloat, AnyStr, Text, Callable, Optional @@ -25,6 +26,7 @@ from .parsers import * from .type_def import ( ExplicitNull, FrozenKeys, DefFactory, NoneType, JSONObject, + PyRequired, PyNotRequired, M, N, T, E, U, DD, LSQ, NT ) from .utils.string_conv import to_snake_case @@ -207,6 +209,11 @@ def load_to_decimal(o: N, base_type: Type[Decimal]) -> Decimal: return base_type(str(o)) + @staticmethod + def load_to_path(o: N, base_type: Type[Path]) -> Path: + + return base_type(str(o)) + @staticmethod @_alias(as_datetime) def load_to_datetime( @@ -360,6 +367,12 @@ def get_parser_for_annotation(cls, ann_type: Type[T], cls.get_parser_for_annotation ) + elif base_type in (PyRequired, PyNotRequired): + # Given `Required[T]` or `NotRequired[T]`, we only need `T` + ann_type = get_args(ann_type)[0] + return cls.get_parser_for_annotation( + ann_type, base_cls, extras) + elif issubclass(base_type, defaultdict): load_hook = hooks[defaultdict] return DefaultDictParser( @@ -479,6 +492,7 @@ def setup_default_loader(cls=LoadMixin): cls.register_load_hook(defaultdict, cls.load_to_defaultdict) cls.register_load_hook(dict, cls.load_to_dict) cls.register_load_hook(Decimal, cls.load_to_decimal) + cls.register_load_hook(Path, cls.load_to_path) # Dates and times cls.register_load_hook(datetime, cls.load_to_datetime) cls.register_load_hook(time, cls.load_to_time) diff --git a/dataclass_wizard/models.py b/dataclass_wizard/models.py index 77e2d307..05b2a96c 100644 --- a/dataclass_wizard/models.py +++ b/dataclass_wizard/models.py @@ -3,7 +3,7 @@ from dataclasses import MISSING, Field, _create_fn from datetime import date, datetime, time from typing import (cast, Collection, Callable, - Optional, List, Union, Type) + Optional, List, Union, Type, Generic) from .bases import META from .constants import PY310_OR_ABOVE @@ -15,9 +15,6 @@ # 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] - class Extras(PyTypedDict): """ @@ -205,7 +202,7 @@ class DateTimePattern(datetime, _PatternBase): __slots__ = () -class _PatternedDT: +class _PatternedDT(Generic[DT]): """ Base class for pattern matching using :meth:`datetime.strptime` when loading (de-serializing) a string to a date / time / datetime object. @@ -216,8 +213,8 @@ class _PatternedDT: __slots__ = ('cls', 'pattern') - def __init__(self, pattern: str, cls: DT_OR_NONE = None): - self.cls = cls + def __init__(self, pattern: str, cls: Optional[Type[DT]] = None): + self.cls: Optional[Type[DT]] = cls self.pattern = pattern def get_transform_func(self) -> Callable[[str], DT]: diff --git a/dataclass_wizard/parsers.py b/dataclass_wizard/parsers.py index e1087af0..23cbb599 100644 --- a/dataclass_wizard/parsers.py +++ b/dataclass_wizard/parsers.py @@ -27,7 +27,7 @@ from .models import _PatternedDT, Extras from .type_def import ( FrozenKeys, NoneType, DefFactory, - T, M, S, DD, LSQ, N, NT + T, M, S, DD, LSQ, N, NT, DT ) from .utils.typing_compat import ( get_origin, get_args, get_named_tuple_field_types, @@ -40,7 +40,7 @@ @dataclass -class IdentityParser(AbstractParser): +class IdentityParser(AbstractParser[Type[T], T]): __slots__ = () def __call__(self, o: Any) -> T: @@ -48,7 +48,7 @@ def __call__(self, o: Any) -> T: @dataclass -class SingleArgParser(AbstractParser): +class SingleArgParser(AbstractParser[Type[T], T]): __slots__ = ('hook', ) hook: Callable[[Any], T] @@ -63,7 +63,7 @@ def __call__(self, o: Any) -> T: @dataclass -class Parser(AbstractParser): +class Parser(AbstractParser[Type[T], T]): __slots__ = ('hook', ) hook: Callable[[Any, Type[T]], T] @@ -73,7 +73,7 @@ def __call__(self, o: Any) -> T: @dataclass -class LiteralParser(AbstractParser): +class LiteralParser(AbstractParser[Type[M], M]): __slots__ = ('value_to_type', ) base_type: Type[M] @@ -92,7 +92,7 @@ def __contains__(self, item) -> bool: """ return item in self.value_to_type.keys() - def __call__(self, o: Any): + def __call__(self, o: Any) -> M: """ Checks for Literal equivalence, as mentioned here: https://www.python.org/dev/peps/pep-0586/#equivalence-of-two-literals @@ -103,7 +103,7 @@ def __call__(self, o: Any): except KeyError: # No such Literal with the value of `o` - e = ValueError('Value not in expected Literal values') + e: Exception = ValueError('Value not in expected Literal values') raise ParseError( e, o, self.base_type, allowed_values=list(self.value_to_type)) @@ -127,7 +127,7 @@ def __call__(self, o: Any): @dataclass -class PatternedDTParser(AbstractParser): +class PatternedDTParser(AbstractParser[_PatternedDT, DT]): __slots__ = ('hook', ) base_type: _PatternedDT @@ -141,7 +141,7 @@ def __post_init__(self, _cls: Type, extras: Extras, *_): self.hook = self.base_type.get_transform_func() - def __call__(self, date_string: str): + def __call__(self, date_string: str) -> DT: try: return self.hook(date_string) except ValueError as e: @@ -152,7 +152,7 @@ def __call__(self, date_string: str): @dataclass -class OptionalParser(AbstractParser): +class OptionalParser(AbstractParser[Type[T], Optional[T]]): __slots__ = ('parser', ) get_parser: InitVar[GetParserType] @@ -170,7 +170,7 @@ def __contains__(self, item): return super().__contains__(item) - def __call__(self, o: Any): + def __call__(self, o: Any) -> Optional[T]: if o is None: return o @@ -178,7 +178,7 @@ def __call__(self, o: Any): @dataclass -class UnionParser(AbstractParser): +class UnionParser(AbstractParser[Tuple[Type[T], ...], Optional[T]]): __slots__ = ('parsers', 'tag_to_parser', 'tag_key') base_type: Tuple[Type[T], ...] @@ -229,7 +229,7 @@ def __contains__(self, item): """Check if parser is expected to handle the specified item type.""" return type(item) in self.base_type - def __call__(self, o: Any): + def __call__(self, o: Any) -> Optional[T]: if o is None: return o @@ -263,7 +263,7 @@ def __call__(self, o: Any): @dataclass -class IterableParser(AbstractParser): +class IterableParser(AbstractParser[Type[LSQ], LSQ]): """ Parser for a :class:`list`, :class:`set`, :class:`frozenset`, :class:`deque`, or a subclass of either type. @@ -312,7 +312,7 @@ def __call__(self, o: Iterable) -> LSQ: @dataclass -class TupleParser(AbstractParser): +class TupleParser(AbstractParser[Type[S], S]): """ Parser for subscripted and un-subscripted :class:`Tuple`'s. @@ -327,7 +327,7 @@ class TupleParser(AbstractParser): # Base type of the object which is instantiable # ex. `Tuple[bool, int]` -> `tuple` base_type: Type[S] - hook: Callable[[Any, Type[S], TupleOfParsers], S] + hook: Callable[[Any, Type[S], Optional[TupleOfParsers]], S] get_parser: InitVar[GetParserType] def __post_init__(self, cls: Type, @@ -339,21 +339,21 @@ def __post_init__(self, cls: Type, elem_types = get_args(self.base_type) self.base_type = get_origin(self.base_type) # A collection with a parser for each type argument - self.elem_parsers = tuple(get_parser(t, cls, extras) - for t in elem_types) + elem_parsers = tuple(get_parser(t, cls, extras) + for t in elem_types) # Total count is generally the number of type arguments to `Tuple`, but # can be `Infinity` when a `Tuple` appears in its un-subscripted form. - self.total_count: N = len(self.elem_parsers) or float('inf') + self.total_count: N = len(elem_parsers) or float('inf') # Minimum number of *required* type arguments # Check for the count of parsers which don't handle `NoneType` - # this should exclude the parsers for `Optional` or `Union` types # that have `None` in the list of args. - self.required_count: int = len(tuple(p for p in self.elem_parsers + self.required_count: int = len(tuple(p for p in elem_parsers if None not in p)) - if not self.elem_parsers: - self.elem_parsers = None - def __call__(self, o: M) -> M: + self.elem_parsers = elem_parsers or None + + def __call__(self, o: S) -> S: """ Load an object `o` into a new object of type `base_type` (generally a :class:`tuple` or a sub-class of one) @@ -365,10 +365,14 @@ def __call__(self, o: M) -> M: if self.required_count != self.total_count: desired_count = f'{self.required_count} - {self.total_count}' else: - desired_count = self.total_count + desired_count = str(self.total_count) + + # self.elem_parsers can be None at this moment + elem_parsers_types = [p.base_type for p in self.elem_parsers] \ + if self.elem_parsers else [] raise ParseError( - e, o, [p.base_type for p in self.elem_parsers], + e, o, elem_parsers_types, desired_count=desired_count, actual_count=len(o)) @@ -421,12 +425,12 @@ def __call__(self, o: M) -> M: @dataclass -class NamedTupleParser(AbstractParser): +class NamedTupleParser(AbstractParser[Type[NT], NT]): __slots__ = ('hook', 'field_to_parser', 'field_parsers') - base_type: Type[S] + base_type: Type[NT] hook: Callable[ [Any, Type[NT], Optional[FieldToParser], List[AbstractParser]], NT @@ -438,7 +442,7 @@ def __post_init__(self, cls: Type, get_parser: GetParserType): # Get the field annotations for the `NamedTuple` type - type_anns: Dict[str, Type[T]] = get_named_tuple_field_types( + type_anns: Dict[str, Type[Any]] = get_named_tuple_field_types( self.base_type ) @@ -449,7 +453,7 @@ def __post_init__(self, cls: Type, self.field_parsers = list(self.field_to_parser.values()) - def __call__(self, o: Any): + def __call__(self, o: Any) -> NT: """ Load a dictionary or list to a `NamedTuple` sub-class (or an un-annotated `namedtuple`) @@ -459,12 +463,12 @@ def __call__(self, o: Any): @dataclass -class NamedTupleUntypedParser(AbstractParser): +class NamedTupleUntypedParser(AbstractParser[Type[NT], NT]): __slots__ = ('hook', 'dict_parser', 'list_parser') - base_type: Type[S] + base_type: Type[NT] hook: Callable[[Any, Type[NT], AbstractParser, AbstractParser], NT] get_parser: InitVar[GetParserType] @@ -475,7 +479,7 @@ def __post_init__(self, cls: Type, self.dict_parser = get_parser(dict, cls, extras) self.list_parser = get_parser(list, cls, extras) - def __call__(self, o: Any): + def __call__(self, o: Any) -> NT: """ Load a dictionary or list to a `NamedTuple` sub-class (or an un-annotated `namedtuple`) @@ -485,7 +489,7 @@ def __call__(self, o: Any): @dataclass -class MappingParser(AbstractParser): +class MappingParser(AbstractParser[Type[M], M]): __slots__ = ('hook', 'key_parser', 'val_parser') @@ -514,7 +518,7 @@ def __call__(self, o: M) -> M: @dataclass -class DefaultDictParser(MappingParser): +class DefaultDictParser(MappingParser[DD]): __slots__ = ('default_factory', ) # Override the type annotations here @@ -530,19 +534,19 @@ def __post_init__(self, cls: Type, # The default factory argument to pass to the `defaultdict` subclass self.default_factory: DefFactory = self.val_parser.base_type - def __call__(self, o: M) -> M: + def __call__(self, o: DD) -> DD: return self.hook(o, self.base_type, self.default_factory, self.key_parser, self.val_parser) @dataclass -class TypedDictParser(AbstractParser): +class TypedDictParser(AbstractParser[Type[M], M]): __slots__ = ('hook', 'key_to_parser', 'required_keys', 'optional_keys') - base_type: Type[S] + base_type: Type[M] hook: Callable[[Any, Type[M], FieldToParser, FrozenKeys, FrozenKeys], M] get_parser: InitVar[GetParserType] @@ -565,13 +569,13 @@ def __call__(self, o: M) -> M: self.required_keys, self.optional_keys) except KeyError as e: - e = KeyError(f'Missing required key: {e.args[0]}') - raise ParseError(e, o, self.base_type) + err: Exception = KeyError(f'Missing required key: {e.args[0]}') + raise ParseError(err, o, self.base_type) except Exception: if not isinstance(o, dict): - e = TypeError('Incorrect type for object') + err = TypeError('Incorrect type for object') raise ParseError( - e, o, self.base_type, desired_type=self.base_type) + err, o, self.base_type, desired_type=self.base_type) else: raise diff --git a/dataclass_wizard/type_def.py b/dataclass_wizard/type_def.py index 03148880..0fab84ed 100644 --- a/dataclass_wizard/type_def.py +++ b/dataclass_wizard/type_def.py @@ -5,6 +5,8 @@ 'PyDeque', 'PyTypedDict', 'PyTypedDicts', + 'PyRequired', + 'PyNotRequired', 'FrozenKeys', 'DefFactory', 'NoneType', @@ -42,7 +44,7 @@ ) from uuid import UUID -from .constants import PY36, PY38_OR_ABOVE +from .constants import PY36, PY38_OR_ABOVE, PY311_OR_ABOVE from .decorators import discard_kwargs @@ -52,6 +54,7 @@ # Generic type T = TypeVar('T') +TT = TypeVar('TT') # Enum subclass type E = TypeVar('E', bound=Enum) @@ -72,7 +75,7 @@ DD = TypeVar('DD', bound=DefaultDict) # Numeric type -N = TypeVar('N', int, float, complex) +N = Union[int, float] # Sequence type S = TypeVar('S', bound=Sequence) @@ -128,10 +131,23 @@ PyTypedDicts.append(PyTypedDict) except ImportError: pass + + # Python 3.11 introduced `Required` and `NotRequired` wrappers for + # `TypedDict` fields (PEP 655). Python 3.8+ users can import the + # wrappers from `typing_extensions`. + if PY311_OR_ABOVE: + from typing import Required as PyRequired + from typing import NotRequired as PyNotRequired + else: + from typing_extensions import Required as PyRequired + from typing_extensions import NotRequired as PyNotRequired + else: # pragma: no cover from typing_extensions import Literal as PyLiteral from typing_extensions import Protocol as PyProtocol from typing_extensions import TypedDict as PyTypedDict + from typing_extensions import Required as PyRequired + from typing_extensions import NotRequired as PyNotRequired # Seems like `Deque` was only introduced to `typing` in 3.6.1, so Python # 3.6.0 won't have it; to be safe, we'll instead import from the # `typing_extensions` module here. diff --git a/dataclass_wizard/utils/typing_compat.py b/dataclass_wizard/utils/typing_compat.py index dc8ab67f..f2f0f281 100644 --- a/dataclass_wizard/utils/typing_compat.py +++ b/dataclass_wizard/utils/typing_compat.py @@ -376,7 +376,7 @@ def eval_forward_ref(base_type: FREF, return typing._eval_type(base_type, base_globals, _TYPING_LOCALS) -def eval_forward_ref_if_needed(base_type: FREF, +def eval_forward_ref_if_needed(base_type: typing.Union[typing.Type, FREF], base_cls: typing.Type): """ If needed, evaluate a forward reference using the class globals, and diff --git a/setup.cfg b/setup.cfg index d195bdee..c6f36ec0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.22.3 +current_version = 0.25.0 commit = True tag = True diff --git a/tests/conftest.py b/tests/conftest.py index 830ead76..0df75146 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,13 +2,18 @@ 'does_not_raise', 'data_file_path', 'PY36', + 'PY38', 'PY39_OR_ABOVE', 'PY310_OR_ABOVE', + 'PY311_OR_ABOVE', # For compatibility with Python 3.6 and 3.7 'Literal', 'TypedDict', 'Annotated', - 'Deque' + 'Deque', + # For compatibility with Python 3.6 through 3.10 + 'Required', + 'NotRequired' ] import sys @@ -21,12 +26,22 @@ # Check if we are running Python 3.6 PY36 = sys.version_info[:2] == (3, 6) +# Check if we are running Python 3.8 +PY38 = sys.version_info[:2] == (3, 8) + # Check if we are running Python 3.9+ PY39_OR_ABOVE = sys.version_info[:2] >= (3, 9) +# Check if we are running Python 3.9 or 3.10 +PY39 = sys.version_info[:2] == (3, 9) +PY310 = sys.version_info[:2] == (3, 10) + # Check if we are running Python 3.10+ PY310_OR_ABOVE = sys.version_info[:2] >= (3, 10) +# Check if we are running Python 3.11+ +PY311_OR_ABOVE = sys.version_info[:2] >= (3, 11) + # Ref: https://docs.pytest.org/en/6.2.x/example/parametrize.html#parametrizing-conditional-raising if sys.version_info[:2] >= (3, 7): from contextlib import nullcontext as does_not_raise @@ -48,12 +63,25 @@ from typing_extensions import Literal from typing_extensions import TypedDict +# Weird, test cases for `TypedDict` fail in Python 3.9 & 3.10.15 (3.10:latest) +# So apparently, we need to use the one from `typing_extensions`. +if PY39 or PY310: + from typing_extensions import TypedDict + # typing.Annotated: Introduced in Python 3.9 if PY39_OR_ABOVE: from typing import Annotated else: from typing_extensions import Annotated +# typing.Required and typing.NotRequired: Introduced in Python 3.11 +if PY311_OR_ABOVE: + from typing import Required + from typing import NotRequired +else: + from typing_extensions import Required + from typing_extensions import NotRequired + def data_file_path(name: str) -> str: """Returns the full path to a test file.""" diff --git a/tests/unit/test_bases_meta.py b/tests/unit/test_bases_meta.py index bf9d8f06..18400fd1 100644 --- a/tests/unit/test_bases_meta.py +++ b/tests/unit/test_bases_meta.py @@ -8,7 +8,7 @@ from pytest_mock import MockerFixture from dataclass_wizard import JSONWizard -from dataclass_wizard.bases import M +from dataclass_wizard.bases import META from dataclass_wizard.bases_meta import BaseJSONWizardMeta from dataclass_wizard.enums import LetterCase, DateTimeTo from dataclass_wizard.errors import ParseError @@ -56,7 +56,7 @@ class B(BaseJSONWizardMeta): json_key_to_field = {'k2': 'v2'} # Merge the two Meta config together - merged_meta: M = A | B + merged_meta: META = A | B # Assert we are a subclass of A, which subclasses from `BaseJSONWizardMeta` assert issubclass(merged_meta, BaseJSONWizardMeta) @@ -99,7 +99,7 @@ class B(BaseJSONWizardMeta): json_key_to_field = {'k2': 'v2'} # Merge the two Meta config together - merged_meta: M = A & B + merged_meta: META = A & B # Assert we are a subclass of A, which subclasses from `BaseJSONWizardMeta` assert issubclass(merged_meta, BaseJSONWizardMeta) diff --git a/tests/unit/test_load.py b/tests/unit/test_load.py index d1af6c1c..9703fd5b 100644 --- a/tests/unit/test_load.py +++ b/tests/unit/test_load.py @@ -28,7 +28,6 @@ from .conftest import MyUUIDSubclass from ..conftest import * - log = logging.getLogger(__name__) @@ -1403,7 +1402,7 @@ class MyClass(JSONSerializable): ) ] ) -def test_typed_dict_with_optional_fields(input, expectation, expected): +def test_typed_dict_with_all_fields_optional(input, expectation, expected): """ Test case for loading to a TypedDict which has `total=False`, indicating that all fields are optional. @@ -1427,6 +1426,130 @@ class MyClass(JSONSerializable): assert result.my_typed_dict == expected +@pytest.mark.skipif(PY36 or PY38, reason='requires Python 3.7 or higher') +@pytest.mark.parametrize( + 'input,expectation,expected', + [ + ( + {}, pytest.raises(ParseError), None + ), + ( + {'key': 'value'}, pytest.raises(ParseError), {} + ), + ( + {'my_str': 'test', 'my_int': 2, + 'my_bool': True, 'other_key': 'testing'}, does_not_raise(), + {'my_str': 'test', 'my_int': 2, 'my_bool': True} + ), + ( + {'my_str': 3}, pytest.raises(ParseError), None + ), + ( + {'my_str': 'test', 'my_int': 'test', 'my_bool': True}, + pytest.raises(ValueError), None, + ), + ( + {'my_str': 'test', 'my_int': 2, 'my_bool': True}, + does_not_raise(), + {'my_str': 'test', 'my_int': 2, 'my_bool': True} + ), + ( + {'my_str': 'test', 'my_bool': True}, + does_not_raise(), + {'my_str': 'test', 'my_bool': True} + ), + ( + # Incorrect type - `list`, but should be a `dict` + [{'my_str': 'test', 'my_int': 2, 'my_bool': True}], + pytest.raises(ParseError), None + ) + ] +) +def test_typed_dict_with_one_field_not_required(input, expectation, expected): + """ + Test case for loading to a TypedDict whose fields are all mandatory + except for one field, whose annotated type is NotRequired. + + """ + class MyDict(TypedDict): + my_str: str + my_bool: bool + my_int: NotRequired[int] + + @dataclass + class MyClass(JSONSerializable): + my_typed_dict: MyDict + + d = {'myTypedDict': input} + + with expectation: + result = MyClass.from_dict(d) + + log.debug('Parsed object: %r', result) + assert result.my_typed_dict == expected + + +@pytest.mark.skipif(PY36 or PY38, reason='requires Python 3.9 or higher') +@pytest.mark.parametrize( + 'input,expectation,expected', + [ + ( + {}, pytest.raises(ParseError), None + ), + ( + {'my_int': 2}, does_not_raise(), {'my_int': 2} + ), + ( + {'key': 'value'}, pytest.raises(ParseError), None + ), + ( + {'key': 'value', 'my_int': 2}, does_not_raise(), + {'my_int': 2} + ), + ( + {'my_str': 'test', 'my_int': 2, + 'my_bool': True, 'other_key': 'testing'}, does_not_raise(), + {'my_str': 'test', 'my_int': 2, 'my_bool': True} + ), + ( + {'my_str': 3}, pytest.raises(ParseError), None + ), + ( + {'my_str': 'test', 'my_int': 'test', 'my_bool': True}, + pytest.raises(ValueError), + {'my_str': 'test', 'my_int': 'test', 'my_bool': True} + ), + ( + {'my_str': 'test', 'my_int': 2, 'my_bool': True}, + does_not_raise(), + {'my_str': 'test', 'my_int': 2, 'my_bool': True} + ) + ] +) +def test_typed_dict_with_one_field_required(input, expectation, expected): + """ + Test case for loading to a TypedDict whose fields are all optional + except for one field, whose annotated type is Required. + + """ + class MyDict(TypedDict, total=False): + my_str: str + my_bool: bool + my_int: Required[int] + + @dataclass + class MyClass(JSONSerializable): + my_typed_dict: MyDict + + d = {'myTypedDict': input} + + with expectation: + result = MyClass.from_dict(d) + + log.debug('Parsed object: %r', result) + assert result.my_typed_dict == expected + + @pytest.mark.parametrize( 'input,expectation,expected', [