diff --git a/Cargo.lock b/Cargo.lock index 7a9cb45f9..668344b7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -327,9 +327,9 @@ checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83" [[package]] name = "speedate" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aae716b5946155732f9b3435a85774fac1f2d5a312a4af68042a56252fd683d6" +checksum = "03ed3f514cc2c3d04409c477e9928ef78fd341b9456a179645f7f2131658ea0d" dependencies = [ "strum", "strum_macros", diff --git a/Cargo.toml b/Cargo.toml index 81d760551..f82f923a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ enum_dispatch = "0.3.8" serde = "1.0.137" indexmap = "1.8.1" mimalloc = { version = "0.1.29", default-features = false, optional = true } -speedate = "0.3.0" +speedate = "0.4.0" [lib] name = "_pydantic_core" diff --git a/src/input/datetime.rs b/src/input/datetime.rs new file mode 100644 index 000000000..b47ee2a26 --- /dev/null +++ b/src/input/datetime.rs @@ -0,0 +1,172 @@ +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::{PyDate, PyDateTime, PyDelta, PyTzInfo}; +use speedate::{Date, DateTime}; +use strum::EnumMessage; + +use super::Input; +use crate::errors::{context, err_val_error, ErrorKind, InputValue, ValResult}; + +pub enum EitherDate<'a> { + Speedate(Date), + Python(&'a PyDate), +} + +impl<'a> EitherDate<'a> { + pub fn as_speedate(&self) -> PyResult { + match self { + Self::Speedate(date) => Ok(date.clone()), + Self::Python(py_date) => { + let date_str: &str = py_date.str()?.extract()?; + match Date::parse_str(date_str) { + Ok(date) => Ok(date), + Err(err) => { + let error_description = err.get_documentation().unwrap_or_default(); + let msg = format!("Unable to parse date {}, error: {}", date_str, error_description); + Err(PyValueError::new_err(msg)) + } + } + } + } + } + + pub fn as_python(&self, py: Python<'a>) -> PyResult<&'a PyDate> { + match self { + Self::Speedate(date) => PyDate::new(py, date.year as i32, date.month, date.day), + Self::Python(date) => Ok(date), + } + } +} + +pub enum EitherDateTime<'a> { + Speedate(DateTime), + Python(&'a PyDateTime), +} + +impl<'a> EitherDateTime<'a> { + pub fn as_speedate(&self) -> PyResult { + match self { + Self::Speedate(dt) => Ok(dt.clone()), + Self::Python(py_dt) => { + let dt_str: &str = py_dt.str()?.extract()?; + match DateTime::parse_str(dt_str) { + Ok(dt) => Ok(dt), + Err(err) => { + let error_description = err.get_documentation().unwrap_or_default(); + let msg = format!("Unable to parse datetime {}, error: {}", dt_str, error_description); + Err(PyValueError::new_err(msg)) + } + } + } + } + } + + pub fn as_python(&self, py: Python<'a>) -> PyResult<&'a PyDateTime> { + match self { + Self::Speedate(datetime) => { + let tz: Option = match datetime.offset { + Some(offset) => { + let tz_info = TzClass::new(offset); + Some(Py::new(py, tz_info)?.to_object(py)) + } + None => None, + }; + PyDateTime::new( + py, + datetime.date.year as i32, + datetime.date.month, + datetime.date.day, + datetime.time.hour, + datetime.time.minute, + datetime.time.second, + datetime.time.microsecond, + tz.as_ref(), + ) + } + Self::Python(dt) => Ok(dt), + } + } +} + +pub fn bytes_as_date<'a>(input: &'a dyn Input, bytes: &[u8]) -> ValResult<'a, EitherDate<'a>> { + match Date::parse_bytes(bytes) { + Ok(date) => Ok(EitherDate::Speedate(date)), + Err(err) => { + err_val_error!( + input_value = InputValue::InputRef(input), + kind = ErrorKind::DateParsing, + context = context!("parsing_error" => err.get_documentation().unwrap_or_default()) + ) + } + } +} + +pub fn bytes_as_datetime<'a, 'b>(input: &'a dyn Input, bytes: &'b [u8]) -> ValResult<'a, EitherDateTime<'a>> { + match DateTime::parse_bytes(bytes) { + Ok(dt) => Ok(EitherDateTime::Speedate(dt)), + Err(err) => { + err_val_error!( + input_value = InputValue::InputRef(input), + kind = ErrorKind::DateTimeParsing, + context = context!("parsing_error" => err.get_documentation().unwrap_or_default()) + ) + } + } +} + +pub fn int_as_datetime(input: &dyn Input, timestamp: i64, timestamp_microseconds: u32) -> ValResult { + match DateTime::from_timestamp(timestamp, timestamp_microseconds) { + Ok(dt) => Ok(EitherDateTime::Speedate(dt)), + Err(err) => { + err_val_error!( + input_value = InputValue::InputRef(input), + kind = ErrorKind::DateTimeParsing, + context = context!("parsing_error" => err.get_documentation().unwrap_or_default()) + ) + } + } +} + +pub fn float_as_datetime(input: &dyn Input, timestamp: f64) -> ValResult { + let microseconds = timestamp.fract().abs() * 1_000_000.0; + if microseconds % 1.0 > 1e-3 { + return err_val_error!( + input_value = InputValue::InputRef(input), + kind = ErrorKind::DateTimeParsing, + // message copied from speedate + context = context!("parsing_error" => "second fraction value is more than 6 digits long") + ); + } + int_as_datetime(input, timestamp.floor() as i64, microseconds as u32) +} + +#[pyclass(module = "pydantic_core._pydantic_core", extends = PyTzInfo)] +#[derive(Debug, Clone)] +struct TzClass { + seconds: i32, +} + +#[pymethods] +impl TzClass { + #[new] + fn new(seconds: i32) -> Self { + Self { seconds } + } + + fn utcoffset<'p>(&self, py: Python<'p>, _dt: &PyDateTime) -> PyResult<&'p PyDelta> { + PyDelta::new(py, 0, self.seconds, 0, true) + } + + fn tzname(&self, _py: Python<'_>, _dt: &PyDateTime) -> String { + if self.seconds == 0 { + "UTC".to_string() + } else { + let mins = self.seconds / 60; + format!("{:+03}:{:02}", mins / 60, (mins % 60).abs()) + } + } + + fn dst(&self, _py: Python<'_>, _dt: &PyDateTime) -> Option<&PyDelta> { + None + } +} diff --git a/src/input/input_abstract.rs b/src/input/input_abstract.rs index a4ffb6dcc..9ee73caee 100644 --- a/src/input/input_abstract.rs +++ b/src/input/input_abstract.rs @@ -1,10 +1,10 @@ use std::fmt; -use pyo3::prelude::*; -use pyo3::types::{PyDate, PyDateTime, PyType}; +use pyo3::types::PyType; use crate::errors::ValResult; +use super::datetime::{EitherDate, EitherDateTime}; use super::{GenericMapping, GenericSequence, ToLocItem, ToPy}; pub trait Input: fmt::Debug + ToPy + ToLocItem { @@ -46,15 +46,15 @@ pub trait Input: fmt::Debug + ToPy + ToLocItem { self.strict_set() } - fn strict_date<'data>(&'data self, py: Python<'data>) -> ValResult<&'data PyDate>; + fn strict_date(&self) -> ValResult; - fn lax_date<'data>(&'data self, py: Python<'data>) -> ValResult<&'data PyDate> { - self.strict_date(py) + fn lax_date(&self) -> ValResult { + self.strict_date() } - fn strict_datetime<'data>(&'data self, py: Python<'data>) -> ValResult<&'data PyDateTime>; + fn strict_datetime(&self) -> ValResult; - fn lax_datetime<'data>(&'data self, py: Python<'data>) -> ValResult<&'data PyDateTime> { - self.strict_datetime(py) + fn lax_datetime(&self) -> ValResult { + self.strict_datetime() } } diff --git a/src/input/input_json.rs b/src/input/input_json.rs index 8acd1cfc0..e2ac13685 100644 --- a/src/input/input_json.rs +++ b/src/input/input_json.rs @@ -1,15 +1,14 @@ -use pyo3::prelude::*; -use pyo3::types::{PyDate, PyDateTime, PyType}; +use pyo3::types::PyType; use crate::errors::{err_val_error, ErrorKind, InputValue, ValResult}; +use super::datetime::{ + bytes_as_date, bytes_as_datetime, float_as_datetime, int_as_datetime, EitherDate, EitherDateTime, +}; use super::generics::{GenericMapping, GenericSequence}; use super::input_abstract::Input; use super::parse_json::JsonInput; -use super::shared::{ - bytes_as_date, bytes_as_datetime, date_as_py_date, datetime_as_py_datetime, float_as_datetime, float_as_int, - int_as_bool, int_as_datetime, str_as_bool, str_as_int, -}; +use super::shared::{float_as_int, int_as_bool, str_as_bool, str_as_int}; impl Input for JsonInput { fn is_none(&self) -> bool { @@ -119,24 +118,20 @@ impl Input for JsonInput { } } - fn strict_date<'data>(&'data self, py: Python<'data>) -> ValResult<&'data PyDate> { + fn strict_date(&self) -> ValResult { match self { - JsonInput::String(v) => { - let date = bytes_as_date(self, v.as_bytes())?; - date_as_py_date!(py, date) - } + JsonInput::String(v) => bytes_as_date(self, v.as_bytes()), _ => err_val_error!(input_value = InputValue::InputRef(self), kind = ErrorKind::DateType), } } - fn strict_datetime<'data>(&'data self, py: Python<'data>) -> ValResult<&'data PyDateTime> { - let dt = match self { + fn strict_datetime(&self) -> ValResult { + match self { JsonInput::String(v) => bytes_as_datetime(self, v.as_bytes()), JsonInput::Int(v) => int_as_datetime(self, *v, 0), JsonInput::Float(v) => float_as_datetime(self, *v), _ => err_val_error!(input_value = InputValue::InputRef(self), kind = ErrorKind::DateTimeType), - }?; - datetime_as_py_datetime!(py, dt) + } } } @@ -214,12 +209,12 @@ impl Input for String { } #[no_coverage] - fn strict_date<'data>(&'data self, _py: Python<'data>) -> ValResult<&'data PyDate> { + fn strict_date(&self) -> ValResult { err_val_error!(input_value = InputValue::InputRef(self), kind = ErrorKind::DateType) } #[no_coverage] - fn strict_datetime<'data>(&'data self, _py: Python<'data>) -> ValResult<&'data PyDateTime> { + fn strict_datetime(&self) -> ValResult { err_val_error!(input_value = InputValue::InputRef(self), kind = ErrorKind::DateTimeType) } } diff --git a/src/input/input_python.rs b/src/input/input_python.rs index 5ccaefba7..a5f760c3d 100644 --- a/src/input/input_python.rs +++ b/src/input/input_python.rs @@ -7,14 +7,13 @@ use pyo3::types::{ }; use crate::errors::{as_internal, err_val_error, ErrorKind, InputValue, ValResult}; -use crate::input::shared::bytes_as_datetime; +use super::datetime::{ + bytes_as_date, bytes_as_datetime, float_as_datetime, int_as_datetime, EitherDate, EitherDateTime, +}; use super::generics::{GenericMapping, GenericSequence}; use super::input_abstract::Input; -use super::shared::{ - bytes_as_date, date_as_py_date, datetime_as_py_datetime, float_as_datetime, float_as_int, int_as_bool, - int_as_datetime, str_as_bool, str_as_int, -}; +use super::shared::{float_as_int, int_as_bool, str_as_bool, str_as_int}; impl Input for PyAny { fn is_none(&self) -> bool { @@ -211,62 +210,54 @@ impl Input for PyAny { } } - fn strict_date<'data>(&'data self, _py: Python<'data>) -> ValResult<&'data PyDate> { + fn strict_date(&self) -> ValResult { if self.cast_as::().is_ok() { // have to check if it's a datetime first, otherwise the line below converts to a date err_val_error!(input_value = InputValue::InputRef(self), kind = ErrorKind::DateType) } else if let Ok(date) = self.cast_as::() { - Ok(date) + Ok(EitherDate::Python(date)) } else { err_val_error!(input_value = InputValue::InputRef(self), kind = ErrorKind::DateType) } } - fn lax_date<'data>(&'data self, py: Python<'data>) -> ValResult<&'data PyDate> { + fn lax_date(&self) -> ValResult { if self.cast_as::().is_ok() { // have to check if it's a datetime first, otherwise the line below converts to a date // even if we later try coercion from a datetime, we don't want to return a datetime now - return err_val_error!(input_value = InputValue::InputRef(self), kind = ErrorKind::DateType); + err_val_error!(input_value = InputValue::InputRef(self), kind = ErrorKind::DateType) } else if let Ok(date) = self.cast_as::() { - return Ok(date); - } - - if let Ok(str) = self.extract::() { - let date = bytes_as_date(self, str.as_bytes())?; - date_as_py_date!(py, date) + Ok(EitherDate::Python(date)) + } else if let Ok(str) = self.extract::() { + bytes_as_date(self, str.as_bytes()) } else if let Ok(py_bytes) = self.cast_as::() { - let date = bytes_as_date(self, py_bytes.as_bytes())?; - date_as_py_date!(py, date) + bytes_as_date(self, py_bytes.as_bytes()) } else { err_val_error!(input_value = InputValue::InputRef(self), kind = ErrorKind::DateType) } } - fn strict_datetime<'data>(&'data self, _py: Python<'data>) -> ValResult<&'data PyDateTime> { + fn strict_datetime(&self) -> ValResult { if let Ok(dt) = self.cast_as::() { - Ok(dt) + Ok(EitherDateTime::Python(dt)) } else { err_val_error!(input_value = InputValue::InputRef(self), kind = ErrorKind::DateTimeType) } } - fn lax_datetime<'data>(&'data self, py: Python<'data>) -> ValResult<&'data PyDateTime> { + fn lax_datetime(&self) -> ValResult { if let Ok(dt) = self.cast_as::() { - Ok(dt) + Ok(EitherDateTime::Python(dt)) } else if let Ok(str) = self.extract::() { - let dt = bytes_as_datetime(self, str.as_bytes())?; - datetime_as_py_datetime!(py, dt) + bytes_as_datetime(self, str.as_bytes()) } else if let Ok(py_bytes) = self.cast_as::() { - let dt = bytes_as_datetime(self, py_bytes.as_bytes())?; - datetime_as_py_datetime!(py, dt) + bytes_as_datetime(self, py_bytes.as_bytes()) } else if self.cast_as::().is_ok() { err_val_error!(input_value = InputValue::InputRef(self), kind = ErrorKind::DateTimeType) } else if let Ok(int) = self.extract::() { - let dt = int_as_datetime(self, int, 0)?; - datetime_as_py_datetime!(py, dt) + int_as_datetime(self, int, 0) } else if let Ok(float) = self.extract::() { - let dt = float_as_datetime(self, float)?; - datetime_as_py_datetime!(py, dt) + float_as_datetime(self, float) } else { err_val_error!(input_value = InputValue::InputRef(self), kind = ErrorKind::DateTimeType) } diff --git a/src/input/mod.rs b/src/input/mod.rs index 8fbfd22b8..5c48bf556 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,3 +1,4 @@ +mod datetime; mod generics; mod input_abstract; mod input_json; @@ -7,6 +8,7 @@ mod shared; mod to_loc_item; mod to_py; +pub use datetime::{EitherDate, EitherDateTime}; pub use generics::{GenericMapping, GenericSequence, MappingLenIter, SequenceLenIter}; pub use input_abstract::Input; pub use parse_json::JsonInput; diff --git a/src/input/shared.rs b/src/input/shared.rs index 424755ed0..93537501f 100644 --- a/src/input/shared.rs +++ b/src/input/shared.rs @@ -1,6 +1,3 @@ -use speedate::{Date, DateTime}; -use strum::EnumMessage; - use super::Input; use crate::errors::{context, err_val_error, ErrorKind, InputValue, ValResult}; @@ -77,80 +74,3 @@ pub fn float_as_int(input: &dyn Input, float: f64) -> ValResult { Ok(float as i64) } } - -pub fn bytes_as_date<'a>(input: &'a dyn Input, bytes: &[u8]) -> ValResult<'a, Date> { - match Date::parse_bytes(bytes) { - Ok(date) => Ok(date), - Err(err) => { - err_val_error!( - input_value = InputValue::InputRef(input), - kind = ErrorKind::DateParsing, - context = context!("parsing_error" => err.get_documentation().unwrap_or_default()) - ) - } - } -} - -pub fn bytes_as_datetime<'a, 'b>(input: &'a dyn Input, bytes: &'b [u8]) -> ValResult<'a, DateTime> { - match DateTime::parse_bytes(bytes) { - Ok(date) => Ok(date), - Err(err) => { - err_val_error!( - input_value = InputValue::InputRef(input), - kind = ErrorKind::DateTimeParsing, - context = context!("parsing_error" => err.get_documentation().unwrap_or_default()) - ) - } - } -} - -pub fn int_as_datetime(input: &dyn Input, timestamp: i64, timestamp_microseconds: u32) -> ValResult { - match DateTime::from_timestamp(timestamp, timestamp_microseconds) { - Ok(date) => Ok(date), - Err(err) => { - err_val_error!( - input_value = InputValue::InputRef(input), - kind = ErrorKind::DateTimeParsing, - context = context!("parsing_error" => err.get_documentation().unwrap_or_default()) - ) - } - } -} - -pub fn float_as_datetime(input: &dyn Input, timestamp: f64) -> ValResult { - let microseconds = timestamp.fract().abs() * 1_000_000.0; - if microseconds % 1.0 > 1e-3 { - return err_val_error!( - input_value = InputValue::InputRef(input), - kind = ErrorKind::DateTimeParsing, - // message copied from speedate - context = context!("parsing_error" => "second fraction value is more than 6 digits long") - ); - } - int_as_datetime(input, timestamp.floor() as i64, microseconds as u32) -} - -macro_rules! date_as_py_date { - ($py:ident, $date:ident) => { - pyo3::types::PyDate::new($py, $date.year as i32, $date.month, $date.day).map_err(crate::errors::as_internal) - }; -} -pub(crate) use date_as_py_date; - -macro_rules! datetime_as_py_datetime { - ($py:ident, $datetime:ident) => { - pyo3::types::PyDateTime::new( - $py, - $datetime.date.year as i32, - $datetime.date.month, - $datetime.date.day, - $datetime.time.hour, - $datetime.time.minute, - $datetime.time.second, - $datetime.time.microsecond, - None, - ) - .map_err(crate::errors::as_internal) - }; -} -pub(crate) use datetime_as_py_datetime; diff --git a/src/validators/date.rs b/src/validators/date.rs index 18de4f118..e120790e2 100644 --- a/src/validators/date.rs +++ b/src/validators/date.rs @@ -1,20 +1,27 @@ +use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -use pyo3::pyclass::CompareOp; -use pyo3::types::{PyDate, PyDict, PyTime}; +use pyo3::types::{PyDate, PyDict}; +use speedate::{Date, Time}; +use strum::EnumMessage; use crate::build_tools::{is_strict, SchemaDict}; use crate::errors::{as_internal, context, err_val_error, ErrorKind, InputValue, ValError, ValResult}; -use crate::input::Input; +use crate::input::{EitherDate, Input}; use super::{BuildContext, BuildValidator, CombinedValidator, Extra, Validator}; #[derive(Debug, Clone)] pub struct DateValidator { strict: bool, - le: Option>, - lt: Option>, - ge: Option>, - gt: Option>, + constraints: Option, +} + +#[derive(Debug, Clone)] +struct DateConstraints { + le: Option, + lt: Option, + ge: Option, + gt: Option, } impl BuildValidator for DateValidator { @@ -25,12 +32,22 @@ impl BuildValidator for DateValidator { config: Option<&PyDict>, _build_context: &mut BuildContext, ) -> PyResult { + let has_constraints = schema.get_item("le").is_some() + || schema.get_item("lt").is_some() + || schema.get_item("ge").is_some() + || schema.get_item("gt").is_some(); + Ok(Self { strict: is_strict(schema, config)?, - le: schema.get_as("le")?, - lt: schema.get_as("lt")?, - ge: schema.get_as("ge")?, - gt: schema.get_as("gt")?, + constraints: match has_constraints { + true => Some(DateConstraints { + le: py_date_as_date(schema, "le")?, + lt: py_date_as_date(schema, "lt")?, + ge: py_date_as_date(schema, "ge")?, + gt: py_date_as_date(schema, "gt")?, + }), + false => None, + }, } .into()) } @@ -45,54 +62,14 @@ impl Validator for DateValidator { _slots: &'data [CombinedValidator], ) -> ValResult<'data, PyObject> { let date = match self.strict { - true => input.strict_date(py)?, + true => input.strict_date()?, false => { - match input.lax_date(py) { + match input.lax_date() { Ok(date) => date, - Err(date_err) => { - let dt = match input.lax_datetime(py) { - Ok(dt) => dt, - Err(dt_err) => { - return match dt_err { - ValError::LineErrors(mut line_errors) => { - for line_error in line_errors.iter_mut() { - match line_error.kind { - ErrorKind::DateTimeParsing => { - line_error.kind = ErrorKind::DateFromDatetimeParsing; - } - _ => { - return Err(date_err); - } - } - } - Err(ValError::LineErrors(line_errors)) - } - ValError::InternalErr(internal_err) => Err(ValError::InternalErr(internal_err)), - }; - } - }; - // TODO replace all this with raw rust types once github.com/samuelcolvin/speedate#6 is done - - // we want to make sure the time is zero - e.g. the dt is an "exact date" - let dt_time: &PyTime = dt - .call_method0("time") - .map_err(as_internal)? - .extract() - .map_err(as_internal)?; - - let zero_time = PyTime::new(py, 0, 0, 0, 0, None).map_err(as_internal)?; - if dt_time.eq(zero_time).map_err(as_internal)? { - dt.call_method0("date") - .map_err(as_internal)? - .extract() - .map_err(as_internal)? - } else { - return err_val_error!( - input_value = InputValue::InputRef(input), - kind = ErrorKind::DateFromDatetimeInexact - ); - } - } + // if the date error was an internal error, return that immediately + Err(ValError::InternalErr(internal_err)) => return Err(ValError::InternalErr(internal_err)), + // otherwise, try creating a date from a datetime input + Err(date_err) => date_from_datetime(input, date_err)?, } } }; @@ -106,7 +83,7 @@ impl Validator for DateValidator { _extra: &Extra, _slots: &'data [CombinedValidator], ) -> ValResult<'data, PyObject> { - self.validation_comparison(py, input, input.strict_date(py)?) + self.validation_comparison(py, input, input.strict_date()?) } fn get_name(&self, _py: Python) -> String { @@ -119,29 +96,94 @@ impl DateValidator { &'s self, py: Python<'data>, input: &'data dyn Input, - date: &'data PyDate, + date: EitherDate<'data>, ) -> ValResult<'data, PyObject> { - macro_rules! check_constraint { - ($constraint_op:expr, $op:path, $error:path, $key:literal) => { - if let Some(constraint_py) = &$constraint_op { - let constraint: &PyDate = constraint_py.extract(py).map_err(as_internal)?; - let comparison_py = date.rich_compare(constraint, $op).map_err(as_internal)?; - let comparison: bool = comparison_py.extract().map_err(as_internal)?; - if !comparison { - return err_val_error!( - input_value = InputValue::InputRef(input), - kind = $error, - context = context!($key => constraint.to_string()) - ); + if let Some(constraints) = &self.constraints { + let speedate_date = date.as_speedate().map_err(as_internal)?; + + macro_rules! check_constraint { + ($constraint:ident, $error:path, $key:literal) => { + if let Some(constraint) = &constraints.$constraint { + if !speedate_date.$constraint(constraint) { + return err_val_error!( + input_value = InputValue::InputRef(input), + kind = $error, + context = context!($key => constraint.to_string()) + ); + } + } + }; + } + + check_constraint!(le, ErrorKind::LessThanEqual, "le"); + check_constraint!(lt, ErrorKind::LessThan, "lt"); + check_constraint!(ge, ErrorKind::GreaterThanEqual, "ge"); + check_constraint!(gt, ErrorKind::GreaterThan, "gt"); + } + Ok(date.as_python(py).map_err(as_internal)?.into_py(py)) + } +} + +/// In lax mode, if the input is not a date, we try parsing the input as a datetime, then check it is an +/// "exact date", e.g. has a zero time component. +fn date_from_datetime<'data>( + input: &'data dyn Input, + date_err: ValError<'data>, +) -> ValResult<'data, EitherDate<'data>> { + let either_dt = match input.lax_datetime() { + Ok(dt) => dt, + Err(dt_err) => { + return match dt_err { + ValError::LineErrors(mut line_errors) => { + // if we got a errors while parsing the datetime, + // convert DateTimeParsing -> DateFromDatetimeParsing but keep the rest of the error unchanged + for line_error in line_errors.iter_mut() { + match line_error.kind { + ErrorKind::DateTimeParsing => { + line_error.kind = ErrorKind::DateFromDatetimeParsing; + } + _ => { + return Err(date_err); + } + } } + Err(ValError::LineErrors(line_errors)) } + ValError::InternalErr(internal_err) => Err(ValError::InternalErr(internal_err)), }; } + }; + let dt = either_dt.as_speedate().map_err(as_internal)?; + let zero_time = Time { + hour: 0, + minute: 0, + second: 0, + microsecond: 0, + }; + if dt.time == zero_time && dt.offset.is_none() { + Ok(EitherDate::Speedate(dt.date)) + } else { + err_val_error!( + input_value = InputValue::InputRef(input), + kind = ErrorKind::DateFromDatetimeInexact + ) + } +} - check_constraint!(self.le, CompareOp::Le, ErrorKind::LessThanEqual, "le"); - check_constraint!(self.lt, CompareOp::Lt, ErrorKind::LessThan, "lt"); - check_constraint!(self.ge, CompareOp::Ge, ErrorKind::GreaterThanEqual, "ge"); - check_constraint!(self.gt, CompareOp::Gt, ErrorKind::GreaterThan, "gt"); - Ok(date.into_py(py)) +fn py_date_as_date(schema: &PyDict, field: &str) -> PyResult> { + let py_date: Option<&PyDate> = schema.get_as(field)?; + match py_date { + Some(py_date) => { + let date_str: &str = py_date.str()?.extract()?; + match Date::parse_str(date_str) { + Ok(date) => Ok(Some(date)), + Err(err) => { + let error_description = err.get_documentation().unwrap_or_default(); + let msg = format!("Unable to parse date {}, error: {}", date_str, error_description); + Err(PyValueError::new_err(msg)) + } + } + } + None => Ok(None), } } diff --git a/src/validators/datetime.rs b/src/validators/datetime.rs index ec98743c0..02e2a4459 100644 --- a/src/validators/datetime.rs +++ b/src/validators/datetime.rs @@ -1,20 +1,27 @@ +use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -use pyo3::pyclass::CompareOp; use pyo3::types::{PyDateTime, PyDict}; +use speedate::DateTime; +use strum::EnumMessage; use crate::build_tools::{is_strict, SchemaDict}; use crate::errors::{as_internal, context, err_val_error, ErrorKind, InputValue, ValResult}; -use crate::input::Input; +use crate::input::{EitherDateTime, Input}; use super::{BuildContext, BuildValidator, CombinedValidator, Extra, Validator}; #[derive(Debug, Clone)] pub struct DateTimeValidator { strict: bool, - le: Option>, - lt: Option>, - ge: Option>, - gt: Option>, + constraints: Option, +} + +#[derive(Debug, Clone)] +struct DateTimeConstraints { + le: Option, + lt: Option, + ge: Option, + gt: Option, } impl BuildValidator for DateTimeValidator { @@ -25,12 +32,22 @@ impl BuildValidator for DateTimeValidator { config: Option<&PyDict>, _build_context: &mut BuildContext, ) -> PyResult { + let has_constraints = schema.get_item("le").is_some() + || schema.get_item("lt").is_some() + || schema.get_item("ge").is_some() + || schema.get_item("gt").is_some(); + Ok(Self { strict: is_strict(schema, config)?, - le: schema.get_as("le")?, - lt: schema.get_as("lt")?, - ge: schema.get_as("ge")?, - gt: schema.get_as("gt")?, + constraints: match has_constraints { + true => Some(DateTimeConstraints { + le: py_datetime_as_datetime(schema, "le")?, + lt: py_datetime_as_datetime(schema, "lt")?, + ge: py_datetime_as_datetime(schema, "ge")?, + gt: py_datetime_as_datetime(schema, "gt")?, + }), + false => None, + }, } .into()) } @@ -45,8 +62,8 @@ impl Validator for DateTimeValidator { _slots: &'data [CombinedValidator], ) -> ValResult<'data, PyObject> { let date = match self.strict { - true => input.strict_datetime(py)?, - false => input.lax_datetime(py)?, + true => input.strict_datetime()?, + false => input.lax_datetime()?, }; self.validation_comparison(py, input, date) } @@ -58,7 +75,7 @@ impl Validator for DateTimeValidator { _extra: &Extra, _slots: &'data [CombinedValidator], ) -> ValResult<'data, PyObject> { - self.validation_comparison(py, input, input.strict_datetime(py)?) + self.validation_comparison(py, input, input.strict_datetime()?) } fn get_name(&self, _py: Python) -> String { @@ -71,29 +88,47 @@ impl DateTimeValidator { &'s self, py: Python<'data>, input: &'data dyn Input, - date: &'data PyDateTime, + datetime: EitherDateTime, ) -> ValResult<'data, PyObject> { - macro_rules! check_constraint { - ($constraint_op:expr, $op:path, $error:path, $key:literal) => { - if let Some(constraint_py) = &$constraint_op { - let constraint: &PyDateTime = constraint_py.extract(py).map_err(as_internal)?; - let comparison_py = date.rich_compare(constraint, $op).map_err(as_internal)?; - let comparison: bool = comparison_py.extract().map_err(as_internal)?; - if !comparison { - return err_val_error!( - input_value = InputValue::InputRef(input), - kind = $error, - context = context!($key => constraint.to_string()) - ); + if let Some(constraints) = &self.constraints { + let speedate_dt = datetime.as_speedate().map_err(as_internal)?; + macro_rules! check_constraint { + ($constraint:ident, $error:path, $key:literal) => { + if let Some(constraint) = &constraints.$constraint { + if !speedate_dt.$constraint(constraint) { + return err_val_error!( + input_value = InputValue::InputRef(input), + kind = $error, + context = context!($key => constraint.to_string()) + ); + } } - } - }; + }; + } + + check_constraint!(le, ErrorKind::LessThanEqual, "le"); + check_constraint!(lt, ErrorKind::LessThan, "lt"); + check_constraint!(ge, ErrorKind::GreaterThanEqual, "ge"); + check_constraint!(gt, ErrorKind::GreaterThan, "gt"); } + Ok(datetime.as_python(py).map_err(as_internal)?.into_py(py)) + } +} - check_constraint!(self.le, CompareOp::Le, ErrorKind::LessThanEqual, "le"); - check_constraint!(self.lt, CompareOp::Lt, ErrorKind::LessThan, "lt"); - check_constraint!(self.ge, CompareOp::Ge, ErrorKind::GreaterThanEqual, "ge"); - check_constraint!(self.gt, CompareOp::Gt, ErrorKind::GreaterThan, "gt"); - Ok(date.into_py(py)) +fn py_datetime_as_datetime(schema: &PyDict, field: &str) -> PyResult> { + let py_dt: Option<&PyDateTime> = schema.get_as(field)?; + match py_dt { + Some(py_dt) => { + let dt_str: &str = py_dt.str()?.extract()?; + match DateTime::parse_str(dt_str) { + Ok(date) => Ok(Some(date)), + Err(err) => { + let error_description = err.get_documentation().unwrap_or_default(); + let msg = format!("Unable to parse datetime {}, error: {}", dt_str, error_description); + Err(PyValueError::new_err(msg)) + } + } + } + None => Ok(None), } } diff --git a/tests/validators/test_datetime.py b/tests/validators/test_datetime.py index 1e96e1c1b..2c04f4c52 100644 --- a/tests/validators/test_datetime.py +++ b/tests/validators/test_datetime.py @@ -1,5 +1,5 @@ import re -from datetime import datetime +from datetime import datetime, timezone from decimal import Decimal import pytest @@ -15,6 +15,7 @@ (datetime(2022, 6, 8, 12, 13, 14), datetime(2022, 6, 8, 12, 13, 14)), ('2022-06-08T12:13:14', datetime(2022, 6, 8, 12, 13, 14)), (b'2022-06-08T12:13:14', datetime(2022, 6, 8, 12, 13, 14)), + (b'2022-06-08T12:13:14Z', datetime(2022, 6, 8, 12, 13, 14, tzinfo=timezone.utc)), ((1,), Err('Value must be a valid datetime [kind=date_time_type')), (Decimal('1654646400'), datetime(2022, 6, 8)), (253_402_300_800_000, Err('must be a valid datetime, dates after 9999 are not supported as unix timestamps')),