diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index daed22d48..ee305284a 100644 --- a/python/pydantic_core/core_schema.py +++ b/python/pydantic_core/core_schema.py @@ -3787,6 +3787,7 @@ def definition_reference_schema( 'datetime_type', 'datetime_parsing', 'datetime_object_invalid', + 'datetime_from_date_parsing', 'datetime_past', 'datetime_future', 'timezone_naive', diff --git a/src/errors/types.rs b/src/errors/types.rs index 5c3fc1a7c..cfa96221e 100644 --- a/src/errors/types.rs +++ b/src/errors/types.rs @@ -338,6 +338,9 @@ error_types! { DatetimeObjectInvalid { error: {ctx_type: String, ctx_fn: field_from_context}, }, + DatetimeFromDateParsing { + error: {ctx_type: Cow<'static, str>, ctx_fn: cow_field_from_context}, + }, DatetimePast {}, DatetimeFuture {}, // --------------------- @@ -529,6 +532,7 @@ impl ErrorType { Self::DatetimeType {..} => "Input should be a valid datetime", Self::DatetimeParsing {..} => "Input should be a valid datetime, {error}", Self::DatetimeObjectInvalid {..} => "Invalid datetime object, got {error}", + Self::DatetimeFromDateParsing {..} => "Input should be a valid datetime or date, {error}", Self::DatetimePast {..} => "Input should be in the past", Self::DatetimeFuture {..} => "Input should be in the future", Self::TimezoneNaive {..} => "Input should not have timezone info", @@ -684,6 +688,7 @@ impl ErrorType { Self::DateFromDatetimeParsing { error, .. } => render!(tmpl, error), Self::TimeParsing { error, .. } => render!(tmpl, error), Self::DatetimeParsing { error, .. } => render!(tmpl, error), + Self::DatetimeFromDateParsing { error, .. } => render!(tmpl, error), Self::DatetimeObjectInvalid { error, .. } => render!(tmpl, error), Self::TimezoneOffset { tz_expected, tz_actual, .. diff --git a/src/validators/datetime.rs b/src/validators/datetime.rs index 156fad699..8779ea76c 100644 --- a/src/validators/datetime.rs +++ b/src/validators/datetime.rs @@ -2,7 +2,7 @@ use pyo3::intern; use pyo3::once_cell::GILOnceCell; use pyo3::prelude::*; use pyo3::types::{PyDateTime, PyDict, PyString}; -use speedate::DateTime; +use speedate::{DateTime, Time}; use std::cmp::Ordering; use strum::EnumMessage; @@ -13,6 +13,7 @@ use crate::input::{EitherDateTime, Input}; use crate::tools::SchemaDict; +use super::Exactness; use super::{BuildValidator, CombinedValidator, DefinitionsBuilder, ValidationState, Validator}; #[derive(Debug, Clone)] @@ -65,9 +66,15 @@ impl Validator for DateTimeValidator { state: &mut ValidationState, ) -> ValResult { let strict = state.strict_or(self.strict); - let datetime = input - .validate_datetime(strict, self.microseconds_precision)? - .unpack(state); + let datetime = match input.validate_datetime(strict, self.microseconds_precision) { + Ok(val_match) => val_match.unpack(state), + // if the error was a parsing error, in lax mode we allow dates and add the time 00:00:00 + Err(line_errors @ ValError::LineErrors(..)) if !strict => { + state.floor_exactness(Exactness::Lax); + datetime_from_date(input)?.ok_or(line_errors)? + } + Err(otherwise) => return Err(otherwise), + }; if let Some(constraints) = &self.constraints { // if we get an error from as_speedate, it's probably because the input datetime was invalid // specifically had an invalid tzinfo, hence here we return a validation error @@ -132,6 +139,48 @@ impl Validator for DateTimeValidator { } } +/// In lax mode, if the input is not a datetime, we try parsing the input as a date and add the "00:00:00" time. +/// +/// Ok(None) means that this is not relevant to datetimes (the input was not a date nor a string) +fn datetime_from_date<'data>(input: &'data impl Input<'data>) -> Result>, ValError> { + let either_date = match input.validate_date(false) { + Ok(val_match) => val_match.into_inner(), + // if the error was a parsing error, update the error type from DateParsing to DatetimeFromDateParsing + Err(ValError::LineErrors(mut line_errors)) => { + if line_errors.iter_mut().fold(false, |has_parsing_error, line_error| { + if let ErrorType::DateParsing { error, .. } = &mut line_error.error_type { + line_error.error_type = ErrorType::DatetimeFromDateParsing { + error: std::mem::take(error), + context: None, + }; + true + } else { + has_parsing_error + } + }) { + return Err(ValError::LineErrors(line_errors)); + } + return Ok(None); + } + // for any other error, don't return it + Err(_) => return Ok(None), + }; + + let zero_time = Time { + hour: 0, + minute: 0, + second: 0, + microsecond: 0, + tz_offset: Some(0), + }; + + let datetime = DateTime { + date: either_date.as_raw()?, + time: zero_time, + }; + Ok(Some(EitherDateTime::Raw(datetime))) +} + #[derive(Debug, Clone)] struct DateTimeConstraints { le: Option, diff --git a/tests/test_errors.py b/tests/test_errors.py index 88dcace8f..fd3f34d8f 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -332,6 +332,7 @@ def f(input_value, info): ('time_parsing', 'Input should be in a valid time format, foobar', {'error': 'foobar'}), ('datetime_type', 'Input should be a valid datetime', None), ('datetime_parsing', 'Input should be a valid datetime, foobar', {'error': 'foobar'}), + ('datetime_from_date_parsing', 'Input should be a valid datetime or date, foobar', {'error': 'foobar'}), ('datetime_object_invalid', 'Invalid datetime object, got foobar', {'error': 'foobar'}), ('datetime_past', 'Input should be in the past', None), ('datetime_future', 'Input should be in the future', None), diff --git a/tests/test_hypothesis.py b/tests/test_hypothesis.py index ea0d67f0d..f02d1b3a9 100644 --- a/tests/test_hypothesis.py +++ b/tests/test_hypothesis.py @@ -47,12 +47,12 @@ def test_datetime_binary(datetime_schema, data): except ValidationError as exc: assert exc.errors(include_url=False) == [ { - 'type': 'datetime_parsing', - 'loc': (), - 'msg': IsStr(regex='Input should be a valid datetime, .+'), - 'input': IsBytes(), 'ctx': {'error': IsStr()}, - } + 'input': IsBytes(), + 'loc': (), + 'msg': IsStr(regex='Input should be a valid datetime or date, .+'), + 'type': 'datetime_from_date_parsing', + }, ] diff --git a/tests/validators/test_datetime.py b/tests/validators/test_datetime.py index 89e9c1c53..1d4a216f9 100644 --- a/tests/validators/test_datetime.py +++ b/tests/validators/test_datetime.py @@ -19,6 +19,7 @@ [ (datetime(2022, 6, 8, 12, 13, 14), datetime(2022, 6, 8, 12, 13, 14)), (date(2022, 6, 8), datetime(2022, 6, 8)), + ('2022-01-01', datetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc)), ('2022-06-08T12:13:14', datetime(2022, 6, 8, 12, 13, 14)), ('1000000000000', datetime(2001, 9, 9, 1, 46, 40, tzinfo=timezone.utc)), (b'2022-06-08T12:13:14', datetime(2022, 6, 8, 12, 13, 14)), @@ -36,8 +37,14 @@ (float('nan'), Err('Input should be a valid datetime, NaN values not permitted [type=datetime_parsing,')), (float('inf'), Err('Input should be a valid datetime, dates after 9999')), (float('-inf'), Err('Input should be a valid datetime, dates before 1600')), - ('-', Err('Input should be a valid datetime, input is too short [type=datetime_parsing,')), - ('+', Err('Input should be a valid datetime, input is too short [type=datetime_parsing,')), + ('-', Err('Input should be a valid datetime or date, input is too short [type=datetime_from_date_parsing,')), + ('+', Err('Input should be a valid datetime or date, input is too short [type=datetime_from_date_parsing,')), + ( + '2022-02-30', + Err( + 'Input should be a valid datetime or date, day value is outside expected range [type=datetime_from_date_parsing,' + ), + ), ], ) def test_datetime(input_value, expected): @@ -119,7 +126,9 @@ def test_keep_tz_bound(): (1655205632.331557, datetime(2022, 6, 14, 11, 20, 32, microsecond=331557, tzinfo=timezone.utc)), ( '2022-06-08T12:13:14+24:00', - Err('Input should be a valid datetime, timezone offset must be less than 24 hours [type=datetime_parsing,'), + Err( + 'Input should be a valid datetime or date, unexpected extra characters at the end of the input [type=datetime_from_date_parsing,' + ), ), (True, Err('Input should be a valid datetime [type=datetime_type')), (None, Err('Input should be a valid datetime [type=datetime_type')),