diff --git a/HISTORY.md b/HISTORY.md index 72093bf5..946e8fcd 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -49,6 +49,7 @@ can now be used as decorators and have gained new features. ([#460](https://github.com/python-attrs/cattrs/issues/460) [#467](https://github.com/python-attrs/cattrs/pull/467)) - `typing_extensions.Any` is now supported and handled like `typing.Any`. ([#488](https://github.com/python-attrs/cattrs/issues/488) [#490](https://github.com/python-attrs/cattrs/pull/490)) +- The BaseConverter now properly generates detailed validation errors for mappings. - [PEP 695](https://peps.python.org/pep-0695/) generics are now tested. ([#452](https://github.com/python-attrs/cattrs/pull/452)) - Imports are now sorted using Ruff. diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 4aaf21d4..acc40e55 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -853,6 +853,38 @@ def _structure_dict(self, obj: Mapping[T, V], cl: Any) -> dict[T, V]: if is_bare(cl) or cl.__args__ == (Any, Any): return dict(obj) key_type, val_type = cl.__args__ + + if self.detailed_validation: + key_handler = self._structure_func.dispatch(key_type) + val_handler = self._structure_func.dispatch(val_type) + errors = [] + res = {} + + for k, v in obj.items(): + try: + value = val_handler(v, val_type) + except Exception as exc: + msg = IterableValidationNote( + f"Structuring mapping value @ key {k!r}", k, val_type + ) + exc.__notes__ = [*getattr(exc, "__notes__", []), msg] + errors.append(exc) + continue + + try: + key = key_handler(k, key_type) + res[key] = value + except Exception as exc: + msg = IterableValidationNote( + f"Structuring mapping key @ key {k!r}", k, key_type + ) + exc.__notes__ = [*getattr(exc, "__notes__", []), msg] + errors.append(exc) + + if errors: + raise IterableValidationError(f"While structuring {cl!r}", errors, cl) + return res + if key_type in ANIES: val_conv = self._structure_func.dispatch(val_type) return {k: val_conv(v, val_type) for k, v in obj.items()} diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 846903d1..8999ec92 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -879,12 +879,12 @@ def make_mapping_structure_fn( globs["enumerate"] = enumerate lines.append(" res = {}; errors = []") - lines.append(" for ix, (k, v) in enumerate(mapping.items()):") + lines.append(" for k, v in mapping.items():") lines.append(" try:") lines.append(f" value = {v_s}") lines.append(" except Exception as e:") lines.append( - " e.__notes__ = getattr(e, '__notes__', []) + [IterableValidationNote('Structuring mapping value @ key ' + repr(k), k, val_type)]" + " e.__notes__ = getattr(e, '__notes__', []) + [IterableValidationNote(f'Structuring mapping value @ key {k!r}', k, val_type)]" ) lines.append(" errors.append(e)") lines.append(" continue") @@ -893,7 +893,7 @@ def make_mapping_structure_fn( lines.append(" res[key] = value") lines.append(" except Exception as e:") lines.append( - " e.__notes__ = getattr(e, '__notes__', []) + [IterableValidationNote('Structuring mapping key @ key ' + repr(k), k, key_type)]" + " e.__notes__ = getattr(e, '__notes__', []) + [IterableValidationNote(f'Structuring mapping key @ key {k!r}', k, key_type)]" ) lines.append(" errors.append(e)") lines.append(" if errors:") diff --git a/tests/test_validation.py b/tests/test_validation.py index 3d2ca6c0..89a715bc 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -104,14 +104,12 @@ def test_deque_validation(): ] -@given(...) -def test_mapping_validation(detailed_validation: bool): +def test_mapping_validation(converter): """Proper validation errors are raised structuring mappings.""" - c = Converter(detailed_validation=detailed_validation) - if detailed_validation: + if converter.detailed_validation: with pytest.raises(IterableValidationError) as exc: - c.structure({"1": 1, "2": "b", "c": 3}, Dict[int, int]) + converter.structure({"1": 1, "2": "b", "c": 3}, Dict[int, int]) assert repr(exc.value.exceptions[0]) == repr( ValueError("invalid literal for int() with base 10: 'b'") @@ -128,7 +126,7 @@ def test_mapping_validation(detailed_validation: bool): ] else: with pytest.raises(ValueError): - c.structure({"1": 1, "2": "b", "c": 3}, Dict[int, int]) + converter.structure({"1": 1, "2": "b", "c": 3}, Dict[int, int]) @given(...)