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
48 changes: 48 additions & 0 deletions docs/source/error_code_list2.rst
Original file line number Diff line number Diff line change
Expand Up @@ -699,3 +699,51 @@ 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-safe-datetime:

Disallow datetime where date is expected [safe-datetime]
---------------------------------------------------------

If enabled with :option:`--enable-error-code safe-datetime <mypy --enable-error-code>`,
mypy will prevent ``datetime.datetime`` objects from being used where ``datetime.date``
is expected. While ``datetime`` is a subclass of ``date`` at runtime, comparing a
``datetime`` with a ``date`` raises a ``TypeError``. This error code catches these
errors at type-check time.

Example:

.. code-block:: python

# mypy: enable-error-code="safe-datetime"
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 ``safe-datetime`` 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.
3 changes: 3 additions & 0 deletions mypy/errorcodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ def __hash__(self) -> int:
COMPARISON_OVERLAP: Final = ErrorCode(
"comparison-overlap", "Check that types in comparisons and 'in' expressions overlap", "General"
)
SAFE_DATETIME: Final = ErrorCode(
"safe-datetime", "Disallow datetime where date is expected", "General", default_enabled=False
)
NO_ANY_UNIMPORTED: Final = ErrorCode(
"no-any-unimported", 'Reject "Any" types from unfollowed imports', "General"
)
Expand Down
14 changes: 14 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 @@ -528,6 +529,19 @@ 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

# Disallow datetime.datetime where datetime.date is expected when safe-datetime is
# enabled. While datetime is a subclass of date at runtime, comparing them raises
# TypeError, making this inheritance relationship problematic in practice.
if (
self.options
and codes.SAFE_DATETIME in self.options.enabled_error_codes
and lname == "datetime.datetime"
and rname == "datetime.date"
):
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-safe-datetime.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
[case testDatetimeVsDateComparison]
# flags: --enable-error-code safe-datetime
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 safe-datetime
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 safe-datetime
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 safe-datetime
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 safe-datetime
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 safe-datetime
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 safe-datetime
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]
22 changes: 22 additions & 0 deletions test-data/unit/fixtures/datetime.pyi
Original file line number Diff line number Diff line change
@@ -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]