diff --git a/src/validators/date.rs b/src/validators/date.rs index 2b64b71c6..d85af4a13 100644 --- a/src/validators/date.rs +++ b/src/validators/date.rs @@ -1,6 +1,7 @@ +use pyo3::exceptions::PyValueError; use pyo3::intern; use pyo3::prelude::*; -use pyo3::types::{PyDate, PyDict, PyString}; +use pyo3::types::{PyDict, PyString}; use speedate::{Date, Time}; use strum::EnumMessage; @@ -175,9 +176,14 @@ impl DateConstraints { } } -fn convert_pydate(schema: &Bound<'_, PyDict>, field: &Bound<'_, PyString>) -> PyResult> { - match schema.get_as::>(field)? { - Some(date) => Ok(Some(EitherDate::Py(date).as_raw()?)), +fn convert_pydate(schema: &Bound<'_, PyDict>, key: &Bound<'_, PyString>) -> PyResult> { + match schema.get_as::>(key)? { + Some(value) => match value.validate_date(false) { + Ok(v) => Ok(Some(v.into_inner().as_raw()?)), + Err(_) => Err(PyValueError::new_err(format!( + "'{key}' must be coercible to a date instance", + ))), + }, None => Ok(None), } } diff --git a/src/validators/datetime.rs b/src/validators/datetime.rs index 0d685216f..fad79deff 100644 --- a/src/validators/datetime.rs +++ b/src/validators/datetime.rs @@ -1,8 +1,9 @@ +use pyo3::exceptions::PyValueError; use pyo3::intern; use pyo3::prelude::*; use pyo3::sync::GILOnceCell; use pyo3::types::{PyDict, PyString}; -use speedate::{DateTime, Time}; +use speedate::{DateTime, MicrosecondsPrecisionOverflowBehavior, Time}; use std::cmp::Ordering; use strum::EnumMessage; @@ -208,9 +209,14 @@ impl DateTimeConstraints { } } -fn py_datetime_as_datetime(schema: &Bound<'_, PyDict>, field: &Bound<'_, PyString>) -> PyResult> { - match schema.get_as(field)? { - Some(dt) => Ok(Some(EitherDateTime::Py(dt).as_raw()?)), +fn py_datetime_as_datetime(schema: &Bound<'_, PyDict>, key: &Bound<'_, PyString>) -> PyResult> { + match schema.get_as::>(key)? { + Some(value) => match value.validate_datetime(false, MicrosecondsPrecisionOverflowBehavior::Truncate) { + Ok(v) => Ok(Some(v.into_inner().as_raw()?)), + Err(_) => Err(PyValueError::new_err(format!( + "'{key}' must be coercible to a datetime instance", + ))), + }, None => Ok(None), } } diff --git a/src/validators/decimal.rs b/src/validators/decimal.rs index 6c2e55806..4f6db8ae8 100644 --- a/src/validators/decimal.rs +++ b/src/validators/decimal.rs @@ -1,7 +1,7 @@ use pyo3::exceptions::{PyTypeError, PyValueError}; use pyo3::intern; use pyo3::sync::GILOnceCell; -use pyo3::types::{IntoPyDict, PyDict, PyTuple, PyType}; +use pyo3::types::{IntoPyDict, PyDict, PyString, PyTuple, PyType}; use pyo3::{prelude::*, PyTypeInfo}; use crate::build_tools::{is_strict, schema_or_config_same}; @@ -28,6 +28,18 @@ pub fn get_decimal_type(py: Python) -> &Bound<'_, PyType> { .bind(py) } +fn validate_as_decimal(py: Python, schema: &Bound<'_, PyDict>, key: &str) -> PyResult>> { + match schema.get_as::>(&PyString::new(py, key))? { + Some(value) => match value.validate_decimal(false, py) { + Ok(v) => Ok(Some(v.into_inner().unbind())), + Err(_) => Err(PyValueError::new_err(format!( + "'{key}' must be coercible to a Decimal instance", + ))), + }, + None => Ok(None), + } +} + #[derive(Debug, Clone)] pub struct DecimalValidator { strict: bool, @@ -50,6 +62,7 @@ impl BuildValidator for DecimalValidator { _definitions: &mut DefinitionsBuilder, ) -> PyResult { let py = schema.py(); + let allow_inf_nan = schema_or_config_same(schema, config, intern!(py, "allow_inf_nan"))?.unwrap_or(false); let decimal_places = schema.get_as(intern!(py, "decimal_places"))?; let max_digits = schema.get_as(intern!(py, "max_digits"))?; @@ -58,16 +71,17 @@ impl BuildValidator for DecimalValidator { "allow_inf_nan=True cannot be used with max_digits or decimal_places", )); } + Ok(Self { strict: is_strict(schema, config)?, allow_inf_nan, check_digits: decimal_places.is_some() || max_digits.is_some(), decimal_places, - multiple_of: schema.get_as(intern!(py, "multiple_of"))?, - le: schema.get_as(intern!(py, "le"))?, - lt: schema.get_as(intern!(py, "lt"))?, - ge: schema.get_as(intern!(py, "ge"))?, - gt: schema.get_as(intern!(py, "gt"))?, + multiple_of: validate_as_decimal(py, schema, "multiple_of")?, + le: validate_as_decimal(py, schema, "le")?, + lt: validate_as_decimal(py, schema, "lt")?, + ge: validate_as_decimal(py, schema, "ge")?, + gt: validate_as_decimal(py, schema, "gt")?, max_digits, } .into()) diff --git a/src/validators/int.rs b/src/validators/int.rs index dd30ef5c3..8d30f215c 100644 --- a/src/validators/int.rs +++ b/src/validators/int.rs @@ -1,7 +1,8 @@ use num_bigint::BigInt; +use pyo3::exceptions::PyValueError; use pyo3::intern; use pyo3::prelude::*; -use pyo3::types::PyDict; +use pyo3::types::{PyDict, PyString}; use pyo3::IntoPyObjectExt; use crate::build_tools::is_strict; @@ -11,6 +12,23 @@ use crate::tools::SchemaDict; use super::{BuildValidator, CombinedValidator, DefinitionsBuilder, ValidationState, Validator}; +fn validate_as_int(py: Python, schema: &Bound<'_, PyDict>, key: &str) -> PyResult> { + match schema.get_as::>(&PyString::new(py, key))? { + Some(value) => match value.validate_int(false) { + Ok(v) => match v.into_inner().as_int() { + Ok(v) => Ok(Some(v)), + Err(_) => Err(PyValueError::new_err(format!( + "'{key}' must be coercible to an integer" + ))), + }, + Err(_) => Err(PyValueError::new_err(format!( + "'{key}' must be coercible to an integer" + ))), + }, + None => Ok(None), + } +} + #[derive(Debug, Clone)] pub struct IntValidator { strict: bool, @@ -70,6 +88,21 @@ pub struct ConstrainedIntValidator { gt: Option, } +impl ConstrainedIntValidator { + fn build(schema: &Bound<'_, PyDict>, config: Option<&Bound<'_, PyDict>>) -> PyResult { + let py = schema.py(); + Ok(Self { + strict: is_strict(schema, config)?, + multiple_of: validate_as_int(py, schema, "multiple_of")?, + le: validate_as_int(py, schema, "le")?, + lt: validate_as_int(py, schema, "lt")?, + ge: validate_as_int(py, schema, "ge")?, + gt: validate_as_int(py, schema, "gt")?, + } + .into()) + } +} + impl_py_gc_traverse!(ConstrainedIntValidator {}); impl Validator for ConstrainedIntValidator { @@ -144,18 +177,3 @@ impl Validator for ConstrainedIntValidator { "constrained-int" } } - -impl ConstrainedIntValidator { - fn build(schema: &Bound<'_, PyDict>, config: Option<&Bound<'_, PyDict>>) -> PyResult { - let py = schema.py(); - Ok(Self { - strict: is_strict(schema, config)?, - multiple_of: schema.get_as(intern!(py, "multiple_of"))?, - le: schema.get_as(intern!(py, "le"))?, - lt: schema.get_as(intern!(py, "lt"))?, - ge: schema.get_as(intern!(py, "ge"))?, - gt: schema.get_as(intern!(py, "gt"))?, - } - .into()) - } -} diff --git a/tests/validators/test_date.py b/tests/validators/test_date.py index 90cb1d797..202a8f205 100644 --- a/tests/validators/test_date.py +++ b/tests/validators/test_date.py @@ -13,6 +13,15 @@ from ..conftest import Err, PyAndJson +@pytest.mark.parametrize( + 'constraint', + ['le', 'lt', 'ge', 'gt'], +) +def test_constraints_schema_validation(constraint: str) -> None: + with pytest.raises(SchemaError, match=f"'{constraint}' must be coercible to a date instance"): + SchemaValidator(cs.date_schema(**{constraint: 'bad_value'})) + + @pytest.mark.parametrize( 'input_value,expected', [ diff --git a/tests/validators/test_datetime.py b/tests/validators/test_datetime.py index f9ee69bf4..16ea0b4e0 100644 --- a/tests/validators/test_datetime.py +++ b/tests/validators/test_datetime.py @@ -14,6 +14,15 @@ from ..conftest import Err, PyAndJson +@pytest.mark.parametrize( + 'constraint', + ['le', 'lt', 'ge', 'gt'], +) +def test_constraints_schema_validation(constraint: str) -> None: + with pytest.raises(SchemaError, match=f"'{constraint}' must be coercible to a datetime instance"): + SchemaValidator(cs.datetime_schema(**{constraint: 'bad_value'})) + + @pytest.mark.parametrize( 'input_value,expected', [ diff --git a/tests/validators/test_decimal.py b/tests/validators/test_decimal.py index 074425b5f..1a8d15871 100644 --- a/tests/validators/test_decimal.py +++ b/tests/validators/test_decimal.py @@ -9,7 +9,7 @@ import pytest from dirty_equals import FunctionCheck, IsStr -from pydantic_core import SchemaValidator, ValidationError, core_schema +from pydantic_core import SchemaError, SchemaValidator, ValidationError from pydantic_core import core_schema as cs from ..conftest import Err, PyAndJson, plain_repr @@ -19,6 +19,17 @@ class DecimalSubclass(Decimal): pass +# Note: there's another constraint validation (allow_inf_nan=True cannot be used with max_digits or decimal_places). +# but it is tested in Pydantic: +@pytest.mark.parametrize( + 'constraint', + ['multiple_of', 'le', 'lt', 'ge', 'gt'], +) +def test_constraints_schema_validation(constraint: str) -> None: + with pytest.raises(SchemaError, match=f"'{constraint}' must be coercible to a Decimal instance"): + SchemaValidator(cs.decimal_schema(**{constraint: 'bad_value'})) + + @pytest.mark.parametrize( 'input_value,expected', [ @@ -487,20 +498,20 @@ def test_validate_max_digits_and_decimal_places_edge_case() -> None: def test_str_validation_w_strict() -> None: - s = SchemaValidator(core_schema.decimal_schema(strict=True)) + s = SchemaValidator(cs.decimal_schema(strict=True)) with pytest.raises(ValidationError): assert s.validate_python('1.23') def test_str_validation_w_lax() -> None: - s = SchemaValidator(core_schema.decimal_schema(strict=False)) + s = SchemaValidator(cs.decimal_schema(strict=False)) assert s.validate_python('1.23') == Decimal('1.23') def test_union_with_str_prefers_str() -> None: - s = SchemaValidator(core_schema.union_schema([core_schema.decimal_schema(), core_schema.str_schema()])) + s = SchemaValidator(cs.union_schema([cs.decimal_schema(), cs.str_schema()])) assert s.validate_python('1.23') == '1.23' assert s.validate_python(1.23) == Decimal('1.23') diff --git a/tests/validators/test_int.py b/tests/validators/test_int.py index ffd8a85c6..f16bf1377 100644 --- a/tests/validators/test_int.py +++ b/tests/validators/test_int.py @@ -6,7 +6,7 @@ import pytest from dirty_equals import IsStr -from pydantic_core import SchemaValidator, ValidationError, core_schema +from pydantic_core import SchemaError, SchemaValidator, ValidationError from pydantic_core import core_schema as cs from ..conftest import Err, PyAndJson, plain_repr @@ -14,6 +14,15 @@ i64_max = 9_223_372_036_854_775_807 +@pytest.mark.parametrize( + 'constraint', + ['multiple_of', 'le', 'lt', 'ge', 'gt'], +) +def test_constraints_schema_validation(constraint: str) -> None: + with pytest.raises(SchemaError, match=f"'{constraint}' must be coercible to an integer"): + SchemaValidator(cs.int_schema(**{constraint: 'bad_value'})) + + @pytest.mark.parametrize( 'input_value,expected', [ @@ -532,7 +541,7 @@ class PlainEnum(Enum): def test_allow_inf_nan_true_json() -> None: - v = SchemaValidator(core_schema.int_schema(), config=core_schema.CoreConfig(allow_inf_nan=True)) + v = SchemaValidator(cs.int_schema(), config=cs.CoreConfig(allow_inf_nan=True)) assert v.validate_json('123') == 123 with pytest.raises(ValidationError, match=r'Input should be a finite number \[type=finite_number'): @@ -544,7 +553,7 @@ def test_allow_inf_nan_true_json() -> None: def test_allow_inf_nan_false_json() -> None: - v = SchemaValidator(core_schema.int_schema(), config=core_schema.CoreConfig(allow_inf_nan=False)) + v = SchemaValidator(cs.int_schema(), config=cs.CoreConfig(allow_inf_nan=False)) assert v.validate_json('123') == 123 with pytest.raises(ValidationError, match=r'Input should be a finite number \[type=finite_number'):