Repository: python/mypy

Issue:
`@final` decorator is not honored on override with --check-untyped-defs
**Bug Report**

Overriding class methods marked with `@final` [(pep-0591)](https://www.python.org/dev/peps/pep-0591/) are not reported as errors. The example script will not produce an error with `mypy` even though it probably should. 

#9612 is potentially related, but they have a `final` problem in the other direction.

**To Reproduce**

```python
from typing import final

class Base:
    @final
    def foo(self):
        pass

    def bar(self):
        pass

class Derived(Base):
    def foo(self):
        pass
```

**Your Environment**

```
✗ python --version && mypy --version && mypy foo.py 
Python 3.8.5
mypy 0.790
Success: no issues found in 1 source file
```



Discussion:
Hi, just wanted to note that when I tried this example, mypy gives the expected error about `final` as soon as any annotation is [added](https://mypy-play.net/?mypy=0.790&python=3.8&gist=7750b13ddcf5af035beac318d3528c51) to `Derived`'s `foo` method. It's possible that the design was intentionally not to check `final` for untyped functions? Either way, the workaround is easy.

(There is the `--check-untyped-defs` option but that does not work to produce the error, which I guess makes sense since the docs say it enables checking the *body* of a function). 
hey @a-reich, i cannot reproduce your statement. adding some other decorator didn't change the outcome. 

```python
class Derived(Base):
    @cached_property
    @final
    def foo(self):   
        return 2 

``` 
also using `@final` again with derived did nothing: 

```python
class Derived(Base):
    @final
    def foo(self):   
        return 2 
``` 
@tfranzel Did you look at the mypy-playground example I linked to? Here's the content:
```
from typing import final

class Base:
    @final
    def foo(self):
        pass

    def bar(self):
        pass

class Derived(Base):
    def foo(self) -> None:
        pass
```
It's the same as your OP but with a return annotation " -> None" added to `Derived.foo`. When I said this is fixed by adding an annotation I meant in the usual PEP 484 sense of type annotations, not decorators.
ahh sorry i somehow missed the link. i see, so the functionality is there. nonetheless kind of weird to only activate the check once a type hint is present.   
I suppose this is because of mypy's general principle to not give errors for unannotated code. It feels like we should give the error if `--check-untyped-defs` is on, though.

Patch:
diff --git a/mypy/checker.py b/mypy/checker.py
--- a/mypy/checker.py
+++ b/mypy/checker.py
@@ -1004,7 +1004,7 @@ def _visit_func_def(self, defn: FuncDef) -> None:
         """Type check a function definition."""
         self.check_func_item(defn, name=defn.name)
         if defn.info:
-            if not defn.is_dynamic() and not defn.is_overload and not defn.is_decorated:
+            if not defn.is_overload and not defn.is_decorated:
                 # If the definition is the implementation for an
                 # overload, the legality of the override has already
                 # been typechecked, and decorated methods will be
@@ -1913,9 +1913,17 @@ def check_method_override(
         Return a list of base classes which contain an attribute with the method name.
         """
         # Check against definitions in base classes.
+        check_override_compatibility = defn.name not in (
+            "__init__",
+            "__new__",
+            "__init_subclass__",
+            "__post_init__",
+        ) and (self.options.check_untyped_defs or not defn.is_dynamic())
         found_method_base_classes: list[TypeInfo] = []
         for base in defn.info.mro[1:]:
-            result = self.check_method_or_accessor_override_for_base(defn, base)
+            result = self.check_method_or_accessor_override_for_base(
+                defn, base, check_override_compatibility
+            )
             if result is None:
                 # Node was deferred, we will have another attempt later.
                 return None
@@ -1924,7 +1932,10 @@ def check_method_override(
         return found_method_base_classes
 
     def check_method_or_accessor_override_for_base(
-        self, defn: FuncDef | OverloadedFuncDef | Decorator, base: TypeInfo
+        self,
+        defn: FuncDef | OverloadedFuncDef | Decorator,
+        base: TypeInfo,
+        check_override_compatibility: bool,
     ) -> bool | None:
         """Check if method definition is compatible with a base class.
 
@@ -1945,10 +1956,8 @@ def check_method_or_accessor_override_for_base(
                 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__", "__post_init__"):
-                # Check method override
+            if check_override_compatibility:
+                # Check compatibility of the override signature
                 # (__init__, __new__, __init_subclass__ are special).
                 if self.check_method_override_for_base_with_name(defn, name, base):
                     return None
diff --git a/mypy/nodes.py b/mypy/nodes.py
--- a/mypy/nodes.py
+++ b/mypy/nodes.py
@@ -615,6 +615,9 @@ def deserialize(cls, data: JsonDict) -> OverloadedFuncDef:
         # NOTE: res.info will be set in the fixup phase.
         return res
 
+    def is_dynamic(self) -> bool:
+        return all(item.is_dynamic() for item in self.items)
+
 
 class Argument(Node):
     """A single argument in a FuncItem."""
@@ -937,6 +940,9 @@ def deserialize(cls, data: JsonDict) -> Decorator:
         dec.is_overload = data["is_overload"]
         return dec
 
+    def is_dynamic(self) -> bool:
+        return self.func.is_dynamic()
+
 
 VAR_FLAGS: Final = [
     "is_self",


Test patch:
diff --git a/test-data/unit/check-dynamic-typing.test b/test-data/unit/check-dynamic-typing.test
--- a/test-data/unit/check-dynamic-typing.test
+++ b/test-data/unit/check-dynamic-typing.test
@@ -756,6 +756,21 @@ main:5: note:          def f(self, x: A) -> None
 main:5: note:      Subclass:
 main:5: note:          def f(self, x: Any, y: Any) -> None
 
+[case testInvalidOverrideArgumentCountWithImplicitSignature4]
+# flags: --check-untyped-defs
+import typing
+class B:
+    def f(self, x: A) -> None: pass
+class A(B):
+    def f(self, x, y):
+        x()
+[out]
+main:6: error: Signature of "f" incompatible with supertype "B"
+main:6: note:      Superclass:
+main:6: note:          def f(self, x: A) -> None
+main:6: note:      Subclass:
+main:6: note:          def f(self, x: Any, y: Any) -> Any
+
 [case testInvalidOverrideWithImplicitSignatureAndClassMethod1]
 class B:
     @classmethod
diff --git a/test-data/unit/check-functions.test b/test-data/unit/check-functions.test
--- a/test-data/unit/check-functions.test
+++ b/test-data/unit/check-functions.test
@@ -3228,3 +3228,15 @@ class A:
 reveal_type(A.f)  # N: Revealed type is "__main__.something_callable"
 reveal_type(A().f)  # N: Revealed type is "builtins.str"
 [builtins fixtures/property.pyi]
+
+[case testFinalOverrideOnUntypedDef]
+from typing import final
+
+class Base:
+    @final
+    def foo(self):
+        pass
+
+class Derived(Base):
+    def foo(self):  # E: Cannot override final attribute "foo" (previously declared in base class "Base")
+        pass