Skip to content

Commit 1a17d42

Browse files
authored
Preserve exception instances and their context in ValidationError (#753)
1 parent 45d812f commit 1a17d42

File tree

6 files changed

+94
-27
lines changed

6 files changed

+94
-27
lines changed

src/errors/types.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,10 @@ pub enum ErrorType {
204204
// ---------------------
205205
// python errors from functions
206206
ValueError {
207-
error: String,
207+
error: Option<PyObject>, // Use Option because EnumIter requires Default to be implemented
208208
},
209209
AssertionError {
210-
error: String,
210+
error: Option<PyObject>, // Use Option because EnumIter requires Default to be implemented
211211
},
212212
// Note: strum message and serialize are not used here
213213
CustomError {
@@ -425,8 +425,8 @@ impl ErrorType {
425425
Self::MappingType { .. } => extract_context!(Cow::Owned, MappingType, ctx, error: String),
426426
Self::BytesTooShort { .. } => extract_context!(BytesTooShort, ctx, min_length: usize),
427427
Self::BytesTooLong { .. } => extract_context!(BytesTooLong, ctx, max_length: usize),
428-
Self::ValueError { .. } => extract_context!(ValueError, ctx, error: String),
429-
Self::AssertionError { .. } => extract_context!(AssertionError, ctx, error: String),
428+
Self::ValueError { .. } => extract_context!(ValueError, ctx, error: Option<PyObject>),
429+
Self::AssertionError { .. } => extract_context!(AssertionError, ctx, error: Option<PyObject>),
430430
Self::LiteralError { .. } => extract_context!(LiteralError, ctx, expected: String),
431431
Self::DateParsing { .. } => extract_context!(Cow::Owned, DateParsing, ctx, error: String),
432432
Self::DateFromDatetimeParsing { .. } => extract_context!(DateFromDatetimeParsing, ctx, error: String),
@@ -639,8 +639,18 @@ impl ErrorType {
639639
Self::MappingType { error } => render!(tmpl, error),
640640
Self::BytesTooShort { min_length } => to_string_render!(tmpl, min_length),
641641
Self::BytesTooLong { max_length } => to_string_render!(tmpl, max_length),
642-
Self::ValueError { error } => render!(tmpl, error),
643-
Self::AssertionError { error } => render!(tmpl, error),
642+
Self::ValueError { error, .. } => {
643+
let error = &error
644+
.as_ref()
645+
.map_or(Cow::Borrowed("None"), |v| Cow::Owned(v.as_ref(py).to_string()));
646+
render!(tmpl, error)
647+
}
648+
Self::AssertionError { error, .. } => {
649+
let error = &error
650+
.as_ref()
651+
.map_or(Cow::Borrowed("None"), |v| Cow::Owned(v.as_ref(py).to_string()));
652+
render!(tmpl, error)
653+
}
644654
Self::CustomError {
645655
custom_error: value_error,
646656
} => value_error.message(py),

src/validators/function.rs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -477,13 +477,12 @@ macro_rules! py_err_string {
477477
($error_value:expr, $type_member:ident, $input:ident) => {
478478
match $error_value.str() {
479479
Ok(py_string) => match py_string.to_str() {
480-
Ok(s) => {
481-
let error = match s.is_empty() {
482-
true => "Unknown error".to_string(),
483-
false => s.to_string(),
484-
};
485-
ValError::new(ErrorType::$type_member { error }, $input)
486-
}
480+
Ok(_) => ValError::new(
481+
ErrorType::$type_member {
482+
error: Some($error_value.into()),
483+
},
484+
$input,
485+
),
487486
Err(e) => ValError::InternalErr(e),
488487
},
489488
Err(e) => ValError::InternalErr(e),

tests/test_errors.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import re
22
from decimal import Decimal
3+
from typing import Any
34

45
import pytest
56
from dirty_equals import HasRepr, IsInstance, IsJson, IsStr
@@ -237,8 +238,8 @@ def f(input_value, info):
237238
('bytes_type', 'Input should be a valid bytes', None),
238239
('bytes_too_short', 'Data should have at least 42 bytes', {'min_length': 42}),
239240
('bytes_too_long', 'Data should have at most 42 bytes', {'max_length': 42}),
240-
('value_error', 'Value error, foobar', {'error': 'foobar'}),
241-
('assertion_error', 'Assertion failed, foobar', {'error': 'foobar'}),
241+
('value_error', 'Value error, foobar', {'error': ValueError('foobar')}),
242+
('assertion_error', 'Assertion failed, foobar', {'error': AssertionError('foobar')}),
242243
('literal_error', 'Input should be foo', {'expected': 'foo'}),
243244
('literal_error', 'Input should be foo or bar', {'expected': 'foo or bar'}),
244245
('date_type', 'Input should be a valid date', None),
@@ -472,6 +473,50 @@ def test_error_json(pydantic_version):
472473
assert exc_info.value.json(indent=2).startswith('[\n {\n "type": "string_too_short",')
473474

474475

476+
def test_error_json_python_error(pydantic_version: str):
477+
def raise_py_error(v: Any) -> Any:
478+
try:
479+
assert False
480+
except AssertionError as e:
481+
raise ValueError('Oh no!') from e
482+
483+
s = SchemaValidator(core_schema.no_info_plain_validator_function(raise_py_error))
484+
with pytest.raises(ValidationError) as exc_info:
485+
s.validate_python('anything')
486+
487+
exc = exc_info.value.errors()[0]['ctx']['error'] # type: ignore
488+
assert isinstance(exc, ValueError)
489+
assert isinstance(exc.__context__, AssertionError)
490+
491+
# insert_assert(exc_info.value.errors(include_url=False))
492+
assert exc_info.value.errors(include_url=False) == [
493+
{
494+
'type': 'value_error',
495+
'loc': (),
496+
'msg': 'Value error, Oh no!',
497+
'input': 'anything',
498+
'ctx': {'error': HasRepr(repr(ValueError('Oh no!')))},
499+
}
500+
]
501+
assert exc_info.value.json() == IsJson(
502+
[
503+
{
504+
'type': 'value_error',
505+
'loc': [],
506+
'msg': 'Value error, Oh no!',
507+
'input': 'anything',
508+
'ctx': {'error': 'Oh no!'},
509+
'url': f'https://errors.pydantic.dev/{pydantic_version}/v/value_error',
510+
}
511+
]
512+
)
513+
assert exc_info.value.json(include_url=False, include_context=False) == IsJson(
514+
[{'type': 'value_error', 'loc': [], 'msg': 'Value error, Oh no!', 'input': 'anything'}]
515+
)
516+
assert exc_info.value.json().startswith('[{"type":"value_error",')
517+
assert exc_info.value.json(indent=2).startswith('[\n {\n "type": "value_error",')
518+
519+
475520
def test_error_json_cycle():
476521
s = SchemaValidator({'type': 'str', 'min_length': 3})
477522
cycle = []

tests/validators/test_function.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Any, Dict, Type, Union
55

66
import pytest
7+
from dirty_equals import HasRepr
78

89
from pydantic_core import SchemaError, SchemaValidator, ValidationError, core_schema
910

@@ -58,7 +59,7 @@ def f(input_value, info):
5859
'loc': (),
5960
'msg': 'Value error, foobar',
6061
'input': 'input value',
61-
'ctx': {'error': 'foobar'},
62+
'ctx': {'error': HasRepr(repr(ValueError('foobar')))},
6263
}
6364
]
6465

@@ -350,7 +351,7 @@ def f(input_value, info):
350351
'loc': (),
351352
'msg': 'Value error, foobar',
352353
'input': 'input value',
353-
'ctx': {'error': 'foobar'},
354+
'ctx': {'error': HasRepr(repr(ValueError('foobar')))},
354355
}
355356
]
356357

@@ -551,7 +552,7 @@ def f(input_value, info):
551552
'loc': (),
552553
'msg': 'Assertion failed, foobar',
553554
'input': 'input value',
554-
'ctx': {'error': 'foobar'},
555+
'ctx': {'error': HasRepr(repr(AssertionError('foobar')))},
555556
}
556557
]
557558

@@ -571,9 +572,9 @@ def f(input_value, info):
571572
{
572573
'type': 'assertion_error',
573574
'loc': (),
574-
'msg': 'Assertion failed, Unknown error',
575+
'msg': 'Assertion failed, ',
575576
'input': 'input value',
576-
'ctx': {'error': 'Unknown error'},
577+
'ctx': {'error': HasRepr(repr(AssertionError()))},
577578
}
578579
]
579580

tests/validators/test_list.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,20 @@ def f(input_value, info):
248248
with pytest.raises(ValidationError) as exc_info:
249249
v.validate_python([1, 2])
250250
assert exc_info.value.errors(include_url=False) == [
251-
{'type': 'value_error', 'loc': (0,), 'msg': 'Value error, error 1', 'input': 1, 'ctx': {'error': 'error 1'}},
252-
{'type': 'value_error', 'loc': (1,), 'msg': 'Value error, error 2', 'input': 2, 'ctx': {'error': 'error 2'}},
251+
{
252+
'type': 'value_error',
253+
'loc': (0,),
254+
'msg': 'Value error, error 1',
255+
'input': 1,
256+
'ctx': {'error': HasRepr(repr(ValueError('error 1')))},
257+
},
258+
{
259+
'type': 'value_error',
260+
'loc': (1,),
261+
'msg': 'Value error, error 2',
262+
'input': 2,
263+
'ctx': {'error': HasRepr(repr(ValueError('error 2')))},
264+
},
253265
]
254266

255267

tests/validators/test_model.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Any, Callable, Dict, List, Set, Tuple
44

55
import pytest
6-
from dirty_equals import IsInstance
6+
from dirty_equals import HasRepr, IsInstance
77

88
from pydantic_core import SchemaError, SchemaValidator, ValidationError, core_schema
99

@@ -139,7 +139,7 @@ def f(
139139
'loc': (),
140140
'msg': 'Assertion failed, assert 456 == 123',
141141
'input': {'field_a': 456},
142-
'ctx': {'error': 'assert 456 == 123'},
142+
'ctx': {'error': HasRepr(repr(AssertionError('assert 456 == 123')))},
143143
}
144144
]
145145

@@ -173,7 +173,7 @@ def f(input_value: Dict[str, Any], info: core_schema.ValidationInfo):
173173
'loc': (),
174174
'msg': 'Assertion failed, assert 456 == 123',
175175
'input': {'field_a': 456},
176-
'ctx': {'error': 'assert 456 == 123'},
176+
'ctx': {'error': HasRepr(repr(AssertionError('assert 456 == 123')))},
177177
}
178178
]
179179

@@ -208,7 +208,7 @@ def f(input_value_and_fields_set: Tuple[Dict[str, Any], Set[str]]):
208208
'loc': (),
209209
'msg': 'Assertion failed, assert 456 == 123',
210210
'input': {'field_a': 456},
211-
'ctx': {'error': 'assert 456 == 123'},
211+
'ctx': {'error': HasRepr(repr(AssertionError('assert 456 == 123')))},
212212
}
213213
]
214214

@@ -876,7 +876,7 @@ def call_me_maybe(self, context, **kwargs):
876876
'loc': (),
877877
'msg': 'Value error, this is broken: test',
878878
'input': {'field_a': 'test'},
879-
'ctx': {'error': 'this is broken: test'},
879+
'ctx': {'error': HasRepr(repr(ValueError('this is broken: test')))},
880880
}
881881
]
882882

0 commit comments

Comments
 (0)