From b971d9d6e070bcbdaadcf31edbe0754c76754245 Mon Sep 17 00:00:00 2001 From: Maciej Olko Date: Wed, 2 Sep 2026 09:35:11 +0200 Subject: [PATCH] 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. --- docs/source/error_code_list.rst | 20 +++ mypy/checkexpr.py | 130 +++++++++++++++++--- test-data/unit/check-unsafe-datetime.test | 143 ++++++++++++++++++++++ 3 files changed, 274 insertions(+), 19 deletions(-) create mode 100644 test-data/unit/check-unsafe-datetime.test diff --git a/docs/source/error_code_list.rst b/docs/source/error_code_list.rst index 034c795e71201..01e2e4d848a71 100644 --- a/docs/source/error_code_list.rst +++ b/docs/source/error_code_list.rst @@ -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] diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 14650b1b5242c..effd20f713d5d 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -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): @@ -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}") @@ -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 ("==", "!="): diff --git a/test-data/unit/check-unsafe-datetime.test b/test-data/unit/check-unsafe-datetime.test new file mode 100644 index 0000000000000..09a0a71c34607 --- /dev/null +++ b/test-data/unit/check-unsafe-datetime.test @@ -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]