Skip to content

Preserve exception instances and their context in ValidationError #753

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jul 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pydantic-core"
version = "2.1.2"
version = "2.1.3"
edition = "2021"
license = "MIT"
homepage = "https://github.com/pydantic/pydantic-core"
Expand Down Expand Up @@ -35,7 +35,7 @@ enum_dispatch = "0.3.8"
serde = "1.0.147"
# disabled for benchmarks since it makes microbenchmark performance more flakey
mimalloc = { version = "0.1.30", optional = true, default-features = false, features = ["local_dynamic_tls"] }
speedate = "0.9.0"
speedate = "0.9.1"
ahash = "0.8.0"
url = "2.3.1"
# idna is already required by url, added here to be explicit
Expand Down
2 changes: 2 additions & 0 deletions python/pydantic_core/_pydantic_core.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ from _typeshed import SupportsAllComparisons
__all__ = [
'__version__',
'build_profile',
'build_info',
'_recursion_limit',
'ArgsKwargs',
'SchemaValidator',
Expand All @@ -45,6 +46,7 @@ __all__ = [
]
__version__: str
build_profile: str
build_info: str
_recursion_limit: int

_T = TypeVar('_T', default=Any, covariant=True)
Expand Down
1 change: 1 addition & 0 deletions python/pydantic_core/core_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -3811,6 +3811,7 @@ def definition_reference_schema(
'string_too_short',
'string_too_long',
'string_pattern_mismatch',
'enum',
'dict_type',
'mapping_type',
'list_type',
Expand Down
31 changes: 25 additions & 6 deletions src/errors/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@ pub enum ErrorType {
pattern: String,
},
// ---------------------
// enum errors
Enum {
expected: String,
},
// ---------------------
// dict errors
DictType,
MappingType {
Expand Down Expand Up @@ -199,10 +204,10 @@ pub enum ErrorType {
// ---------------------
// python errors from functions
ValueError {
error: String,
error: Option<PyObject>, // Use Option because EnumIter requires Default to be implemented
},
AssertionError {
error: String,
error: Option<PyObject>, // Use Option because EnumIter requires Default to be implemented
},
// Note: strum message and serialize are not used here
CustomError {
Expand Down Expand Up @@ -415,12 +420,13 @@ impl ErrorType {
Self::IterationError { .. } => extract_context!(IterationError, ctx, error: String),
Self::StringTooShort { .. } => extract_context!(StringTooShort, ctx, min_length: usize),
Self::StringTooLong { .. } => extract_context!(StringTooLong, ctx, max_length: usize),
Self::Enum { .. } => extract_context!(Enum, ctx, expected: String),
Self::StringPatternMismatch { .. } => extract_context!(StringPatternMismatch, ctx, pattern: String),
Self::MappingType { .. } => extract_context!(Cow::Owned, MappingType, ctx, error: String),
Self::BytesTooShort { .. } => extract_context!(BytesTooShort, ctx, min_length: usize),
Self::BytesTooLong { .. } => extract_context!(BytesTooLong, ctx, max_length: usize),
Self::ValueError { .. } => extract_context!(ValueError, ctx, error: String),
Self::AssertionError { .. } => extract_context!(AssertionError, ctx, error: String),
Self::ValueError { .. } => extract_context!(ValueError, ctx, error: Option<PyObject>),
Self::AssertionError { .. } => extract_context!(AssertionError, ctx, error: Option<PyObject>),
Self::LiteralError { .. } => extract_context!(LiteralError, ctx, expected: String),
Self::DateParsing { .. } => extract_context!(Cow::Owned, DateParsing, ctx, error: String),
Self::DateFromDatetimeParsing { .. } => extract_context!(DateFromDatetimeParsing, ctx, error: String),
Expand Down Expand Up @@ -492,6 +498,7 @@ impl ErrorType {
Self::StringTooShort {..} => "String should have at least {min_length} characters",
Self::StringTooLong {..} => "String should have at most {max_length} characters",
Self::StringPatternMismatch {..} => "String should match pattern '{pattern}'",
Self::Enum {..} => "Input should be {expected}",
Self::DictType => "Input should be a valid dictionary",
Self::MappingType {..} => "Input should be a valid mapping, error: {error}",
Self::ListType => "Input should be a valid list",
Expand Down Expand Up @@ -628,11 +635,22 @@ impl ErrorType {
Self::StringTooShort { min_length } => to_string_render!(tmpl, min_length),
Self::StringTooLong { max_length } => to_string_render!(tmpl, max_length),
Self::StringPatternMismatch { pattern } => render!(tmpl, pattern),
Self::Enum { expected } => to_string_render!(tmpl, expected),
Self::MappingType { error } => render!(tmpl, error),
Self::BytesTooShort { min_length } => to_string_render!(tmpl, min_length),
Self::BytesTooLong { max_length } => to_string_render!(tmpl, max_length),
Self::ValueError { error } => render!(tmpl, error),
Self::AssertionError { error } => render!(tmpl, error),
Self::ValueError { error, .. } => {
let error = &error
.as_ref()
.map_or(Cow::Borrowed("None"), |v| Cow::Owned(v.as_ref(py).to_string()));
render!(tmpl, error)
}
Self::AssertionError { error, .. } => {
let error = &error
.as_ref()
.map_or(Cow::Borrowed("None"), |v| Cow::Owned(v.as_ref(py).to_string()));
render!(tmpl, error)
}
Self::CustomError {
custom_error: value_error,
} => value_error.message(py),
Expand Down Expand Up @@ -687,6 +705,7 @@ impl ErrorType {
Self::StringTooShort { min_length } => py_dict!(py, min_length),
Self::StringTooLong { max_length } => py_dict!(py, max_length),
Self::StringPatternMismatch { pattern } => py_dict!(py, pattern),
Self::Enum { expected } => py_dict!(py, expected),
Self::MappingType { error } => py_dict!(py, error),
Self::BytesTooShort { min_length } => py_dict!(py, min_length),
Self::BytesTooLong { max_length } => py_dict!(py, max_length),
Expand Down
9 changes: 7 additions & 2 deletions src/errors/validation_exception.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use serde_json::ser::PrettyFormatter;

use crate::build_tools::py_schema_error_type;
use crate::errors::LocItem;
use crate::get_version;
use crate::get_pydantic_version;
use crate::serializers::{SerMode, SerializationState};
use crate::tools::{safe_repr, SchemaDict};

Expand Down Expand Up @@ -113,7 +113,12 @@ static URL_PREFIX: GILOnceCell<String> = GILOnceCell::new();

fn get_url_prefix(py: Python, include_url: bool) -> Option<&str> {
if include_url {
Some(URL_PREFIX.get_or_init(py, || format!("https://errors.pydantic.dev/{}/v/", get_version())))
Some(URL_PREFIX.get_or_init(py, || {
format!(
"https://errors.pydantic.dev/{}/v/",
get_pydantic_version(py).unwrap_or("latest")
)
}))
} else {
None
}
Expand Down
49 changes: 39 additions & 10 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

extern crate core;

use pyo3::prelude::*;
use std::sync::OnceLock;

use pyo3::{prelude::*, sync::GILOnceCell};

#[cfg(feature = "mimalloc")]
#[global_allocator]
Expand Down Expand Up @@ -33,20 +35,47 @@ pub use serializers::{
};
pub use validators::{PySome, SchemaValidator};

pub fn get_version() -> String {
let version = env!("CARGO_PKG_VERSION");
// cargo uses "1.0-alpha1" etc. while python uses "1.0.0a1", this is not full compatibility,
// but it's good enough for now
// see https://docs.rs/semver/1.0.9/semver/struct.Version.html#method.parse for rust spec
// see https://peps.python.org/pep-0440/ for python spec
// it seems the dot after "alpha/beta" e.g. "-alpha.1" is not necessary, hence why this works
version.replace("-alpha", "a").replace("-beta", "b")
pub fn get_pydantic_core_version() -> &'static str {
static PYDANTIC_CORE_VERSION: OnceLock<String> = OnceLock::new();

PYDANTIC_CORE_VERSION.get_or_init(|| {
let version = env!("CARGO_PKG_VERSION");
// cargo uses "1.0-alpha1" etc. while python uses "1.0.0a1", this is not full compatibility,
// but it's good enough for now
// see https://docs.rs/semver/1.0.9/semver/struct.Version.html#method.parse for rust spec
// see https://peps.python.org/pep-0440/ for python spec
// it seems the dot after "alpha/beta" e.g. "-alpha.1" is not necessary, hence why this works
version.replace("-alpha", "a").replace("-beta", "b")
})
}

/// Returns the installed version of pydantic.
fn get_pydantic_version(py: Python<'_>) -> Option<&'static str> {
static PYDANTIC_VERSION: GILOnceCell<Option<String>> = GILOnceCell::new();

PYDANTIC_VERSION
.get_or_init(py, || {
py.import("pydantic")
.and_then(|pydantic| pydantic.getattr("__version__")?.extract())
.ok()
})
.as_deref()
}

pub fn build_info() -> String {
format!(
"profile={} pgo={} mimalloc={}",
env!("PROFILE"),
option_env!("RUSTFLAGS").unwrap_or("").contains("-Cprofile-use="),
cfg!(feature = "mimalloc")
)
}

#[pymodule]
fn _pydantic_core(py: Python, m: &PyModule) -> PyResult<()> {
m.add("__version__", get_version())?;
m.add("__version__", get_pydantic_core_version())?;
m.add("build_profile", env!("PROFILE"))?;
m.add("build_info", build_info())?;
m.add("_recursion_limit", recursion_guard::RECURSION_GUARD_LIMIT)?;
m.add("PydanticUndefined", PydanticUndefinedType::new(py))?;
m.add_class::<PydanticUndefinedType>()?;
Expand Down
10 changes: 9 additions & 1 deletion src/recursion_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,15 @@ pub struct RecursionGuard {
}

// A hard limit to avoid stack overflows when rampant recursion occurs
pub const RECURSION_GUARD_LIMIT: u16 = if cfg!(target_family = "wasm") { 50 } else { 255 };
pub const RECURSION_GUARD_LIMIT: u16 = if cfg!(any(target_family = "wasm", all(windows, PyPy))) {
// wasm and windows PyPy have very limited stack sizes
50
} else if cfg!(any(PyPy, windows)) {
// PyPy and Windows in general have more restricted stack space
100
} else {
255
};

impl RecursionGuard {
// insert a new id into the set, return whether the set already had the id in it
Expand Down
13 changes: 6 additions & 7 deletions src/validators/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -477,13 +477,12 @@ macro_rules! py_err_string {
($error_value:expr, $type_member:ident, $input:ident) => {
match $error_value.str() {
Ok(py_string) => match py_string.to_str() {
Ok(s) => {
let error = match s.is_empty() {
true => "Unknown error".to_string(),
false => s.to_string(),
};
ValError::new(ErrorType::$type_member { error }, $input)
}
Ok(_) => ValError::new(
ErrorType::$type_member {
error: Some($error_value.into()),
},
$input,
),
Err(e) => ValError::InternalErr(e),
},
Err(e) => ValError::InternalErr(e),
Expand Down
7 changes: 4 additions & 3 deletions tests/benchmarks/test_micro_benchmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ def definition_model_data():
data = {'width': -1}

_data = data
for i in range(100):
for i in range(pydantic_core._pydantic_core._recursion_limit - 2):
_data['branch'] = {'width': i}
_data = _data['branch']
return data
Expand Down Expand Up @@ -1189,14 +1189,15 @@ def f(v: int, info: core_schema.FieldValidationInfo) -> int:
return v + 1

schema: core_schema.CoreSchema = core_schema.int_schema()
limit = pydantic_core._pydantic_core._recursion_limit - 3

for _ in range(100):
for _ in range(limit):
schema = core_schema.field_after_validator_function(f, 'x', schema)

schema = core_schema.typed_dict_schema({'x': core_schema.typed_dict_field(schema)})

v = SchemaValidator(schema)
payload = {'x': 0}
assert v.validate_python(payload) == {'x': 100}
assert v.validate_python(payload) == {'x': limit}

benchmark(v.validate_python, payload)
10 changes: 10 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,16 @@ def _import_execute(source: str, *, custom_module_name: 'str | None' = None):
return _import_execute


@pytest.fixture
def pydantic_version():
try:
import pydantic

return pydantic.__version__
except ImportError:
return 'latest'


def infinite_generator():
i = 0
while True:
Expand Down
6 changes: 3 additions & 3 deletions tests/test_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import pytest

from pydantic_core import SchemaError, SchemaValidator, __version__
from pydantic_core import SchemaError, SchemaValidator
from pydantic_core import core_schema as cs


Expand Down Expand Up @@ -32,13 +32,13 @@ def test_schema_as_string():
assert v.validate_python('tRuE') is True


def test_schema_wrong_type():
def test_schema_wrong_type(pydantic_version):
with pytest.raises(SchemaError) as exc_info:
SchemaValidator(1)
assert str(exc_info.value) == (
'Invalid Schema:\n Input should be a valid dictionary or object to'
' extract fields from [type=model_attributes_type, input_value=1, input_type=int]\n'
f' For further information visit https://errors.pydantic.dev/{__version__}/v/model_attributes_type'
f' For further information visit https://errors.pydantic.dev/{pydantic_version}/v/model_attributes_type'
)
assert exc_info.value.errors() == [
{
Expand Down
Loading