Repository: python/mypy

Issue:
Give an error if there is a variable annotation within a function but no signature
Mypy should perhaps give an error about a variable annotation in an otherwise unanotated function. Example:

```py
def f():
    a: int = 'x'   # Maybe this annotation should be an error?
```

The rationale is that mypy will ignore the type annotation since the function is still considered unannotated, but this is confusing because there is an annotation *within* the function so it can appear to be annotated.

If `--check-untyped-defs` is being used this error shouldn't be generated.

Originally reported in #3945.


Discussion:
Maybe mypy should simply consider function as being annotated if there is any annotation inside it. Not only arguments.
> Maybe mypy should simply consider function as being annotated if there is any annotation inside it. Not only arguments.

I think a warning/note would be better. People are sometimes unfamiliar with ``--check-untyped-defs``, when reminded, they can decide what to do.
I agree with @ilevkivskyi. Also, it's a nice property that you can always recognize an annotated function by just looking at the signature.
Let's do it.
Another instance: #4636. Increasing priority since this could give a pretty bad impression for new users.
@JukkaL 

I don't think it's just about new users. I can imagine cases of experienced users forgetting to annotate a function return type or working on a partially annotated codebase that's in the process of conversion (possibly by multiple people).

Also, as a longtime TypeScript user. I'm used to having function return type inferred as `undefined` (rough equivalent of `void`/`None`) by default if a `return` statement is not included in the function body. I find it strange why it isn't the case here as well.
@JukkaL

In this example (one of the examples I gave in #4636) the `main` function body doesn't include an annotated variable. So I guess the error described in this issue wouldn't apply here?

```py
from typing import List

def f(a: List[List[int]]) -> List[List[int]]:
	# a.append([1,2,3]) # <-- uncommenting this line seems to have no effect as well
	return a

def main(): # <-- would there be a warning here?
	test = f([])
	test[0]['abcd'] = 'hi' # <- doesn't error?
```

@rotemdan If you use `--disallow-untyped-defs` mypy will complain about your example.
@JukkaL 

Thanks. I've already discovered and using it :) (and enabled several other options).

It seems quite confusing/unexpected that errors do appear in unannotated functions in some circumstances -- In such a way that it might give a false sense that the functions are actually being fully checked. If there were no errors at all it would have been easier to suspect that was possibly related. 

I believe that many users would rather 'stick to the defaults' and not tinker with command line arguments unless they felt it was necessary -- for that reason I would suggest to consider not to show any errors at all for unannotated functions, or alternatively, have the 'check-all-methods' flag as an 'opt-out' instead of an 'opt-in'.

Also, the error/warning described here should probably include the case where the function scope has at least one call/assignment to/from an annotated function/attribute/property etc. -- since it would be natural to assume the arguments passed and return value would be checked correctly (after all, it does appear to be annotated, as the IDE might support that assumption as well).
> It seems quite confusing/unexpected that errors do appear in unannotated functions in some circumstances -- In such a way that it might give a false sense that the functions are actually being fully checked. If there were no errors at all it would have been easier to suspect that was possibly related.

Do you remember which errors have you seen in unannotated functions? Most errors are disabled in unannotated functions.

> Also, the error/warning described here should probably include the case where the function scope has at least one call/assignment to/from an annotated function/attribute/property etc. -- since it would be natural to assume the arguments passed and return value would be checked correctly (after all, it does appear to be annotated, as the IDE might support that assumption as well).

There are various `--disallow-*` options that you can use to achieve this, but they tend to generate a lot of errors, some of which are hard to work around (`Any` types in stubs or missing stubs, etc.).
@JukkaL 

Trying again seems like the errors seen in the annotated functions were actually from pylint, not mypy! :) Sorry for that! I'm running both at the same time (and also reasonably new to mypy/Python).

Anyway, this seems like a usability issue, rather than having to do with the checking itself. I've tried some of the flags, in particular `--disallow-untyped-defs` and I found it to be generally unusable since the amount of unaddressable errors was too high (personally even 1 is too many for me). I did enable `--disallow-incomplete-defs` and found it helpful.

Anyway, without `--check-untyped-defs` enabled it now feels like having this added mental overhead of always being concerned about some method not being checked for the silly reason that it doesn't have a spurious `-> None` attached to it. This sort of defeats the purpose of a type checker -- that's supposed to protect against common or careless mistakes. I would have expected having something like an opt-out `@unchecked` decorator or `# mypy: unchecked` comment at the head or middle of the file, though right now I'd have no use for these personally.

I might also consider suggesting the `vscode` extension authors to have `--check-untyped-defs` as a default.  Right now their defaults are `--ignore-missing-imports` and `--follow-imports=silent`.
I would prefer "opt-out" style checking options rather than "opt-in" --
that is, specify "--most-strict --allow-somecheck1 --allow-somecheck2".
The reason: for opt-in, if some new check is implemented, I need to keep
abreast of the development discussions or else I'll not take advantage of
the new check; whereas with opt-out, if the new check fails, I can just add
"--allow-somecheck3" to the mypy command and continue.

I'd also like a more subtle "type: ignore-somecheck" because (if I
understand the documentation correctly), "type: ignore" turns off all the
type checking for that statement whereas I simply want to suppress an
error/warning message.

On 26 February 2018 at 09:26, Rotem Dan <notifications@github.com> wrote:

> @JukkaL <https://github.com/jukkal>
>
> Trying again seems like the errors seen in the annotated functions were
> actually from pylint, not mypy! :) Sorry for that! I'm running both at the
> same time (and also reasonably new to mypy/Python).
>
> Anyway, this seems like a usability issue, rather than having to do with
> the checking itself. I've tried some of the flags, in particular
> --disallow-untyped-defs and I found it to be generally unusable since the
> amount of unaddressable errors was too high (personally even 1 is too many
> for me). I did enable --disallow-incomplete-defs and found it helpful.
>
> Anyway, without --check-untyped-defs enabled it now feels like having
> this added mental overhead of always being concerned about some method not
> being checked for the silly reason that it doesn't have a spurious -> None
> attached to it. This sort of defeats the purpose of a type checker --
> that's supposed to protect against common or careless mistakes. I would
> have expected having something an opt-out @unchecked decorator or # mypy:
> unchecked comment at the head or middle of the file, though right now I'd
> have no use for these personally.
>
> I might also consider suggesting the vscode extension authors to have
> --check-untyped-defs as a default. Right now their defaults are
> --ignore-missing-imports and --follow-imports=silent.
>
> —
> You are receiving this because you are subscribed to this thread.
> Reply to this email directly, view it on GitHub
> <https://github.com/python/mypy/issues/3948#issuecomment-368580492>, or mute
> the thread
> <https://github.com/notifications/unsubscribe-auth/AhBmtd2sGx4QEa-MIzhCtBraFua4eMfjks5tYulLgaJpZM4PU7OU>
> .
>

A user was confused by this again in #6986.
I think it would be better to make `--check-untyped-defs` a multi-state argument. If we enabled warning about type annotations in untyped defs by default I'm not sure how one would turn it off without sprinkling a bunch of type ignores across the code base.

I propose the following instead, allowing an escape hatch for those that do not want this behavior:

`--untyped-defs` takes 3 values: 

- `check` (which is the same as the current `--check-untyped-defs`)
- `warn`, which would become the default (we check the body and warn about types in the body)
- `silent`, which is the current default, we ignore everything in the body of an untyped def.
Has there been any consideration of enabling "--check-untyped-defs" by default — or removing the option entirely and always analyzing all functions? I see frequent mypy bug reports where users are confused about why obvious type errors are not reported, and often it's because the signature has no annotation. If all unannotated parameters are assumed to be `Any`, there's little or no risk of false positive errors. Is the concern more about performance?

For comparison, pyright always analyzes all code without regard for whether it's annotated. It doesn't even provide a way to disable analysis for non-annotated functions. We have not had problems with false positives.
> For comparison, pyright always analyzes all code without regard for whether it's annotated. It doesn't even provide a way to disable analysis for non-annotated functions. We have not had problems with false positives.

Personally the main reason I prefer the current default is it makes gradual adoption of typing much easier. If someone adds mypy to a new code base with `--check-untyped-defs` the default, it would add thousands of errors. We already hear a lot of feedback about the high number of errors making adoption discouraging.

Another way of looking at that is that the current gradual typing system doesn't do a good job of making typing really gradual: mypy already produces tons of errors even for a codebase without annotations. So maybe we need a better heuristic than the current one, which is to ignore the bodies of functions with no type annotations.
@ethanhs, like I mentioned, pyright doesn't provide any way to skip analysis of individual functions based on whether their signature is annotated. We haven't found that to be an impediment to gradual typing. If some mypy users find that `--check-untyped-defs = False` is useful in some circumstances, the option could be retained even if it defaults to True.

Here are some other cases where the current default has tripped up mypy users who have reported the default behavior as a bug:
#10895, #10841, #10643, #10055, #9207, #9072, #8775, #8566, #8531, #8434. That's a pretty strong signal!
> Another way of looking at that is that the current gradual typing system doesn't do a good job of making typing really gradual: mypy already produces tons of errors even for a codebase without annotations. So maybe we need a better heuristic than the current one, which is to ignore the bodies of functions with no type annotations.

Absolutely! I bet a lot of errors we give are for cases where someone passes `Optional[T]` to a function expecting `T` due to some missed narrowing. But checks like those are really important and provide a lot of the value of a type checker, so I'd be very hesitant to turn them off.

>pyright doesn't provide any way to skip analysis of individual functions based on whether their signature is annotated. We haven't found that to be an impediment to gradual typing.

Hm, how are you evaluating that it isn't an impediment to people's adoption of gradual typing? I think it would be quite hard to measure that.

> If some mypy users find that --check-untyped-defs = False is useful in some circumstances, the option could be retained even if it defaults to True.

This is certainly an option. I worry that "welcome to static typing in Python, download mypy, here are a list of flags you will want to add to not get a thousand errors" is a bad UX, but I think that is certainly a balancing act with "why is this error not getting caught".

 > Here are some other cases where the current default has tripped up mypy users who have reported the default behavior as a bug:
#10895, #10841, #10643, #10055, #9207, #9072, #8775, #8566, #8531, #8434. That's a pretty strong signal!

Certainly! And I am very aware of it, but I also want to keep in mind the signal of people I've talked to in conferences, meetups, and online who have said the large number of initial errors is demoralizing and been a blocker to adopting typing.

Perhaps mypy could just say "not reporting errors in <number> untyped functions, for more see <link to docs>" or something and exit 0 still. I don't love this option either but I think it is a reasonable middle ground.
> Hm, how are you evaluating that it isn't an impediment to people's adoption of gradual typing? I think it would be quite hard to measure that.

This is based on personal experience of gradually typing a half-million-line Python code base. Admittedly, other users' experience may differ from mine. But I developed pyright with gradual typing in mind.
> This is based on personal experience of gradually typing a half-million-line Python code base. Admittedly, other users' experience may differ from mine. But I developed pyright with gradual typing in mind.

Mypy can certainly be strict in some cases where it shouldn't be but we've definitely made improvements to make gradual typing easier. Unfortunately I expect you and I both are poor test subjects as we already have familiarity with static typing, but perhaps I should try gradually typing a codebase with pyright and see what we can improve on in mypy :)

Resolving patch:
diff --git a/mypy/checker.py b/mypy/checker.py
--- a/mypy/checker.py
+++ b/mypy/checker.py
@@ -2643,6 +2643,9 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None:
         ):
             self.fail(message_registry.DEPENDENT_FINAL_IN_CLASS_BODY, s)
 
+        if s.unanalyzed_type and not self.in_checked_function():
+            self.msg.annotation_in_unchecked_function(context=s)
+
     def check_type_alias_rvalue(self, s: AssignmentStmt) -> None:
         if not (self.is_stub and isinstance(s.rvalue, OpExpr) and s.rvalue.op == "|"):
             # We do this mostly for compatibility with old semantic analyzer.
diff --git a/mypy/errorcodes.py b/mypy/errorcodes.py
--- a/mypy/errorcodes.py
+++ b/mypy/errorcodes.py
@@ -137,6 +137,9 @@ def __str__(self) -> str:
 UNREACHABLE: Final = ErrorCode(
     "unreachable", "Warn about unreachable statements or expressions", "General"
 )
+ANNOTATION_UNCHECKED = ErrorCode(
+    "annotation-unchecked", "Notify about type annotations in unchecked functions", "General"
+)
 PARTIALLY_DEFINED: Final[ErrorCode] = ErrorCode(
     "partially-defined",
     "Warn about variables that are defined only in some execution paths",
diff --git a/mypy/messages.py b/mypy/messages.py
--- a/mypy/messages.py
+++ b/mypy/messages.py
@@ -2148,6 +2148,14 @@ def add_fixture_note(self, fullname: str, ctx: Context) -> None:
                 ctx,
             )
 
+    def annotation_in_unchecked_function(self, context: Context) -> None:
+        self.note(
+            "By default the bodies of untyped functions are not checked,"
+            " consider using --check-untyped-defs",
+            context,
+            code=codes.ANNOTATION_UNCHECKED,
+        )
+
 
 def quote_type_string(type_string: str) -> str:
     """Quotes a type representation for use in messages."""


Test patch:
diff --git a/test-data/unit/check-classes.test b/test-data/unit/check-classes.test
--- a/test-data/unit/check-classes.test
+++ b/test-data/unit/check-classes.test
@@ -1189,7 +1189,7 @@ reveal_type(Foo().Meta.name)  # N: Revealed type is "builtins.str"
 
 class A:
     def __init__(self):
-        self.x = None # type: int
+        self.x = None # type: int  # N: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs
 a = None # type: A
 a.x = 1
 a.x = '' # E: Incompatible types in assignment (expression has type "str", variable has type "int")
@@ -1201,7 +1201,7 @@ a.x = 1
 a.x = '' # E: Incompatible types in assignment (expression has type "str", variable has type "int")
 class A:
     def __init__(self):
-        self.x = None # type: int
+        self.x = None # type: int  # N: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs
 
 
 -- Special cases
diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test
--- a/test-data/unit/check-dataclasses.test
+++ b/test-data/unit/check-dataclasses.test
@@ -1641,7 +1641,7 @@ A(a=func).a = func  # E: Property "a" defined in "A" is read-only
 # flags: --python-version 3.7
 from dataclasses import dataclass
 
-def foo():
+def foo() -> None:
     @dataclass
     class Foo:
         foo: int
diff --git a/test-data/unit/check-errorcodes.test b/test-data/unit/check-errorcodes.test
--- a/test-data/unit/check-errorcodes.test
+++ b/test-data/unit/check-errorcodes.test
@@ -964,3 +964,8 @@ class C(abc.ABC):
 T = TypeVar("T")
 def test(tp: Type[T]) -> T: ...
 test(C)  # E: Only concrete class can be given where "Type[C]" is expected  [type-abstract]
+
+[case testUncheckedAnnotationSuppressed]
+# flags: --disable-error-code=annotation-unchecked
+def f():
+    x: int = "no"  # No warning here
diff --git a/test-data/unit/check-incremental.test b/test-data/unit/check-incremental.test
--- a/test-data/unit/check-incremental.test
+++ b/test-data/unit/check-incremental.test
@@ -3544,11 +3544,11 @@ class Bar(Baz): pass
 
 [file c.py]
 class Baz:
-    def __init__(self):
+    def __init__(self) -> None:
         self.x = 12  # type: int
 [file c.py.2]
 class Baz:
-    def __init__(self):
+    def __init__(self) -> None:
         self.x = 'lol'  # type: str
 [out]
 [out2]
@@ -5730,6 +5730,7 @@ class C:
 tmp/a.py:2: error: "object" has no attribute "xyz"
 
 [case testIncrementalInvalidNamedTupleInUnannotatedFunction]
+# flags: --disable-error-code=annotation-unchecked
 import a
 
 [file a.py]
diff --git a/test-data/unit/check-inference-context.test b/test-data/unit/check-inference-context.test
--- a/test-data/unit/check-inference-context.test
+++ b/test-data/unit/check-inference-context.test
@@ -814,7 +814,7 @@ if int():
 from typing import List
 class A:
   def __init__(self):
-    self.x = [] # type: List[int]
+    self.x = [] # type: List[int]  # N: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs
 a = A()
 a.x = []
 a.x = [1]
diff --git a/test-data/unit/check-newsemanal.test b/test-data/unit/check-newsemanal.test
--- a/test-data/unit/check-newsemanal.test
+++ b/test-data/unit/check-newsemanal.test
@@ -2963,6 +2963,7 @@ def g() -> None:
     reveal_type(y)  # N: Revealed type is "__main__.G[Any]"
 
 [case testNewAnalyzerRedefinedNonlocal]
+# flags: --disable-error-code=annotation-unchecked
 import typing
 
 def f():
@@ -2977,7 +2978,7 @@ def g() -> None:
 
     def foo() -> None:
         nonlocal bar
-        bar = []  # type: typing.List[int] # E: Name "bar" already defined on line 11
+        bar = []  # type: typing.List[int] # E: Name "bar" already defined on line 12
 [builtins fixtures/list.pyi]
 
 [case testNewAnalyzerMoreInvalidTypeVarArgumentsDeferred]
diff --git a/test-data/unit/check-statements.test b/test-data/unit/check-statements.test
--- a/test-data/unit/check-statements.test
+++ b/test-data/unit/check-statements.test
@@ -2186,7 +2186,7 @@ N = TypedDict('N', {'x': int})
 [out]
 
 [case testGlobalWithoutInitialization]
-
+# flags: --disable-error-code=annotation-unchecked
 from typing import List
 
 def foo() -> None:
@@ -2200,3 +2200,9 @@ def foo2():
     bar2 = []  # type: List[str]
     bar2
 [builtins fixtures/list.pyi]
+
+[case testNoteUncheckedAnnotation]
+def foo():
+    x: int = "no"  # N: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs
+    y = "no"  # type: int  # N: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs
+    z: int  # N: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs