From 1475a7982c81efb92530421882aeb948d2808310 Mon Sep 17 00:00:00 2001 From: Jim Crist-Harif Date: Wed, 5 Jul 2023 22:34:16 -0500 Subject: [PATCH] Wrap errors in dataclass/attrs post-init methods When decoding/converting to a dataclass/attrs type with a `__post_init__`/`__attrs_post_init__` method, we now wrap all `ValueError`/`TypeError` exceptions in a `ValidationError`. This mirrors the behavior used in the new `Struct.__post_init__` support, and helps provide more uniform error handling support. --- msgspec/_core.c | 11 ++++++++--- tests/test_common.py | 32 ++++++++++++++++++++++++-------- tests/test_convert.py | 4 ++-- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/msgspec/_core.c b/msgspec/_core.c index 160ff702..f995d183 100644 --- a/msgspec/_core.c +++ b/msgspec/_core.c @@ -4746,7 +4746,6 @@ ms_error_with_path(const char *msg, PathNode *path) { return NULL; } -/* TODO */ static MS_NOINLINE void ms_maybe_wrap_validation_error(PathNode *path) { PyObject *exc_type, *exc, *tb; @@ -7972,7 +7971,10 @@ DataclassInfo_post_decode(DataclassInfo *self, PyObject *obj, PathNode *path) { } if (self->post_init != NULL) { PyObject *res = CALL_ONE_ARG(self->post_init, obj); - if (res == NULL) return -1; + if (res == NULL) { + ms_maybe_wrap_validation_error(path); + return -1; + } Py_DECREF(res); } return 0; @@ -19260,7 +19262,10 @@ convert_object_to_dataclass( } if (info->post_init != NULL) { PyObject *res = CALL_ONE_ARG(info->post_init, out); - if (res == NULL) goto error; + if (res == NULL) { + ms_maybe_wrap_validation_error(path); + goto error; + } Py_DECREF(res); } Py_LeaveRecursiveCall(); diff --git a/tests/test_common.py b/tests/test_common.py index 53a4afd5..0438a87e 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -2617,16 +2617,24 @@ def __post_init__(self): assert res.a == 1 assert called - def test_decode_dataclass_post_init_errors(self, proto): + @pytest.mark.parametrize("exc_class", [ValueError, TypeError, OSError]) + def test_decode_dataclass_post_init_errors(self, proto, exc_class): @dataclass class Example: a: int def __post_init__(self): - raise ValueError("Oh no!") + raise exc_class("Oh no!") - with pytest.raises(ValueError, match="Oh no!"): - proto.decode(proto.encode({"a": 1}), type=Example) + expected = ( + ValidationError if exc_class in (ValueError, TypeError) else exc_class + ) + + with pytest.raises(expected, match="Oh no!") as rec: + proto.decode(proto.encode([{"a": 1}]), type=List[Example]) + + if expected is ValidationError: + assert "- at `$[0]`" in str(rec.value) def test_decode_dataclass_not_object(self, proto): @dataclass @@ -2811,16 +2819,24 @@ def __attrs_post_init__(self): assert res.a == 1 assert called - def test_decode_attrs_post_init_errors(self, proto): + @pytest.mark.parametrize("exc_class", [ValueError, TypeError, OSError]) + def test_decode_attrs_post_init_errors(self, proto, exc_class): @attrs.define class Example: a: int def __attrs_post_init__(self): - raise ValueError("Oh no!") + raise exc_class("Oh no!") - with pytest.raises(ValueError, match="Oh no!"): - proto.decode(proto.encode({"a": 1}), type=Example) + expected = ( + ValidationError if exc_class in (ValueError, TypeError) else exc_class + ) + + with pytest.raises(expected, match="Oh no!") as rec: + proto.decode(proto.encode([{"a": 1}]), type=List[Example]) + + if expected is ValidationError: + assert "- at `$[0]`" in str(rec.value) def test_decode_attrs_pre_init(self, proto): called = False diff --git a/tests/test_convert.py b/tests/test_convert.py index 9b442046..0dc32de7 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -1344,7 +1344,7 @@ def __post_init__(self): msg = mapcls(a=1) - with pytest.raises(ValueError, match="Oh no!"): + with pytest.raises(ValidationError, match="Oh no!"): convert(msg, Example, from_attributes=from_attributes) @mapcls_and_from_attributes @@ -1529,7 +1529,7 @@ class Example: def __attrs_post_init__(self): raise ValueError("Oh no!") - with pytest.raises(ValueError, match="Oh no!"): + with pytest.raises(ValidationError, match="Oh no!"): convert(mapcls(a=1), Example, from_attributes=from_attributes) def test_attrs_to_attrs(self):