diff --git a/docs/source/error_code_list2.rst b/docs/source/error_code_list2.rst index bd2436061974..daf89b6b33f4 100644 --- a/docs/source/error_code_list2.rst +++ b/docs/source/error_code_list2.rst @@ -699,3 +699,55 @@ Example: @printing_decorator # E: Untyped decorator makes function "add_forty_two" untyped [untyped-decorator] def add_forty_two(value: int) -> int: return value + 42 + +.. _code-unsafe-subtype: + +Check for unsafe subtype relationships [unsafe-subtype] +-------------------------------------------------------- + +If enabled with :option:`--enable-error-code unsafe-subtype `, +mypy will block certain subtype relationships that are unsafe at runtime despite +being valid in Python's type system. + +The primary use case is blocking the ``datetime.datetime`` to ``datetime.date`` +inheritance relationship. While ``datetime`` is a subclass of ``date`` at runtime, +comparing a ``datetime`` with a ``date`` raises a ``TypeError``. When this error +code is enabled, mypy will prevent ``datetime`` objects from being used where +``date`` is expected, catching these errors at type-check time. + +Example: + +.. code-block:: python + + # mypy: enable-error-code="unsafe-subtype" + from datetime import date, datetime + + # Error: Incompatible types in assignment (expression has type "datetime", variable has type "date") + d: date = datetime.now() + + def accept_date(d: date) -> None: + pass + + # Error: Argument 1 to "accept_date" has incompatible type "datetime"; expected "date" + accept_date(datetime.now()) + +Without this error code enabled, the above code passes type checking (as ``datetime`` +is a valid subtype of ``date``), but comparisons between the two types will fail at +runtime: + +.. code-block:: python + + from datetime import date, datetime + + dt = datetime.now() + d = date.today() + + # This raises: TypeError: can't compare datetime.datetime to datetime.date + if dt < d: + print("never reached") + +When ``unsafe-subtype`` is enabled, assignment and parameter passing are blocked, +preventing the runtime error. + +**Note:** Equality comparisons (``==`` and ``!=``) still work between these types, +as ``__eq__`` accepts ``object`` as its parameter. diff --git a/mypy/errorcodes.py b/mypy/errorcodes.py index 927cd32f8fe0..c01e1e01f716 100644 --- a/mypy/errorcodes.py +++ b/mypy/errorcodes.py @@ -172,6 +172,12 @@ def __hash__(self) -> int: COMPARISON_OVERLAP: Final = ErrorCode( "comparison-overlap", "Check that types in comparisons and 'in' expressions overlap", "General" ) +UNSAFE_SUBTYPE: Final = ErrorCode( + "unsafe-subtype", + "Warn about unsafe subtyping relationships that may cause runtime errors", + "General", + default_enabled=False, +) NO_ANY_UNIMPORTED: Final = ErrorCode( "no-any-unimported", 'Reject "Any" types from unfollowed imports', "General" ) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 350d57a7e4ad..51f062fc4459 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -7,6 +7,7 @@ import mypy.applytype import mypy.constraints import mypy.typeops +from mypy import errorcodes as codes from mypy.checker_state import checker_state from mypy.erasetype import erase_type from mypy.expandtype import ( @@ -84,6 +85,12 @@ IS_VAR: Final = 4 IS_EXPLICIT_SETTER: Final = 5 +# Known unsafe subtyping relationships that should trigger warnings. +# Each tuple is (subclass_fullname, superclass_fullname). +# These are cases where a class is a subclass at runtime but treating it +# as a subtype can cause runtime errors. +UNSAFE_SUBTYPING_PAIRS: Final = [("datetime.datetime", "datetime.date")] + TypeParameterChecker: _TypeAlias = Callable[[Type, Type, int, bool, "SubtypeContext"], bool] @@ -528,6 +535,15 @@ def visit_instance(self, left: Instance) -> bool: if left.type.alt_promote and left.type.alt_promote.type is right.type: return True rname = right.type.fullname + lname = left.type.fullname + + # Check if this is an unsafe subtype relationship that should be blocked + if self.options and codes.UNSAFE_SUBTYPE in self.options.enabled_error_codes: + # Block unsafe subtyping relationships when the error code is enabled + for subclass, superclass in UNSAFE_SUBTYPING_PAIRS: + if lname == subclass and rname == superclass: + return False + # Always try a nominal check if possible, # there might be errors that a user wants to silence *once*. # NamedTuples are a special case, because `NamedTuple` is not listed diff --git a/test-data/unit/check-unsafe-subtype.test b/test-data/unit/check-unsafe-subtype.test new file mode 100644 index 000000000000..992dea92b500 --- /dev/null +++ b/test-data/unit/check-unsafe-subtype.test @@ -0,0 +1,276 @@ +[case testDatetimeVsDateComparison] +# flags: --enable-error-code unsafe-subtype +from datetime import date, datetime + +dt: datetime +d: date + +if dt < d: # E: Unsupported operand types for < ("datetime" and "date") + pass + +if d > dt: # E: Unsupported operand types for < ("datetime" and "date") + pass + +if dt == d: + pass + +if dt != d: + pass + +if dt <= d: # E: Unsupported operand types for <= ("datetime" and "date") + pass + +if dt >= d: # E: Unsupported operand types for >= ("datetime" and "date") + pass +[builtins fixtures/classmethod.pyi] +[file datetime.pyi] +class date: + def __init__(self, year: int, month: int, day: int) -> None: ... + @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: ... + +class datetime(date): + def __init__(self, year: int, month: int, day: int, hour: int = 0, minute: int = 0, second: int = 0, microsecond: int = 0) -> None: ... + @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] + +[case testDatetimeVsDateComparisonDisabled] +# No flags, so the error should not appear +from datetime import date, datetime + +dt: datetime +d: date + +if dt < d: + pass + +if d > dt: + pass +[builtins fixtures/classmethod.pyi] +[file datetime.pyi] +class date: + def __init__(self, year: int, month: int, day: int) -> None: ... + @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: ... + +class datetime(date): + def __init__(self, year: int, month: int, day: int, hour: int = 0, minute: int = 0, second: int = 0, microsecond: int = 0) -> None: ... + @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] + +[case testDatetimeVsDateComparisonExplicitTypes] +# flags: --enable-error-code unsafe-subtype +from datetime import date, datetime + +def compare_datetime_date(dt: datetime, d: date) -> bool: + return dt < d # E: Unsupported operand types for < ("datetime" and "date") +[builtins fixtures/classmethod.pyi] +[file datetime.pyi] +class date: + def __init__(self, year: int, month: int, day: int) -> None: ... + @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: ... + +class datetime(date): + def __init__(self, year: int, month: int, day: int, hour: int = 0, minute: int = 0, second: int = 0, microsecond: int = 0) -> None: ... + @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] + +[case testDatetimeComparisonOK] +# flags: --enable-error-code unsafe-subtype +from datetime import date, datetime + +dt1: datetime +dt2: datetime +d1: date +d2: date + +# datetime vs datetime is safe +if dt1 < dt2: + pass + +# date vs date is now OK since inheritance blocking prevents datetime from being passed as date +if d1 < d2: + pass +[builtins fixtures/classmethod.pyi] +[file datetime.pyi] +class date: + def __init__(self, year: int, month: int, day: int) -> None: ... + @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: ... + +class datetime(date): + def __init__(self, year: int, month: int, day: int, hour: int = 0, minute: int = 0, second: int = 0, microsecond: int = 0) -> None: ... + @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] + +[case testDatetimeVsDateComparisonWithNow] +# flags: --enable-error-code unsafe-subtype +from datetime import date, datetime + +if datetime.now() < date.today(): # E: Unsupported operand types for < ("datetime" and "date") + pass +[builtins fixtures/classmethod.pyi] +[file datetime.pyi] +class date: + def __init__(self, year: int, month: int, day: int) -> None: ... + @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: ... + +class datetime(date): + def __init__(self, year: int, month: int, day: int, hour: int = 0, minute: int = 0, second: int = 0, microsecond: int = 0) -> None: ... + @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] + +[case testDatetimeVsDateComparisonInExpression] +# flags: --enable-error-code unsafe-subtype +from datetime import date, datetime + +dt: datetime +d: date + +result = dt < d # E: Unsupported operand types for < ("datetime" and "date") +[builtins fixtures/classmethod.pyi] +[file datetime.pyi] +class date: + def __init__(self, year: int, month: int, day: int) -> None: ... + @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: ... + +class datetime(date): + def __init__(self, year: int, month: int, day: int, hour: int = 0, minute: int = 0, second: int = 0, microsecond: int = 0) -> None: ... + @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] + +[case testDateVsDateMixedWithDatetime] +# flags: --enable-error-code unsafe-subtype +from datetime import date, datetime + +def compare_dates(d1: date, d2: date) -> bool: + # With inheritance blocking, this is now safe - datetime cannot be passed here + return d1 < d2 + +# Example usage that would fail at runtime: +dt = datetime.now() +d = date.today() +# This now errors because datetime is not assignable to date +result = compare_dates(dt, d) # E: Argument 1 to "compare_dates" has incompatible type "datetime"; expected "date" +[builtins fixtures/classmethod.pyi] +[file datetime.pyi] +class date: + def __init__(self, year: int, month: int, day: int) -> None: ... + @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: ... + +class datetime(date): + def __init__(self, year: int, month: int, day: int, hour: int = 0, minute: int = 0, second: int = 0, microsecond: int = 0) -> None: ... + @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] + +[case testInheritanceBlocking] +# flags: --enable-error-code unsafe-subtype +from datetime import date, datetime + +# Assignment should be blocked +d: date = datetime.now() # E: Incompatible types in assignment (expression has type "datetime", variable has type "date") + +# Function parameters should be blocked +def accept_date(d: date) -> None: + pass + +accept_date(datetime.now()) # E: Argument 1 to "accept_date" has incompatible type "datetime"; expected "date" + +# But date to date should still work +d2: date = date.today() # OK +accept_date(date.today()) # OK +[builtins fixtures/classmethod.pyi] +[file datetime.pyi] +class date: + def __init__(self, year: int, month: int, day: int) -> None: ... + @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: ... + +class datetime(date): + def __init__(self, year: int, month: int, day: int, hour: int = 0, minute: int = 0, second: int = 0, microsecond: int = 0) -> None: ... + @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] diff --git a/test-data/unit/fixtures/datetime.pyi b/test-data/unit/fixtures/datetime.pyi new file mode 100644 index 000000000000..f17c778be997 --- /dev/null +++ b/test-data/unit/fixtures/datetime.pyi @@ -0,0 +1,22 @@ +# Minimal datetime stub for testing unsafe subtype checking +from typing import ClassVar + +class date: + def __init__(self, year: int, month: int, day: int) -> None: ... + @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: ... + +class datetime(date): + def __init__(self, year: int, month: int, day: int, hour: int = 0, minute: int = 0, second: int = 0, microsecond: int = 0) -> None: ... + @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]