|
2 | 2 |
|
3 | 3 | from abc import ABCMeta, abstractmethod
|
4 | 4 | import cgi
|
| 5 | +import json |
5 | 6 | import os
|
6 | 7 | import shutil
|
7 | 8 | import tokenize
|
8 | 9 |
|
9 |
| -from typing import Callable, Dict, List, Tuple, cast |
| 10 | +from typing import Callable, Dict, List, Optional, Tuple, cast |
10 | 11 |
|
11 | 12 | from mypy.nodes import MypyFile, Node, FuncDef
|
12 | 13 | from mypy import stats
|
@@ -105,6 +106,115 @@ def on_finish(self) -> None:
|
105 | 106 | reporter_classes['linecount'] = LineCountReporter
|
106 | 107 |
|
107 | 108 |
|
| 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 | + |
108 | 218 | class OldHtmlReporter(AbstractReporter):
|
109 | 219 | """Old HTML reporter.
|
110 | 220 |
|
|
0 commit comments