From c13d7cbf833c402aa74efa32d8dc38dccec10dcd Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Wed, 18 Sep 2024 07:28:29 -0400 Subject: [PATCH 01/10] Update HISTORY.rst --- HISTORY.rst | 13 +++++++++++++ dataclass_wizard/enums.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index e078c85c..5ba9e2aa 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,6 +2,19 @@ History ======= +0.23.0 (2024-09-18) +------------------- + +* `#94`_: Allows the ability to define keys in JSON/dataclass + that do not undergo transformation -- credits to `@cquick01`_. + + * ``LetterCase.NONE`` - Performs no conversion on strings. + + * ex: `MY_FIELD_NAME` -> `MY_FIELD_NAME` + +.. _@cquick01: https://github.com/cquick01 +.. _#94: https://github.com/rnag/dataclass-wizard/pull/94 + 0.22.3 (2024-01-29) ------------------- 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) From 47a9e548feccfc24a276f7e190d681231c97bebc Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Wed, 18 Sep 2024 07:29:37 -0400 Subject: [PATCH 02/10] =?UTF-8?q?Bump=20version:=200.22.3=20=E2=86=92=200.?= =?UTF-8?q?23.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dataclass_wizard/__version__.py | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dataclass_wizard/__version__.py b/dataclass_wizard/__version__.py index e56e887a..e4c87495 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.23.0' __author__ = 'Ritvik Nag' __author_email__ = 'rv.kvetch@gmail.com' __license__ = 'Apache 2.0' diff --git a/setup.cfg b/setup.cfg index d195bdee..11e68b29 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.22.3 +current_version = 0.23.0 commit = True tag = True From fcd412d647043d5e760dd528c70fa06962955d61 Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Mon, 4 Nov 2024 03:27:51 +0100 Subject: [PATCH 03/10] feat: add support for typing.Required, NotRequired (#125) Previously, annotating a `TypedDict` field with one of the `Required` and `NotRequired` wrappers introduced in Python 3.11, dataclass-wizard would raise the following error: > TypeError: issubclass() arg 1 must be a class Fix that error by adding support for `Required` and `NotRequired`. Partially addresses issue #121. [1] [1]: https://github.com/rnag/dataclass-wizard/issues/121 --- dataclass_wizard/constants.py | 3 + dataclass_wizard/loaders.py | 7 ++ dataclass_wizard/type_def.py | 17 ++++- tests/conftest.py | 17 ++++- tests/unit/test_load.py | 126 +++++++++++++++++++++++++++++++++- 5 files changed, 167 insertions(+), 3 deletions(-) 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/loaders.py b/dataclass_wizard/loaders.py index e1bcc360..9683f685 100644 --- a/dataclass_wizard/loaders.py +++ b/dataclass_wizard/loaders.py @@ -25,6 +25,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 @@ -360,6 +361,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( diff --git a/dataclass_wizard/type_def.py b/dataclass_wizard/type_def.py index 03148880..fcafa420 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 @@ -128,10 +130,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/tests/conftest.py b/tests/conftest.py index 830ead76..3fc69df0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,11 +4,15 @@ 'PY36', '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 @@ -27,6 +31,9 @@ # 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 @@ -54,6 +61,14 @@ 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_load.py b/tests/unit/test_load.py index d1af6c1c..8f2dc1fc 100644 --- a/tests/unit/test_load.py +++ b/tests/unit/test_load.py @@ -1403,7 +1403,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 +1427,130 @@ class MyClass(JSONSerializable): assert result.my_typed_dict == expected +@pytest.mark.skipif(PY36, 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, reason='requires Python 3.7 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', [ From 595f1bb20f4a1efb7a61bb15058ec684af18917d Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Sun, 3 Nov 2024 22:41:58 -0500 Subject: [PATCH 04/10] Prep for v0.24.0 (#132) * Update HISTORY.rst * hope this fixes tests * hope this fixes tests * getting tired of this * gotta fix * gotta fix --- HISTORY.rst | 19 ++++++++++++++----- tests/conftest.py | 13 +++++++++++++ tests/unit/test_load.py | 5 ++--- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 5ba9e2aa..df563478 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,19 +2,28 @@ History ======= +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) ------------------- -* `#94`_: Allows the ability to define keys in JSON/dataclass - that do not undergo transformation -- credits to `@cquick01`_. +* :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` -.. _@cquick01: https://github.com/cquick01 -.. _#94: https://github.com/rnag/dataclass-wizard/pull/94 - 0.22.3 (2024-01-29) ------------------- diff --git a/tests/conftest.py b/tests/conftest.py index 3fc69df0..0df75146 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ 'does_not_raise', 'data_file_path', 'PY36', + 'PY38', 'PY39_OR_ABOVE', 'PY310_OR_ABOVE', 'PY311_OR_ABOVE', @@ -25,9 +26,16 @@ # 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) @@ -55,6 +63,11 @@ 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 diff --git a/tests/unit/test_load.py b/tests/unit/test_load.py index 8f2dc1fc..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__) @@ -1427,7 +1426,7 @@ class MyClass(JSONSerializable): assert result.my_typed_dict == expected -@pytest.mark.skipif(PY36, reason='requires Python 3.7 or higher') +@pytest.mark.skipif(PY36 or PY38, reason='requires Python 3.7 or higher') @pytest.mark.parametrize( 'input,expectation,expected', [ @@ -1490,7 +1489,7 @@ class MyClass(JSONSerializable): assert result.my_typed_dict == expected -@pytest.mark.skipif(PY36, reason='requires Python 3.7 or higher') +@pytest.mark.skipif(PY36 or PY38, reason='requires Python 3.9 or higher') @pytest.mark.parametrize( 'input,expectation,expected', [ From f279ba9b84ff2e9baf1ffe05e15de180be12eaf9 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Sun, 3 Nov 2024 22:43:18 -0500 Subject: [PATCH 05/10] =?UTF-8?q?Bump=20version:=200.23.0=20=E2=86=92=200.?= =?UTF-8?q?24.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dataclass_wizard/__version__.py | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dataclass_wizard/__version__.py b/dataclass_wizard/__version__.py index e4c87495..7dd6b54a 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.23.0' +__version__ = '0.24.0' __author__ = 'Ritvik Nag' __author_email__ = 'rv.kvetch@gmail.com' __license__ = 'Apache 2.0' diff --git a/setup.cfg b/setup.cfg index 11e68b29..fd2cb1ff 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.23.0 +current_version = 0.24.0 commit = True tag = True From aaa2cf66aa18de2372597c427ad7db95eb47e596 Mon Sep 17 00:00:00 2001 From: AdiNar Date: Mon, 4 Nov 2024 04:55:17 +0100 Subject: [PATCH 06/10] Fix mypy type errors (#64) * Rename Meta type from M to META From the code it seems that the M type should not be used (as it may be mistaken with one for Mappings). I've renamed it to META to avoid problems. Also, mypy complains about reassigning the same type variable, so there are META_ and META now. * Fix tuples types Tuple[Field] describes only one element tuple. Tuple[Field, ...] describes tuple of any length. * Change numeric type from constrained generic to Union * `N` type is used only to assert the returned variable type. There is no relationship that would justify it being the TypeVar, which has to be listed in the class definiton. Union type suits better. * `complex` type was removed from the type as it's never used in the project. It even causes problems during comparisons, as it's imcomparable with real numbers. * Fix mypy issues in parsers.py * Add generic types to Parsers Generic types were used in the class body, but they also have to be specified in the class definition. * There is one error left - in the type of `DefaultDictParser` hook. It differs from what was defined in `MappingParser` and is seems that it breaks Liskov principle. I don't know how to fix that without some big changes in code. --- dataclass_wizard/abstractions.py | 10 +-- dataclass_wizard/bases.py | 12 ++-- dataclass_wizard/bases_meta.py | 6 +- dataclass_wizard/class_helper.py | 16 ++--- dataclass_wizard/errors.py | 2 +- dataclass_wizard/models.py | 11 ++-- dataclass_wizard/parsers.py | 86 +++++++++++++------------ dataclass_wizard/type_def.py | 3 +- dataclass_wizard/utils/typing_compat.py | 2 +- tests/unit/test_bases_meta.py | 6 +- 10 files changed, 78 insertions(+), 76 deletions(-) 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/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/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 e3c810ef..f7c338cd 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] @@ -84,7 +84,7 @@ def __post_init__(self, *_): val: type(val) for val in get_args(self.base_type) } - 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 @@ -95,7 +95,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)) @@ -119,7 +119,7 @@ def __call__(self, o: Any): @dataclass -class PatternedDTParser(AbstractParser): +class PatternedDTParser(AbstractParser[_PatternedDT, DT]): __slots__ = ('hook', ) base_type: _PatternedDT @@ -133,7 +133,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: @@ -144,7 +144,7 @@ def __call__(self, date_string: str): @dataclass -class OptionalParser(AbstractParser): +class OptionalParser(AbstractParser[Type[T], Optional[T]]): __slots__ = ('parser', ) get_parser: InitVar[GetParserType] @@ -162,7 +162,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 @@ -170,7 +170,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], ...] @@ -221,7 +221,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 @@ -255,7 +255,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. @@ -304,7 +304,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. @@ -319,7 +319,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, @@ -331,21 +331,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) @@ -357,10 +357,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)) @@ -413,12 +417,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 @@ -430,7 +434,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 ) @@ -441,7 +445,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`) @@ -451,12 +455,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] @@ -467,7 +471,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`) @@ -477,7 +481,7 @@ def __call__(self, o: Any): @dataclass -class MappingParser(AbstractParser): +class MappingParser(AbstractParser[Type[M], M]): __slots__ = ('hook', 'key_parser', 'val_parser') @@ -506,7 +510,7 @@ def __call__(self, o: M) -> M: @dataclass -class DefaultDictParser(MappingParser): +class DefaultDictParser(MappingParser[DD]): __slots__ = ('default_factory', ) # Override the type annotations here @@ -522,19 +526,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] @@ -557,13 +561,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 fcafa420..0fab84ed 100644 --- a/dataclass_wizard/type_def.py +++ b/dataclass_wizard/type_def.py @@ -54,6 +54,7 @@ # Generic type T = TypeVar('T') +TT = TypeVar('TT') # Enum subclass type E = TypeVar('E', bound=Enum) @@ -74,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) 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/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) From c14d3c1b71b36e9bdca31fb0e69b6d77b6efbebd Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Sun, 3 Nov 2024 23:02:40 -0500 Subject: [PATCH 07/10] fix `mypy` typing issues (#133) --- HISTORY.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/HISTORY.rst b/HISTORY.rst index df563478..e06e4264 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,6 +2,11 @@ History ======= +0.24.1 (2024-11-03) +------------------- + +* Resolve ``mypy`` typing issues. Thanks to :user:`AdiNar` in :pr:`64`. + 0.24.0 (2024-11-03) ------------------- From 8c8df28b684801550c79b8c0cc076fba534b322a Mon Sep 17 00:00:00 2001 From: Assaf Ge Date: Mon, 4 Nov 2024 06:25:34 +0200 Subject: [PATCH 08/10] added load hook for pathlib.Path (#79) --- dataclass_wizard/loaders.py | 7 +++++++ testing.json | 1 + 2 files changed, 8 insertions(+) create mode 100644 testing.json diff --git a/dataclass_wizard/loaders.py b/dataclass_wizard/loaders.py index 9683f685..26371424 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 @@ -207,6 +208,11 @@ def load_to_typed_dict( 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) @@ -486,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/testing.json b/testing.json new file mode 100644 index 00000000..9082d25e --- /dev/null +++ b/testing.json @@ -0,0 +1 @@ +{"key": "value"} \ No newline at end of file From f91eadc24cc83cc005008d4151c1a01c241521a1 Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Sun, 3 Nov 2024 23:36:22 -0500 Subject: [PATCH 09/10] add support for `pathlib.Path` --- HISTORY.rst | 9 +++++++++ dataclass_wizard/loaders.py | 2 +- testing.json | 1 - 3 files changed, 10 insertions(+), 2 deletions(-) delete mode 100644 testing.json diff --git a/HISTORY.rst b/HISTORY.rst index e06e4264..077ff49a 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,6 +2,15 @@ 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) ------------------- diff --git a/dataclass_wizard/loaders.py b/dataclass_wizard/loaders.py index 26371424..3407d45f 100644 --- a/dataclass_wizard/loaders.py +++ b/dataclass_wizard/loaders.py @@ -208,7 +208,7 @@ def load_to_typed_dict( 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: diff --git a/testing.json b/testing.json deleted file mode 100644 index 9082d25e..00000000 --- a/testing.json +++ /dev/null @@ -1 +0,0 @@ -{"key": "value"} \ No newline at end of file From e7a56014cd5e4d514dfc015729ec47046a90296e Mon Sep 17 00:00:00 2001 From: Ritvik Nag Date: Sun, 3 Nov 2024 23:37:04 -0500 Subject: [PATCH 10/10] =?UTF-8?q?Bump=20version:=200.24.0=20=E2=86=92=200.?= =?UTF-8?q?25.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dataclass_wizard/__version__.py | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dataclass_wizard/__version__.py b/dataclass_wizard/__version__.py index 7dd6b54a..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.24.0' +__version__ = '0.25.0' __author__ = 'Ritvik Nag' __author_email__ = 'rv.kvetch@gmail.com' __license__ = 'Apache 2.0' diff --git a/setup.cfg b/setup.cfg index fd2cb1ff..c6f36ec0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.24.0 +current_version = 0.25.0 commit = True tag = True