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 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions python/pydantic_core/core_schema.py
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading