Disallow implicit Any types from Silent Imports#3405
Disallow implicit Any types from Silent Imports#3405ddfisher merged 14 commits intopython:masterfrom
Conversation
These types can appear from an unanalyzed module. If mypy encounters a type annotation that uses such a type, it will report an error. Fixes python#3205
704a7b7 to
29848f8
Compare
JukkaL
left a comment
There was a problem hiding this comment.
Did a quick review pass. Looks pretty good! We may want to spend a little more time thinking about the names of various Any-related options, since it looks like we might have a few options that are pretty closely related and it's unclear how to distinguish the different options from each other in a way that is intuitive to users.
test-data/unit/check-flags.test
Outdated
| from missing import MyType | ||
|
|
||
| def f(x: MyType) -> None: # E: Argument 1 to 'f' is implicitly converted to 'Any' due to import from unanalyzed module | ||
| pass |
There was a problem hiding this comment.
Use a 4-space indent here and elsewhere in tests (you can leave existing tests that use a 2-space indent unmodified).
test-data/unit/check-flags.test
Outdated
| return l | ||
| [builtins fixtures/list.pyi] | ||
| [out] | ||
| main:5: error: Return type is implicitly converted to 'builtins.list[Any]' due to import from unanalyzed module |
There was a problem hiding this comment.
Would it make sense to update the types to be formatted as List[Any] etc. for consistency with most other messages? builtins.list[Any] and such are pretty verbose and not how users write the types.
test-data/unit/check-flags.test
Outdated
| class C(Unchecked): # E: Subclassing type 'Unchecked' that is implicitly converted to 'Any' due to import from unanalyzed module | ||
| pass | ||
|
|
||
| class A(List[Unchecked]): # E: Subclassing a type that is implicitly converted to 'builtins.list[Any]' due to import from unanalyzed module |
There was a problem hiding this comment.
Similar to above (List[Any] preferred over builtins.list[Any]).
test-data/unit/check-flags.test
Outdated
| pass | ||
|
|
||
| class A(List[Unchecked]): # E: Subclassing a type that is implicitly converted to 'builtins.list[Any]' due to import from unanalyzed module | ||
| pass |
There was a problem hiding this comment.
Ideas for additional test cases:
- Type alias like
X = List[Unchecked]orX = Unchecked. cast(List[Unchecked], foo)NamedTupleuses an implicitAnytype.T = TypeVar('T', Unchecked, str)T = TypeVar('T', bound=Unchecked)X = NewType('X', Unchecked)- Implicitly converted type within a callable type or a tuple type.
|
The code review feedback should be addressed now. |
|
FWIW, I think the name of the flag you propose is too generic, e.g. |
|
@ilevkivskyi Yes, I defintely agree with you. In #3427 I mentioned |
ddfisher
left a comment
There was a problem hiding this comment.
I haven't had a chance to look closely at the tests yet, but here's a review of everything else.
Overall, nicely done! Most of my comments are relatively small tweaks. A couple things to note that didn't fit well elsewhere:
- This diff includes a typeshed change -- be careful about that! It's super easy to do so accidentally. I found the typeshed subrepo to be a pain to manage until I added the following as a
post-checkoutandpost-mergegit hook:
#!/bin/sh
git submodule update
After that, it's basically never been in my way. Highly recommended.
- In some comments and error messages, this code talks about types that are "implicitly converted to Any", which isn't quite right. The types aren't being converted, per se, it's more that we don't have any information about them, so they've fallen back to Any. The exact messaging around this (like the name of the flag) probably bears some more thought.
Finally, as a stylistic point, I'd recommend responding individually to all review comments so reviewers can know exactly which things you've addressed in your changes so far, which things you plan to address in future changes, and which things you disagree with. I often write these responses as I'm making the changes, which helps me keep track of things and make sure I'm not missing anything (and sometimes actually making the change provides important context for my response). On the review side, it's always super helpful to know which comments were actually addressed and not, as a "respond to review" commit can mean many things.
mypy/options.py
Outdated
| def select_options_affecting_cache(self) -> Mapping[str, bool]: | ||
| return {opt: getattr(self, opt) for opt in self.OPTIONS_AFFECTING_CACHE} | ||
|
|
||
| def silent_mode(self) -> bool: |
There was a problem hiding this comment.
I'd consider silent_imports_mode instead.
There was a problem hiding this comment.
Though actually I think one of the call sites will probably be removed, making this no longer necessary.
mypy/checker.py
Outdated
| elif (self.options.disallow_implicit_any_types | ||
| and has_any_from_silent_import(lvalue_type)): | ||
| prefix = "Type of {}".format(lvalue_name) | ||
| self.msg.implicit_any_from_silent_import(prefix, lvalue_type, context) |
There was a problem hiding this comment.
This gives an error on every line where a silent-import-Any-typed variable is assigned to, which is undesirable. We'd prefer to only give an error on the first assignment.
mypy/checkexpr.py
Outdated
| if options.warn_redundant_casts and is_same_type(source_type, target_type): | ||
| self.msg.redundant_cast(target_type, expr) | ||
| if options.disallow_implicit_any_types and has_any_from_silent_import(target_type): | ||
| self.msg.implicit_any_from_silent_import("Target type of cast", target_type, expr) |
There was a problem hiding this comment.
This won't work properly if the variable is casted to a Callable.
There was a problem hiding this comment.
I'm not totally sure what you mean. I tried the following code and it gave an error (like it's supposed to).
from typing import cast, Callable
from missing import Unchecked
foo = [1, 2, 3]
call = cast(Callable[[], Unchecked], foo)
Produces the following output:
m.py:6: error: Target type of cast is implicitly converted to Callable[[], Any] due to import from unanalyzed module
There was a problem hiding this comment.
Oh, I see what you're saying. Maybe it should saysomething along the lines of Return type is converted Any from Unchecked (not exact wording). Is that what you mean?
I am actually not sure about this. In another case we are typechecking a function definition (where the user types in def foo ...), so there is an error specific to return type and arguments. However, when the user types in Callable, I think talking about Callable as its own type makes sense. Do you have a strong opinion on this?
There was a problem hiding this comment.
Ah, that was my misunderstanding. I saw you dissecting Callable types here and assumed that was because has_any_from_silent_import didn't properly handle them. Taking a closer look, you're just trying to give people a better error message. That seems reasonable!
mypy/semanal.py
Outdated
| if self.options.disallow_subclassing_any: | ||
| # if --disallow-implicit-any-types is set, the issue is reported later | ||
| implicit = self.options.disallow_implicit_any_types and base.is_from_silent_import | ||
| if self.options.disallow_subclassing_any and not implicit: |
There was a problem hiding this comment.
I actually think that emitting both errors here is the correct thing to do.
There was a problem hiding this comment.
Yeah, I think it's okay to have both. I will remove the extra check.
As long as it's not overwhelming for the user, it's actually better to have more information than less. The new message would explain more. This is what it outputs now for a simple case:
error: Cannot find module named 'missing'
note: (Perhaps setting MYPYPATH or using the "--ignore-missing-imports" flag would help)
error: Class cannot subclass 'Unchecked' (has type 'Any')
error: Base type Unchecked is implicitly converted to "Any" due to import from unanalyzed module
mypy/semanal.py
Outdated
| elif isinstance(base, AnyType): | ||
| if self.options.disallow_subclassing_any: | ||
| # if --disallow-implicit-any-types is set, the issue is reported later | ||
| implicit = self.options.disallow_implicit_any_types and base.is_from_silent_import |
There was a problem hiding this comment.
implicit isn't a great name for this variable -- that sounds like something that should be only a property of base, but in fact it also depends on an error-related option. I'd consider something like more explicit like disallowed_implicit_any instead.
There was a problem hiding this comment.
Yeah, I'll remove this variable as per comment below.
mypy/semanal.py
Outdated
| any_type = AnyType() | ||
| if self.options.silent_mode(): | ||
| # if silent mode is not enabled, no need to report implicit conversion to Any, | ||
| # let mypy report an import error. |
There was a problem hiding this comment.
I think we should still report an errors in this instance, actually -- there's no point in making the flag only work with a very specific combination of other flags when a slightly more general use is perfectly valid. Even if the exact use-case is less clear, having consistent behavior is worthwhile.
There was a problem hiding this comment.
Yeah, this makes sense. It's not obvious that the new flag should be used with the silent imports.
mypy/typeanal.py
Outdated
| # is pretty minor. | ||
| return AnyType() | ||
| any_type = AnyType() | ||
| any_type.is_from_silent_import = sym.node.type.is_from_silent_import |
There was a problem hiding this comment.
This suggests is_from_silent_import to me that should be an optional argument to AnyType's constructor.
There was a problem hiding this comment.
Yes, I was thinking about it. Would probably make it much cleaner.
What stopped me was that there is already an parameter implicit in the constructor from this commit.
It should be fine if I comment it though.
mypy/typeanal.py
Outdated
| return [] | ||
|
|
||
|
|
||
| def has_any_from_silent_import(t: Type) -> bool: |
There was a problem hiding this comment.
Rather than special-casing Callable at some of the call sites, it'd be better to handle it here.
There was a problem hiding this comment.
Responded to this in this discussion: #3405 (comment)
|
Thanks for the feedback, David! Yeah, I realized I commited typeshed after I submitted the PR. It should be good now! Thanks for the advice. I responded to all your comments - and will follow it in the future. Thanks! For now I addressed all of the feedback, except for the |
Also, handle multiple declaration
ddfisher
left a comment
There was a problem hiding this comment.
This is looking good. I had a few minor nitpicks, but after we figure out naming/messaging it should be good to go!
mypy/checker.py
Outdated
| if (s.type is not None and | ||
| self.options.disallow_implicit_any_types and | ||
| has_any_from_silent_import(s.type)): | ||
| if isinstance(s.lvalues[-1], TupleExpr): # is multiple |
There was a problem hiding this comment.
Nitpick: this comment should be a bit more explicit. In general, I'd err on the side of trying to be very very clear in comments, even if that feels a little verbose. In this case, I'd consider moving the comment to the next line and writing something like:
# This is a multiple assignment. Instead of figuring out which type is problematic, give a generic error message.
There was a problem hiding this comment.
Yep, that makes sense! Agree.
mypy/checkexpr.py
Outdated
| if options.warn_redundant_casts and is_same_type(source_type, target_type): | ||
| self.msg.redundant_cast(target_type, expr) | ||
| if options.disallow_implicit_any_types and has_any_from_silent_import(target_type): | ||
| self.msg.implicit_any_from_silent_import("Target type of cast", target_type, expr) |
There was a problem hiding this comment.
Ah, that was my misunderstanding. I saw you dissecting Callable types here and assumed that was because has_any_from_silent_import didn't properly handle them. Taking a closer look, you're just trying to give people a better error message. That seems reasonable!
mypy/types.py
Outdated
|
|
||
| def __init__(self, implicit: bool = False, line: int = -1, column: int = -1) -> None: | ||
| def __init__(self, implicit: bool = False, from_silent_import: bool = False, | ||
| line: int = -1, column: int = -1) -> None: |
There was a problem hiding this comment.
Nitpick: if you have to wrap a function def line, put each argument on a line of its own. There's a lot of existing code in mypy which doesn't do this, but it's much easier to read if done this way, so we're trying to do this for new code.
mypy/types.py
Outdated
| line: int = -1, column: int = -1) -> None: | ||
| super().__init__(line, column) | ||
| self.implicit = implicit | ||
| self.implicit = implicit # Was this Any type was inferred without a type annotation? |
There was a problem hiding this comment.
Nitpick: probably better to just put this comment on the preceding line for consistency with the next member variable initalization.
mypy/types.py
Outdated
| super().__init__(line, column) | ||
| self.implicit = implicit | ||
| self.implicit = implicit # Was this Any type was inferred without a type annotation? | ||
| # Does this come from an unresolved import? See--disallow-implicit-any-types flag |
There was a problem hiding this comment.
Trivial: See-- -> See --
There was a problem hiding this comment.
Didn't notice when I was committing! :)
| def f(x: X) -> None: # E: Argument 1 to "f" is implicitly converted to List[Any] due to import from unanalyzed module | ||
| pass | ||
| [builtins fixtures/list.pyi] | ||
| [case testDisallowImplicitAnyCast] |
There was a problem hiding this comment.
Trivial: missing blank line before this line. It's a lot harder to read the tests if there isn't a blank line before each new testcase.
There was a problem hiding this comment.
Yes! Updated my other tests too.
and wording in variable names and comments to reflect the name change.
|
I updated the commit to use the new option name and syntax (for more, see #3470). In the near future, this option will accept a list of parameters but for now it only accepts one ( |
mypy/messages.py
Outdated
| self.note('Redundant cast to {}'.format(self.format(typ)), context) | ||
|
|
||
| def unimported_type_becomes_any(self, prefix: str, typ: Type, ctx: Context) -> None: | ||
| self.fail("{} becomes {} due to an unfollowed import (such imports occur either " |
There was a problem hiding this comment.
I'm not sure it is good to have an error message that long.
We could remove the explanation in parens but I'm not sure that most users will understand what an unfollowed import is otherwise.
@ddfisher what do you think?
There was a problem hiding this comment.
I agree -- this error message is too long. I also agree that it'll probably be somewhat confusing. I'd say: remove the parenthetical explanation for now, and we can consider some other mechanism of explaining to the user if we think it's a big enough problem. (But we can/should do that in another PR.) We could consider outputting one Note: at the end if there are any of these errors explaining what an unfollowed import is.
There was a problem hiding this comment.
Yeah, a note sounds like a better option!
ddfisher
left a comment
There was a problem hiding this comment.
This is looking good -- just a few things to fix up in the new code!
mypy/main.py
Outdated
| current_options = raw_options.split(',') | ||
| for option in current_options: | ||
| if option not in valid_disallow_any_options: | ||
| formatted_opts = ', '.join(map("'{}'".format, valid_disallow_any_options)) |
There was a problem hiding this comment.
Nit: strongly prefer list comprehensions to map (or filter) in Python. I'm also a fan of functional programming, but list comprehensions are considered more pythonic.
There was a problem hiding this comment.
I'd also consider calling this "formatted_valid_options" in order to disambiguate from the other "*_options" variables. I'd also write out "options" fully here -- it's a bit more verbose, but is more consistent with the rest of the variable names.
There was a problem hiding this comment.
Yep!
The reason I chose map in that situation was that it was shorter than using a comprehension.
But it's probably better to use whatever is more pythonic :)
There was a problem hiding this comment.
If you're interested, talk to Guido about his views on map vs list comprehensions. (Hint: they are strong views.)
mypy/main.py
Outdated
| strict_flag_assignments.append((dest, not default)) | ||
|
|
||
| def disallow_any_argument_type(raw_options: str) -> List[str]: | ||
| current_options = raw_options.split(',') |
There was a problem hiding this comment.
I'm not sure this is named quite right. "current_options" implies that the options will be changing over time, which doesn't really happen. Maybe consider just "options"?
There was a problem hiding this comment.
Good point. I was trying to make it clear that these options are the ones provided by user. I think just options works too!
mypy/main.py
Outdated
|
|
||
| strict_flag_names = [] # type: List[str] | ||
| strict_flag_assignments = [] # type: List[Tuple[str, bool]] | ||
| valid_disallow_any_options = {'unimported'} |
There was a problem hiding this comment.
Tiny nit: I think the "valid_" part of this name doesn't really add anything (and is very slightly misleading because it isn't only used in a checking-for-validity context: it's also displayed to the user).
There was a problem hiding this comment.
More importantly, this should be a list instead of a set, because it's displayed to our users in a couple places. This is important for two reasons: 1) We don't want a nondeterministic --help output. 2) We're going to want to specify the options in a particular order that we think makes the most sense to read them in.
A set is canonically the "right" data structure to use here, because you're mainly checking for membership. However, it's so small that it doesn't matter (especially because it's not going to be run in a tight loop or anything).
There was a problem hiding this comment.
Yep, all that makes sense!
Yeah, I didn't think about the fact that the help output could be in different order -- that's a good point!
mypy/main.py
Outdated
| for option in current_options: | ||
| if option not in valid_disallow_any_options: | ||
| formatted_opts = ', '.join(map("'{}'".format, valid_disallow_any_options)) | ||
| message = "Unrecognized option '{}' (valid options are: {}).".format( |
There was a problem hiding this comment.
This line needs to mention that the option is related to --disallow-any. Much less importantly, I'd also consider "Invalid" instead of "Unrecognized", as it seems slightly more accurate.
I'd consider something like "Invalid '--disallow-any' option ...".
There was a problem hiding this comment.
Oh wow, yeah good catch. I assumed when the error would be thrown, it would say the option name but I guess not.
Yep!
There was a problem hiding this comment.
It's always good to run these things on the command-line yourself to make sure the output really looks as expected! (And sometimes the way you feel about phrasing, etc, can change when you see it in context.)
mypy/messages.py
Outdated
| self.note('Redundant cast to {}'.format(self.format(typ)), context) | ||
|
|
||
| def unimported_type_becomes_any(self, prefix: str, typ: Type, ctx: Context) -> None: | ||
| self.fail("{} becomes {} due to an unfollowed import (such imports occur either " |
There was a problem hiding this comment.
I agree -- this error message is too long. I also agree that it'll probably be somewhat confusing. I'd say: remove the parenthetical explanation for now, and we can consider some other mechanism of explaining to the user if we think it's a big enough problem. (But we can/should do that in another PR.) We could consider outputting one Note: at the end if there are any of these errors explaining what an unfollowed import is.
mypy/main.py
Outdated
| for option in options: | ||
| if option not in disallow_any_options: | ||
| formatted_valid_options = ', '.join( | ||
| "'{}'".format(option) for option in disallow_any_options) |
There was a problem hiding this comment.
option here shadows option defined on line 209 -- you really don't want to do that. I think a one-letter o would be fine as a name here (because the context you need to see what that means is all right on the same line).
There was a problem hiding this comment.
Hmm, good point.
Actually, variable options on line 208 overshadows options from outer scope (line 372).
Will fix!
mypy/main.py
Outdated
| for option in current_options: | ||
| if option not in valid_disallow_any_options: | ||
| formatted_opts = ', '.join(map("'{}'".format, valid_disallow_any_options)) | ||
| message = "Unrecognized option '{}' (valid options are: {}).".format( |
There was a problem hiding this comment.
It's always good to run these things on the command-line yourself to make sure the output really looks as expected! (And sometimes the way you feel about phrasing, etc, can change when you see it in context.)
ddfisher
left a comment
There was a problem hiding this comment.
This looks good now! There's just one final style nit that I think is important enough to address before merging (see inline comment).
|
Should be good now! |
|
Thanks! |
These types can appear from an unanalyzed module.
If mypy encounters a type annotation that uses such a type,
it will report an error.
Note that this PR is different from #3141 although the flags have similar names. Perhaps, we should figure out the proper names for all flags.