Skip to content

Commit 8c76666

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 8c76666

File tree

5 files changed

+77
-1
lines changed

5 files changed

+77
-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: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ impl Validator for DateTimeValidator {
9292
}
9393
}
9494
}
95+
96+
match (&constraints.tz, speedate_dt.offset) {
97+
(Some(TZConstraint::Aware), None) => return Err(ValError::new(ErrorType::DatetimeAware, input)),
98+
(Some(TZConstraint::Naive), Some(_)) => return Err(ValError::new(ErrorType::DatetimeNaive, input)),
99+
_ => (),
100+
}
95101
}
96102
Ok(datetime.try_into_py(py)?)
97103
}
@@ -108,6 +114,7 @@ struct DateTimeConstraints {
108114
ge: Option<DateTime>,
109115
gt: Option<DateTime>,
110116
now: Option<NowConstraint>,
117+
tz: Option<TZConstraint>,
111118
}
112119

113120
impl DateTimeConstraints {
@@ -119,8 +126,9 @@ impl DateTimeConstraints {
119126
ge: py_datetime_as_datetime(schema, intern!(py, "ge"))?,
120127
gt: py_datetime_as_datetime(schema, intern!(py, "gt"))?,
121128
now: NowConstraint::from_py(schema)?,
129+
tz: TZConstraint::from_py(schema)?,
122130
};
123-
if c.le.is_some() || c.lt.is_some() || c.ge.is_some() || c.gt.is_some() || c.now.is_some() {
131+
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() {
124132
Ok(Some(c))
125133
} else {
126134
Ok(None)
@@ -199,3 +207,27 @@ impl NowConstraint {
199207
}
200208
}
201209
}
210+
211+
#[derive(Debug, Clone)]
212+
pub enum TZConstraint {
213+
Aware,
214+
Naive,
215+
}
216+
217+
impl TZConstraint {
218+
pub fn from_str(s: &str) -> PyResult<Self> {
219+
match s {
220+
"aware" => Ok(TZConstraint::Aware),
221+
"naive" => Ok(TZConstraint::Naive),
222+
_ => py_err!("Invalid tz_constraint {:?}", s),
223+
}
224+
}
225+
226+
pub fn from_py(schema: &PyDict) -> PyResult<Option<Self>> {
227+
let py = schema.py();
228+
match schema.get_as(intern!(py, "tz_constraint"))? {
229+
Some(kind) => Ok(Some(Self::from_str(kind)?)),
230+
None => Ok(None),
231+
}
232+
}
233+
}

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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,3 +366,36 @@ def test_mock_utc_offset_8_hours(mocker):
366366
def test_offset_too_large():
367367
with pytest.raises(SchemaError, match=r'Input should be greater than -86400 \[type=greater_than,'):
368368
SchemaValidator(core_schema.datetime_schema(now_op='past', now_utc_offset=-24 * 3600))
369+
370+
371+
class TestTZConstraints:
372+
aware_validator = SchemaValidator(core_schema.datetime_schema(tz_constraint='aware'))
373+
naive_validator = SchemaValidator(core_schema.datetime_schema(tz_constraint='naive'))
374+
375+
def test_raises_schema_error_for_unknown_constraint_kind(self):
376+
with pytest.raises(
377+
SchemaError,
378+
match=(
379+
r'Input should be \'aware\' or \'naive\' '
380+
r'\[type=literal_error, input_value=\'foo\', input_type=str\]'
381+
),
382+
):
383+
SchemaValidator({'type': 'datetime', 'tz_constraint': 'foo'})
384+
385+
def test_can_validate_aware_value(self):
386+
value = datetime.now(tz=timezone.utc)
387+
assert value is self.aware_validator.validate_python(value)
388+
389+
def test_raises_validation_error_when_aware_given_naive(self):
390+
value = datetime.now()
391+
with pytest.raises(ValidationError, match=r'Datetime should have timezone info'):
392+
assert self.aware_validator.validate_python(value)
393+
394+
def test_can_validate_naive_value(self):
395+
value = datetime.now()
396+
assert value is self.naive_validator.validate_python(value)
397+
398+
def test_raises_validation_error_when_naive_given_aware(self):
399+
value = datetime.now(tz=timezone.utc)
400+
with pytest.raises(ValidationError, match=r'Datetime should not have timezone info'):
401+
assert self.naive_validator.validate_python(value)

0 commit comments

Comments
 (0)