From ad1611c02e9d8dbae7b688883571f2262c5a1f73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sat, 18 Nov 2023 01:23:56 +0100 Subject: [PATCH 01/11] Improve TypedDict coverage --- src/cattrs/_compat.py | 11 +++++++--- src/cattrs/gen/typeddicts.py | 14 ++++++++----- tests/typeddicts.py | 39 ++++++++++++++++++++++++++++++++++-- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index 428734d7..4d01dd41 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -6,7 +6,7 @@ from dataclasses import fields as dataclass_fields from dataclasses import is_dataclass from typing import AbstractSet as TypingAbstractSet -from typing import Any, Deque, Dict, Final, FrozenSet, List +from typing import Any, Deque, Dict, Final, FrozenSet, List, Literal from typing import Mapping as TypingMapping from typing import MutableMapping as TypingMutableMapping from typing import MutableSequence as TypingMutableSequence @@ -243,6 +243,9 @@ def get_newtype_base(typ: Any) -> Optional[type]: return None def get_notrequired_base(type) -> "Union[Any, Literal[NOTHING]]": + if is_annotated(type): + # Handle `Annotated[NotRequired[int]]` + type = get_args(type)[0] if get_origin(type) in (NotRequired, Required): return get_args(type)[0] return NOTHING @@ -438,8 +441,6 @@ def is_counter(type): or getattr(type, "__origin__", None) is ColCounter ) - from typing import Literal - def is_literal(type) -> bool: return type.__class__ is _GenericAlias and type.__origin__ is Literal @@ -453,6 +454,10 @@ def copy_with(type, args): return type.copy_with(args) def get_notrequired_base(type) -> "Union[Any, Literal[NOTHING]]": + if is_annotated(type): + # Handle `Annotated[NotRequired[int]]` + type = get_origin(type) + if get_origin(type) in (NotRequired, Required): return get_args(type)[0] return NOTHING diff --git a/src/cattrs/gen/typeddicts.py b/src/cattrs/gen/typeddicts.py index ed02249d..c7a89a91 100644 --- a/src/cattrs/gen/typeddicts.py +++ b/src/cattrs/gen/typeddicts.py @@ -568,6 +568,7 @@ def _required_keys(cls: type) -> set[str]: from typing_extensions import Annotated, NotRequired, Required, get_args def _required_keys(cls: type) -> set[str]: + """Own own processor for required keys.""" if _is_extensions_typeddict(cls): return cls.__required_keys__ @@ -600,6 +601,7 @@ def _required_keys(cls: type) -> set[str]: # On 3.8, typing.TypedDicts do not have __required_keys__. def _required_keys(cls: type) -> set[str]: + """Own own processor for required keys.""" if _is_extensions_typeddict(cls): return cls.__required_keys__ @@ -613,12 +615,14 @@ def _required_keys(cls: type) -> set[str]: if key in superclass_keys: continue annotation_type = own_annotations[key] + + if is_annotated(annotation_type): + # If this is `Annotated`, we need to get the origin twice. + annotation_type = get_origin(annotation_type) + annotation_origin = get_origin(annotation_type) - if annotation_origin is Annotated: - annotation_args = get_args(annotation_type) - if annotation_args: - annotation_type = annotation_args[0] - annotation_origin = get_origin(annotation_type) + print(annotation_type) + print(annotation_origin) if annotation_origin is Required: required_keys.add(key) diff --git a/tests/typeddicts.py b/tests/typeddicts.py index 4f7804d4..df38b2af 100644 --- a/tests/typeddicts.py +++ b/tests/typeddicts.py @@ -3,7 +3,7 @@ from string import ascii_lowercase from typing import Any, Dict, Generic, List, Optional, Set, Tuple, TypeVar -from attr import NOTHING +from attrs import NOTHING from hypothesis.strategies import ( DrawFn, SearchStrategy, @@ -17,7 +17,13 @@ text, ) -from cattrs._compat import ExtensionsTypedDict, NotRequired, Required, TypedDict +from cattrs._compat import ( + Annotated, + ExtensionsTypedDict, + NotRequired, + Required, + TypedDict, +) from .untyped import gen_attr_names @@ -55,6 +61,34 @@ def int_attributes( return int, integers() | just(NOTHING), text(ascii_lowercase) +@composite +def annotated_int_attributes( + draw: DrawFn, total: bool = True, not_required: bool = False +) -> Tuple[int, SearchStrategy, SearchStrategy]: + """Generate combinations of Annotated types.""" + if total: + if not_required and draw(booleans()): + return ( + NotRequired[Annotated[int, "test"]] + if draw(booleans()) + else Annotated[NotRequired[int], "test"], + integers() | just(NOTHING), + text(ascii_lowercase), + ) + return Annotated[int, "test"], integers(), text(ascii_lowercase) + + if not_required and draw(booleans()): + return ( + Required[Annotated[int, "test"]] + if draw(booleans()) + else Annotated[Required[int], "test"], + integers(), + text(ascii_lowercase), + ) + + return Annotated[int, "test"], integers() | just(NOTHING), text(ascii_lowercase) + + @composite def datetime_attributes( draw: DrawFn, total: bool = True, not_required: bool = False @@ -120,6 +154,7 @@ def simple_typeddicts( attrs = draw( lists( int_attributes(total, not_required) + | annotated_int_attributes(total, not_required) | list_of_int_attributes(total, not_required) | datetime_attributes(total, not_required), min_size=min_attrs, From a39fe5c109d4afa7a831371c1bd442a9f149cb24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sat, 18 Nov 2023 02:41:06 +0100 Subject: [PATCH 02/11] Remove print --- src/cattrs/gen/typeddicts.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/cattrs/gen/typeddicts.py b/src/cattrs/gen/typeddicts.py index c7a89a91..d00d5bc1 100644 --- a/src/cattrs/gen/typeddicts.py +++ b/src/cattrs/gen/typeddicts.py @@ -621,8 +621,6 @@ def _required_keys(cls: type) -> set[str]: annotation_type = get_origin(annotation_type) annotation_origin = get_origin(annotation_type) - print(annotation_type) - print(annotation_origin) if annotation_origin is Required: required_keys.add(key) From 060ac71a69b43c490bb2b2337f60c2be0588f211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sat, 18 Nov 2023 02:42:44 +0100 Subject: [PATCH 03/11] HISTORY --- HISTORY.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/HISTORY.md b/HISTORY.md index b772db02..ebcf92ba 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,10 @@ # History +## 24.1.0 (UNRELEASED) + +- More robust support for `Annotated` and `NotRequired` in TypedDicts. + ([#450](https://github.com/python-attrs/cattrs/pull/450)) + ## 23.2.1 (2023-11-18) - Fix unnecessary `typing_extensions` import on Python 3.11. From 5ad1ef2e7ad74e42e5f4c882489036fd758c3a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sat, 18 Nov 2023 19:45:27 +0100 Subject: [PATCH 04/11] Improve typeddict tests --- src/cattrs/gen/typeddicts.py | 1 + tests/test_typeddicts.py | 25 +++++++++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/cattrs/gen/typeddicts.py b/src/cattrs/gen/typeddicts.py index d00d5bc1..cc234dad 100644 --- a/src/cattrs/gen/typeddicts.py +++ b/src/cattrs/gen/typeddicts.py @@ -133,6 +133,7 @@ def make_dict_unstructure_fn( if t.__name__ in mapping: t = mapping[t.__name__] else: + # Unbound typevars use late binding. handler = converter.unstructure elif is_generic(t) and not is_bare(t) and not is_annotated(t): t = deep_copy_with(t, mapping) diff --git a/tests/test_typeddicts.py b/tests/test_typeddicts.py index f805945b..6655cfef 100644 --- a/tests/test_typeddicts.py +++ b/tests/test_typeddicts.py @@ -1,6 +1,6 @@ """Tests for TypedDict un/structuring.""" from datetime import datetime -from typing import Dict, Set, Tuple +from typing import Dict, Generic, Set, Tuple, TypedDict, TypeVar import pytest from hypothesis import assume, given @@ -9,7 +9,11 @@ from cattrs import BaseConverter, Converter from cattrs._compat import ExtensionsTypedDict, is_generic -from cattrs.errors import ClassValidationError, ForbiddenExtraKeysError +from cattrs.errors import ( + ClassValidationError, + ForbiddenExtraKeysError, + StructureHandlerNotFoundError, +) from cattrs.gen import already_generating, override from cattrs.gen._generics import generate_mapping from cattrs.gen.typeddicts import ( @@ -168,6 +172,23 @@ def test_generics( assert restructured == instance +@given(booleans()) +def test_generics_with_unbound(detailed_validation: bool): + """TypedDicts with unbound TypeVars work.""" + c = mk_converter(detailed_validation=detailed_validation) + + T = TypeVar("T") + + class GenericTypedDict(TypedDict, Generic[T]): + a: T + + assert c.unstructure({"a": 1}, GenericTypedDict) + + with pytest.raises(StructureHandlerNotFoundError): + # This doesn't work since we refuse the temptation to guess. + c.structure({"a": 1}, GenericTypedDict) + + @given(simple_typeddicts(total=True, not_required=True), booleans()) def test_not_required( cls_and_instance: Tuple[type, Dict], detailed_validation: bool From cda71ced4276395366ccf193932e2218a2318b5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sat, 18 Nov 2023 19:46:23 +0100 Subject: [PATCH 05/11] Test is 3.11+ --- tests/test_typeddicts.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_typeddicts.py b/tests/test_typeddicts.py index 6655cfef..9dad933f 100644 --- a/tests/test_typeddicts.py +++ b/tests/test_typeddicts.py @@ -172,6 +172,7 @@ def test_generics( assert restructured == instance +@pytest.mark.skipif(not is_py311_plus, reason="3.11+ only") @given(booleans()) def test_generics_with_unbound(detailed_validation: bool): """TypedDicts with unbound TypeVars work.""" From dceca77ca12e232cb1a97369ad1935788b00f22e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sat, 18 Nov 2023 19:52:38 +0100 Subject: [PATCH 06/11] Remove some dead code --- src/cattrs/converters.py | 16 ++++++++++++++-- src/cattrs/gen/typeddicts.py | 23 ++++++++++------------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 3ba1ecad..d36e4f6f 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -55,7 +55,13 @@ is_union_type, ) from .disambiguators import create_default_dis_func, is_supported_union -from .dispatch import HookFactory, MultiStrategyDispatch, StructureHook, UnstructureHook +from .dispatch import ( + HookFactory, + MultiStrategyDispatch, + StructureHook, + UnstructuredValue, + UnstructureHook, +) from .errors import ( IterableValidationError, IterableValidationNote, @@ -327,7 +333,7 @@ def register_structure_hook_factory( """ self._structure_func.register_func_list([(predicate, factory, True)]) - def structure(self, obj: Any, cl: Type[T]) -> T: + def structure(self, obj: UnstructuredValue, cl: Type[T]) -> T: """Convert unstructured Python data structures to structured data.""" return self._structure_func.dispatch(cl)(obj, cl) @@ -1093,4 +1099,10 @@ def copy( return res +GenConverter = Converter + self._structure_func.copy_to(res._structure_func, skip=self._struct_copy_skip) + + return res + + GenConverter = Converter diff --git a/src/cattrs/gen/typeddicts.py b/src/cattrs/gen/typeddicts.py index cc234dad..6286cba2 100644 --- a/src/cattrs/gen/typeddicts.py +++ b/src/cattrs/gen/typeddicts.py @@ -488,20 +488,17 @@ def make_dict_structure_fn( kn = an if override.rename is None else override.rename allowed_fields.add(kn) post_lines.append(f" if '{kn}' in o:") - if handler: - if handler == converter._structure_call: - internal_arg_parts[struct_handler_name] = t - post_lines.append( - f" res['{ian}'] = {struct_handler_name}(o['{kn}'])" - ) - else: - tn = f"__c_type_{ix}" - internal_arg_parts[tn] = t - post_lines.append( - f" res['{ian}'] = {struct_handler_name}(o['{kn}'], {tn})" - ) + if handler == converter._structure_call: + internal_arg_parts[struct_handler_name] = t + post_lines.append( + f" res['{ian}'] = {struct_handler_name}(o['{kn}'])" + ) else: - post_lines.append(f" res['{ian}'] = o['{kn}']") + tn = f"__c_type_{ix}" + internal_arg_parts[tn] = t + post_lines.append( + f" res['{ian}'] = {struct_handler_name}(o['{kn}'], {tn})" + ) if override.rename is not None: lines.append(f" res.pop('{override.rename}', None)") From dc6664f4ebf8543b84c7f0796b0c778c0cc531c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sat, 18 Nov 2023 19:56:38 +0100 Subject: [PATCH 07/11] Remove more dead code --- src/cattrs/gen/typeddicts.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/cattrs/gen/typeddicts.py b/src/cattrs/gen/typeddicts.py index 6286cba2..bc1e5b99 100644 --- a/src/cattrs/gen/typeddicts.py +++ b/src/cattrs/gen/typeddicts.py @@ -436,22 +436,17 @@ def make_dict_structure_fn( kn = an if override.rename is None else override.rename allowed_fields.add(kn) - if handler: - struct_handler_name = f"__c_structure_{ix}" - internal_arg_parts[struct_handler_name] = handler - if handler == converter._structure_call: - internal_arg_parts[struct_handler_name] = t - invocation_line = ( - f" res['{an}'] = {struct_handler_name}(o['{kn}'])" - ) - else: - tn = f"__c_type_{ix}" - internal_arg_parts[tn] = t - invocation_line = ( - f" res['{an}'] = {struct_handler_name}(o['{kn}'], {tn})" - ) + struct_handler_name = f"__c_structure_{ix}" + internal_arg_parts[struct_handler_name] = handler + if handler == converter._structure_call: + internal_arg_parts[struct_handler_name] = t + invocation_line = f" res['{an}'] = {struct_handler_name}(o['{kn}'])" else: - invocation_line = f" res['{an}'] = o['{kn}']" + tn = f"__c_type_{ix}" + internal_arg_parts[tn] = t + invocation_line = ( + f" res['{an}'] = {struct_handler_name}(o['{kn}'], {tn})" + ) lines.append(invocation_line) if override.rename is not None: From 1b6adf55e3e8ac7e4be007b9d700fe0254f24d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sat, 18 Nov 2023 19:58:21 +0100 Subject: [PATCH 08/11] Fix black burp --- src/cattrs/converters.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index d36e4f6f..9b2b99cd 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -1099,10 +1099,4 @@ def copy( return res -GenConverter = Converter - self._structure_func.copy_to(res._structure_func, skip=self._struct_copy_skip) - - return res - - GenConverter = Converter From 4bd6e1d2ca5e84ff9e518ef31289d3ca8e1ebd53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sat, 18 Nov 2023 21:01:24 +0100 Subject: [PATCH 09/11] Test and fix typeddict struct hooks --- src/cattrs/gen/typeddicts.py | 30 ++++++++++++++++++------------ tests/test_typeddicts.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/cattrs/gen/typeddicts.py b/src/cattrs/gen/typeddicts.py index bc1e5b99..862d1273 100644 --- a/src/cattrs/gen/typeddicts.py +++ b/src/cattrs/gen/typeddicts.py @@ -425,13 +425,16 @@ def make_dict_structure_fn( elif is_generic(t) and not is_bare(t) and not is_annotated(t): t = deep_copy_with(t, mapping) - # For each attribute, we try resolving the type here and now. - # If a type is manually overwritten, this function should be - # regenerated. - if t is not None: - handler = converter._structure_func.dispatch(t) + if override.struct_hook is not None: + handler = override.struct_hook else: - handler = converter.structure + # For each attribute, we try resolving the type here and now. + # If a type is manually overwritten, this function should be + # regenerated. + if t is not None: + handler = converter._structure_func.dispatch(t) + else: + handler = converter.structure kn = an if override.rename is None else override.rename allowed_fields.add(kn) @@ -468,13 +471,16 @@ def make_dict_structure_fn( elif is_generic(t) and not is_bare(t) and not is_annotated(t): t = deep_copy_with(t, mapping) - # For each attribute, we try resolving the type here and now. - # If a type is manually overwritten, this function should be - # regenerated. - if t is not None: - handler = converter._structure_func.dispatch(t) + if override.struct_hook is not None: + handler = override.struct_hook else: - handler = converter.structure + # For each attribute, we try resolving the type here and now. + # If a type is manually overwritten, this function should be + # regenerated. + if t is not None: + handler = converter._structure_func.dispatch(t) + else: + handler = converter.structure struct_handler_name = f"__c_structure_{ix}" internal_arg_parts[struct_handler_name] = handler diff --git a/tests/test_typeddicts.py b/tests/test_typeddicts.py index 9dad933f..707c0e27 100644 --- a/tests/test_typeddicts.py +++ b/tests/test_typeddicts.py @@ -6,6 +6,7 @@ from hypothesis import assume, given from hypothesis.strategies import booleans from pytest import raises +from typing_extensions import NotRequired from cattrs import BaseConverter, Converter from cattrs._compat import ExtensionsTypedDict, is_generic @@ -437,3 +438,33 @@ class A(ExtensionsTypedDict): else: with pytest.raises(ValueError): converter.structure({"a": "a"}, A) + + +def test_override_entire_hooks(converter: BaseConverter): + """Overriding entire hooks works.""" + + class A(ExtensionsTypedDict): + a: int + b: NotRequired[int] + + converter.register_structure_hook( + A, + make_dict_structure_fn( + A, + converter, + a=override(struct_hook=lambda v, _: 1), + b=override(struct_hook=lambda v, _: 2), + ), + ) + converter.register_unstructure_hook( + A, + make_dict_unstructure_fn( + A, + converter, + a=override(unstruct_hook=lambda v: 1), + b=override(unstruct_hook=lambda v: 2), + ), + ) + + assert converter.unstructure({"a": 10, "b": 10}, A) == {"a": 1, "b": 2} + assert converter.structure({"a": 10, "b": 10}, A) == {"a": 1, "b": 2} From f652d68cadc7f5934fd6ef0d7d72d0769b3286b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sun, 19 Nov 2023 02:54:19 +0100 Subject: [PATCH 10/11] More tests --- src/cattrs/gen/typeddicts.py | 48 +++++++++++++++++------------------- tests/test_typeddicts.py | 5 +++- tests/typeddicts.py | 10 +++++--- 3 files changed, 33 insertions(+), 30 deletions(-) diff --git a/src/cattrs/gen/typeddicts.py b/src/cattrs/gen/typeddicts.py index 862d1273..23b67bb2 100644 --- a/src/cattrs/gen/typeddicts.py +++ b/src/cattrs/gen/typeddicts.py @@ -125,9 +125,6 @@ def make_dict_unstructure_fn( break handler = None t = a.type - nrb = get_notrequired_base(t) - if nrb is not NOTHING: - t = nrb if isinstance(t, TypeVar): if t.__name__ in mapping: @@ -139,6 +136,9 @@ def make_dict_unstructure_fn( t = deep_copy_with(t, mapping) if handler is None: + nrb = get_notrequired_base(t) + if nrb is not NOTHING: + t = nrb try: handler = converter._unstructure_func.dispatch(t) except RecursionError: @@ -172,9 +172,6 @@ def make_dict_unstructure_fn( handler = override.unstruct_hook else: t = a.type - nrb = get_notrequired_base(t) - if nrb is not NOTHING: - t = nrb if isinstance(t, TypeVar): if t.__name__ in mapping: @@ -185,6 +182,9 @@ def make_dict_unstructure_fn( t = deep_copy_with(t, mapping) if handler is None: + nrb = get_notrequired_base(t) + if nrb is not NOTHING: + t = nrb try: handler = converter._unstructure_func.dispatch(t) except RecursionError: @@ -338,6 +338,12 @@ def make_dict_structure_fn( if override.omit: continue t = a.type + + if isinstance(t, TypeVar): + t = mapping.get(t.__name__, t) + elif is_generic(t) and not is_bare(t) and not is_annotated(t): + t = deep_copy_with(t, mapping) + nrb = get_notrequired_base(t) if nrb is not NOTHING: t = nrb @@ -371,16 +377,11 @@ def make_dict_structure_fn( tn = f"__c_type_{ix}" internal_arg_parts[tn] = t - if handler: - if handler == converter._structure_call: - internal_arg_parts[struct_handler_name] = t - lines.append(f"{i}res['{an}'] = {struct_handler_name}(o['{kn}'])") - else: - lines.append( - f"{i}res['{an}'] = {struct_handler_name}(o['{kn}'], {tn})" - ) + if handler == converter._structure_call: + internal_arg_parts[struct_handler_name] = t + lines.append(f"{i}res['{an}'] = {struct_handler_name}(o['{kn}'])") else: - lines.append(f"{i}res['{an}'] = o['{kn}']") + lines.append(f"{i}res['{an}'] = {struct_handler_name}(o['{kn}'], {tn})") if override.rename is not None: lines.append(f"{i}del res['{kn}']") i = i[:-2] @@ -416,25 +417,23 @@ def make_dict_structure_fn( continue t = a.type - nrb = get_notrequired_base(t) - if nrb is not NOTHING: - t = nrb if isinstance(t, TypeVar): t = mapping.get(t.__name__, t) elif is_generic(t) and not is_bare(t) and not is_annotated(t): t = deep_copy_with(t, mapping) + nrb = get_notrequired_base(t) + if nrb is not NOTHING: + t = nrb + if override.struct_hook is not None: handler = override.struct_hook else: # For each attribute, we try resolving the type here and now. # If a type is manually overwritten, this function should be # regenerated. - if t is not None: - handler = converter._structure_func.dispatch(t) - else: - handler = converter.structure + handler = converter._structure_func.dispatch(t) kn = an if override.rename is None else override.rename allowed_fields.add(kn) @@ -477,10 +476,7 @@ def make_dict_structure_fn( # For each attribute, we try resolving the type here and now. # If a type is manually overwritten, this function should be # regenerated. - if t is not None: - handler = converter._structure_func.dispatch(t) - else: - handler = converter.structure + handler = converter._structure_func.dispatch(t) struct_handler_name = f"__c_structure_{ix}" internal_arg_parts[struct_handler_name] = handler diff --git a/tests/test_typeddicts.py b/tests/test_typeddicts.py index 707c0e27..1ffa455c 100644 --- a/tests/test_typeddicts.py +++ b/tests/test_typeddicts.py @@ -160,8 +160,11 @@ def test_generics( cls, instance = cls_and_instance unstructured = c.unstructure(instance, unstructure_as=cls) + assert not any(isinstance(v, datetime) for v in unstructured.values()) - if all(a is not datetime for _, a in get_annot(cls).items()): + if all( + a not in (datetime, NotRequired[datetime]) for _, a in get_annot(cls).items() + ): assert unstructured == instance if all(a is int for _, a in get_annot(cls).items()): diff --git a/tests/typeddicts.py b/tests/typeddicts.py index df38b2af..18453d70 100644 --- a/tests/typeddicts.py +++ b/tests/typeddicts.py @@ -236,7 +236,11 @@ def generic_typeddicts( if ix in generic_attrs: typevar = TypeVar(f"T{ix+1}") generics.append(typevar) - actual_types.append(attr_type) + if total and draw(booleans()): + # We might decide to make these NotRequired + actual_types.append(NotRequired[attr_type]) + else: + actual_types.append(attr_type) attrs_dict[attr_name] = typevar cls = make_typeddict( @@ -262,13 +266,13 @@ def make_typeddict( globs = {"TypedDict": TypedDict} lines = [] - bases_snippet = ",".join(f"_base{ix}" for ix in range(len(bases))) + bases_snippet = ", ".join(f"_base{ix}" for ix in range(len(bases))) for ix, base in enumerate(bases): globs[f"_base{ix}"] = base if bases_snippet: bases_snippet = f", {bases_snippet}" - lines.append(f"class {cls_name}(TypedDict{bases_snippet},total={total}):") + lines.append(f"class {cls_name}(TypedDict{bases_snippet}, total={total}):") for n, t in attrs.items(): # Strip the initial underscore if present, to prevent mangling. trimmed = n[1:] if n.startswith("_") else n From 7b5ab59921053510c1cb0040656a06af81404bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sun, 19 Nov 2023 17:03:06 +0100 Subject: [PATCH 11/11] Remove dead code, set coverage threshold --- .github/workflows/main.yml | 7 +++++-- src/cattrs/gen/typeddicts.py | 3 --- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8a0e89dd..df73dd76 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -47,7 +47,7 @@ jobs: steps: - uses: "actions/checkout@v3" - + - uses: "actions/setup-python@v4" with: cache: "pip" @@ -71,12 +71,15 @@ jobs: export TOTAL=$(python -c "import json;print(json.load(open('coverage.json'))['totals']['percent_covered_display'])") echo "total=$TOTAL" >> $GITHUB_ENV + # Report again and fail if under the threshold. + python -Im coverage report --fail-under=97 + - name: "Upload HTML report." uses: "actions/upload-artifact@v3" with: name: "html-report" path: "htmlcov" - + - name: "Make badge" if: github.ref == 'refs/heads/main' uses: "schneegans/dynamic-badges-action@v1.4.0" diff --git a/src/cattrs/gen/typeddicts.py b/src/cattrs/gen/typeddicts.py index 23b67bb2..023d625a 100644 --- a/src/cattrs/gen/typeddicts.py +++ b/src/cattrs/gen/typeddicts.py @@ -283,9 +283,6 @@ def make_dict_structure_fn( mapping = generate_mapping(base, mapping) break - if isinstance(cl, TypeVar): - cl = mapping.get(cl.__name__, cl) - cl_name = cl.__name__ fn_name = "structure_" + cl_name