diff --git a/docs/source/class_basics.rst b/docs/source/class_basics.rst index 1d4164192318..82bbf00b830d 100644 --- a/docs/source/class_basics.rst +++ b/docs/source/class_basics.rst @@ -208,6 +208,31 @@ override has a compatible signature: subtype such as ``list[int]``. Similarly, you can vary argument types **contravariantly** -- subclasses can have more general argument types. +In order to ensure that your code remains correct when renaming methods, +it can be helpful to explicitly mark a method as overriding a base +method. This can be done with the ``@override`` decorator. If the base +method is then renamed while the overriding method is not, mypy will +show an error: + +.. code-block:: python + + from typing import override + + class Base: + def f(self, x: int) -> None: + ... + def g_renamed(self, y: str) -> None: + ... + + class Derived1(Base): + @override + def f(self, x: int) -> None: # OK + ... + + @override + def g(self, y: str) -> None: # Error: no corresponding base method found + ... + You can also override a statically typed method with a dynamically typed one. This allows dynamically typed code to override methods defined in library classes without worrying about their type diff --git a/mypy/checker.py b/mypy/checker.py index a52e8956145d..b8b85be3fbe8 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -641,7 +641,9 @@ def _visit_overloaded_func_def(self, defn: OverloadedFuncDef) -> None: if defn.impl: defn.impl.accept(self) if defn.info: - self.check_method_override(defn) + found_base_method = self.check_method_override(defn) + if defn.is_explicit_override and found_base_method is False: + self.msg.no_overridable_method(defn.name, defn) self.check_inplace_operator_method(defn) if not defn.is_property: self.check_overlapping_overloads(defn) @@ -1807,25 +1809,35 @@ def expand_typevars( else: return [(defn, typ)] - def check_method_override(self, defn: FuncDef | OverloadedFuncDef | Decorator) -> None: + def check_method_override(self, defn: FuncDef | OverloadedFuncDef | Decorator) -> bool | None: """Check if function definition is compatible with base classes. This may defer the method if a signature is not available in at least one base class. + Return ``None`` if that happens. + + Return ``True`` if an attribute with the method name was found in the base class. """ # Check against definitions in base classes. + found_base_method = False for base in defn.info.mro[1:]: - if self.check_method_or_accessor_override_for_base(defn, base): + result = self.check_method_or_accessor_override_for_base(defn, base) + if result is None: # Node was deferred, we will have another attempt later. - return + return None + found_base_method |= result + return found_base_method def check_method_or_accessor_override_for_base( self, defn: FuncDef | OverloadedFuncDef | Decorator, base: TypeInfo - ) -> bool: + ) -> bool | None: """Check if method definition is compatible with a base class. - Return True if the node was deferred because one of the corresponding + Return ``None`` if the node was deferred because one of the corresponding superclass nodes is not ready. + + Return ``True`` if an attribute with the method name was found in the base class. """ + found_base_method = False if base: name = defn.name base_attr = base.names.get(name) @@ -1836,13 +1848,14 @@ def check_method_or_accessor_override_for_base( # Second, final can't override anything writeable independently of types. if defn.is_final: self.check_if_final_var_override_writable(name, base_attr.node, defn) + found_base_method = True # Check the type of override. if name not in ("__init__", "__new__", "__init_subclass__"): # Check method override # (__init__, __new__, __init_subclass__ are special). if self.check_method_override_for_base_with_name(defn, name, base): - return True + return None if name in operators.inplace_operator_methods: # Figure out the name of the corresponding operator method. method = "__" + name[3:] @@ -1850,8 +1863,9 @@ def check_method_or_accessor_override_for_base( # always introduced safely if a base class defined __add__. # TODO can't come up with an example where this is # necessary; now it's "just in case" - return self.check_method_override_for_base_with_name(defn, method, base) - return False + if self.check_method_override_for_base_with_name(defn, method, base): + return None + return found_base_method def check_method_override_for_base_with_name( self, defn: FuncDef | OverloadedFuncDef | Decorator, name: str, base: TypeInfo @@ -4715,7 +4729,9 @@ def visit_decorator(self, e: Decorator) -> None: self.check_incompatible_property_override(e) # For overloaded functions we already checked override for overload as a whole. if e.func.info and not e.func.is_dynamic() and not e.is_overload: - self.check_method_override(e) + found_base_method = self.check_method_override(e) + if e.func.is_explicit_override and found_base_method is False: + self.msg.no_overridable_method(e.func.name, e.func) if e.func.info and e.func.name in ("__init__", "__new__"): if e.type and not isinstance(get_proper_type(e.type), (FunctionLike, AnyType)): diff --git a/mypy/messages.py b/mypy/messages.py index b40b32c487bd..981849df663d 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1493,6 +1493,13 @@ def cant_assign_to_method(self, context: Context) -> None: def cant_assign_to_classvar(self, name: str, context: Context) -> None: self.fail(f'Cannot assign to class variable "{name}" via instance', context) + def no_overridable_method(self, name: str, context: Context) -> None: + self.fail( + f'Method "{name}" is marked as an override, ' + "but no base method was found with this name", + context, + ) + def final_cant_override_writable(self, name: str, ctx: Context) -> None: self.fail(f'Cannot override writable attribute "{name}" with a final one', ctx) diff --git a/mypy/nodes.py b/mypy/nodes.py index f36bda13d53c..414b5c190aa0 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -512,6 +512,7 @@ class FuncBase(Node): "is_class", # Uses "@classmethod" (explicit or implicit) "is_static", # Uses "@staticmethod" "is_final", # Uses "@final" + "is_explicit_override", # Uses "@override" "_fullname", ) @@ -529,6 +530,7 @@ def __init__(self) -> None: self.is_class = False self.is_static = False self.is_final = False + self.is_explicit_override = False # Name with module prefix self._fullname = "" diff --git a/mypy/semanal.py b/mypy/semanal.py index bfc67e498ad6..70bd876af46e 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -245,6 +245,7 @@ FINAL_TYPE_NAMES, NEVER_NAMES, OVERLOAD_NAMES, + OVERRIDE_DECORATOR_NAMES, PROTOCOL_NAMES, REVEAL_TYPE_NAMES, TPDICT_NAMES, @@ -1196,6 +1197,9 @@ def analyze_overload_sigs_and_impl( types.append(callable) if item.var.is_property: self.fail("An overload can not be a property", item) + # If any item was decorated with `@override`, the whole overload + # becomes an explicit override. + defn.is_explicit_override |= item.func.is_explicit_override elif isinstance(item, FuncDef): if i == len(defn.items) - 1 and not self.is_stub_file: impl = item @@ -1495,6 +1499,10 @@ def visit_decorator(self, dec: Decorator) -> None: dec.func.is_class = True dec.var.is_classmethod = True self.check_decorated_function_is_method("classmethod", dec) + elif refers_to_fullname(d, OVERRIDE_DECORATOR_NAMES): + removed.append(i) + dec.func.is_explicit_override = True + self.check_decorated_function_is_method("override", dec) elif refers_to_fullname( d, ( diff --git a/mypy/types.py b/mypy/types.py index 49a5fecbd36f..f23800234600 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -156,6 +156,8 @@ "typing.dataclass_transform", "typing_extensions.dataclass_transform", ) +# Supported @override decorator names. +OVERRIDE_DECORATOR_NAMES: Final = ("typing.override", "typing_extensions.override") # A placeholder used for Bogus[...] parameters _dummy: Final[Any] = object() diff --git a/test-data/unit/check-functions.test b/test-data/unit/check-functions.test index b76abd31e3dc..4bad19af539c 100644 --- a/test-data/unit/check-functions.test +++ b/test-data/unit/check-functions.test @@ -2724,3 +2724,278 @@ TS = TypeVar("TS", bound=str) f: Callable[[Sequence[TI]], None] g: Callable[[Union[Sequence[TI], Sequence[TS]]], None] f = g + +[case explicitOverride] +# flags: --python-version 3.12 +from typing import override + +class A: + def f(self, x: int) -> str: pass + @override + def g(self, x: int) -> str: pass # E: Method "g" is marked as an override, but no base method was found with this name + +class B(A): + @override + def f(self, x: int) -> str: pass + @override + def g(self, x: int) -> str: pass + +class C(A): + @override + def f(self, x: str) -> str: pass # E: Argument 1 of "f" is incompatible with supertype "A"; supertype defines the argument type as "int" \ + # N: This violates the Liskov substitution principle \ + # N: See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides + def g(self, x: int) -> str: pass + +class D(A): pass +class E(D): pass +class F(E): + @override + def f(self, x: int) -> str: pass +[typing fixtures/typing-full.pyi] +[builtins fixtures/tuple.pyi] + +[case explicitOverrideStaticmethod] +# flags: --python-version 3.12 +from typing import override + +class A: + @staticmethod + def f(x: int) -> str: pass + +class B(A): + @staticmethod + @override + def f(x: int) -> str: pass + @override + @staticmethod + def g(x: int) -> str: pass # E: Method "g" is marked as an override, but no base method was found with this name + +class C(A): # inverted order of decorators + @override + @staticmethod + def f(x: int) -> str: pass + @override + @staticmethod + def g(x: int) -> str: pass # E: Method "g" is marked as an override, but no base method was found with this name + +class D(A): + @staticmethod + @override + def f(x: str) -> str: pass # E: Argument 1 of "f" is incompatible with supertype "A"; supertype defines the argument type as "int" \ + # N: This violates the Liskov substitution principle \ + # N: See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides +[typing fixtures/typing-full.pyi] +[builtins fixtures/callable.pyi] + +[case explicitOverrideClassmethod] +# flags: --python-version 3.12 +from typing import override + +class A: + @classmethod + def f(cls, x: int) -> str: pass + +class B(A): + @classmethod + @override + def f(cls, x: int) -> str: pass + @override + @classmethod + def g(cls, x: int) -> str: pass # E: Method "g" is marked as an override, but no base method was found with this name + +class C(A): # inverted order of decorators + @override + @classmethod + def f(cls, x: int) -> str: pass + @override + @classmethod + def g(cls, x: int) -> str: pass # E: Method "g" is marked as an override, but no base method was found with this name + +class D(A): + @classmethod + @override + def f(cls, x: str) -> str: pass # E: Argument 1 of "f" is incompatible with supertype "A"; supertype defines the argument type as "int" \ + # N: This violates the Liskov substitution principle \ + # N: See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides +[typing fixtures/typing-full.pyi] +[builtins fixtures/callable.pyi] + +[case explicitOverrideProperty] +# flags: --python-version 3.12 +from typing import override + +class A: + @property + def f(self) -> str: pass + +class B(A): + @property + @override + def f(self) -> str: pass + @override + @property + def g(self) -> str: pass # E: Method "g" is marked as an override, but no base method was found with this name + +class C(A): # inverted order of decorators + @override + @property + def f(self) -> str: pass + @override + @property + def g(self) -> str: pass # E: Method "g" is marked as an override, but no base method was found with this name + +class D(A): + @property + @override + def f(self) -> int: pass # E: Signature of "f" incompatible with supertype "A" +[builtins fixtures/property.pyi] +[typing fixtures/typing-full.pyi] + +[case explicitOverrideSettableProperty] +# flags: --python-version 3.12 +from typing import override + +class A: + @property + def f(self) -> str: pass + + @f.setter + def f(self, value: str) -> None: pass + +class B(A): + @property # E: Read-only property cannot override read-write property + @override + def f(self) -> str: pass + +class C(A): + @override + @property + def f(self) -> str: pass + + @f.setter + def f(self, value: str) -> None: pass + +class D(A): + @override # E: Signature of "f" incompatible with supertype "A" + @property + def f(self) -> int: pass + + @f.setter + def f(self, value: int) -> None: pass +[builtins fixtures/property.pyi] +[typing fixtures/typing-full.pyi] + +[case invalidExplicitOverride] +# flags: --python-version 3.12 +from typing import override + +@override # E: "override" used with a non-method +def f(x: int) -> str: pass + +@override # this should probably throw an error but the signature from typeshed should ensure this already +class A: pass + +def g() -> None: + @override # E: "override" used with a non-method + def h(b: bool) -> int: pass +[typing fixtures/typing-full.pyi] +[builtins fixtures/tuple.pyi] + +[case explicitOverrideSpecialMethods] +# flags: --python-version 3.12 +from typing import override + +class A: + def __init__(self, a: int) -> None: pass + +class B(A): + @override + def __init__(self, b: str) -> None: pass + +class C: + @override + def __init__(self, a: int) -> None: pass +[typing fixtures/typing-full.pyi] +[builtins fixtures/tuple.pyi] + +[case explicitOverrideFromExtensions] +from typing_extensions import override + +class A: + def f(self, x: int) -> str: pass + +class B(A): + @override + def f2(self, x: int) -> str: pass # E: Method "f2" is marked as an override, but no base method was found with this name +[typing fixtures/typing-full.pyi] +[builtins fixtures/tuple.pyi] + +[case explicitOverrideOverloads] +# flags: --python-version 3.12 +from typing import overload, override + +class A: + def f(self, x: int) -> str: pass + +class B(A): + @overload # E: Method "f2" is marked as an override, but no base method was found with this name + def f2(self, x: int) -> str: pass + @overload + def f2(self, x: str) -> str: pass + @override + def f2(self, x: int | str) -> str: pass +[typing fixtures/typing-full.pyi] +[builtins fixtures/tuple.pyi] + +[case explicitOverrideNotOnOverloadsImplementation] +# flags: --python-version 3.12 +from typing import overload, override + +class A: + def f(self, x: int) -> str: pass + +class B(A): + @overload # E: Method "f2" is marked as an override, but no base method was found with this name + def f2(self, x: int) -> str: pass + @override + @overload + def f2(self, x: str) -> str: pass + def f2(self, x: int | str) -> str: pass + +class C(A): + @overload + def f(self, y: int) -> str: pass + @override + @overload + def f(self, y: str) -> str: pass + def f(self, y: int | str) -> str: pass +[typing fixtures/typing-full.pyi] +[builtins fixtures/tuple.pyi] + +[case explicitOverrideOnMultipleOverloads] +# flags: --python-version 3.12 +from typing import overload, override + +class A: + def f(self, x: int) -> str: pass + +class B(A): + @override # E: Method "f2" is marked as an override, but no base method was found with this name + @overload + def f2(self, x: int) -> str: pass + @override + @overload + def f2(self, x: str) -> str: pass + def f2(self, x: int | str) -> str: pass + +class C(A): + @overload + def f(self, y: int) -> str: pass + @override + @overload + def f(self, y: str) -> str: pass + @override + def f(self, y: int | str) -> str: pass +[typing fixtures/typing-full.pyi] +[builtins fixtures/tuple.pyi] diff --git a/test-data/unit/fixtures/property.pyi b/test-data/unit/fixtures/property.pyi index 2397c05c78d5..667bdc02d0f5 100644 --- a/test-data/unit/fixtures/property.pyi +++ b/test-data/unit/fixtures/property.pyi @@ -16,6 +16,7 @@ class classmethod: pass class list: pass class dict: pass class int: pass +class float: pass class str: pass class bytes: pass class bool: pass diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index 2f0d51dd2b92..417ae6baf491 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -191,3 +191,4 @@ def dataclass_transform( field_specifiers: tuple[type[Any] | Callable[..., Any], ...] = ..., **kwargs: Any, ) -> Callable[[T], T]: ... +def override(__arg: T) -> T: ... diff --git a/test-data/unit/lib-stub/typing_extensions.pyi b/test-data/unit/lib-stub/typing_extensions.pyi index 3202c3d49e01..216005e3cf83 100644 --- a/test-data/unit/lib-stub/typing_extensions.pyi +++ b/test-data/unit/lib-stub/typing_extensions.pyi @@ -74,4 +74,6 @@ def dataclass_transform( **kwargs: Any, ) -> Callable[[T], T]: ... +def override(__arg: _T) -> _T: ... + _FutureFeatureFixture = 0