Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle code guarded by 'if TYPE_CHECKING' correctly #530

Closed
wants to merge 17 commits into from

Conversation

PetterS
Copy link
Contributor

@PetterS PetterS commented Apr 15, 2020

This PR finds a problem that has been observed in real code.

Consider the following code:

from typing import TYPE_CHECKING
...
if TYPE_CHECKING
    from a import A, B
    from b import C

...
def f() -> "B":
    ...

def f()
    # Oops! C is actually used here.
    C()

This PR ignores definitions that are guarded by TYPE_CHECKING in
order to find mistakes like this. This constant is always False
when mypy is not running anyway.

@asottile
Copy link
Member

there's also if False: and MYPY = False\nif MYPY: and various other conventions for accomplishing the same thing

I don't think skipping the body there is quite right either as it would hide any errors in that block -- the block is not always just imports

your current patch breaks this for instance:

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    T = ...

def f() -> 'T':
    pass

@asottile
Copy link
Member

I'm adding tests demonstrating that: ^ #531

@PetterS
Copy link
Contributor Author

PetterS commented Apr 15, 2020

There is also this case:

from typing import TYPE_CHECKING
from a import a

if TYPE_CHECKING:
    a()

Which probably should not raise an error? But on second thought maybe it should, since a could be moved inside the check?

@asottile
Copy link
Member

asottile commented Apr 15, 2020

pyflakes currently doesn't do any branch analysis which in some cases requires solving the halting problem

I'd argue that that code should raise an error, as it does during type checking time

@PetterS
Copy link
Contributor Author

PetterS commented Apr 15, 2020

your current patch breaks this for instance:

This means the solution to this problem will have to be more complicated. Basically imports within the TYPE_CHECKING should be kept around in case they are used in string annotations, but ignored for all other purposes?

@asottile
Copy link
Member

Yes in some capacity, I also don't think this is limited to imports as any code can appear in such a block (functions, classes, etc.)

@PetterS PetterS force-pushed the omit_type_checking branch from 824a12d to 11a8266 Compare April 15, 2020 18:31
@PetterS
Copy link
Contributor Author

PetterS commented Apr 15, 2020

The updated code passes all tests including those added in #531.

scope[n.fullName].used = (self.scope, node)
except KeyError:
pass
if isinstance(n, Importation):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not just imports -- it's any code in there

for instance:

from typing import TYPE_CHECKING
if TYPE_CHECKING:
    class C:
        ...

Copy link
Contributor Author

@PetterS PetterS Apr 15, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I wasn't too worried about that case, but let's of course fix that!

@@ -2120,6 +2131,7 @@ def IMPORT(self, node):
else:
name = alias.asname or alias.name
importation = Importation(name, node, alias.name)
importation.during_type_checking = self._in_type_checking
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be part of the construction

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do! There is quite a hierarchy of classes that will touch but it makes sense I suppose.

This commit finds a problem that has been observed in read code.

Consider the following code:

```
from typing import TYPE_CHECKING
...
if TYPE_CHECKING
    from a import A, B
    from b import C

...
def f() -> "B":
    ...

def f()
    # Oops! C is actually used here.
    C()
```

This commit ignores all code that is guarded by TYPE_CHECKING in
order to find mistakes like this. This constant is always False
when mypy is not running anyway.
@PetterS PetterS force-pushed the omit_type_checking branch from 11a8266 to 44aab70 Compare April 15, 2020 18:39
@asottile
Copy link
Member

do you plan to handle if False / if MYPY? they're also common ways to approach this problem but I don't think if False is universally used and I don't think if MYPY is reliably detectable as being this particular case -- and the omission of handling those seems odd but I don't quite know the best thing to do there

@PetterS
Copy link
Contributor Author

PetterS commented Apr 15, 2020

do you plan to handle if False / if MYPY? they're also common ways to approach this problem but I don't think if False is universally used and I don't think if MYPY is reliably detectable as being this particular case -- and the omission of handling those seems odd but I don't quite know the best thing to do there

I wasn't planning on handling those. if False would be feasible I guess but as you say, MYPY could be hard to get right in all cases.

@asottile
Copy link
Member

do you plan to handle if False / if MYPY? they're also common ways to approach this problem but I don't think if False is universally used and I don't think if MYPY is reliably detectable as being this particular case -- and the omission of handling those seems odd but I don't quite know the best thing to do there

I wasn't planning on handling those. if False would be feasible I guess but as you say, MYPY could be hard to get right in all cases.

cool, just checking :)

@PetterS
Copy link
Contributor Author

PetterS commented Apr 15, 2020

Just double-checking: this is a feasible approach then?

Asking because adding during_type_checking to all Binding subclass constructors and handling all other types of bindings will expand the patch a bit.

Never mind, it was easy. Will rebase the PR once we agree on an implementation.

@PetterS PetterS force-pushed the omit_type_checking branch from e2b27b9 to 241262e Compare April 15, 2020 19:02
@PetterS PetterS force-pushed the omit_type_checking branch from 241262e to d66aeb8 Compare April 15, 2020 19:03
@@ -1072,6 +1074,8 @@ def addBinding(self, node, value):
break
existing = scope.get(value.name)

value.during_type_checking = self._in_type_checking
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my comment before was because attribute stuffing is a smell (and a type checker such as mypy would error accordingly on this line), this should either be:

  1. added to each of the types as part of their __init__ signature
  2. stored separately alongside the type (a tuple?)

and not tacked onto (~essentially immutable by interface) objects after construction

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(another alternative)
3. stored in a separate structure

Copy link
Contributor Author

@PetterS PetterS Apr 15, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I agree. But I think we should store it separately somehow since otherwise we will have to change so many places where we create various bindings. Makes sense to only have addBinding know that this has to be done.

I think this is sound from a type-checking perspective, though? Since value is documented to be an instance of Binding we know that this property exists.

Copy link
Contributor Author

@PetterS PetterS Apr 16, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@asottile Binding has another attribute used that is used in a similar way as during_type_checking currently is. So it's not exactly immutable but I see what you mean since it's more obvious that used will change perhaps.

But I think we can store this property somewhere else in the scope. Shouldn't be too complicated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uploaded an example. Not really sure about it, but at least it is another solution.

This reverts commit a6f0dd2.
@PetterS
Copy link
Contributor Author

PetterS commented Apr 16, 2020

  • Having a separate stack would mean a lot of work keeping track of both stacks when creating scopes and iterating over the stacks. I don't see how this could be done in a good way. I am not at this point comfortable with any large refactors to this class.
  • Adding a parameter to the constructors of all Binding subclasses would mean a lot of places suddenly needing to know about during_type_checking. Not a good solution either.

I think it is really good to keep this new functionality in a single place. But at this point I almost feel that the current during_type_checking attribute on Binding is the cleanest solution. It follows used in the sense that it is a mutable property, albeit not one meant to be changed after the binding is put into the scope.

Here is another solution that could be acceptable:

  • Add the parameter to all Binding constructors. All different Bindings are then created in a new method:
    def createBinding(self, klass, *args, **kwargs):
      return klass(*args, during_type_checking=self._in_type_checking, **kwargs)
    
    so instead of e.g.
    importation = SubmoduleImportation(alias.name, node)
    
    we would write
    importation = self.createBinding(SubmoduleImportation, alias.name, node)
    

@PetterS
Copy link
Contributor Author

PetterS commented Apr 19, 2020

@asottile

added to each of the types as part of their init signature

The current version of this PR now does this.

Copy link
Member

@asottile asottile left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool!

''', m.UndefinedName)

@skipIf(version_info < (3,), 'has type annotations')
def test_usesTypingImportsForAnnotations(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fwiw, the new tests are using snake_case

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

self.name = name
self.source = source
self.used = False
assert isinstance(during_type_checking, bool)
self.during_type_checking = during_type_checking
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe for_annotations is a more descriptive name here? (idk, I'm bikeshedding on this, feel free to ignore this suggestion, it just feels a little like the meaning of the name gets lost a bit)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's fine; takes a couple of seconds to change.

@PetterS
Copy link
Contributor Author

PetterS commented Mar 24, 2021

@asottile Should I merge this to master again? Is this likely to be merged?

@PetterS PetterS force-pushed the omit_type_checking branch 2 times, most recently from ce08c41 to 7eb8484 Compare March 25, 2021 07:57
Copy link
Member

@asottile asottile left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry I lapsed on reviewing this -- looks good, just some very minor comments

Comment on lines +1997 to +1998
if not isinstance(body_nodes, list):
body_nodes = [body_nodes]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this code ever run? I think the body is always a list here (?)

Copy link
Contributor

@terencehonles terencehonles Mar 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in IFEXP body is not a list.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, there is a test that will fail if this if statement is removed.

@@ -1031,6 +1039,48 @@ def test_futureImportStar(self):
from __future__ import *
''', m.FutureFeatureNotDefined)

def test_ignores_typing_imports(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's put these tests into the type_annotations test

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

def test_typing_guard_with_elif_branch(self):
# This test will actually not raise an error, even though it by analysis can
# be shown to have an error (Protocol is not defined outside TYPE_CHECKING).
# Pyflakes currently does not to case analysis.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to => do

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

Copy link
Contributor

@terencehonles terencehonles left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the main thing compared to #622 is this doesn't support if not TYPE_CHECKING, but that's probably not as likely as the other way so it's probably fine to not handle that.

@@ -1216,6 +1226,10 @@ def handleNodeLoad(self, node):
scope[n.fullName].used = (self.scope, node)
except KeyError:
pass
if n.for_annotations and not self._in_annotation:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this not happen before scope[name].used is marked? It's not really being used and the variable should be eligible to be released.

See: https://github.com/PyCQA/pyflakes/pull/622/files#diff-5df44d79406311387e0056faae7a8092d1ce9cc85c7d15420186e45a0c712283L1204-R1218

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we do this, the following test will change:

from typing import TYPE_CHECKING
if TYPE_CHECKING:                                                                                                                                                                              
    from a import b
b()

It will also return 'a.b' imported but unused after this change. Perhaps that is better.

We can do this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Thanks.

Comment on lines +1997 to +1998
if not isinstance(body_nodes, list):
body_nodes = [body_nodes]
Copy link
Contributor

@terencehonles terencehonles Mar 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in IFEXP body is not a list.

@PetterS
Copy link
Contributor Author

PetterS commented Mar 26, 2021

Thanks for the comments! Now I have to remember what I wrote before! 😄 Will try to update the PR soon.

@PetterS
Copy link
Contributor Author

PetterS commented Mar 26, 2021

@asottile made changes based on your review now.

@PetterS
Copy link
Contributor Author

PetterS commented Jun 10, 2021

@asottile ping on this.

@asottile
Copy link
Member

@asottile ping on this.

I already approved, pyflakes (unfortunately) has multi-sign-off

@terencehonles
Copy link
Contributor

@asottile is there anyone specific that should be signing off?

@asottile
Copy link
Member

@asottile is there anyone specific that should be signing off?

I've been going with the loosest interpretation of that possible and haven't had any pushback -- that is "anyone" + "a maintainer" being 2

Copy link
Contributor

@srittau srittau left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, although I would personally default the for_annotations to False. Especially the test code would be a bit cleaner.

self.name = name
self.source = source
self.used = False
assert isinstance(for_annotations, bool)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the purpose of this assert here?

@terencehonles
Copy link
Contributor

LGTM, although I would personally default the for_annotations to False. Especially the test code would be a bit cleaner.

I +1 this suggestion and what I had done in my copy of this same effort #622. I didn't bring it up because it's kinda a nit, but since the comment has been made I'll let @PetterS know there is at least one other person who was biting their tongue before 😅

@PetterS
Copy link
Contributor Author

PetterS commented Jun 14, 2021

@asottile is there anyone specific that should be signing off?

I've been going with the loosest interpretation of that possible and haven't had any pushback -- that is "anyone" + "a maintainer" being 2

Great, let's merge it then!

@asottile
Copy link
Member

@asottile is there anyone specific that should be signing off?

I've been going with the loosest interpretation of that possible and haven't had any pushback -- that is "anyone" + "a maintainer" being 2

Great, let's merge it then!

wellll now there's two open discussions that should probably be decided / resolved first

@rggjan
Copy link

rggjan commented Sep 21, 2022

Any updates on merging this PR?

@asottile
Copy link
Member

Any updates on merging this PR?

just checking:

are you able to see the merge conflict message?

screenshot of merge conflict

and the comment about unresolved discussions?

screenshot of my comment

@rggjan
Copy link

rggjan commented Sep 21, 2022

are you able to see the merge conflict message?

Actually, I missed this when focusing on the big green checkmarks

Screenshot 2022-09-21 at 15 47 11

and the comment about unresolved discussions?

Yes, and I assumed it was related to the Merged and Closed point here:

Screenshot 2022-09-21 at 15 46 27

Thanks for the hints, though :)

@asottile
Copy link
Member

Yes, and I assumed it was related to the Merged and Closed point here:

Screenshot 2022-09-21 at 15 46 27

Thanks for the hints, though :)

easy to click through and see one of those is closing an issue as a duplicate and the other is a PR on an unrelated project!

@joaoe
Copy link

joaoe commented Feb 8, 2023

Howdy. Just a friendly reminder that is is sorely needed :) Can anyone rebase and merge ? Thanks.

@PetterS
Copy link
Contributor Author

PetterS commented Feb 12, 2023

I will not be updating this PR.

@terencehonles
Copy link
Contributor

I haven't been following this PR in awhile, but I've been making updates to my PR #622 since we're using it at my work and I just updated it again now and saw this conversation.

I believe it addresses all comments that were raised in this PR which was developed independently

@asottile asottile closed this Sep 27, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants