Skip to content

Commit a86be75

Browse files
authored
Allow passing path/to/file.py:line instead of fully.qualified.name to dmypy suggest (python#7483)
This should help with integrations with editors and IDEs, where getting line number of a function might be much easier than the full name. The implementation in mostly straightforward. I also did a little refactoring to reuse the existing logic efficiently. The only important difference that I introduced after few experiments is that I force reloading of the file before trying to search the function, since even the smallest edit can offset the line number and lead to some weird results.
1 parent 226a4f1 commit a86be75

File tree

3 files changed

+209
-35
lines changed

3 files changed

+209
-35
lines changed

mypy/suggestions.py

Lines changed: 87 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@
4242
reverse_builtin_aliases,
4343
)
4444
from mypy.server.update import FineGrainedBuildManager
45-
from mypy.util import module_prefix, split_target
45+
from mypy.util import split_target
46+
from mypy.find_sources import SourceFinder, InvalidSourceList
47+
from mypy.modulefinder import PYTHON_EXTENSIONS
4648
from mypy.plugin import Plugin, FunctionContext, MethodContext
4749
from mypy.traverser import TraverserVisitor
4850
from mypy.checkexpr import has_any_type
@@ -162,6 +164,7 @@ def __init__(self, fgmanager: FineGrainedBuildManager,
162164
self.manager = fgmanager.manager
163165
self.plugin = self.manager.plugin
164166
self.graph = fgmanager.graph
167+
self.finder = SourceFinder(self.manager.fscache)
165168

166169
self.give_json = json
167170
self.no_errors = no_errors
@@ -174,19 +177,21 @@ def __init__(self, fgmanager: FineGrainedBuildManager,
174177

175178
def suggest(self, function: str) -> str:
176179
"""Suggest an inferred type for function."""
177-
with self.restore_after(function):
180+
mod, func_name, node = self.find_node(function)
181+
182+
with self.restore_after(mod):
178183
with self.with_export_types():
179-
suggestion = self.get_suggestion(function)
184+
suggestion = self.get_suggestion(mod, node)
180185

181186
if self.give_json:
182-
return self.json_suggestion(function, suggestion)
187+
return self.json_suggestion(mod, func_name, node, suggestion)
183188
else:
184189
return self.format_signature(suggestion)
185190

186191
def suggest_callsites(self, function: str) -> str:
187192
"""Find a list of call sites of function."""
188-
with self.restore_after(function):
189-
_, _, node = self.find_node(function)
193+
mod, _, node = self.find_node(function)
194+
with self.restore_after(mod):
190195
callsites, _ = self.get_callsites(node)
191196

192197
return '\n'.join(dedup(
@@ -195,17 +200,15 @@ def suggest_callsites(self, function: str) -> str:
195200
))
196201

197202
@contextmanager
198-
def restore_after(self, target: str) -> Iterator[None]:
203+
def restore_after(self, module: str) -> Iterator[None]:
199204
"""Context manager that reloads a module after executing the body.
200205
201206
This should undo any damage done to the module state while mucking around.
202207
"""
203208
try:
204209
yield
205210
finally:
206-
module = module_prefix(self.graph, target)
207-
if module:
208-
self.reload(self.graph[module])
211+
self.reload(self.graph[module])
209212

210213
@contextmanager
211214
def with_export_types(self) -> Iterator[None]:
@@ -321,13 +324,12 @@ def find_best(self, func: FuncDef, guesses: List[CallableType]) -> Tuple[Callabl
321324
key=lambda s: (count_errors(errors[s]), self.score_callable(s)))
322325
return best, count_errors(errors[best])
323326

324-
def get_suggestion(self, function: str) -> PyAnnotateSignature:
327+
def get_suggestion(self, mod: str, node: FuncDef) -> PyAnnotateSignature:
325328
"""Compute a suggestion for a function.
326329
327330
Return the type and whether the first argument should be ignored.
328331
"""
329332
graph = self.graph
330-
mod, _, node = self.find_node(function)
331333
callsites, orig_errors = self.get_callsites(node)
332334

333335
if self.no_errors and orig_errors:
@@ -386,15 +388,49 @@ def format_args(self,
386388
return "(%s)" % (", ".join(args))
387389

388390
def find_node(self, key: str) -> Tuple[str, str, FuncDef]:
389-
"""From a target name, return module/target names and the func def."""
391+
"""From a target name, return module/target names and the func def.
392+
393+
The 'key' argument can be in one of two formats:
394+
* As the function full name, e.g., package.module.Cls.method
395+
* As the function location as file and line separated by column,
396+
e.g., path/to/file.py:42
397+
"""
390398
# TODO: Also return OverloadedFuncDef -- currently these are ignored.
391-
graph = self.fgmanager.graph
392-
target = split_target(graph, key)
393-
if not target:
394-
raise SuggestionFailure("Cannot find module for %s" % (key,))
395-
modname, tail = target
399+
node = None # type: Optional[SymbolNode]
400+
if ':' in key:
401+
if key.count(':') > 1:
402+
raise SuggestionFailure(
403+
'Malformed location for function: {}. Must be either'
404+
' package.module.Class.method or path/to/file.py:line'.format(key))
405+
file, line = key.split(':')
406+
if not line.isdigit():
407+
raise SuggestionFailure('Line number must be a number. Got {}'.format(line))
408+
line_number = int(line)
409+
modname, node = self.find_node_by_file_and_line(file, line_number)
410+
tail = node.fullname()[len(modname) + 1:] # add one to account for '.'
411+
else:
412+
target = split_target(self.fgmanager.graph, key)
413+
if not target:
414+
raise SuggestionFailure("Cannot find module for %s" % (key,))
415+
modname, tail = target
416+
node = self.find_node_by_module_and_name(modname, tail)
396417

397-
tree = self.ensure_loaded(graph[modname])
418+
if isinstance(node, Decorator):
419+
node = self.extract_from_decorator(node)
420+
if not node:
421+
raise SuggestionFailure("Object %s is a decorator we can't handle" % key)
422+
423+
if not isinstance(node, FuncDef):
424+
raise SuggestionFailure("Object %s is not a function" % key)
425+
426+
return modname, tail, node
427+
428+
def find_node_by_module_and_name(self, modname: str, tail: str) -> Optional[SymbolNode]:
429+
"""Find symbol node by module id and qualified name.
430+
431+
Raise SuggestionFailure if can't find one.
432+
"""
433+
tree = self.ensure_loaded(self.fgmanager.graph[modname])
398434

399435
# N.B. This is reimplemented from update's lookup_target
400436
# basically just to produce better error messages.
@@ -416,18 +452,38 @@ def find_node(self, key: str) -> Tuple[str, str, FuncDef]:
416452
# Look for the actual function/method
417453
funcname = components[-1]
418454
if funcname not in names:
455+
key = modname + '.' + tail
419456
raise SuggestionFailure("Unknown %s %s" %
420457
("method" if len(components) > 1 else "function", key))
421-
node = names[funcname].node
422-
if isinstance(node, Decorator):
423-
node = self.extract_from_decorator(node)
424-
if not node:
425-
raise SuggestionFailure("Object %s is a decorator we can't handle" % key)
458+
return names[funcname].node
426459

427-
if not isinstance(node, FuncDef):
428-
raise SuggestionFailure("Object %s is not a function" % key)
460+
def find_node_by_file_and_line(self, file: str, line: int) -> Tuple[str, SymbolNode]:
461+
"""Find symbol node by path to file and line number.
429462
430-
return (modname, tail, node)
463+
Return module id and the node found. Raise SuggestionFailure if can't find one.
464+
"""
465+
if not any(file.endswith(ext) for ext in PYTHON_EXTENSIONS):
466+
raise SuggestionFailure('Source file is not a Python file')
467+
try:
468+
modname, _ = self.finder.crawl_up(os.path.normpath(file))
469+
except InvalidSourceList:
470+
raise SuggestionFailure('Invalid source file name: ' + file)
471+
if modname not in self.graph:
472+
raise SuggestionFailure('Unknown module: ' + modname)
473+
# We must be sure about any edits in this file as this might affect the line numbers.
474+
tree = self.ensure_loaded(self.fgmanager.graph[modname], force=True)
475+
node = None # type: Optional[SymbolNode]
476+
for _, sym, _ in tree.local_definitions():
477+
if isinstance(sym.node, FuncDef) and sym.node.line == line:
478+
node = sym.node
479+
break
480+
elif isinstance(sym.node, Decorator) and sym.node.func.line == line:
481+
node = sym.node
482+
break
483+
# TODO: add support for OverloadedFuncDef.
484+
if not node:
485+
raise SuggestionFailure('Cannot find a function at line {}'.format(line))
486+
return modname, node
431487

432488
def extract_from_decorator(self, node: Decorator) -> Optional[FuncDef]:
433489
for dec in node.decorators:
@@ -483,19 +539,19 @@ def reload(self, state: State, check_errors: bool = False) -> List[str]:
483539
raise SuggestionFailure("Error while trying to load %s" % state.id)
484540
return res
485541

486-
def ensure_loaded(self, state: State) -> MypyFile:
542+
def ensure_loaded(self, state: State, force: bool = False) -> MypyFile:
487543
"""Make sure that the module represented by state is fully loaded."""
488-
if not state.tree or state.tree.is_cache_skeleton:
544+
if not state.tree or state.tree.is_cache_skeleton or force:
489545
self.reload(state, check_errors=True)
490546
assert state.tree is not None
491547
return state.tree
492548

493549
def builtin_type(self, s: str) -> Instance:
494550
return self.manager.semantic_analyzer.builtin_type(s)
495551

496-
def json_suggestion(self, function: str, suggestion: PyAnnotateSignature) -> str:
552+
def json_suggestion(self, mod: str, func_name: str, node: FuncDef,
553+
suggestion: PyAnnotateSignature) -> str:
497554
"""Produce a json blob for a suggestion suitable for application by pyannotate."""
498-
mod, func_name, node = self.find_node(function)
499555
# pyannotate irritatingly drops class names for class and static methods
500556
if node.is_class or node.is_static:
501557
func_name = func_name.split('.', 1)[-1]

mypy/test/testfinegrained.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ def run_case(self, testcase: DataDrivenTestCase) -> None:
9696
if messages:
9797
a.extend(normalize_messages(messages))
9898

99-
a.extend(self.maybe_suggest(step, server, main_src))
99+
assert testcase.tmpdir
100+
a.extend(self.maybe_suggest(step, server, main_src, testcase.tmpdir.name))
100101

101102
if server.fine_grained_manager:
102103
if CHECK_CONSISTENCY:
@@ -155,7 +156,8 @@ def run_case(self, testcase: DataDrivenTestCase) -> None:
155156

156157
a.append('==')
157158
a.extend(new_messages)
158-
a.extend(self.maybe_suggest(step, server, main_src))
159+
assert testcase.tmpdir
160+
a.extend(self.maybe_suggest(step, server, main_src, testcase.tmpdir.name))
159161

160162
# Normalize paths in test output (for Windows).
161163
a = [line.replace('\\', '/') for line in a]
@@ -268,7 +270,7 @@ def parse_sources(self, program_text: str,
268270
return [base] + create_source_list([test_temp_dir], options,
269271
allow_empty_dir=True)
270272

271-
def maybe_suggest(self, step: int, server: Server, src: str) -> List[str]:
273+
def maybe_suggest(self, step: int, server: Server, src: str, tmp_dir: str) -> List[str]:
272274
output = [] # type: List[str]
273275
targets = self.get_suggest(src, step)
274276
for flags, target in targets:
@@ -285,13 +287,17 @@ def maybe_suggest(self, step: int, server: Server, src: str) -> List[str]:
285287
try_text=try_text, flex_any=flex_any,
286288
callsites=callsites))
287289
val = res['error'] if 'error' in res else res['out'] + res['err']
290+
if json:
291+
# JSON contains already escaped \ on Windows, so requires a bit of care.
292+
val = val.replace('\\\\', '\\')
293+
val = val.replace(tmp_dir + os.path.sep, '')
288294
output.extend(val.strip().split('\n'))
289295
return normalize_messages(output)
290296

291297
def get_suggest(self, program_text: str,
292298
incremental_step: int) -> List[Tuple[str, str]]:
293299
step_bit = '1?' if incremental_step == 1 else str(incremental_step)
294-
regex = '# suggest{}: (--[a-zA-Z0-9_\\-./=?^ ]+ )*([a-zA-Z0-9_./?^ ]+)$'.format(step_bit)
300+
regex = '# suggest{}: (--[a-zA-Z0-9_\\-./=?^ ]+ )*([a-zA-Z0-9_.:/?^ ]+)$'.format(step_bit)
295301
m = re.findall(regex, program_text, flags=re.MULTILINE)
296302
return m
297303

test-data/unit/fine-grained-suggest.test

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,3 +607,115 @@ def bar(iany) -> None:
607607
(int, int) -> int
608608
(str, int) -> str
609609
==
610+
611+
[case testSuggestColonBasic]
612+
# suggest: tmp/foo.py:1
613+
# suggest: tmp/bar/baz.py:2
614+
[file foo.py]
615+
def func(arg):
616+
return 0
617+
func('test')
618+
from bar.baz import C
619+
C().method('test')
620+
[file bar/__init__.py]
621+
[file bar/baz.py]
622+
class C:
623+
def method(self, x):
624+
return 0
625+
[out]
626+
(str) -> int
627+
(str) -> int
628+
==
629+
630+
[case testSuggestColonBadLocation]
631+
# suggest: tmp/foo.py:7:8:9
632+
[file foo.py]
633+
[out]
634+
Malformed location for function: tmp/foo.py:7:8:9. Must be either package.module.Class.method or path/to/file.py:line
635+
==
636+
637+
[case testSuggestColonBadLine]
638+
# suggest: tmp/foo.py:bad
639+
[file foo.py]
640+
[out]
641+
Line number must be a number. Got bad
642+
==
643+
644+
[case testSuggestColonBadFile]
645+
# suggest: tmp/foo.txt:1
646+
[file foo.txt]
647+
def f(): pass
648+
[out]
649+
Source file is not a Python file
650+
==
651+
652+
[case testSuggestColonUnknownLine]
653+
# suggest: tmp/foo.py:42
654+
[file foo.py]
655+
def func(x):
656+
return 0
657+
func('test')
658+
[out]
659+
Cannot find a function at line 42
660+
==
661+
662+
[case testSuggestColonClass]
663+
# suggest: tmp/foo.py:1
664+
[file foo.py]
665+
class C:
666+
pass
667+
[out]
668+
Cannot find a function at line 1
669+
==
670+
671+
[case testSuggestColonDecorator]
672+
# suggest: tmp/foo.py:6
673+
[file foo.py]
674+
from typing import TypeVar, Callable, Any
675+
F = TypeVar('F', bound=Callable[..., Any])
676+
def deco(f: F) -> F: ...
677+
678+
@deco
679+
def func(arg):
680+
return 0
681+
func('test')
682+
[out]
683+
(str) -> int
684+
==
685+
686+
[case testSuggestColonMethod]
687+
# suggest: tmp/foo.py:3
688+
[file foo.py]
689+
class Out:
690+
class In:
691+
def method(self, x):
692+
return Out()
693+
x: Out.In
694+
x.method(x)
695+
[out]
696+
(foo:Out.In) -> foo.Out
697+
==
698+
699+
[case testSuggestColonMethodJSON]
700+
# suggest: --json tmp/foo.py:3
701+
[file foo.py]
702+
class Out:
703+
class In:
704+
def method(self, x):
705+
return Out()
706+
x: Out.In
707+
x.method(x)
708+
[out]
709+
[[{"func_name": "Out.In.method", "line": 3, "path": "tmp/foo.py", "samples": 0, "signature": {"arg_types": ["foo:Out.In"], "return_type": "foo.Out"}}]
710+
==
711+
712+
[case testSuggestColonNonPackageDir]
713+
# cmd: mypy foo/bar/baz.py
714+
# suggest: tmp/foo/bar/baz.py:1
715+
[file foo/bar/baz.py]
716+
def func(arg):
717+
return 0
718+
func('test')
719+
[out]
720+
(str) -> int
721+
==

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy