Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support yyyy-MM-DD string for datetimes #1124

Merged
merged 3 commits into from Dec 19, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
53 changes: 49 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 Down Expand Up @@ -65,9 +65,12 @@ 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 => datetime_from_date(input)?.ok_or(line_errors)?,
sydney-runkle marked this conversation as resolved.
Show resolved Hide resolved
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 +135,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
6 changes: 3 additions & 3 deletions tests/test_hypothesis.py
Expand Up @@ -47,11 +47,11 @@ def test_datetime_binary(datetime_schema, data):
except ValidationError as exc:
assert exc.errors(include_url=False) == [
{
'type': 'datetime_parsing',
'type': 'datetime_from_date_parsing',
'loc': (),
'msg': IsStr(regex='Input should be a valid datetime, .+'),
'msg': IsStr(regex='Input should be a valid datetime or date, .+'),
'input': IsBytes(),
'ctx': {'error': IsStr()},
'ctx': {'error': 'input is too short'},
}
]

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