Skip to content

Commit 7641643

Browse files
authored
Fix quoted type annotations in unusual contexts (#516)
* Extract a context manager for when we're in a annotation context * Detect quoted type annotations within typing.cast calls * Refactor typing name check This will make it easier to detect an unspecified typing module member as well as opening up other checks (such as testing for one of a collection many members all in one go). * Detect quoted annotations within subscripts of typing classes * Add nested quoted test case (Callable) * Use a lambda here for clarity This is slightly more usual than accessing a .__eq__ method and is more obviously similar to the other usage of this helper.
1 parent ef3c5cb commit 7641643

File tree

2 files changed

+102
-13
lines changed

2 files changed

+102
-13
lines changed

pyflakes/checker.py

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import ast
99
import bisect
1010
import collections
11+
import contextlib
1112
import doctest
1213
import functools
1314
import os
@@ -663,17 +664,26 @@ def getNodeName(node):
663664
return node.name
664665

665666

666-
def _is_typing(node, typing_attr, scope_stack):
667+
TYPING_MODULES = frozenset(('typing', 'typing_extensions'))
668+
669+
670+
def _is_typing_helper(node, is_name_match_fn, scope_stack):
671+
"""
672+
Internal helper to determine whether or not something is a member of a
673+
typing module. This is used as part of working out whether we are within a
674+
type annotation context.
675+
676+
Note: you probably don't want to use this function directly. Instead see the
677+
utils below which wrap it (`_is_typing` and `_is_any_typing_member`).
678+
"""
679+
667680
def _bare_name_is_attr(name):
668-
expected_typing_names = {
669-
'typing.{}'.format(typing_attr),
670-
'typing_extensions.{}'.format(typing_attr),
671-
}
672681
for scope in reversed(scope_stack):
673682
if name in scope:
674683
return (
675684
isinstance(scope[name], ImportationFrom) and
676-
scope[name].fullName in expected_typing_names
685+
scope[name].module in TYPING_MODULES and
686+
is_name_match_fn(scope[name].real_name)
677687
)
678688

679689
return False
@@ -685,12 +695,33 @@ def _bare_name_is_attr(name):
685695
) or (
686696
isinstance(node, ast.Attribute) and
687697
isinstance(node.value, ast.Name) and
688-
node.value.id in {'typing', 'typing_extensions'} and
689-
node.attr == typing_attr
698+
node.value.id in TYPING_MODULES and
699+
is_name_match_fn(node.attr)
690700
)
691701
)
692702

693703

704+
def _is_typing(node, typing_attr, scope_stack):
705+
"""
706+
Determine whether `node` represents the member of a typing module specified
707+
by `typing_attr`.
708+
709+
This is used as part of working out whether we are within a type annotation
710+
context.
711+
"""
712+
return _is_typing_helper(node, lambda x: x == typing_attr, scope_stack)
713+
714+
715+
def _is_any_typing_member(node, scope_stack):
716+
"""
717+
Determine whether `node` represents any member of a typing module.
718+
719+
This is used as part of working out whether we are within a type annotation
720+
context.
721+
"""
722+
return _is_typing_helper(node, lambda x: True, scope_stack)
723+
724+
694725
def is_typing_overload(value, scope_stack):
695726
return (
696727
isinstance(value.source, FUNCTION_TYPES) and
@@ -704,11 +735,8 @@ def is_typing_overload(value, scope_stack):
704735
def in_annotation(func):
705736
@functools.wraps(func)
706737
def in_annotation_func(self, *args, **kwargs):
707-
orig, self._in_annotation = self._in_annotation, True
708-
try:
738+
with self._enter_annotation():
709739
return func(self, *args, **kwargs)
710-
finally:
711-
self._in_annotation = orig
712740
return in_annotation_func
713741

714742

@@ -1236,6 +1264,14 @@ def on_conditional_branch():
12361264
except KeyError:
12371265
self.report(messages.UndefinedName, node, name)
12381266

1267+
@contextlib.contextmanager
1268+
def _enter_annotation(self):
1269+
orig, self._in_annotation = self._in_annotation, True
1270+
try:
1271+
yield
1272+
finally:
1273+
self._in_annotation = orig
1274+
12391275
def _handle_type_comments(self, node):
12401276
for (lineno, col_offset), comment in self._type_comments.get(node, ()):
12411277
comment = comment.split(':', 1)[1].strip()
@@ -1428,7 +1464,11 @@ def SUBSCRIPT(self, node):
14281464
finally:
14291465
self._in_typing_literal = orig
14301466
else:
1431-
self.handleChildren(node)
1467+
if _is_any_typing_member(node.value, self.scopeStack):
1468+
with self._enter_annotation():
1469+
self.handleChildren(node)
1470+
else:
1471+
self.handleChildren(node)
14321472

14331473
def _handle_string_dot_format(self, node):
14341474
try:
@@ -1557,6 +1597,15 @@ def CALL(self, node):
15571597
node.func.attr == 'format'
15581598
):
15591599
self._handle_string_dot_format(node)
1600+
1601+
if (
1602+
_is_typing(node.func, 'cast', self.scopeStack) and
1603+
len(node.args) >= 1 and
1604+
isinstance(node.args[0], ast.Str)
1605+
):
1606+
with self._enter_annotation():
1607+
self.handleNode(node.args[0], node)
1608+
15601609
self.handleChildren(node)
15611610

15621611
def _handle_percent_format(self, node):

pyflakes/test/test_type_annotations.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,46 @@ def f() -> Optional['Queue[str]']:
449449
return None
450450
""")
451451

452+
def test_partially_quoted_type_assignment(self):
453+
self.flakes("""
454+
from queue import Queue
455+
from typing import Optional
456+
457+
MaybeQueue = Optional['Queue[str]']
458+
""")
459+
460+
def test_nested_partially_quoted_type_assignment(self):
461+
self.flakes("""
462+
from queue import Queue
463+
from typing import Callable
464+
465+
Func = Callable[['Queue[str]'], None]
466+
""")
467+
468+
def test_quoted_type_cast(self):
469+
self.flakes("""
470+
from typing import cast, Optional
471+
472+
maybe_int = cast('Optional[int]', 42)
473+
""")
474+
475+
def test_type_cast_literal_str_to_str(self):
476+
# Checks that our handling of quoted type annotations in the first
477+
# argument to `cast` doesn't cause issues when (only) the _second_
478+
# argument is a literal str which looks a bit like a type annoation.
479+
self.flakes("""
480+
from typing import cast
481+
482+
a_string = cast(str, 'Optional[int]')
483+
""")
484+
485+
def test_quoted_type_cast_renamed_import(self):
486+
self.flakes("""
487+
from typing import cast as tsac, Optional as Maybe
488+
489+
maybe_int = tsac('Maybe[int]', 42)
490+
""")
491+
452492
@skipIf(version_info < (3,), 'new in Python 3')
453493
def test_literal_type_typing(self):
454494
self.flakes("""

0 commit comments

Comments
 (0)