Skip to content

Commit

Permalink
Support yyyy-MM-DD string for datetimes (#1124)
Browse files Browse the repository at this point in the history
  • Loading branch information
sydney-runkle committed Dec 19, 2023
1 parent bec63db commit 10ad10f
Show file tree
Hide file tree
Showing 6 changed files with 77 additions and 12 deletions.
1 change: 1 addition & 0 deletions python/pydantic_core/core_schema.py
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions src/errors/types.rs
Expand Up @@ -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<String, _>},
},
DatetimePast {},
DatetimeFuture {},
// ---------------------
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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, ..
Expand Down
57 changes: 53 additions & 4 deletions src/validators/datetime.rs
Expand Up @@ -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;

Expand All @@ -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)]
Expand Down Expand Up @@ -65,9 +66,15 @@ impl Validator for DateTimeValidator {
state: &mut ValidationState,
) -> ValResult<PyObject> {
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
Expand Down Expand Up @@ -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<Option<EitherDateTime<'data>>, 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<DateTime>,
Expand Down
1 change: 1 addition & 0 deletions tests/test_errors.py
Expand Up @@ -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),
Expand Down
10 changes: 5 additions & 5 deletions tests/test_hypothesis.py
Expand Up @@ -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',
},
]


Expand Down
15 changes: 12 additions & 3 deletions tests/validators/test_datetime.py
Expand Up @@ -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)),
Expand All @@ -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):
Expand Down Expand Up @@ -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')),
Expand Down

0 comments on commit 10ad10f

Please sign in to comment.