diff --git a/HISTORY.md b/HISTORY.md index 5f20c100..bf6b8ac3 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -20,7 +20,7 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ([#577](https://github.com/python-attrs/cattrs/pull/577)) - Expose {func}`cattrs.cols.mapping_unstructure_factory` through {mod}`cattrs.cols`. - Some `defaultdicts` are now [supported by default](https://catt.rs/en/latest/defaulthooks.html#defaultdicts), and - {func}`cattrs.cols.is_defaultdict`{func} and `cattrs.cols.defaultdict_structure_factory` are exposed through {mod}`cattrs.cols`. + {func}`cattrs.cols.is_defaultdict` and {func}`cattrs.cols.defaultdict_structure_factory` are exposed through {mod}`cattrs.cols`. ([#519](https://github.com/python-attrs/cattrs/issues/519) [#588](https://github.com/python-attrs/cattrs/pull/588)) - Many preconf converters (_bson_, stdlib JSON, _cbor2_, _msgpack_, _msgspec_, _orjson_, _ujson_) skip unstructuring `int` and `str` enums, leaving them to the underlying libraries to handle with greater efficiency. @@ -29,7 +29,9 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ([#598](https://github.com/python-attrs/cattrs/pull/598)) - Preconf converters now handle dictionaries with literal keys properly. ([#599](https://github.com/python-attrs/cattrs/pull/599)) -- Replace `cattrs.gen.MappingStructureFn` with `cattrs.SimpleStructureHook[In, T]`. +- Structuring TypedDicts from invalid inputs now properly raises a {class}`ClassValidationError`. + ([#615](https://github.com/python-attrs/cattrs/issues/615) [#616](https://github.com/python-attrs/cattrs/pull/616)) +- Replace `cattrs.gen.MappingStructureFn` with {class}`cattrs.SimpleStructureHook`. - Python 3.13 is now supported. ([#543](https://github.com/python-attrs/cattrs/pull/543) [#547](https://github.com/python-attrs/cattrs/issues/547)) - Python 3.8 is no longer supported, as it is end-of-life. Use previous versions on this Python version. diff --git a/docs/defaulthooks.md b/docs/defaulthooks.md index 4b3097d9..7b15100b 100644 --- a/docs/defaulthooks.md +++ b/docs/defaulthooks.md @@ -333,7 +333,7 @@ Generic TypedDicts work on Python 3.11 and later, since that is the first Python [`typing.Required` and `typing.NotRequired`](https://peps.python.org/pep-0655/) are supported. -[Similar to _attrs_ classes](customizing.md#using-cattrsgen-generators), un/structuring can be customized using {meth}`cattrs.gen.typeddicts.make_dict_structure_fn` and {meth}`cattrs.gen.typeddicts.make_dict_unstructure_fn`. +[Similar to _attrs_ classes](customizing.md#using-cattrsgen-hook-factories), un/structuring can be customized using {meth}`cattrs.gen.typeddicts.make_dict_structure_fn` and {meth}`cattrs.gen.typeddicts.make_dict_unstructure_fn`. ```{doctest} >>> from typing import TypedDict diff --git a/src/cattrs/gen/typeddicts.py b/src/cattrs/gen/typeddicts.py index d5dcdab6..5fac557e 100644 --- a/src/cattrs/gen/typeddicts.py +++ b/src/cattrs/gen/typeddicts.py @@ -307,9 +307,16 @@ def make_dict_structure_fn( globs["__c_a"] = allowed_fields globs["__c_feke"] = ForbiddenExtraKeysError - lines.append(" res = o.copy()") - if _cattrs_detailed_validation: + # When running under detailed validation, be extra careful about copying + # so that the correct error is raised if the input isn't a dict. + lines.append(" try:") + lines.append(" res = o.copy()") + lines.append(" except Exception as exc:") + lines.append( + f" raise __c_cve('While structuring ' + {cl.__name__!r}, [exc], __cl)" + ) + lines.append(" errors = []") internal_arg_parts["__c_cve"] = ClassValidationError internal_arg_parts["__c_avn"] = AttributeValidationNote @@ -383,6 +390,7 @@ def make_dict_structure_fn( f" if errors: raise __c_cve('While structuring ' + {cl.__name__!r}, errors, __cl)" ) else: + lines.append(" res = o.copy()") non_required = [] # The first loop deals with required args. diff --git a/tests/test_typeddicts.py b/tests/test_typeddicts.py index da0cf109..7583d6aa 100644 --- a/tests/test_typeddicts.py +++ b/tests/test_typeddicts.py @@ -10,7 +10,7 @@ from pytest import raises from typing_extensions import NotRequired, Required -from cattrs import BaseConverter, Converter +from cattrs import BaseConverter, Converter, transform_error from cattrs._compat import ExtensionsTypedDict, get_notrequired_base, is_generic from cattrs.errors import ( ClassValidationError, @@ -509,3 +509,12 @@ class A(ExtensionsTypedDict): assert converter.unstructure({"a": 10, "b": 10}, A) == {"a": 1, "b": 2} assert converter.structure({"a": 10, "b": 10}, A) == {"a": 1, "b": 2} + + +def test_nondict_input(): + """Trying to structure typeddict from a non-dict raises the proper exception.""" + converter = Converter(detailed_validation=True) + with raises(ClassValidationError) as exc: + converter.structure(1, TypedDictA) + + assert transform_error(exc.value) == ["expected a mapping @ $"]