diff --git a/docs/source/error_code_list2.rst b/docs/source/error_code_list2.rst index bd2436061974..5902cf1eb591 100644 --- a/docs/source/error_code_list2.rst +++ b/docs/source/error_code_list2.rst @@ -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 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. diff --git a/mypy/errorcodes.py b/mypy/errorcodes.py index 927cd32f8fe0..b695258a4e0a 100644 --- a/mypy/errorcodes.py +++ b/mypy/errorcodes.py @@ -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" ) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 350d57a7e4ad..7a6fb8828684 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 ( @@ -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 diff --git a/test-data/unit/check-safe-datetime.test b/test-data/unit/check-safe-datetime.test new file mode 100644 index 000000000000..46a9c4c6e7b9 --- /dev/null +++ b/test-data/unit/check-safe-datetime.test @@ -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] 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]