Skip to content

Commit d128c76

Browse files
msullivanGuido van Rossum
authored andcommitted
Support handling nested classes (#85)
Without understanding the module hierarchy, we can't distinguish between a module and a class in a dotted name like `foo.bar.A.B`, and will insert code like `from foo.bar.A import B`, even though `A` is a class. Work around this by using : to separate the module and class name if the class is nested inside another class. The fix works only on Python 3 since it uses `__qualname__`.
1 parent 2c3bc39 commit d128c76

File tree

5 files changed

+52
-8
lines changed

5 files changed

+52
-8
lines changed

pyannotate_runtime/collect_types.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
)
5555
from contextlib import contextmanager
5656

57-
MYPY=False
57+
MYPY=False
5858
if MYPY:
5959
# MYPY is True when mypy is running
6060
# 'Type' is only required for running mypy, not for running pyannotate
@@ -430,7 +430,9 @@ def name_from_type(type_):
430430
# Also ignore '<uknown>' modules so pyannotate can parse these types
431431
return type_.__name__
432432
else:
433-
return '%s.%s' % (module, type_.__name__)
433+
name = getattr(type_, '__qualname__', None) or type_.__name__
434+
delim = '.' if '.' not in name else ':'
435+
return '%s%s%s' % (module, delim, name)
434436
else:
435437
return 'None'
436438

pyannotate_runtime/tests/test_collect_types.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import json
1010
import os
1111
import sched
12+
import sys
1213
import time
1314
import unittest
1415
from collections import namedtuple
@@ -51,14 +52,18 @@ def noop_dec(a):
5152
# type: (Any) -> Any
5253
return a
5354

55+
def discard(a):
56+
# type: (Any) -> None
57+
pass
5458

5559
@noop_dec
5660
class FoosParent(object):
5761
pass
5862

5963

6064
class FooObject(FoosParent):
61-
pass
65+
class FooNested(object):
66+
pass
6267

6368

6469
class FooReturn(FoosParent):
@@ -314,6 +319,8 @@ def test_run_a_bunch_of_tests(self):
314319

315320
OldStyleClass().foo(10)
316321

322+
discard(FooObject.FooNested())
323+
317324
# TODO(svorobev): add checks for the rest of the functions
318325
# print_int,
319326
self.assert_type_comments(
@@ -324,6 +331,12 @@ def test_run_a_bunch_of_tests(self):
324331
['(int, pyannotate_runtime.tests.test_collect_types.FooNamedTuple) -> EOFError'])
325332
self.assert_type_comments('OldStyleClass.foo', ['(int) -> int'])
326333

334+
# Need __qualname__ to get this right
335+
if sys.version_info >= (3, 3):
336+
self.assert_type_comments(
337+
'discard',
338+
['(pyannotate_runtime.tests.test_collect_types:FooObject.FooNested) -> None'])
339+
327340
# TODO: that could be better
328341
self.assert_type_comments('takes_different_lists', ['(List[Union[int, str]]) -> None'])
329342

pyannotate_tools/annotations/parse.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ def tokenize(s):
188188
tokens.append(Separator('->'))
189189
s = s[2:]
190190
else:
191-
m = re.match(r'[-\w]+( *\. *[-/\w]*)*', s)
191+
m = re.match(r'[-\w]+(\s*(\.|:)\s*[-/\w]*)*', s)
192192
if not m:
193193
raise ParseError(original)
194194
fullname = m.group(0)

pyannotate_tools/fixes/fix_annotate_json.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -266,19 +266,27 @@ def get_annotation_from_stub(self, node, results, funcname):
266266
def update_type_names(self, type_str):
267267
# Replace e.g. `List[pkg.mod.SomeClass]` with
268268
# `List[SomeClass]` and remember to import it.
269-
return re.sub(r'[\w.]+', self.type_updater, type_str)
269+
return re.sub(r'[\w.:]+', self.type_updater, type_str)
270270

271271
def type_updater(self, match):
272272
# Replace `pkg.mod.SomeClass` with `SomeClass`
273273
# and remember to import it.
274274
word = match.group()
275275
if word == '...':
276276
return word
277-
if '.' not in word:
277+
if '.' not in word and ':' not in word:
278278
# Assume it's either builtin or from `typing`
279279
if word in typing_all:
280280
self.add_import('typing', word)
281281
return word
282-
mod, name = word.rsplit('.', 1)
283-
self.add_import(mod, name)
282+
# If there is a :, treat that as the separator between the
283+
# module and the class. Otherwise assume everything but the
284+
# last element is the module.
285+
if ':' in word:
286+
mod, name = word.split(':')
287+
to_import = name.split('.', 1)[0]
288+
else:
289+
mod, name = word.rsplit('.', 1)
290+
to_import = name
291+
self.add_import(mod, to_import)
284292
return name

pyannotate_tools/fixes/tests/test_annotate_json_py2.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,3 +660,24 @@ def nop(a):
660660
return 0
661661
"""
662662
self.check(a, b)
663+
664+
def test_nested(self):
665+
self.setTestData(
666+
[{"func_name": "nop",
667+
"path": "<string>",
668+
"line": 1,
669+
"signature": {
670+
"arg_types": ["foo:A.B"],
671+
"return_type": "None"},
672+
}])
673+
a = """\
674+
def nop(a):
675+
pass
676+
"""
677+
b = """\
678+
from foo import A
679+
def nop(a):
680+
# type: (A.B) -> None
681+
pass
682+
"""
683+
self.check(a, b)

0 commit comments

Comments
 (0)