|
1 | 1 | """Tests for TypedDict un/structuring.""" |
2 | 2 | from datetime import datetime, timezone |
3 | | -from typing import Dict, Generic, Set, Tuple, TypedDict, TypeVar |
| 3 | +from typing import Dict, Generic, NewType, Set, Tuple, TypedDict, TypeVar |
4 | 4 |
|
5 | 5 | import pytest |
| 6 | +from attrs import NOTHING |
6 | 7 | from hypothesis import assume, given |
7 | 8 | from hypothesis.strategies import booleans |
8 | 9 | from pytest import raises |
9 | 10 | from typing_extensions import NotRequired |
10 | 11 |
|
11 | 12 | from cattrs import BaseConverter, Converter |
12 | | -from cattrs._compat import ExtensionsTypedDict, is_generic |
| 13 | +from cattrs._compat import ExtensionsTypedDict, get_notrequired_base, is_generic |
13 | 14 | from cattrs.errors import ( |
14 | 15 | ClassValidationError, |
15 | 16 | ForbiddenExtraKeysError, |
@@ -51,17 +52,27 @@ def get_annot(t) -> dict: |
51 | 52 | args = t.__args__ |
52 | 53 | params = origin.__parameters__ |
53 | 54 | param_to_args = dict(zip(params, args)) |
54 | | - return { |
55 | | - k: param_to_args[v] if v in param_to_args else v |
56 | | - for k, v in origin_annotations.items() |
57 | | - } |
| 55 | + res = {} |
| 56 | + for k, v in origin_annotations.items(): |
| 57 | + if (nrb := get_notrequired_base(v)) is not NOTHING: |
| 58 | + res[k] = ( |
| 59 | + NotRequired[param_to_args[nrb]] if nrb in param_to_args else v |
| 60 | + ) |
| 61 | + else: |
| 62 | + res[k] = param_to_args[v] if v in param_to_args else v |
| 63 | + return res |
58 | 64 |
|
59 | 65 | # Origin is `None`, so this is a subclass for a generic typeddict. |
60 | 66 | mapping = generate_mapping(t) |
61 | | - return { |
62 | | - k: mapping[v.__name__] if v.__name__ in mapping else v |
63 | | - for k, v in get_annots(t).items() |
64 | | - } |
| 67 | + res = {} |
| 68 | + for k, v in get_annots(t).items(): |
| 69 | + if (nrb := get_notrequired_base(v)) is not NOTHING: |
| 70 | + res[k] = ( |
| 71 | + NotRequired[mapping[nrb.__name__]] if nrb.__name__ in mapping else v |
| 72 | + ) |
| 73 | + else: |
| 74 | + res[k] = mapping[v.__name__] if v.__name__ in mapping else v |
| 75 | + return res |
65 | 76 | return get_annots(t) |
66 | 77 |
|
67 | 78 |
|
@@ -196,6 +207,27 @@ class GenericTypedDict(TypedDict, Generic[T]): |
196 | 207 | c.structure({"a": 1}, GenericTypedDict) |
197 | 208 |
|
198 | 209 |
|
| 210 | +@pytest.mark.skipif(not is_py311_plus, reason="3.11+ only") |
| 211 | +@given(detailed_validation=...) |
| 212 | +def test_deep_generics(detailed_validation: bool): |
| 213 | + c = mk_converter(detailed_validation=detailed_validation) |
| 214 | + |
| 215 | + Int = NewType("Int", int) |
| 216 | + |
| 217 | + c.register_unstructure_hook_func(lambda t: t is Int, lambda v: v - 1) |
| 218 | + |
| 219 | + T = TypeVar("T") |
| 220 | + T1 = TypeVar("T1") |
| 221 | + |
| 222 | + class GenericParent(TypedDict, Generic[T]): |
| 223 | + a: T |
| 224 | + |
| 225 | + class GenericChild(GenericParent[Int], Generic[T1]): |
| 226 | + b: T1 |
| 227 | + |
| 228 | + assert c.unstructure({"b": 2, "a": 2}, GenericChild[Int]) == {"a": 1, "b": 1} |
| 229 | + |
| 230 | + |
199 | 231 | @given(simple_typeddicts(total=True, not_required=True), booleans()) |
200 | 232 | def test_not_required( |
201 | 233 | cls_and_instance: Tuple[type, Dict], detailed_validation: bool |
@@ -273,36 +305,25 @@ def test_omit(cls_and_instance: Tuple[type, Dict], detailed_validation: bool) -> |
273 | 305 | assert restructured == instance |
274 | 306 |
|
275 | 307 |
|
276 | | -@given(simple_typeddicts(min_attrs=1, total=True), booleans()) |
| 308 | +@given(simple_typeddicts(min_attrs=1, total=True, not_required=True), booleans()) |
277 | 309 | def test_rename(cls_and_instance: Tuple[type, Dict], detailed_validation: bool) -> None: |
278 | 310 | """`override(rename=...)` works.""" |
279 | 311 | c = mk_converter(detailed_validation=detailed_validation) |
280 | 312 |
|
281 | 313 | cls, instance = cls_and_instance |
282 | 314 | key = next(iter(get_annot(cls))) |
283 | 315 | c.register_unstructure_hook( |
284 | | - cls, |
285 | | - make_dict_unstructure_fn( |
286 | | - cls, |
287 | | - c, |
288 | | - _cattrs_detailed_validation=detailed_validation, |
289 | | - **{key: override(rename="renamed")}, |
290 | | - ), |
| 316 | + cls, make_dict_unstructure_fn(cls, c, **{key: override(rename="renamed")}) |
291 | 317 | ) |
292 | 318 |
|
293 | 319 | unstructured = c.unstructure(instance, unstructure_as=cls) |
294 | 320 |
|
295 | 321 | assert key not in unstructured |
296 | | - assert "renamed" in unstructured |
| 322 | + if key in instance: |
| 323 | + assert "renamed" in unstructured |
297 | 324 |
|
298 | 325 | c.register_structure_hook( |
299 | | - cls, |
300 | | - make_dict_structure_fn( |
301 | | - cls, |
302 | | - c, |
303 | | - _cattrs_detailed_validation=detailed_validation, |
304 | | - **{key: override(rename="renamed")}, |
305 | | - ), |
| 326 | + cls, make_dict_structure_fn(cls, c, **{key: override(rename="renamed")}) |
306 | 327 | ) |
307 | 328 | restructured = c.structure(unstructured, cls) |
308 | 329 |
|
|
0 commit comments