diff --git a/HISTORY.md b/HISTORY.md index 4389efee..2bc9ab0a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -24,7 +24,10 @@ Our backwards-compatibility policy can be found [here](https://github.com/python If you're using these functions directly, the old behavior can be restored by passing in the desired value directly. ([#596](https://github.com/python-attrs/cattrs/issues/596) [#660](https://github.com/python-attrs/cattrs/pull/660)) - Fix unstructuring of generic classes with stringified annotations. - ([#661](https://github.com/python-attrs/cattrs/issues/661) [#662](https://github.com/python-attrs/cattrs/issues/662)) + ([#661](https://github.com/python-attrs/cattrs/issues/661) [#662](https://github.com/python-attrs/cattrs/issues/662) +- For {class}`cattrs.errors.StructureHandlerNotFoundError` and {class}`cattrs.errors.ForbiddenExtraKeysError` + correctly set {attr}`BaseException.args` in `super()` and hence make them pickable. + ([#666](https://github.com/python-attrs/cattrs/pull/666)) ## 25.1.1 (2025-06-04) diff --git a/src/cattrs/errors.py b/src/cattrs/errors.py index 4f9a7377..570c2367 100644 --- a/src/cattrs/errors.py +++ b/src/cattrs/errors.py @@ -13,14 +13,18 @@ class StructureHandlerNotFoundError(Exception): """ def __init__(self, message: str, type_: type) -> None: - super().__init__(message) + super().__init__(message, type_) + self.message = message self.type_ = type_ + def __str__(self) -> str: + return self.message + class BaseValidationError(ExceptionGroup): cl: type - def __new__(cls, message: str, excs: Sequence[Exception], cl: type): + def __new__(cls, message: str, excs: Sequence[Exception], cl: type) -> Self: obj = super().__new__(cls, message, excs) obj.cl = cl return obj @@ -35,9 +39,7 @@ class IterableValidationNote(str): index: Union[int, str] # Ints for list indices, strs for dict keys type: Any - def __new__( - cls, string: str, index: Union[int, str], type: Any - ) -> "IterableValidationNote": + def __new__(cls, string: str, index: Union[int, str], type: Any) -> Self: instance = str.__new__(cls, string) instance.index = index instance.type = type @@ -76,7 +78,7 @@ class AttributeValidationNote(str): name: str type: Any - def __new__(cls, string: str, name: str, type: Any) -> "AttributeValidationNote": + def __new__(cls, string: str, name: str, type: Any) -> Self: instance = str.__new__(cls, string) instance.name = name instance.type = type @@ -122,11 +124,15 @@ class ForbiddenExtraKeysError(Exception): def __init__( self, message: Optional[str], cl: type, extra_fields: set[str] ) -> None: + self.message = message self.cl = cl self.extra_fields = extra_fields - cln = cl.__name__ - super().__init__( - message - or f"Extra fields in constructor for {cln}: {', '.join(extra_fields)}" + super().__init__(message, cl, extra_fields) + + def __str__(self) -> str: + return ( + self.message + or f"Extra fields in constructor for {self.cl.__name__}: " + f"{', '.join(self.extra_fields)}" ) diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 00000000..fcea0a54 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,74 @@ +import pickle +from pathlib import Path +from typing import Any + +import pytest + +from cattrs._compat import ExceptionGroup +from cattrs.errors import ( + BaseValidationError, + ClassValidationError, + ForbiddenExtraKeysError, + IterableValidationError, + StructureHandlerNotFoundError, +) + + +@pytest.mark.parametrize( + "err_cls, err_args", + [ + (StructureHandlerNotFoundError, ("Structure Message", int)), + (ForbiddenExtraKeysError, ("Forbidden Message", int, {"foo", "bar"})), + (ForbiddenExtraKeysError, ("", str, {"foo", "bar"})), + (ForbiddenExtraKeysError, (None, list, {"foo", "bar"})), + ( + BaseValidationError, + ("BaseValidation Message", [ValueError("Test BaseValidation")], int), + ), + ( + IterableValidationError, + ("IterableValidation Msg", [ValueError("Test IterableValidation")], int), + ), + ( + ClassValidationError, + ("ClassValidation Message", [ValueError("Test ClassValidation")], int), + ), + ], +) +def test_errors_pickling( + err_cls: type[Exception], err_args: tuple[Any, ...], tmp_path: Path +) -> None: + """Test if a round of pickling and unpickling works for errors.""" + before = err_cls(*err_args) + + assert before.args == err_args + + with (tmp_path / (err_cls.__name__.lower() + ".pypickle")).open("wb") as f: + pickle.dump(before, f) + + with (tmp_path / (err_cls.__name__.lower() + ".pypickle")).open("rb") as f: + after = pickle.load(f) # noqa: S301 + + assert isinstance(after, err_cls) + assert str(after) == str(before) + if issubclass(err_cls, ExceptionGroup): + assert after.message == before.message + assert after.args[0] == before.args[0] + + # We need to do the exceptions within the group (i.e. args[1]) + # separately, as on unpickling new objects are created and hence + # they will never be equal to the original ones. + for after_exc, before_exc in zip(after.exceptions, before.exceptions): + assert str(after_exc) == str(before_exc) + + # The problem with args[1] might be also for other parameters, but + # we ignore this here and if needed then we need a separate test + assert after.args[2:] == before.args[2:] + + else: + assert after.args == err_args + assert after.args == before.args + + assert after.__cause__ == before.__cause__ + assert after.__context__ == before.__context__ + assert after.__traceback__ == before.__traceback__ diff --git a/tests/test_typeddicts.py b/tests/test_typeddicts.py index 35085444..9fdb9e93 100644 --- a/tests/test_typeddicts.py +++ b/tests/test_typeddicts.py @@ -382,13 +382,7 @@ def test_forbid_extra_keys( assert repr(ctx.value) == repr( ClassValidationError( f"While structuring {cls.__name__}", - [ - ForbiddenExtraKeysError( - f"Extra fields in constructor for {cls.__name__}: test", - cls, - {"test"}, - ) - ], + [ForbiddenExtraKeysError("", cls, {"test"})], cls, ) )