Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
20 changes: 20 additions & 0 deletions docs/source/error_code_list.rst
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,26 @@ Example:
# Error: Unsupported operand types for + ("int" and "str") [operator]
1 + 'x'

Because ``datetime`` is a subclass of ``date``, normal subtype checking can
also accept mixed operations that raise :py:exc:`TypeError` at runtime. Mypy
reports mixed ordering comparisons and subtraction:

.. code-block:: python

from datetime import date, datetime

d = date.today()
dt = datetime.now()

dt < d # Error: Unsupported operand types for < ("datetime" and "date") [operator]
d - dt # Error: Unsupported operand types for - ("date" and "datetime") [operator]

For subclasses, ordering checks follow the target Python version, since Python
3.13 changed how ``datetime`` compares with ``date`` subclasses. Inherited
subtraction is checked on all versions. The check does not affect normal
subtyping, equality comparisons, or operations whose operands both have the
static type ``date``.

.. _code-index:

Check indexing operations [index]
Expand Down
130 changes: 111 additions & 19 deletions mypy/checkexpr.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,75 @@
"builtins.bytearray",
"builtins.memoryview",
}
UNSAFE_DATETIME_COMPARISONS: Final = {"<", "<=", ">", ">="}
DATETIME_TYPE_FULLNAMES: Final = {"datetime.date", "datetime.datetime"}


def has_type_component(
typ: Type,
fullname: str,
method: str,
excluded_fullname: str | None = None,
*,
allow_subclasses: bool = True,
) -> bool:
"""Return whether a type contains an instance derived from the given class."""
typ = get_proper_type(typ)
if isinstance(typ, Instance):
info = typ.type
if not allow_subclasses:
while info.is_newtype:
assert len(info.bases) == 1
info = info.bases[0].type
matches = info.has_base(fullname) if allow_subclasses else info.fullname == fullname
excluded = excluded_fullname is not None and (
info.has_base(excluded_fullname)
if allow_subclasses
else info.fullname == excluded_fullname
)
if not matches or excluded:
return False
for base in typ.type.mro:
if method in base.names:
return base.fullname in DATETIME_TYPE_FULLNAMES
return True
if isinstance(typ, UnionType):
return any(
has_type_component(
item, fullname, method, excluded_fullname, allow_subclasses=allow_subclasses
)
for item in typ.relevant_items()
)
if isinstance(typ, TypeVarType):
return has_type_component(
erase_to_union_or_bound(typ),
fullname,
method,
excluded_fullname,
allow_subclasses=allow_subclasses,
)
return False


def is_unsafe_datetime_pair(
left: Type, right: Type, operator: str, *, check_date_subclasses_on_left: bool = True
) -> bool:
"""Return whether the types contain both date-only and datetime components."""
method = operators.op_methods[operator]
reverse_method = operators.reverse_op_methods[method]
return (
has_type_component(
left,
"datetime.date",
method,
"datetime.datetime",
allow_subclasses=check_date_subclasses_on_left,
)
and has_type_component(right, "datetime.datetime", reverse_method)
) or (
has_type_component(left, "datetime.datetime", method)
and has_type_component(right, "datetime.date", reverse_method, "datetime.datetime")
)


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

if e.op in operators.op_methods:
method = operators.op_methods[e.op]
if use_reverse is UseReverse.DEFAULT or use_reverse is UseReverse.NEVER:
result, method_type = self.check_op(
method,
base_type=left_type,
arg=e.right,
context=e,
allow_reverse=use_reverse is UseReverse.DEFAULT,
)
elif use_reverse is UseReverse.ALWAYS:
result, method_type = self.check_op(
# The reverse operator here gives better error messages:
operators.reverse_op_methods[method],
base_type=self.accept(e.right),
arg=e.left,
context=e,
allow_reverse=False,
)
else:
assert_never(use_reverse)
check_unsafe_datetime = e.op == "-" and self.msg.errors.is_error_code_enabled(
codes.OPERATOR
)
w = ErrorWatcher(self.msg.errors) if check_unsafe_datetime else None
with w if w is not None else nullcontext():
if use_reverse is UseReverse.DEFAULT or use_reverse is UseReverse.NEVER:
result, method_type = self.check_op(
method,
base_type=left_type,
arg=e.right,
context=e,
allow_reverse=use_reverse is UseReverse.DEFAULT,
)
elif use_reverse is UseReverse.ALWAYS:
result, method_type = self.check_op(
# The reverse operator here gives better error messages:
operators.reverse_op_methods[method],
base_type=self.accept(e.right),
arg=e.left,
context=e,
allow_reverse=False,
)
else:
assert_never(use_reverse)
e.method_type = method_type
if w is not None and not w.has_new_errors():
right_type = self.chk.lookup_type(e.right)
if is_unsafe_datetime_pair(left_type, right_type, e.op):
self.msg.unsupported_operand_types(e.op, left_type, right_type, e)
return result
else:
raise RuntimeError(f"Unknown operator {e.op}")
Expand Down Expand Up @@ -3828,6 +3906,20 @@ def visit_comparison_expr(self, e: ComparisonExpr) -> Type:
)
e.method_types.append(method_type)

if (
operator in UNSAFE_DATETIME_COMPARISONS
and not w.has_new_errors()
and self.msg.errors.is_error_code_enabled(codes.OPERATOR)
):
right_type = self.chk.lookup_type(right)
if is_unsafe_datetime_pair(
left_type,
right_type,
operator,
check_date_subclasses_on_left=self.chk.options.python_version >= (3, 13),
):
self.msg.unsupported_operand_types(operator, left_type, right_type, e)

# Only show dangerous overlap if there are no other errors. See
# testCustomEqCheckStrictEquality for an example.
if not w.has_new_errors() and operator in ("==", "!="):
Expand Down
143 changes: 143 additions & 0 deletions test-data/unit/check-unsafe-datetime.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
[case testUnsafeDatetime]
# flags: --show-error-codes --python-version 3.10
from datetime import date, datetime, timedelta
from typing import NewType, TypeVar

d: date
dt: datetime

if dt < d: # E: Unsupported operand types for < ("datetime" and "date") [operator]
pass
if d > dt: # E: Unsupported operand types for > ("date" and "datetime") [operator]
pass
if dt <= d: # E: Unsupported operand types for <= ("datetime" and "date") [operator]
pass
if d >= dt: # E: Unsupported operand types for >= ("date" and "datetime") [operator]
pass

d - dt # E: Unsupported operand types for - ("date" and "datetime") [operator]

# Equality and identity do not raise TypeError.
if dt == d:
pass
if d != dt:
pass
if dt is d:
pass

# Do not warn when both operands have the same static type.
d2: date
dt2: datetime
if d < d2:
pass
if dt < dt2:
pass
d - d2
dt - dt2

# Normal subtyping is preserved.
d = datetime.now()

def accept_date(value: date) -> None:
pass

accept_date(datetime.now())

# Narrowed optional operands and bounded type variables retain their precise type.
optional_dt: datetime | None
if optional_dt is not None and optional_dt < d2: # E: Unsupported operand types for < ("datetime" and "date") [operator]
pass

DT = TypeVar("DT", bound=datetime)

def compare(value: DT, other: date) -> bool:
return value < other # E: Unsupported operand types for < ("DT" and "date") [operator]

# Descendants with inherited date and datetime behavior are unsafe as well.
class DateSubclass(date):
pass

class DatetimeSubclass(datetime):
pass

sub_d: DateSubclass
sub_dt: DatetimeSubclass
# Before Python 3.13, a date subclass on the left is compared by date only.
sub_d < sub_dt
sub_dt < sub_d # E: Unsupported operand types for < ("DatetimeSubclass" and "DateSubclass") [operator]
sub_d - sub_dt # E: Unsupported operand types for - ("DateSubclass" and "DatetimeSubclass") [operator]

DateNewType = NewType("DateNewType", date)
DatetimeNewType = NewType("DatetimeNewType", datetime)
new_d: DateNewType
new_dt: DatetimeNewType
new_d < new_dt # E: Unsupported operand types for < ("DateNewType" and "DatetimeNewType") [operator]
new_d - new_dt # E: Unsupported operand types for - ("DateNewType" and "DatetimeNewType") [operator]

# Descendants from the same side of the date/datetime boundary are safe.
sub_d2: DateSubclass
sub_dt2: DatetimeSubclass
sub_d < sub_d2
sub_dt < sub_dt2
new_d < new_d
new_dt < new_dt

# Do not warn when a descendant overrides an operator to support mixed operands.
class ComparableDate(date):
def __lt__(self, other: date) -> bool: ...
def __sub__(self, other: date) -> timedelta: ...

class ComparableDatetime(datetime):
def __gt__(self, other: date) -> bool: ... # type: ignore[override]

comparable_d: ComparableDate
comparable_dt: ComparableDatetime
comparable_d < sub_dt
sub_d < comparable_dt
comparable_d - sub_dt

[builtins fixtures/classmethod.pyi]
[file datetime.pyi]
class timedelta: ...

class date:
@classmethod
def today(cls) -> date: ...
def __lt__(self, other: date) -> bool: ...
def __le__(self, other: date) -> bool: ...
def __gt__(self, other: date) -> bool: ...
def __ge__(self, other: date) -> bool: ...
def __eq__(self, other: object) -> bool: ...
def __ne__(self, other: object) -> bool: ...
def __sub__(self, other: date) -> timedelta: ...

class datetime(date):
@classmethod
def now(cls) -> datetime: ...
def __lt__(self, other: datetime) -> bool: ... # type: ignore[override]
def __le__(self, other: datetime) -> bool: ... # type: ignore[override]
def __gt__(self, other: datetime) -> bool: ... # type: ignore[override]
def __ge__(self, other: datetime) -> bool: ... # type: ignore[override]
def __sub__(self, other: datetime) -> timedelta: ... # type: ignore[override]

[case testUnsafeDatetimeSubclassComparisonPython313]
# flags: --show-error-codes --python-version 3.13
from datetime import date, datetime

class DateSubclass(date):
pass

class DatetimeSubclass(datetime):
pass

d: DateSubclass
dt: DatetimeSubclass
d < dt # E: Unsupported operand types for < ("DateSubclass" and "DatetimeSubclass") [operator]
[file datetime.pyi]
class date:
def __lt__(self, other: date) -> bool: ...
def __gt__(self, other: date) -> bool: ...

class datetime(date):
def __lt__(self, other: datetime) -> bool: ... # type: ignore[override]
def __gt__(self, other: datetime) -> bool: ... # type: ignore[override]
Loading