From 5a9efed1afc5aa93ccca40b31b33c3a574d3d1da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:59:32 +0000 Subject: [PATCH 01/11] Initial plan From 126ffab3ebf5030b3cdf03b43e91ec97d5dfb780 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:14:11 +0000 Subject: [PATCH 02/11] Implement unsafe subtype checking for datetime/date comparisons Co-authored-by: m-aciek <9288014+m-aciek@users.noreply.github.com> --- mypy/checkexpr.py | 32 ++++ mypy/errorcodes.py | 6 + mypy/messages.py | 12 ++ mypy/subtypes.py | 8 + test-data/unit/check-unsafe-subtype.test | 202 +++++++++++++++++++++++ test-data/unit/fixtures/datetime.pyi | 22 +++ 6 files changed, 282 insertions(+) create mode 100644 test-data/unit/check-unsafe-subtype.test create mode 100644 test-data/unit/fixtures/datetime.pyi diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 9990caaeb7a1..9a56a7b6a7e2 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -3697,6 +3697,12 @@ def visit_comparison_expr(self, e: ComparisonExpr) -> Type: ) e.method_types.append(method_type) + # Check for unsafe subtype relationships if enabled + if not w.has_new_errors() and codes.UNSAFE_SUBTYPE in self.chk.options.enabled_error_codes: + right_type = self.accept(right) + if self.has_unsafe_subtype_relationship(left_type, right_type): + self.msg.unsafe_subtype_comparison(left_type, right_type, operator, 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 ("==", "!="): @@ -3875,6 +3881,32 @@ def dangerous_comparison( return False return not is_overlapping_types(left, right, ignore_promotions=False) + def has_unsafe_subtype_relationship(self, left: Type, right: Type) -> bool: + """Check if left and right have an unsafe subtyping relationship. + + Returns True if they are instances with a nominal subclass relationship + that is known to be unsafe (e.g., datetime and date). + """ + from mypy.subtypes import UNSAFE_SUBTYPING_PAIRS + + left = get_proper_type(left) + right = get_proper_type(right) + + if not isinstance(left, Instance) or not isinstance(right, Instance): + return False + + left_name = left.type.fullname + right_name = right.type.fullname + + # Check if this pair is in our list of known unsafe subtyping relationships + # Check both directions since we want to catch comparisons either way + for subclass, superclass in UNSAFE_SUBTYPING_PAIRS: + if (left_name == subclass and right_name == superclass) or \ + (left_name == superclass and right_name == subclass): + return True + + return False + def check_method_call_by_name( self, method: str, 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/messages.py b/mypy/messages.py index bbcc93ebfb25..f29b51ab6af0 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1655,6 +1655,18 @@ def dangerous_comparison(self, left: Type, right: Type, kind: str, ctx: Context) code=codes.COMPARISON_OVERLAP, ) + def unsafe_subtype_comparison( + self, left: Type, right: Type, operator: str, ctx: Context + ) -> None: + """Report a comparison between types with an unsafe subtyping relationship. + + This warns about comparisons where the types have a nominal subclass relationship + but comparing them can cause runtime errors (e.g., datetime vs date). + """ + left_typ, right_typ = format_type_distinctly(left, right, options=self.options) + message = f"Unsafe comparison between {left_typ} and {right_typ}; runtime comparison may raise TypeError" + self.fail(message, ctx, code=codes.UNSAFE_SUBTYPE) + def overload_inconsistently_applies_decorator(self, decorator: str, context: Context) -> None: self.fail( f'Overload does not consistently use the "@{decorator}" ' diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 350d57a7e4ad..c9acdcb4885f 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -84,6 +84,14 @@ 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] diff --git a/test-data/unit/check-unsafe-subtype.test b/test-data/unit/check-unsafe-subtype.test new file mode 100644 index 000000000000..cc142d9ddd67 --- /dev/null +++ b/test-data/unit/check-unsafe-subtype.test @@ -0,0 +1,202 @@ +[case testDatetimeVsDateComparison] +# flags: --enable-error-code unsafe-subtype +from datetime import date, datetime + +dt: datetime +d: date + +if dt < d: # E: Unsafe comparison between "datetime" and "date"; runtime comparison may raise TypeError + pass + +if d > dt: # E: Unsafe comparison between "date" and "datetime"; runtime comparison may raise TypeError + pass + +if dt == d: # E: Unsafe comparison between "datetime" and "date"; runtime comparison may raise TypeError + pass + +if dt != d: # E: Unsafe comparison between "datetime" and "date"; runtime comparison may raise TypeError + pass + +if dt <= d: # E: Unsafe comparison between "datetime" and "date"; runtime comparison may raise TypeError + pass + +if dt >= d: # E: Unsafe comparison between "datetime" and "date"; runtime comparison may raise TypeError + 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: Unsafe comparison between "datetime" and "date"; runtime comparison may raise TypeError +[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 + +# These are OK - comparing within the same type +if dt1 < dt2: + pass + +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: Unsafe comparison between "datetime" and "date"; runtime comparison may raise TypeError + 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: Unsafe comparison between "datetime" and "date"; runtime comparison may raise TypeError +[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] From 7ff17b259766f433ac0a88d9da29ae6f0de86a11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:16:10 +0000 Subject: [PATCH 03/11] Fix docstring indentation and line continuation style Co-authored-by: m-aciek <9288014+m-aciek@users.noreply.github.com> --- mypy/checkexpr.py | 16 ++++++++-------- mypy/messages.py | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 9a56a7b6a7e2..593d74a722e8 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -3883,28 +3883,28 @@ def dangerous_comparison( def has_unsafe_subtype_relationship(self, left: Type, right: Type) -> bool: """Check if left and right have an unsafe subtyping relationship. - + Returns True if they are instances with a nominal subclass relationship that is known to be unsafe (e.g., datetime and date). """ from mypy.subtypes import UNSAFE_SUBTYPING_PAIRS - + left = get_proper_type(left) right = get_proper_type(right) - + if not isinstance(left, Instance) or not isinstance(right, Instance): return False - + left_name = left.type.fullname right_name = right.type.fullname - + # Check if this pair is in our list of known unsafe subtyping relationships # Check both directions since we want to catch comparisons either way for subclass, superclass in UNSAFE_SUBTYPING_PAIRS: - if (left_name == subclass and right_name == superclass) or \ - (left_name == superclass and right_name == subclass): + if ((left_name == subclass and right_name == superclass) + or (left_name == superclass and right_name == subclass)): return True - + return False def check_method_call_by_name( diff --git a/mypy/messages.py b/mypy/messages.py index f29b51ab6af0..25817af10d4e 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1659,7 +1659,7 @@ def unsafe_subtype_comparison( self, left: Type, right: Type, operator: str, ctx: Context ) -> None: """Report a comparison between types with an unsafe subtyping relationship. - + This warns about comparisons where the types have a nominal subclass relationship but comparing them can cause runtime errors (e.g., datetime vs date). """ From 857f0fad7fcc20f94e88070a112b7e7b3e3c160a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:37:22 +0000 Subject: [PATCH 04/11] Flag date vs date comparisons as unsafe when datetime subclass exists Co-authored-by: m-aciek <9288014+m-aciek@users.noreply.github.com> --- mypy/checkexpr.py | 5 +++ test-data/unit/check-unsafe-subtype.test | 40 ++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 593d74a722e8..8ea24a2f91d5 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -3904,6 +3904,11 @@ def has_unsafe_subtype_relationship(self, left: Type, right: Type) -> bool: if ((left_name == subclass and right_name == superclass) or (left_name == superclass and right_name == subclass)): return True + + # Also flag when both types are the supertype, as one could be the subclass at runtime. + # This catches cases like comparing two 'date' values where one could be a 'datetime'. + if left_name == superclass and right_name == superclass: + return True return False diff --git a/test-data/unit/check-unsafe-subtype.test b/test-data/unit/check-unsafe-subtype.test index cc142d9ddd67..341cc2927907 100644 --- a/test-data/unit/check-unsafe-subtype.test +++ b/test-data/unit/check-unsafe-subtype.test @@ -115,11 +115,12 @@ dt2: datetime d1: date d2: date -# These are OK - comparing within the same type +# datetime vs datetime is safe if dt1 < dt2: pass -if d1 < d2: +# date vs date is unsafe because one could be a datetime at runtime +if d1 < d2: # E: Unsafe comparison between "date" and "date"; runtime comparison may raise TypeError pass [builtins fixtures/classmethod.pyi] [file datetime.pyi] @@ -200,3 +201,38 @@ class datetime(date): 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: + # This is flagged because one of the dates could be a datetime at runtime + return d1 < d2 # E: Unsafe comparison between "date" and "date"; runtime comparison may raise TypeError + +# Example usage that would fail at runtime: +dt = datetime.now() +d = date.today() +# This passes type checking since datetime is a subclass of date +result = compare_dates(dt, d) # But the comparison inside will fail at runtime +[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] From ffc35922d40c0ad564bef1be0f7e1acc89d79512 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:39:17 +0000 Subject: [PATCH 05/11] Add detailed documentation for supertype comparison checking Co-authored-by: m-aciek <9288014+m-aciek@users.noreply.github.com> --- mypy/checkexpr.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 8ea24a2f91d5..d27a502f5282 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -3886,6 +3886,11 @@ def has_unsafe_subtype_relationship(self, left: Type, right: Type) -> bool: Returns True if they are instances with a nominal subclass relationship that is known to be unsafe (e.g., datetime and date). + + This also flags comparisons between two values of the supertype (e.g., date vs date) + because at runtime, one could be an instance of the unsafe subclass (e.g., datetime). + While conservative, this is necessary to catch LSP violations where the subclass + has incompatible comparison operators. """ from mypy.subtypes import UNSAFE_SUBTYPING_PAIRS @@ -3906,7 +3911,9 @@ def has_unsafe_subtype_relationship(self, left: Type, right: Type) -> bool: return True # Also flag when both types are the supertype, as one could be the subclass at runtime. - # This catches cases like comparing two 'date' values where one could be a 'datetime'. + # Example: comparing 'date' vs 'date' is unsafe because one could be a 'datetime'. + # This is conservative but necessary due to Python's subtyping allowing datetime + # instances to be used wherever date is expected, despite incompatible comparison. if left_name == superclass and right_name == superclass: return True From 901cdecdcb9d132db86f5dbe42511fdc59614a36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:57:12 +0000 Subject: [PATCH 06/11] Block datetime/date inheritance when unsafe-subtype is enabled Co-authored-by: m-aciek <9288014+m-aciek@users.noreply.github.com> --- mypy/subtypes.py | 11 +++++ test-data/unit/check-unsafe-subtype.test | 56 ++++++++++++++++++++---- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index c9acdcb4885f..e76ed392cb14 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -536,6 +536,17 @@ 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: + from mypy import errorcodes as codes + if 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 index 341cc2927907..b5a676aa5a50 100644 --- a/test-data/unit/check-unsafe-subtype.test +++ b/test-data/unit/check-unsafe-subtype.test @@ -5,10 +5,10 @@ from datetime import date, datetime dt: datetime d: date -if dt < d: # E: Unsafe comparison between "datetime" and "date"; runtime comparison may raise TypeError +if dt < d: # E: Unsupported operand types for < ("datetime" and "date") pass -if d > dt: # E: Unsafe comparison between "date" and "datetime"; runtime comparison may raise TypeError +if d > dt: # E: Unsupported operand types for < ("datetime" and "date") pass if dt == d: # E: Unsafe comparison between "datetime" and "date"; runtime comparison may raise TypeError @@ -17,10 +17,10 @@ if dt == d: # E: Unsafe comparison between "datetime" and "date"; runtime compa if dt != d: # E: Unsafe comparison between "datetime" and "date"; runtime comparison may raise TypeError pass -if dt <= d: # E: Unsafe comparison between "datetime" and "date"; runtime comparison may raise TypeError +if dt <= d: # E: Unsupported operand types for <= ("datetime" and "date") pass -if dt >= d: # E: Unsafe comparison between "datetime" and "date"; runtime comparison may raise TypeError +if dt >= d: # E: Unsupported operand types for >= ("datetime" and "date") pass [builtins fixtures/classmethod.pyi] [file datetime.pyi] @@ -83,7 +83,7 @@ class datetime(date): from datetime import date, datetime def compare_datetime_date(dt: datetime, d: date) -> bool: - return dt < d # E: Unsafe comparison between "datetime" and "date"; runtime comparison may raise TypeError + return dt < d # E: Unsupported operand types for < ("datetime" and "date") [builtins fixtures/classmethod.pyi] [file datetime.pyi] class date: @@ -148,7 +148,7 @@ class datetime(date): # flags: --enable-error-code unsafe-subtype from datetime import date, datetime -if datetime.now() < date.today(): # E: Unsafe comparison between "datetime" and "date"; runtime comparison may raise TypeError +if datetime.now() < date.today(): # E: Unsupported operand types for < ("datetime" and "date") pass [builtins fixtures/classmethod.pyi] [file datetime.pyi] @@ -179,7 +179,7 @@ from datetime import date, datetime dt: datetime d: date -result = dt < d # E: Unsafe comparison between "datetime" and "date"; runtime comparison may raise TypeError +result = dt < d # E: Unsupported operand types for < ("datetime" and "date") [builtins fixtures/classmethod.pyi] [file datetime.pyi] class date: @@ -213,8 +213,46 @@ def compare_dates(d1: date, d2: date) -> bool: # Example usage that would fail at runtime: dt = datetime.now() d = date.today() -# This passes type checking since datetime is a subclass of date -result = compare_dates(dt, d) # But the comparison inside will fail at runtime +# 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: From cb83a2a616834295d3ac564d0f094a9ab3c927d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:59:41 +0000 Subject: [PATCH 07/11] Move errorcodes import to module level for performance Co-authored-by: m-aciek <9288014+m-aciek@users.noreply.github.com> --- mypy/subtypes.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index e76ed392cb14..5f0a9135f4e7 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -76,6 +76,7 @@ from mypy.types_utils import flatten_types from mypy.typestate import SubtypeKind, type_state from mypy.typevars import fill_typevars, fill_typevars_with_any +from mypy import errorcodes as codes # Flags for detected protocol members IS_SETTABLE: Final = 1 @@ -539,13 +540,11 @@ def visit_instance(self, left: Instance) -> bool: lname = left.type.fullname # Check if this is an unsafe subtype relationship that should be blocked - if self.options: - from mypy import errorcodes as codes - if 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 + 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*. From 1b55f52dd64a1315e549df1e271894d33ee1611e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 00:09:16 +0000 Subject: [PATCH 08/11] Remove date/date comparison warnings as inheritance blocking makes them redundant Co-authored-by: m-aciek <9288014+m-aciek@users.noreply.github.com> --- mypy/checkexpr.py | 12 ------------ test-data/unit/check-unsafe-subtype.test | 8 ++++---- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index d27a502f5282..593d74a722e8 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -3886,11 +3886,6 @@ def has_unsafe_subtype_relationship(self, left: Type, right: Type) -> bool: Returns True if they are instances with a nominal subclass relationship that is known to be unsafe (e.g., datetime and date). - - This also flags comparisons between two values of the supertype (e.g., date vs date) - because at runtime, one could be an instance of the unsafe subclass (e.g., datetime). - While conservative, this is necessary to catch LSP violations where the subclass - has incompatible comparison operators. """ from mypy.subtypes import UNSAFE_SUBTYPING_PAIRS @@ -3909,13 +3904,6 @@ def has_unsafe_subtype_relationship(self, left: Type, right: Type) -> bool: if ((left_name == subclass and right_name == superclass) or (left_name == superclass and right_name == subclass)): return True - - # Also flag when both types are the supertype, as one could be the subclass at runtime. - # Example: comparing 'date' vs 'date' is unsafe because one could be a 'datetime'. - # This is conservative but necessary due to Python's subtyping allowing datetime - # instances to be used wherever date is expected, despite incompatible comparison. - if left_name == superclass and right_name == superclass: - return True return False diff --git a/test-data/unit/check-unsafe-subtype.test b/test-data/unit/check-unsafe-subtype.test index b5a676aa5a50..6be496a38148 100644 --- a/test-data/unit/check-unsafe-subtype.test +++ b/test-data/unit/check-unsafe-subtype.test @@ -119,8 +119,8 @@ d2: date if dt1 < dt2: pass -# date vs date is unsafe because one could be a datetime at runtime -if d1 < d2: # E: Unsafe comparison between "date" and "date"; runtime comparison may raise TypeError +# 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] @@ -207,8 +207,8 @@ class datetime(date): from datetime import date, datetime def compare_dates(d1: date, d2: date) -> bool: - # This is flagged because one of the dates could be a datetime at runtime - return d1 < d2 # E: Unsafe comparison between "date" and "date"; runtime comparison may raise TypeError + # 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() From 2d231f578623950fd915bde6b3a6259d81236da3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 00:20:28 +0000 Subject: [PATCH 09/11] Remove redundant comparison check code in visit_comparison_expr Co-authored-by: m-aciek <9288014+m-aciek@users.noreply.github.com> --- mypy/checkexpr.py | 32 ------------------------ mypy/messages.py | 12 --------- test-data/unit/check-unsafe-subtype.test | 4 +-- 3 files changed, 2 insertions(+), 46 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 593d74a722e8..9990caaeb7a1 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -3697,12 +3697,6 @@ def visit_comparison_expr(self, e: ComparisonExpr) -> Type: ) e.method_types.append(method_type) - # Check for unsafe subtype relationships if enabled - if not w.has_new_errors() and codes.UNSAFE_SUBTYPE in self.chk.options.enabled_error_codes: - right_type = self.accept(right) - if self.has_unsafe_subtype_relationship(left_type, right_type): - self.msg.unsafe_subtype_comparison(left_type, right_type, operator, 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 ("==", "!="): @@ -3881,32 +3875,6 @@ def dangerous_comparison( return False return not is_overlapping_types(left, right, ignore_promotions=False) - def has_unsafe_subtype_relationship(self, left: Type, right: Type) -> bool: - """Check if left and right have an unsafe subtyping relationship. - - Returns True if they are instances with a nominal subclass relationship - that is known to be unsafe (e.g., datetime and date). - """ - from mypy.subtypes import UNSAFE_SUBTYPING_PAIRS - - left = get_proper_type(left) - right = get_proper_type(right) - - if not isinstance(left, Instance) or not isinstance(right, Instance): - return False - - left_name = left.type.fullname - right_name = right.type.fullname - - # Check if this pair is in our list of known unsafe subtyping relationships - # Check both directions since we want to catch comparisons either way - for subclass, superclass in UNSAFE_SUBTYPING_PAIRS: - if ((left_name == subclass and right_name == superclass) - or (left_name == superclass and right_name == subclass)): - return True - - return False - def check_method_call_by_name( self, method: str, diff --git a/mypy/messages.py b/mypy/messages.py index 25817af10d4e..bbcc93ebfb25 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1655,18 +1655,6 @@ def dangerous_comparison(self, left: Type, right: Type, kind: str, ctx: Context) code=codes.COMPARISON_OVERLAP, ) - def unsafe_subtype_comparison( - self, left: Type, right: Type, operator: str, ctx: Context - ) -> None: - """Report a comparison between types with an unsafe subtyping relationship. - - This warns about comparisons where the types have a nominal subclass relationship - but comparing them can cause runtime errors (e.g., datetime vs date). - """ - left_typ, right_typ = format_type_distinctly(left, right, options=self.options) - message = f"Unsafe comparison between {left_typ} and {right_typ}; runtime comparison may raise TypeError" - self.fail(message, ctx, code=codes.UNSAFE_SUBTYPE) - def overload_inconsistently_applies_decorator(self, decorator: str, context: Context) -> None: self.fail( f'Overload does not consistently use the "@{decorator}" ' diff --git a/test-data/unit/check-unsafe-subtype.test b/test-data/unit/check-unsafe-subtype.test index 6be496a38148..992dea92b500 100644 --- a/test-data/unit/check-unsafe-subtype.test +++ b/test-data/unit/check-unsafe-subtype.test @@ -11,10 +11,10 @@ if dt < d: # E: Unsupported operand types for < ("datetime" and "date") if d > dt: # E: Unsupported operand types for < ("datetime" and "date") pass -if dt == d: # E: Unsafe comparison between "datetime" and "date"; runtime comparison may raise TypeError +if dt == d: pass -if dt != d: # E: Unsafe comparison between "datetime" and "date"; runtime comparison may raise TypeError +if dt != d: pass if dt <= d: # E: Unsupported operand types for <= ("datetime" and "date") From 5ee1d68d289f4ea7558e02627533bd5c56eab802 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 01:30:30 +0000 Subject: [PATCH 10/11] Add documentation for unsafe-subtype error code Co-authored-by: m-aciek <9288014+m-aciek@users.noreply.github.com> --- docs/source/error_code_list2.rst | 52 ++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) 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. From 427f31cac0a10901a776e7f0534b929c9323a6b6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 20 Dec 2025 01:35:56 +0000 Subject: [PATCH 11/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/subtypes.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 5f0a9135f4e7..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 ( @@ -76,7 +77,6 @@ from mypy.types_utils import flatten_types from mypy.typestate import SubtypeKind, type_state from mypy.typevars import fill_typevars, fill_typevars_with_any -from mypy import errorcodes as codes # Flags for detected protocol members IS_SETTABLE: Final = 1 @@ -89,9 +89,7 @@ # 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"), -] +UNSAFE_SUBTYPING_PAIRS: Final = [("datetime.datetime", "datetime.date")] TypeParameterChecker: _TypeAlias = Callable[[Type, Type, int, bool, "SubtypeContext"], bool] @@ -538,14 +536,14 @@ def visit_instance(self, left: Instance) -> bool: 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