Skip to content

Commit 4687cec

Browse files
ilevkivskyiIvan Levkivskyi
and
Ivan Levkivskyi
authored
Add daemon command to get type of an expression (#13209)
Implementation is straightforward (I also tried to make it future-proof, so it is easy to add new inspections). Few things to point out: * I added a more flexible traverser class, that supports shared visit logic and allows for `O(log n)` search by early return. * I noticed a bunch of places where line/column/full name were not set. Did a quick pass and fixed as many as I can. * I also implement basic support for expression attributes and go to definition (there are few tricky corner cases left, but they can be addressed later). The current _default_ logic is optimized for speed, while it may give stale results (e.g if file was edited, but has full tree loaded in memory). My thinking here is that users typically want to get inspections really fast (like on mouse hover over an expression), so the editor integrations that would use this can call with default flag values, and only use `--force-reload` or a full `dmypy recheck` if they know some file(s) were edited. Co-authored-by: Ivan Levkivskyi <[email protected]>
1 parent 086e823 commit 4687cec

23 files changed

+2060
-94
lines changed

docs/source/mypy_daemon.rst

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,12 @@ Additional daemon flags
152152
Write performance profiling information to ``FILE``. This is only available
153153
for the ``check``, ``recheck``, and ``run`` commands.
154154

155+
.. option:: --export-types
156+
157+
Store all expression types in memory for future use. This is useful to speed
158+
up future calls to ``dmypy inspect`` (but uses more memory). Only valid for
159+
``check``, ``recheck``, and ``run`` command.
160+
155161
Static inference of annotations
156162
*******************************
157163

@@ -243,8 +249,129 @@ command.
243249

244250
Set the maximum number of types to try for a function (default: ``64``).
245251

246-
.. TODO: Add similar sections about go to definition, find usages, and
247-
reveal type when added, and then move this to a separate file.
252+
Statically inspect expressions
253+
******************************
254+
255+
The daemon allows to get declared or inferred type of an expression (or other
256+
information about an expression, such as known attributes or definition location)
257+
using ``dmypy inspect LOCATION`` command. The location of the expression should be
258+
specified in the format ``path/to/file.py:line:column[:end_line:end_column]``.
259+
Both line and column are 1-based. Both start and end position are inclusive.
260+
These rules match how mypy prints the error location in error messages.
261+
262+
If a span is given (i.e. all 4 numbers), then only an exactly matching expression
263+
is inspected. If only a position is given (i.e. 2 numbers, line and column), mypy
264+
will inspect all *expressions*, that include this position, starting from the
265+
innermost one.
266+
267+
Consider this Python code snippet:
268+
269+
.. code-block:: python
270+
271+
def foo(x: int, longer_name: str) -> None:
272+
x
273+
longer_name
274+
275+
Here to find the type of ``x`` one needs to call ``dmypy inspect src.py:2:5:2:5``
276+
or ``dmypy inspect src.py:2:5``. While for ``longer_name`` one needs to call
277+
``dmypy inspect src.py:3:5:3:15`` or, for example, ``dmypy inspect src.py:3:10``.
278+
Please note that this command is only valid after daemon had a successful type
279+
check (without parse errors), so that types are populated, e.g. using
280+
``dmypy check``. In case where multiple expressions match the provided location,
281+
their types are returned separated by a newline.
282+
283+
Important note: it is recommended to check files with :option:`--export-types`
284+
since otherwise most inspections will not work without :option:`--force-reload`.
285+
286+
.. option:: --show INSPECTION
287+
288+
What kind of inspection to run for expression(s) found. Currently the supported
289+
inspections are:
290+
291+
* ``type`` (default): Show the best known type of a given expression.
292+
* ``attrs``: Show which attributes are valid for an expression (e.g. for
293+
auto-completion). Format is ``{"Base1": ["name_1", "name_2", ...]; "Base2": ...}``.
294+
Names are sorted by method resolution order. If expression refers to a module,
295+
then module attributes will be under key like ``"<full.module.name>"``.
296+
* ``definition`` (experimental): Show the definition location for a name
297+
expression or member expression. Format is ``path/to/file.py:line:column:Symbol``.
298+
If multiple definitions are found (e.g. for a Union attribute), they are
299+
separated by comma.
300+
301+
.. option:: --verbose
302+
303+
Increase verbosity of types string representation (can be repeated).
304+
For example, this will print fully qualified names of instance types (like
305+
``"builtins.str"``), instead of just a short name (like ``"str"``).
306+
307+
.. option:: --limit NUM
308+
309+
If the location is given as ``line:column``, this will cause daemon to
310+
return only at most ``NUM`` inspections of innermost expressions.
311+
Value of 0 means no limit (this is the default). For example, if one calls
312+
``dmypy inspect src.py:4:10 --limit=1`` with this code
313+
314+
.. code-block:: python
315+
316+
def foo(x: int) -> str: ..
317+
def bar(x: str) -> None: ...
318+
baz: int
319+
bar(foo(baz))
320+
321+
This will output just one type ``"int"`` (for ``baz`` name expression).
322+
While without the limit option, it would output all three types: ``"int"``,
323+
``"str"``, and ``"None"``.
324+
325+
.. option:: --include-span
326+
327+
With this option on, the daemon will prepend each inspection result with
328+
the full span of corresponding expression, formatted as ``1:2:1:4 -> "int"``.
329+
This may be useful in case multiple expressions match a location.
330+
331+
.. option:: --include-kind
332+
333+
With this option on, the daemon will prepend each inspection result with
334+
the kind of corresponding expression, formatted as ``NameExpr -> "int"``.
335+
If both this option and :option:`--include-span` are on, the kind will
336+
appear first, for example ``NameExpr:1:2:1:4 -> "int"``.
337+
338+
.. option:: --include-object-attrs
339+
340+
This will make the daemon include attributes of ``object`` (excluded by
341+
default) in case of an ``atts`` inspection.
342+
343+
.. option:: --union-attrs
344+
345+
Include attributes valid for some of possible expression types (by default
346+
an intersection is returned). This is useful for union types of type variables
347+
with values. For example, with this code:
348+
349+
.. code-block:: python
350+
351+
from typing import Union
352+
353+
class A:
354+
x: int
355+
z: int
356+
class B:
357+
y: int
358+
z: int
359+
var: Union[A, B]
360+
var
361+
362+
The command ``dmypy inspect --show attrs src.py:10:1`` will return
363+
``{"A": ["z"], "B": ["z"]}``, while with ``--union-attrs`` it will return
364+
``{"A": ["x", "z"], "B": ["y", "z"]}``.
365+
366+
.. option:: --force-reload
367+
368+
Force re-parsing and re-type-checking file before inspection. By default
369+
this is done only when needed (for example file was not loaded from cache
370+
or daemon was initially run without ``--export-types`` mypy option),
371+
since reloading may be slow (up to few seconds for very large files).
372+
373+
.. TODO: Add similar section about find usages when added, and then move
374+
this to a separate file.
248375
249376
250377
.. _watchman: https://facebook.github.io/watchman/

mypy/checker.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1727,7 +1727,7 @@ def expand_typevars(
17271727
# Make a copy of the function to check for each combination of
17281728
# value restricted type variables. (Except when running mypyc,
17291729
# where we need one canonical version of the function.)
1730-
if subst and not self.options.mypyc:
1730+
if subst and not (self.options.mypyc or self.options.inspections):
17311731
result: List[Tuple[FuncItem, CallableType]] = []
17321732
for substitutions in itertools.product(*subst):
17331733
mapping = dict(substitutions)
@@ -3205,7 +3205,7 @@ def check_assignment_to_multiple_lvalues(
32053205
lr_pairs = list(zip(left_lvs, left_rvs))
32063206
if star_lv:
32073207
rv_list = ListExpr(star_rvs)
3208-
rv_list.set_line(rvalue.get_line())
3208+
rv_list.set_line(rvalue)
32093209
lr_pairs.append((star_lv.expr, rv_list))
32103210
lr_pairs.extend(zip(right_lvs, right_rvs))
32113211

@@ -3406,7 +3406,7 @@ def check_multi_assignment_from_tuple(
34063406
list_expr = ListExpr(
34073407
[self.temp_node(rv_type, context) for rv_type in star_rv_types]
34083408
)
3409-
list_expr.set_line(context.get_line())
3409+
list_expr.set_line(context)
34103410
self.check_assignment(star_lv.expr, list_expr, infer_lvalue_type)
34113411
for lv, rv_type in zip(right_lvs, right_rv_types):
34123412
self.check_assignment(lv, self.temp_node(rv_type, context), infer_lvalue_type)
@@ -4065,7 +4065,7 @@ def visit_if_stmt(self, s: IfStmt) -> None:
40654065
def visit_while_stmt(self, s: WhileStmt) -> None:
40664066
"""Type check a while statement."""
40674067
if_stmt = IfStmt([s.expr], [s.body], None)
4068-
if_stmt.set_line(s.get_line(), s.get_column())
4068+
if_stmt.set_line(s)
40694069
self.accept_loop(if_stmt, s.else_body, exit_condition=s.expr)
40704070

40714071
def visit_operator_assignment_stmt(self, s: OperatorAssignmentStmt) -> None:
@@ -6540,7 +6540,7 @@ def flatten_types(t: Type) -> List[Type]:
65406540

65416541
def expand_func(defn: FuncItem, map: Dict[TypeVarId, Type]) -> FuncItem:
65426542
visitor = TypeTransformVisitor(map)
6543-
ret = defn.accept(visitor)
6543+
ret = visitor.node(defn)
65446544
assert isinstance(ret, FuncItem)
65456545
return ret
65466546

mypy/checkmember.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,10 @@ def _analyze_member_access(
224224
elif isinstance(typ, NoneType):
225225
return analyze_none_member_access(name, typ, mx)
226226
elif isinstance(typ, TypeVarLikeType):
227+
if isinstance(typ, TypeVarType) and typ.values:
228+
return _analyze_member_access(
229+
name, make_simplified_union(typ.values), mx, override_info
230+
)
227231
return _analyze_member_access(name, typ.upper_bound, mx, override_info)
228232
elif isinstance(typ, DeletedType):
229233
mx.msg.deleted_as_rvalue(typ, mx.context)

mypy/dmypy/client.py

Lines changed: 119 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ def __init__(self, prog: str) -> None:
8383
p.add_argument("--junit-xml", help="Write junit.xml to the given file")
8484
p.add_argument("--perf-stats-file", help="write performance information to the given file")
8585
p.add_argument("files", metavar="FILE", nargs="+", help="File (or directory) to check")
86+
p.add_argument(
87+
"--export-types",
88+
action="store_true",
89+
help="Store types of all expressions in a shared location (useful for inspections)",
90+
)
8691

8792
run_parser = p = subparsers.add_parser(
8893
"run",
@@ -96,6 +101,11 @@ def __init__(self, prog: str) -> None:
96101
"--timeout", metavar="TIMEOUT", type=int, help="Server shutdown timeout (in seconds)"
97102
)
98103
p.add_argument("--log-file", metavar="FILE", type=str, help="Direct daemon stdout/stderr to FILE")
104+
p.add_argument(
105+
"--export-types",
106+
action="store_true",
107+
help="Store types of all expressions in a shared location (useful for inspections)",
108+
)
99109
p.add_argument(
100110
"flags",
101111
metavar="ARG",
@@ -113,6 +123,11 @@ def __init__(self, prog: str) -> None:
113123
p.add_argument("-q", "--quiet", action="store_true", help=argparse.SUPPRESS) # Deprecated
114124
p.add_argument("--junit-xml", help="Write junit.xml to the given file")
115125
p.add_argument("--perf-stats-file", help="write performance information to the given file")
126+
p.add_argument(
127+
"--export-types",
128+
action="store_true",
129+
help="Store types of all expressions in a shared location (useful for inspections)",
130+
)
116131
p.add_argument(
117132
"--update",
118133
metavar="FILE",
@@ -164,6 +179,68 @@ def __init__(self, prog: str) -> None:
164179
help="Set the maximum number of types to try for a function (default 64)",
165180
)
166181

182+
inspect_parser = p = subparsers.add_parser(
183+
"inspect", help="Locate and statically inspect expression(s)"
184+
)
185+
p.add_argument(
186+
"location",
187+
metavar="LOCATION",
188+
type=str,
189+
help="Location specified as path/to/file.py:line:column[:end_line:end_column]."
190+
" If position is given (i.e. only line and column), this will return all"
191+
" enclosing expressions",
192+
)
193+
p.add_argument(
194+
"--show",
195+
metavar="INSPECTION",
196+
type=str,
197+
default="type",
198+
choices=["type", "attrs", "definition"],
199+
help="What kind of inspection to run",
200+
)
201+
p.add_argument(
202+
"--verbose",
203+
"-v",
204+
action="count",
205+
default=0,
206+
help="Increase verbosity of the type string representation (can be repeated)",
207+
)
208+
p.add_argument(
209+
"--limit",
210+
metavar="NUM",
211+
type=int,
212+
default=0,
213+
help="Return at most NUM innermost expressions (if position is given); 0 means no limit",
214+
)
215+
p.add_argument(
216+
"--include-span",
217+
action="store_true",
218+
help="Prepend each inspection result with the span of corresponding expression"
219+
' (e.g. 1:2:3:4:"int")',
220+
)
221+
p.add_argument(
222+
"--include-kind",
223+
action="store_true",
224+
help="Prepend each inspection result with the kind of corresponding expression"
225+
' (e.g. NameExpr:"int")',
226+
)
227+
p.add_argument(
228+
"--include-object-attrs",
229+
action="store_true",
230+
help='Include attributes of "object" in "attrs" inspection',
231+
)
232+
p.add_argument(
233+
"--union-attrs",
234+
action="store_true",
235+
help="Include attributes valid for some of possible expression types"
236+
" (by default an intersection is returned)",
237+
)
238+
p.add_argument(
239+
"--force-reload",
240+
action="store_true",
241+
help="Re-parse and re-type-check file before inspection (may be slow)",
242+
)
243+
167244
hang_parser = p = subparsers.add_parser("hang", help="Hang for 100 seconds")
168245

169246
daemon_parser = p = subparsers.add_parser("daemon", help="Run daemon in foreground")
@@ -321,12 +398,24 @@ def do_run(args: argparse.Namespace) -> None:
321398
# Bad or missing status file or dead process; good to start.
322399
start_server(args, allow_sources=True)
323400
t0 = time.time()
324-
response = request(args.status_file, "run", version=__version__, args=args.flags)
401+
response = request(
402+
args.status_file,
403+
"run",
404+
version=__version__,
405+
args=args.flags,
406+
export_types=args.export_types,
407+
)
325408
# If the daemon signals that a restart is necessary, do it
326409
if "restart" in response:
327410
print(f"Restarting: {response['restart']}")
328411
restart_server(args, allow_sources=True)
329-
response = request(args.status_file, "run", version=__version__, args=args.flags)
412+
response = request(
413+
args.status_file,
414+
"run",
415+
version=__version__,
416+
args=args.flags,
417+
export_types=args.export_types,
418+
)
330419

331420
t1 = time.time()
332421
response["roundtrip_time"] = t1 - t0
@@ -383,7 +472,7 @@ def do_kill(args: argparse.Namespace) -> None:
383472
def do_check(args: argparse.Namespace) -> None:
384473
"""Ask the daemon to check a list of files."""
385474
t0 = time.time()
386-
response = request(args.status_file, "check", files=args.files)
475+
response = request(args.status_file, "check", files=args.files, export_types=args.export_types)
387476
t1 = time.time()
388477
response["roundtrip_time"] = t1 - t0
389478
check_output(response, args.verbose, args.junit_xml, args.perf_stats_file)
@@ -406,9 +495,15 @@ def do_recheck(args: argparse.Namespace) -> None:
406495
"""
407496
t0 = time.time()
408497
if args.remove is not None or args.update is not None:
409-
response = request(args.status_file, "recheck", remove=args.remove, update=args.update)
498+
response = request(
499+
args.status_file,
500+
"recheck",
501+
export_types=args.export_types,
502+
remove=args.remove,
503+
update=args.update,
504+
)
410505
else:
411-
response = request(args.status_file, "recheck")
506+
response = request(args.status_file, "recheck", export_types=args.export_types)
412507
t1 = time.time()
413508
response["roundtrip_time"] = t1 - t0
414509
check_output(response, args.verbose, args.junit_xml, args.perf_stats_file)
@@ -437,6 +532,25 @@ def do_suggest(args: argparse.Namespace) -> None:
437532
check_output(response, verbose=False, junit_xml=None, perf_stats_file=None)
438533

439534

535+
@action(inspect_parser)
536+
def do_inspect(args: argparse.Namespace) -> None:
537+
"""Ask daemon to print the type of an expression."""
538+
response = request(
539+
args.status_file,
540+
"inspect",
541+
show=args.show,
542+
location=args.location,
543+
verbosity=args.verbose,
544+
limit=args.limit,
545+
include_span=args.include_span,
546+
include_kind=args.include_kind,
547+
include_object_attrs=args.include_object_attrs,
548+
union_attrs=args.union_attrs,
549+
force_reload=args.force_reload,
550+
)
551+
check_output(response, verbose=False, junit_xml=None, perf_stats_file=None)
552+
553+
440554
def check_output(
441555
response: Dict[str, Any],
442556
verbose: bool,

0 commit comments

Comments
 (0)