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
52 changes: 52 additions & 0 deletions docs/source/error_code_list2.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 --enable-error-code>`,
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.
6 changes: 6 additions & 0 deletions mypy/errorcodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
16 changes: 16 additions & 0 deletions mypy/subtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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]


Expand Down Expand Up @@ -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
Expand Down
276 changes: 276 additions & 0 deletions test-data/unit/check-unsafe-subtype.test
Original file line number Diff line number Diff line change
@@ -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]
Loading