Skip to content

Commit 7ab19c1

Browse files
ddfishergvanrossum
authored andcommitted
Add exact line coverage report (#2057)
The output is intended to be fed into coverage.py, the Python code coverage tool, for example: ``` (echo -n "!coverage.py: This is a private format, don't read it directly!"; cat coverage.json) > .coverage coverage html ```
1 parent 347cd58 commit 7ab19c1

File tree

2 files changed

+113
-1
lines changed

2 files changed

+113
-1
lines changed

mypy/main.py

+2
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@ def process_options(args: List[str]) -> Tuple[List[BuildSource], Options]:
219219
dest='special-opts:xslt_txt_report')
220220
report_group.add_argument('--linecount-report', metavar='DIR',
221221
dest='special-opts:linecount_report')
222+
report_group.add_argument('--linecoverage-report', metavar='DIR',
223+
dest='special-opts:linecoverage_report')
222224

223225
code_group = parser.add_argument_group(title='How to specify the code to type check')
224226
code_group.add_argument('-m', '--module', action='append', metavar='MODULE',

mypy/report.py

+111-1
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
from abc import ABCMeta, abstractmethod
44
import cgi
5+
import json
56
import os
67
import shutil
78
import tokenize
89

9-
from typing import Callable, Dict, List, Tuple, cast
10+
from typing import Callable, Dict, List, Optional, Tuple, cast
1011

1112
from mypy.nodes import MypyFile, Node, FuncDef
1213
from mypy import stats
@@ -105,6 +106,115 @@ def on_finish(self) -> None:
105106
reporter_classes['linecount'] = LineCountReporter
106107

107108

109+
class LineCoverageVisitor(TraverserVisitor):
110+
def __init__(self, source: List[str]) -> None:
111+
self.source = source
112+
113+
# For each line of source, we maintain a pair of
114+
# * the indentation level of the surrounding function
115+
# (-1 if not inside a function), and
116+
# * whether the surrounding function is typed.
117+
# Initially, everything is covered at indentation level -1.
118+
self.lines_covered = [(-1, True) for l in source]
119+
120+
# The Python AST has position information for the starts of
121+
# elements, but not for their ends. Fortunately the
122+
# indentation-based syntax makes it pretty easy to find where a
123+
# block ends without doing any real parsing.
124+
125+
# TODO: Handle line continuations (explicit and implicit) and
126+
# multi-line string literals. (But at least line continuations
127+
# are normally more indented than their surrounding block anyways,
128+
# by PEP 8.)
129+
130+
def indentation_level(self, line_number: int) -> Optional[int]:
131+
"""Return the indentation of a line of the source (specified by
132+
zero-indexed line number). Returns None for blank lines or comments."""
133+
line = self.source[line_number]
134+
indent = 0
135+
for char in list(line):
136+
if char == ' ':
137+
indent += 1
138+
elif char == '\t':
139+
indent = 8 * ((indent + 8) // 8)
140+
elif char == '#':
141+
# Line is a comment; ignore it
142+
return None
143+
elif char == '\n':
144+
# Line is entirely whitespace; ignore it
145+
return None
146+
# TODO line continuation (\)
147+
else:
148+
# Found a non-whitespace character
149+
return indent
150+
# Line is entirely whitespace, and at end of file
151+
# with no trailing newline; ignore it
152+
return None
153+
154+
def visit_func_def(self, defn: FuncDef) -> None:
155+
start_line = defn.get_line() - 1
156+
start_indent = self.indentation_level(start_line)
157+
cur_line = start_line + 1
158+
end_line = cur_line
159+
# After this loop, function body will be lines [start_line, end_line)
160+
while cur_line < len(self.source):
161+
cur_indent = self.indentation_level(cur_line)
162+
if cur_indent is None:
163+
# Consume the line, but don't mark it as belonging to the function yet.
164+
cur_line += 1
165+
elif cur_indent > start_indent:
166+
# A non-blank line that belongs to the function.
167+
cur_line += 1
168+
end_line = cur_line
169+
else:
170+
# We reached a line outside the function definition.
171+
break
172+
173+
is_typed = defn.type is not None
174+
for line in range(start_line, end_line):
175+
old_indent, _ = self.lines_covered[line]
176+
assert start_indent > old_indent
177+
self.lines_covered[line] = (start_indent, is_typed)
178+
179+
# Visit the body, in case there are nested functions
180+
super().visit_func_def(defn)
181+
182+
183+
class LineCoverageReporter(AbstractReporter):
184+
"""Exact line coverage reporter.
185+
186+
This reporter writes a JSON dictionary with one field 'lines' to
187+
the file 'coverage.json' in the specified report directory. The
188+
value of that field is a dictionary which associates to each
189+
source file's absolute pathname the list of line numbers that
190+
belong to typed functions in that file.
191+
"""
192+
def __init__(self, reports: Reports, output_dir: str) -> None:
193+
super().__init__(reports, output_dir)
194+
self.lines_covered = {} # type: Dict[str, List[int]]
195+
196+
stats.ensure_dir_exists(output_dir)
197+
198+
def on_file(self, tree: MypyFile, type_map: Dict[Node, Type]) -> None:
199+
tree_source = open(tree.path).readlines()
200+
201+
coverage_visitor = LineCoverageVisitor(tree_source)
202+
tree.accept(coverage_visitor)
203+
204+
covered_lines = []
205+
for line_number, (_, typed) in enumerate(coverage_visitor.lines_covered):
206+
if typed:
207+
covered_lines.append(line_number + 1)
208+
209+
self.lines_covered[os.path.abspath(tree.path)] = covered_lines
210+
211+
def on_finish(self) -> None:
212+
with open(os.path.join(self.output_dir, 'coverage.json'), 'w') as f:
213+
json.dump({'lines': self.lines_covered}, f)
214+
215+
reporter_classes['linecoverage'] = LineCoverageReporter
216+
217+
108218
class OldHtmlReporter(AbstractReporter):
109219
"""Old HTML reporter.
110220

0 commit comments

Comments
 (0)