Skip to content

Commit

Permalink
Error URLs (#570)
Browse files Browse the repository at this point in the history
  • Loading branch information
samuelcolvin authored May 2, 2023
1 parent 817af72 commit b6eff4c
Show file tree
Hide file tree
Showing 49 changed files with 558 additions and 423 deletions.
4 changes: 2 additions & 2 deletions pydantic_core/_pydantic_core.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,8 @@ class ValidationError(ValueError):
@property
def title(self) -> str: ...
def error_count(self) -> int: ...
def errors(self, include_context: bool = True) -> 'list[ErrorDetails]': ...
def json(self, indent: 'int | None' = None, include_context: bool = False) -> str: ...
def errors(self, *, include_url: bool = True, include_context: bool = True) -> 'list[ErrorDetails]': ...
def json(self, *, indent: 'int | None' = None, include_url: bool = True, include_context: bool = False) -> str: ...

class PydanticCustomError(ValueError):
def __init__(
Expand Down
2 changes: 1 addition & 1 deletion src/build_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ impl SchemaError {
fn errors(&self, py: Python) -> PyResult<Py<PyList>> {
match &self.0 {
SchemaErrorEnum::Message(_) => Ok(PyList::empty(py).into_py(py)),
SchemaErrorEnum::ValidationError(error) => error.errors(py, None),
SchemaErrorEnum::ValidationError(error) => error.errors(py, false, true),
}
}

Expand Down
92 changes: 79 additions & 13 deletions src/errors/validation_exception.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,26 @@ use std::fmt;
use std::fmt::{Display, Write};
use std::str::from_utf8;

use crate::errors::LocItem;
use pyo3::exceptions::{PyKeyError, PyTypeError, PyValueError};
use pyo3::ffi::Py_ssize_t;
use pyo3::once_cell::GILOnceCell;
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyList, PyString};
use pyo3::{ffi, intern};
use serde::ser::{Error, SerializeMap, SerializeSeq};
use serde::{Serialize, Serializer};

use serde_json::ser::PrettyFormatter;

use crate::build_tools::{py_error_type, safe_repr, SchemaDict};
use crate::errors::LocItem;
use crate::get_version;
use crate::serializers::{SerMode, SerializationState};
use crate::PydanticCustomError;

use super::line_error::ValLineError;
use super::location::Location;
use super::types::{ErrorMode, ErrorType};
use super::value_exception::PydanticCustomError;
use super::ValError;

#[pyclass(subclass, extends=PyValueError, module="pydantic_core._pydantic_core")]
Expand Down Expand Up @@ -67,7 +70,8 @@ impl ValidationError {
}

pub fn display(&self, py: Python, prefix_override: Option<&'static str>) -> String {
let line_errors = pretty_py_line_errors(py, &self.error_mode, self.line_errors.iter());
let url_prefix = get_url_prefix(py, include_url_env(py));
let line_errors = pretty_py_line_errors(py, &self.error_mode, self.line_errors.iter(), url_prefix);
if let Some(prefix) = prefix_override {
format!("{prefix}\n{line_errors}")
} else {
Expand All @@ -83,6 +87,29 @@ impl ValidationError {
}
}

static URL_ENV_VAR: GILOnceCell<bool> = GILOnceCell::new();

fn _get_include_url_env() -> bool {
match std::env::var("PYDANTIC_ERRORS_OMIT_URL") {
Ok(val) => val.is_empty(),
Err(_) => true,
}
}

fn include_url_env(py: Python) -> bool {
*URL_ENV_VAR.get_or_init(py, _get_include_url_env)
}

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())))
} else {
None
}
}

// used to convert a validation error back to ValError for wrap functions
impl<'a> IntoPy<ValError<'a>> for ValidationError {
fn into_py(self, py: Python) -> ValError<'a> {
Expand Down Expand Up @@ -114,7 +141,9 @@ impl ValidationError {
self.line_errors.len()
}

pub fn errors(&self, py: Python, include_context: Option<bool>) -> PyResult<Py<PyList>> {
#[pyo3(signature = (*, include_url = true, include_context = true))]
pub fn errors(&self, py: Python, include_url: bool, include_context: bool) -> PyResult<Py<PyList>> {
let url_prefix = get_url_prefix(py, include_url);
// taken approximately from the pyo3, but modified to return the error during iteration
// https://github.com/PyO3/pyo3/blob/a3edbf4fcd595f0e234c87d4705eb600a9779130/src/types/list.rs#L27-L55
unsafe {
Expand All @@ -126,26 +155,29 @@ impl ValidationError {
let list: Py<PyList> = Py::from_owned_ptr(py, ptr);

for (index, line_error) in (0_isize..).zip(&self.line_errors) {
let item = line_error.as_dict(py, include_context, &self.error_mode)?;
let item = line_error.as_dict(py, url_prefix, include_context, &self.error_mode)?;
ffi::PyList_SET_ITEM(ptr, index, item.into_ptr());
}

Ok(list)
}
}

#[pyo3(signature = (*, indent = None, include_url = true, include_context = true))]
pub fn json<'py>(
&self,
py: Python<'py>,
indent: Option<usize>,
include_context: Option<bool>,
include_url: bool,
include_context: bool,
) -> PyResult<&'py PyString> {
let state = SerializationState::new(None, None);
let extra = state.extra(py, &SerMode::Json, true, false, false, true, None);
let serializer = ValidationErrorSerializer {
py,
line_errors: &self.line_errors,
include_context: include_context.unwrap_or(true),
url_prefix: get_url_prefix(py, include_url),
include_context,
extra: &extra,
error_mode: &self.error_mode,
};
Expand Down Expand Up @@ -197,9 +229,10 @@ pub fn pretty_py_line_errors<'a>(
py: Python,
error_mode: &ErrorMode,
line_errors_iter: impl Iterator<Item = &'a PyLineError>,
url_prefix: Option<&str>,
) -> String {
line_errors_iter
.map(|i| i.pretty(py, error_mode))
.map(|i| i.pretty(py, error_mode, url_prefix))
.collect::<Result<Vec<_>, _>>()
.unwrap_or_else(|err| vec![format!("[error formatting line errors: {err}]")])
.join("\n")
Expand Down Expand Up @@ -274,21 +307,34 @@ impl TryFrom<&PyAny> for PyLineError {
}

impl PyLineError {
pub fn as_dict(&self, py: Python, include_context: Option<bool>, error_mode: &ErrorMode) -> PyResult<PyObject> {
fn get_error_url(&self, url_prefix: &str) -> String {
format!("{url_prefix}{}", self.error_type.type_string())
}

pub fn as_dict(
&self,
py: Python,
url_prefix: Option<&str>,
include_context: bool,
error_mode: &ErrorMode,
) -> PyResult<PyObject> {
let dict = PyDict::new(py);
dict.set_item("type", self.error_type.type_string())?;
dict.set_item("loc", self.location.to_object(py))?;
dict.set_item("msg", self.error_type.render_message(py, error_mode)?)?;
dict.set_item("input", &self.input_value)?;
if include_context.unwrap_or(true) {
if include_context {
if let Some(context) = self.error_type.py_dict(py)? {
dict.set_item("ctx", context)?;
}
}
if let Some(url_prefix) = url_prefix {
dict.set_item("url", self.get_error_url(url_prefix))?;
}
Ok(dict.into_py(py))
}

fn pretty(&self, py: Python, error_mode: &ErrorMode) -> Result<String, fmt::Error> {
fn pretty(&self, py: Python, error_mode: &ErrorMode, url_prefix: Option<&str>) -> Result<String, fmt::Error> {
let mut output = String::with_capacity(200);
write!(output, "{}", self.location)?;

Expand All @@ -305,7 +351,15 @@ impl PyLineError {
if let Ok(type_) = input_value.get_type().name() {
write!(output, ", input_type={type_}")?;
}
output.push(']');
if let Some(url_prefix) = url_prefix {
write!(
output,
"]\n For further information visit {}",
self.get_error_url(url_prefix)
)?;
} else {
output.push(']');
}
Ok(output)
}
}
Expand All @@ -324,6 +378,7 @@ where
struct ValidationErrorSerializer<'py> {
py: Python<'py>,
line_errors: &'py [PyLineError],
url_prefix: Option<&'py str>,
include_context: bool,
extra: &'py crate::serializers::Extra<'py>,
error_mode: &'py ErrorMode,
Expand All @@ -339,6 +394,7 @@ impl<'py> Serialize for ValidationErrorSerializer<'py> {
let line_s = PyLineErrorSerializer {
py: self.py,
line_error,
url_prefix: self.url_prefix,
include_context: self.include_context,
extra: self.extra,
error_mode: self.error_mode,
Expand All @@ -352,6 +408,7 @@ impl<'py> Serialize for ValidationErrorSerializer<'py> {
struct PyLineErrorSerializer<'py> {
py: Python<'py>,
line_error: &'py PyLineError,
url_prefix: Option<&'py str>,
include_context: bool,
extra: &'py crate::serializers::Extra<'py>,
error_mode: &'py ErrorMode,
Expand All @@ -363,7 +420,13 @@ impl<'py> Serialize for PyLineErrorSerializer<'py> {
S: Serializer,
{
let py = self.py;
let size = if self.include_context { 5 } else { 4 };
let mut size = 4;
if self.url_prefix.is_some() {
size += 1;
}
if self.include_context {
size += 1;
}
let mut map = serializer.serialize_map(Some(size))?;

map.serialize_entry("type", &self.line_error.error_type.type_string())?;
Expand All @@ -387,6 +450,9 @@ impl<'py> Serialize for PyLineErrorSerializer<'py> {
map.serialize_entry("ctx", &self.extra.serialize_infer(context.as_ref(py)))?;
}
}
if let Some(url_prefix) = self.url_prefix {
map.serialize_entry("url", &self.line_error.get_error_url(url_prefix))?;
}
map.end()
}
}
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ pub use serializers::{
pub use validators::SchemaValidator;

pub fn get_version() -> String {
let version = env!("CARGO_PKG_VERSION").to_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
Expand Down
2 changes: 1 addition & 1 deletion tests/benchmarks/test_complete_benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def test_complete_invalid():
lax_validator = SchemaValidator(lax_schema)
with pytest.raises(ValidationError) as exc_info:
lax_validator.validate_python(input_data_wrong())
assert len(exc_info.value.errors()) == 738
assert len(exc_info.value.errors(include_url=False)) == 738

model = pydantic_model()
if model is None:
Expand Down
17 changes: 17 additions & 0 deletions tests/benchmarks/test_micro_benchmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import Any, Dict, FrozenSet, List, Optional, Set, Union

import pytest
from dirty_equals import IsStr

from pydantic_core import ArgsKwargs, PydanticCustomError, SchemaValidator, ValidationError, core_schema
from pydantic_core import ValidationError as CoreValidationError
Expand Down Expand Up @@ -263,6 +264,21 @@ def t():
pass


@pytest.mark.benchmark(group='string')
def test_core_string_strict_wrong_str_e(benchmark):
validator = SchemaValidator(core_schema.str_schema(strict=True))

with pytest.raises(ValidationError, match='Input should be a valid string'):
validator.validate_python(123)

@benchmark
def t():
try:
validator.validate_python(123)
except ValidationError as e:
str(e)


@pytest.mark.benchmark(group='isinstance-string')
def test_isinstance_string_lax_true(benchmark):
validator = SchemaValidator(core_schema.str_schema())
Expand Down Expand Up @@ -1141,6 +1157,7 @@ def test_int_error(benchmark):
'loc': (),
'msg': 'Input should be a valid integer, unable to parse string as an integer',
'input': 'bar',
'url': IsStr(regex=r'https://errors.pydantic.dev/.*?/v/int_parsing'),
}
]
else:
Expand Down
5 changes: 3 additions & 2 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
from pydantic_core import SchemaError, SchemaValidator, __version__


def test_build_error_type():
Expand Down Expand Up @@ -36,7 +36,8 @@ def test_schema_wrong_type():
SchemaValidator(1)
assert str(exc_info.value) == (
'Invalid Schema:\n Input should be a valid dictionary or instance to'
' extract fields from [type=dict_attributes_type, input_value=1, input_type=int]'
' extract fields from [type=dict_attributes_type, input_value=1, input_type=int]\n'
f' For further information visit https://errors.pydantic.dev/{__version__}/v/dict_attributes_type'
)
assert exc_info.value.errors() == [
{
Expand Down
2 changes: 1 addition & 1 deletion tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ def test_sub_model_merge():
assert output == IsInstance(MyModel) & HasAttributes(f='test', sub_model=HasAttributes(f='TESTS'))
with pytest.raises(ValidationError) as exc_info:
v.validate_python({'f': 'tests', 'sub_model': {'f': ''}})
assert exc_info.value.errors() == [
assert exc_info.value.errors(include_url=False) == [
{
'type': 'string_too_long',
'loc': ('f',),
Expand Down
Loading

0 comments on commit b6eff4c

Please sign in to comment.