Skip to content

Commit 2429637

Browse files
committed
Implement datetime timezone constraints
This implements support for constraining datetime objects based on them having or not having timezone info. The aware kind constrains to objects that have timezone info, and symmetrically, the naive kind constrains to objects that do not have timezone info. In addition to testing the constraints individually, a test is added to assert the property that all datetime objects are either aware or naive, but never both.
1 parent bb28794 commit 2429637

File tree

5 files changed

+115
-1
lines changed

5 files changed

+115
-1
lines changed

pydantic_core/core_schema.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ class DatetimeSchema(TypedDict, total=False):
276276
lt: datetime
277277
gt: datetime
278278
now_op: Literal['past', 'future']
279+
tz_constraint: Literal['aware', 'naive']
279280
# defaults to current local utc offset from `time.localtime().tm_gmtoff`
280281
# value is restricted to -86_400 < offset < 86_400 by bounds in generate_self_schema.py
281282
now_utc_offset: int
@@ -291,6 +292,7 @@ def datetime_schema(
291292
lt: datetime | None = None,
292293
gt: datetime | None = None,
293294
now_op: Literal['past', 'future'] | None = None,
295+
tz_constraint: Literal['aware', 'naive'] | None = None,
294296
now_utc_offset: int | None = None,
295297
ref: str | None = None,
296298
extra: Any = None,
@@ -303,6 +305,7 @@ def datetime_schema(
303305
lt=lt,
304306
gt=gt,
305307
now_op=now_op,
308+
tz_constraint=tz_constraint,
306309
now_utc_offset=now_utc_offset,
307310
ref=ref,
308311
extra=extra,
@@ -1226,6 +1229,8 @@ def multi_host_url_schema(
12261229
'datetime_object_invalid',
12271230
'datetime_past',
12281231
'datetime_future',
1232+
'datetime_aware',
1233+
'datetime_naive',
12291234
'time_delta_type',
12301235
'time_delta_parsing',
12311236
'frozen_set_type',

src/errors/types.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,10 @@ pub enum ErrorType {
261261
DatetimePast,
262262
#[strum(message = "Datetime should be in the future")]
263263
DatetimeFuture,
264+
#[strum(message = "Datetime should have timezone info")]
265+
DatetimeAware,
266+
#[strum(message = "Datetime should not have timezone info")]
267+
DatetimeNaive,
264268
// ---------------------
265269
// timedelta errors
266270
#[strum(message = "Input should be a valid timedelta")]

src/validators/datetime.rs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ impl Validator for DateTimeValidator {
9292
}
9393
}
9494
}
95+
96+
if let Some(ref tz_constraint) = constraints.tz {
97+
match (&tz_constraint.kind, speedate_dt.offset) {
98+
(TZConstraintKind::Aware, None) => return Err(ValError::new(ErrorType::DatetimeAware, input)),
99+
(TZConstraintKind::Naive, Some(_)) => return Err(ValError::new(ErrorType::DatetimeNaive, input)),
100+
_ => (),
101+
}
102+
}
95103
}
96104
Ok(datetime.try_into_py(py)?)
97105
}
@@ -108,6 +116,7 @@ struct DateTimeConstraints {
108116
ge: Option<DateTime>,
109117
gt: Option<DateTime>,
110118
now: Option<NowConstraint>,
119+
tz: Option<TZConstraint>,
111120
}
112121

113122
impl DateTimeConstraints {
@@ -119,8 +128,9 @@ impl DateTimeConstraints {
119128
ge: py_datetime_as_datetime(schema, intern!(py, "ge"))?,
120129
gt: py_datetime_as_datetime(schema, intern!(py, "gt"))?,
121130
now: NowConstraint::from_py(schema)?,
131+
tz: TZConstraint::from_py(schema)?,
122132
};
123-
if c.le.is_some() || c.lt.is_some() || c.ge.is_some() || c.gt.is_some() || c.now.is_some() {
133+
if c.le.is_some() || c.lt.is_some() || c.ge.is_some() || c.gt.is_some() || c.now.is_some() || c.tz.is_some() {
124134
Ok(Some(c))
125135
} else {
126136
Ok(None)
@@ -199,3 +209,37 @@ impl NowConstraint {
199209
}
200210
}
201211
}
212+
213+
#[derive(Debug, Clone)]
214+
pub enum TZConstraintKind {
215+
Aware,
216+
Naive,
217+
}
218+
219+
impl TZConstraintKind {
220+
pub fn from_str(s: &str) -> PyResult<Self> {
221+
match s {
222+
"aware" => Ok(TZConstraintKind::Aware),
223+
"naive" => Ok(TZConstraintKind::Naive),
224+
_ => py_err!("Invalid tz_constraint {:?}", s),
225+
}
226+
}
227+
}
228+
229+
#[derive(Debug, Clone)]
230+
pub struct TZConstraint {
231+
pub kind: TZConstraintKind,
232+
}
233+
234+
impl TZConstraint {
235+
pub fn from_py(schema: &PyDict) -> PyResult<Option<Self>> {
236+
let py = schema.py();
237+
238+
match schema.get_as(intern!(py, "tz_constraint"))? {
239+
Some(kind) => Ok(Some(Self {
240+
kind: TZConstraintKind::from_str(kind)?,
241+
})),
242+
None => Ok(None),
243+
}
244+
}
245+
}

tests/test_errors.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,8 @@ def f(input_value, **kwargs):
235235
('datetime_object_invalid', 'Invalid datetime object, got foobar', {'error': 'foobar'}),
236236
('datetime_past', 'Datetime should be in the past', None),
237237
('datetime_future', 'Datetime should be in the future', None),
238+
('datetime_aware', 'Datetime should have timezone info', None),
239+
('datetime_naive', 'Datetime should not have timezone info', None),
238240
('time_delta_type', 'Input should be a valid timedelta', None),
239241
('time_delta_parsing', 'Input should be a valid timedelta, foobar', {'error': 'foobar'}),
240242
('frozen_set_type', 'Input should be a valid frozenset', None),

tests/validators/test_datetime.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
import pytest
88
import pytz
9+
from hypothesis import example, given
10+
from hypothesis.strategies import datetimes, timezones
911

1012
from pydantic_core import SchemaError, SchemaValidator, ValidationError, core_schema
1113

@@ -366,3 +368,60 @@ def test_mock_utc_offset_8_hours(mocker):
366368
def test_offset_too_large():
367369
with pytest.raises(SchemaError, match=r'Input should be greater than -86400 \[type=greater_than,'):
368370
SchemaValidator(core_schema.datetime_schema(now_op='past', now_utc_offset=-24 * 3600))
371+
372+
373+
class TestTZConstraints:
374+
aware_validator = SchemaValidator(core_schema.datetime_schema(tz_constraint='aware'))
375+
naive_validator = SchemaValidator(core_schema.datetime_schema(tz_constraint='naive'))
376+
377+
def test_raises_schema_error_for_unknown_constraint_kind(self):
378+
with pytest.raises(
379+
SchemaError,
380+
match=(
381+
r'Input should be \'aware\' or \'naive\' '
382+
r'\[type=literal_error, input_value=\'foo\', input_type=str\]'
383+
),
384+
):
385+
SchemaValidator({'type': 'datetime', 'tz_constraint': 'foo'})
386+
387+
@given(datetimes(timezones=timezones()))
388+
@example(datetime.now(tz=timezone.utc))
389+
def test_can_validate_aware_value(self, value: datetime):
390+
assert value is self.aware_validator.validate_python(value)
391+
392+
@given(datetimes())
393+
@example(datetime.now())
394+
def test_raises_validation_error_when_aware_given_naive(self, value: datetime):
395+
with pytest.raises(ValidationError, match=r'Datetime should have timezone info'):
396+
assert self.aware_validator.validate_python(value)
397+
398+
@given(datetimes())
399+
@example(datetime.now())
400+
def test_can_validate_naive_value(self, value: datetime):
401+
assert value is self.naive_validator.validate_python(value)
402+
403+
@given(datetimes(timezones=timezones()))
404+
@example(datetime.now(tz=timezone.utc))
405+
def test_raises_validation_error_when_naive_given_aware(self, value: datetime):
406+
with pytest.raises(ValidationError, match=r'Datetime should not have timezone info'):
407+
assert self.naive_validator.validate_python(value)
408+
409+
@given(datetimes() | datetimes(timezones=timezones()))
410+
@example(datetime.now())
411+
@example(datetime.now(tz=timezone.utc))
412+
def test_aware_and_naive_are_mutually_exclusive(self, value: datetime):
413+
try:
414+
self.aware_validator.validate_python(value)
415+
except ValidationError:
416+
is_aware = False
417+
else:
418+
is_aware = True
419+
420+
try:
421+
self.naive_validator.validate_python(value)
422+
except ValidationError:
423+
is_naive = False
424+
else:
425+
is_naive = True
426+
427+
assert (is_aware or is_naive) and not (is_aware and is_naive)

0 commit comments

Comments
 (0)