Skip to content

Commit b971d9d

Browse files
committed
Detect unsafe datetime/date operators
datetime is a subclass of date, but their comparison and subtraction dunder methods are not substitutable: datetime.__lt__ only accepts another datetime, and date.__lt__ only accepts another date. Because of the subclass relationship, mypy currently accepts mixed date/datetime ordering comparisons and subtraction, which raise TypeError at runtime. Report these under the existing `operator` error code by checking, at each binary operator site, whether the operand that would resolve to date's dunder is satisfied by a datetime value with no override to support it (and vice versa). Descendants of date/datetime are covered by walking the MRO for the first class that defines the dunder. Ordering-comparison handling is Python-version-aware, since 3.13 changed how a date subclass compares against a datetime subclass; subtraction is checked on all versions. Equality, identity, and same-static-type operations are left untouched, since they don't raise TypeError. This covers only the operator-usage half of #9015. A datetime can still survive under a date-only annotation across an assignment, argument, or return -- undetected here -- until it later reaches one of these operators; a follow-up narrowing-based check is planned separately. Towards #9015.
1 parent 3ad6157 commit b971d9d

3 files changed

Lines changed: 274 additions & 19 deletions

File tree

docs/source/error_code_list.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,26 @@ Example:
505505
# Error: Unsupported operand types for + ("int" and "str") [operator]
506506
1 + 'x'
507507
508+
Because ``datetime`` is a subclass of ``date``, normal subtype checking can
509+
also accept mixed operations that raise :py:exc:`TypeError` at runtime. Mypy
510+
reports mixed ordering comparisons and subtraction:
511+
512+
.. code-block:: python
513+
514+
from datetime import date, datetime
515+
516+
d = date.today()
517+
dt = datetime.now()
518+
519+
dt < d # Error: Unsupported operand types for < ("datetime" and "date") [operator]
520+
d - dt # Error: Unsupported operand types for - ("date" and "datetime") [operator]
521+
522+
For subclasses, ordering checks follow the target Python version, since Python
523+
3.13 changed how ``datetime`` compares with ``date`` subclasses. Inherited
524+
subtraction is checked on all versions. The check does not affect normal
525+
subtyping, equality comparisons, or operations whose operands both have the
526+
static type ``date``.
527+
508528
.. _code-index:
509529

510530
Check indexing operations [index]

mypy/checkexpr.py

Lines changed: 111 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,75 @@
241241
"builtins.bytearray",
242242
"builtins.memoryview",
243243
}
244+
UNSAFE_DATETIME_COMPARISONS: Final = {"<", "<=", ">", ">="}
245+
DATETIME_TYPE_FULLNAMES: Final = {"datetime.date", "datetime.datetime"}
246+
247+
248+
def has_type_component(
249+
typ: Type,
250+
fullname: str,
251+
method: str,
252+
excluded_fullname: str | None = None,
253+
*,
254+
allow_subclasses: bool = True,
255+
) -> bool:
256+
"""Return whether a type contains an instance derived from the given class."""
257+
typ = get_proper_type(typ)
258+
if isinstance(typ, Instance):
259+
info = typ.type
260+
if not allow_subclasses:
261+
while info.is_newtype:
262+
assert len(info.bases) == 1
263+
info = info.bases[0].type
264+
matches = info.has_base(fullname) if allow_subclasses else info.fullname == fullname
265+
excluded = excluded_fullname is not None and (
266+
info.has_base(excluded_fullname)
267+
if allow_subclasses
268+
else info.fullname == excluded_fullname
269+
)
270+
if not matches or excluded:
271+
return False
272+
for base in typ.type.mro:
273+
if method in base.names:
274+
return base.fullname in DATETIME_TYPE_FULLNAMES
275+
return True
276+
if isinstance(typ, UnionType):
277+
return any(
278+
has_type_component(
279+
item, fullname, method, excluded_fullname, allow_subclasses=allow_subclasses
280+
)
281+
for item in typ.relevant_items()
282+
)
283+
if isinstance(typ, TypeVarType):
284+
return has_type_component(
285+
erase_to_union_or_bound(typ),
286+
fullname,
287+
method,
288+
excluded_fullname,
289+
allow_subclasses=allow_subclasses,
290+
)
291+
return False
292+
293+
294+
def is_unsafe_datetime_pair(
295+
left: Type, right: Type, operator: str, *, check_date_subclasses_on_left: bool = True
296+
) -> bool:
297+
"""Return whether the types contain both date-only and datetime components."""
298+
method = operators.op_methods[operator]
299+
reverse_method = operators.reverse_op_methods[method]
300+
return (
301+
has_type_component(
302+
left,
303+
"datetime.date",
304+
method,
305+
"datetime.datetime",
306+
allow_subclasses=check_date_subclasses_on_left,
307+
)
308+
and has_type_component(right, "datetime.datetime", reverse_method)
309+
) or (
310+
has_type_component(left, "datetime.datetime", method)
311+
and has_type_component(right, "datetime.date", reverse_method, "datetime.datetime")
312+
)
244313

245314

246315
class TooManyUnions(Exception):
@@ -3686,26 +3755,35 @@ def visit_op_expr(self, e: OpExpr) -> Type:
36863755

36873756
if e.op in operators.op_methods:
36883757
method = operators.op_methods[e.op]
3689-
if use_reverse is UseReverse.DEFAULT or use_reverse is UseReverse.NEVER:
3690-
result, method_type = self.check_op(
3691-
method,
3692-
base_type=left_type,
3693-
arg=e.right,
3694-
context=e,
3695-
allow_reverse=use_reverse is UseReverse.DEFAULT,
3696-
)
3697-
elif use_reverse is UseReverse.ALWAYS:
3698-
result, method_type = self.check_op(
3699-
# The reverse operator here gives better error messages:
3700-
operators.reverse_op_methods[method],
3701-
base_type=self.accept(e.right),
3702-
arg=e.left,
3703-
context=e,
3704-
allow_reverse=False,
3705-
)
3706-
else:
3707-
assert_never(use_reverse)
3758+
check_unsafe_datetime = e.op == "-" and self.msg.errors.is_error_code_enabled(
3759+
codes.OPERATOR
3760+
)
3761+
w = ErrorWatcher(self.msg.errors) if check_unsafe_datetime else None
3762+
with w if w is not None else nullcontext():
3763+
if use_reverse is UseReverse.DEFAULT or use_reverse is UseReverse.NEVER:
3764+
result, method_type = self.check_op(
3765+
method,
3766+
base_type=left_type,
3767+
arg=e.right,
3768+
context=e,
3769+
allow_reverse=use_reverse is UseReverse.DEFAULT,
3770+
)
3771+
elif use_reverse is UseReverse.ALWAYS:
3772+
result, method_type = self.check_op(
3773+
# The reverse operator here gives better error messages:
3774+
operators.reverse_op_methods[method],
3775+
base_type=self.accept(e.right),
3776+
arg=e.left,
3777+
context=e,
3778+
allow_reverse=False,
3779+
)
3780+
else:
3781+
assert_never(use_reverse)
37083782
e.method_type = method_type
3783+
if w is not None and not w.has_new_errors():
3784+
right_type = self.chk.lookup_type(e.right)
3785+
if is_unsafe_datetime_pair(left_type, right_type, e.op):
3786+
self.msg.unsupported_operand_types(e.op, left_type, right_type, e)
37093787
return result
37103788
else:
37113789
raise RuntimeError(f"Unknown operator {e.op}")
@@ -3828,6 +3906,20 @@ def visit_comparison_expr(self, e: ComparisonExpr) -> Type:
38283906
)
38293907
e.method_types.append(method_type)
38303908

3909+
if (
3910+
operator in UNSAFE_DATETIME_COMPARISONS
3911+
and not w.has_new_errors()
3912+
and self.msg.errors.is_error_code_enabled(codes.OPERATOR)
3913+
):
3914+
right_type = self.chk.lookup_type(right)
3915+
if is_unsafe_datetime_pair(
3916+
left_type,
3917+
right_type,
3918+
operator,
3919+
check_date_subclasses_on_left=self.chk.options.python_version >= (3, 13),
3920+
):
3921+
self.msg.unsupported_operand_types(operator, left_type, right_type, e)
3922+
38313923
# Only show dangerous overlap if there are no other errors. See
38323924
# testCustomEqCheckStrictEquality for an example.
38333925
if not w.has_new_errors() and operator in ("==", "!="):
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
[case testUnsafeDatetime]
2+
# flags: --show-error-codes --python-version 3.10
3+
from datetime import date, datetime, timedelta
4+
from typing import NewType, TypeVar
5+
6+
d: date
7+
dt: datetime
8+
9+
if dt < d: # E: Unsupported operand types for < ("datetime" and "date") [operator]
10+
pass
11+
if d > dt: # E: Unsupported operand types for > ("date" and "datetime") [operator]
12+
pass
13+
if dt <= d: # E: Unsupported operand types for <= ("datetime" and "date") [operator]
14+
pass
15+
if d >= dt: # E: Unsupported operand types for >= ("date" and "datetime") [operator]
16+
pass
17+
18+
d - dt # E: Unsupported operand types for - ("date" and "datetime") [operator]
19+
20+
# Equality and identity do not raise TypeError.
21+
if dt == d:
22+
pass
23+
if d != dt:
24+
pass
25+
if dt is d:
26+
pass
27+
28+
# Do not warn when both operands have the same static type.
29+
d2: date
30+
dt2: datetime
31+
if d < d2:
32+
pass
33+
if dt < dt2:
34+
pass
35+
d - d2
36+
dt - dt2
37+
38+
# Normal subtyping is preserved.
39+
d = datetime.now()
40+
41+
def accept_date(value: date) -> None:
42+
pass
43+
44+
accept_date(datetime.now())
45+
46+
# Narrowed optional operands and bounded type variables retain their precise type.
47+
optional_dt: datetime | None
48+
if optional_dt is not None and optional_dt < d2: # E: Unsupported operand types for < ("datetime" and "date") [operator]
49+
pass
50+
51+
DT = TypeVar("DT", bound=datetime)
52+
53+
def compare(value: DT, other: date) -> bool:
54+
return value < other # E: Unsupported operand types for < ("DT" and "date") [operator]
55+
56+
# Descendants with inherited date and datetime behavior are unsafe as well.
57+
class DateSubclass(date):
58+
pass
59+
60+
class DatetimeSubclass(datetime):
61+
pass
62+
63+
sub_d: DateSubclass
64+
sub_dt: DatetimeSubclass
65+
# Before Python 3.13, a date subclass on the left is compared by date only.
66+
sub_d < sub_dt
67+
sub_dt < sub_d # E: Unsupported operand types for < ("DatetimeSubclass" and "DateSubclass") [operator]
68+
sub_d - sub_dt # E: Unsupported operand types for - ("DateSubclass" and "DatetimeSubclass") [operator]
69+
70+
DateNewType = NewType("DateNewType", date)
71+
DatetimeNewType = NewType("DatetimeNewType", datetime)
72+
new_d: DateNewType
73+
new_dt: DatetimeNewType
74+
new_d < new_dt # E: Unsupported operand types for < ("DateNewType" and "DatetimeNewType") [operator]
75+
new_d - new_dt # E: Unsupported operand types for - ("DateNewType" and "DatetimeNewType") [operator]
76+
77+
# Descendants from the same side of the date/datetime boundary are safe.
78+
sub_d2: DateSubclass
79+
sub_dt2: DatetimeSubclass
80+
sub_d < sub_d2
81+
sub_dt < sub_dt2
82+
new_d < new_d
83+
new_dt < new_dt
84+
85+
# Do not warn when a descendant overrides an operator to support mixed operands.
86+
class ComparableDate(date):
87+
def __lt__(self, other: date) -> bool: ...
88+
def __sub__(self, other: date) -> timedelta: ...
89+
90+
class ComparableDatetime(datetime):
91+
def __gt__(self, other: date) -> bool: ... # type: ignore[override]
92+
93+
comparable_d: ComparableDate
94+
comparable_dt: ComparableDatetime
95+
comparable_d < sub_dt
96+
sub_d < comparable_dt
97+
comparable_d - sub_dt
98+
99+
[builtins fixtures/classmethod.pyi]
100+
[file datetime.pyi]
101+
class timedelta: ...
102+
103+
class date:
104+
@classmethod
105+
def today(cls) -> date: ...
106+
def __lt__(self, other: date) -> bool: ...
107+
def __le__(self, other: date) -> bool: ...
108+
def __gt__(self, other: date) -> bool: ...
109+
def __ge__(self, other: date) -> bool: ...
110+
def __eq__(self, other: object) -> bool: ...
111+
def __ne__(self, other: object) -> bool: ...
112+
def __sub__(self, other: date) -> timedelta: ...
113+
114+
class datetime(date):
115+
@classmethod
116+
def now(cls) -> datetime: ...
117+
def __lt__(self, other: datetime) -> bool: ... # type: ignore[override]
118+
def __le__(self, other: datetime) -> bool: ... # type: ignore[override]
119+
def __gt__(self, other: datetime) -> bool: ... # type: ignore[override]
120+
def __ge__(self, other: datetime) -> bool: ... # type: ignore[override]
121+
def __sub__(self, other: datetime) -> timedelta: ... # type: ignore[override]
122+
123+
[case testUnsafeDatetimeSubclassComparisonPython313]
124+
# flags: --show-error-codes --python-version 3.13
125+
from datetime import date, datetime
126+
127+
class DateSubclass(date):
128+
pass
129+
130+
class DatetimeSubclass(datetime):
131+
pass
132+
133+
d: DateSubclass
134+
dt: DatetimeSubclass
135+
d < dt # E: Unsupported operand types for < ("DateSubclass" and "DatetimeSubclass") [operator]
136+
[file datetime.pyi]
137+
class date:
138+
def __lt__(self, other: date) -> bool: ...
139+
def __gt__(self, other: date) -> bool: ...
140+
141+
class datetime(date):
142+
def __lt__(self, other: datetime) -> bool: ... # type: ignore[override]
143+
def __gt__(self, other: datetime) -> bool: ... # type: ignore[override]

0 commit comments

Comments
 (0)