2
A Cython plugin for coverage.py
4
Requires the coverage package at least in version 4.0 (which added the plugin API).
6
This plugin requires the generated C sources to be available, next to the extension module.
7
It parses the C file and reads the original source files from it, which are stored in C comments.
8
It then reports a source file to coverage.py when it hits one of its lines during line tracing.
10
Basically, Cython can (on request) emit explicit trace calls into the C code that it generates,
11
and as a general human debugging helper, it always copies the current source code line
12
(and its surrounding context) into the C files before it generates code for that line, e.g.
16
/* "line_trace.pyx":147
17
* def cy_add_with_nogil(a,b):
18
* cdef int z, x=a, y=b # 1
19
* with nogil: # 2 # <<<<<<<<<<<<<<
21
* z += cy_add_nogil(x, y) # 4
23
__Pyx_TraceLine(147,6,1,__PYX_ERR(0, 147, __pyx_L4_error))
24
[C code generated for file line_trace.pyx, line 147, follows here]
26
The crux is that multiple source files can contribute code to a single C (or C++) file
27
(and thus, to a single extension module) besides the main module source file (.py/.pyx),
28
usually shared declaration files (.pxd) but also literally included files (.pxi).
30
Therefore, the coverage plugin doesn't actually try to look at the file that happened
31
to contribute the current source line for the trace call, but simply looks up the single
32
.c file from which the extension was compiled (which usually lies right next to it after
33
the build, having the same name), and parses the code copy comments from that .c file
34
to recover the original source files and their code as a line-to-file mapping.
36
That mapping is then used to report the ``__Pyx_TraceLine()`` calls to the coverage tool.
37
The plugin also reports the line of source code that it found in the C file to the coverage
38
tool to support annotated source representations. For this, again, it does not look at the
39
actual source files but only reports the source code that it found in the C code comments.
41
Apart from simplicity (read one file instead of finding and parsing many), part of the
42
reasoning here is that any line in the original sources for which there is no comment line
43
(and trace call) in the generated C code cannot count as executed, really, so the C code
44
comments are a very good source for coverage reporting. They already filter out purely
45
declarative code lines that do not contribute executable code, and such (missing) lines
46
can then be marked as excluded from coverage analysis.
53
from collections import defaultdict
55
from coverage.plugin import CoveragePlugin, FileTracer, FileReporter
56
from coverage.files import canonical_filename
58
from .Utils import find_root_package_dir, is_package_dir, is_cython_generated_file, open_source_file
61
from . import __version__
64
C_FILE_EXTENSIONS = ['.c', '.cpp', '.cc', '.cxx']
65
MODULE_FILE_EXTENSIONS = set(['.py', '.pyx', '.pxd'] + C_FILE_EXTENSIONS)
68
def _find_c_source(base_path):
69
file_exists = os.path.exists
70
for ext in C_FILE_EXTENSIONS:
71
file_name = base_path + ext
72
if file_exists(file_name):
77
def _find_dep_file_path(main_file, file_path, relative_path_search=False):
78
abs_path = os.path.abspath(file_path)
79
if not os.path.exists(abs_path) and (file_path.endswith('.pxi') or
80
relative_path_search):
82
rel_file_path = os.path.join(os.path.dirname(main_file), file_path)
83
if os.path.exists(rel_file_path):
84
abs_path = os.path.abspath(rel_file_path)
86
abs_no_ext = os.path.splitext(abs_path)[0]
87
file_no_ext, extension = os.path.splitext(file_path)
91
abs_no_ext = os.path.normpath(abs_no_ext)
92
file_no_ext = os.path.normpath(file_no_ext)
93
matching_paths = zip(reversed(abs_no_ext.split(os.sep)), reversed(file_no_ext.split(os.sep)))
94
for one, other in matching_paths:
98
matching_abs_path = os.path.splitext(main_file)[0] + extension
99
if os.path.exists(matching_abs_path):
100
return canonical_filename(matching_abs_path)
103
if not os.path.exists(abs_path):
104
for sys_path in sys.path:
105
test_path = os.path.realpath(os.path.join(sys_path, file_path))
106
if os.path.exists(test_path):
107
return canonical_filename(test_path)
108
return canonical_filename(abs_path)
111
def _offset_to_line(offset):
115
class Plugin(CoveragePlugin):
117
_file_path_map = None
121
_parsed_c_files = None
123
_excluded_lines_map = None
125
_excluded_line_patterns = ()
128
return [('Cython version', __version__)]
130
def configure(self, config):
133
self._excluded_line_patterns = config.get_option("report:exclude_lines")
135
def file_tracer(self, filename):
137
Try to find a C source file for a file path found by the tracer.
139
if filename.startswith('<') or filename.startswith('memory:'):
141
c_file = py_file = None
142
filename = canonical_filename(os.path.abspath(filename))
143
if self._c_files_map and filename in self._c_files_map:
144
c_file = self._c_files_map[filename][0]
147
c_file, py_file = self._find_source_files(filename)
157
_, code = self._read_source_lines(c_file, filename)
161
if self._file_path_map is None:
162
self._file_path_map = {}
163
return CythonModuleTracer(filename, py_file, c_file, self._c_files_map, self._file_path_map)
165
def file_reporter(self, filename):
172
filename = canonical_filename(os.path.abspath(filename))
173
if self._c_files_map and filename in self._c_files_map:
174
c_file, rel_file_path, code = self._c_files_map[filename]
176
c_file, _ = self._find_source_files(filename)
179
rel_file_path, code = self._read_source_lines(c_file, filename)
182
return CythonModuleReporter(
187
self._excluded_lines_map.get(rel_file_path, frozenset())
190
def _find_source_files(self, filename):
191
basename, ext = os.path.splitext(filename)
193
if ext in MODULE_FILE_EXTENSIONS:
197
platform_suffix = re.search(r'[.]cp[0-9]+-win[_a-z0-9]*$', basename, re.I)
199
basename = basename[:platform_suffix.start()]
202
platform_suffix = re.search(r'[.](?:cpython|pypy)-[0-9]+[-_a-z0-9]*$', basename, re.I)
204
basename = basename[:platform_suffix.start()]
209
self._find_c_source_files(os.path.dirname(filename), filename)
210
if filename in self._c_files_map:
211
return self._c_files_map[filename][0], None
216
c_file = filename if ext in C_FILE_EXTENSIONS else _find_c_source(basename)
219
package_root = find_root_package_dir.uncached(filename)
220
package_path = os.path.relpath(basename, package_root).split(os.path.sep)
221
if len(package_path) > 1:
222
test_basepath = os.path.join(os.path.dirname(filename), '.'.join(package_path))
223
c_file = _find_c_source(test_basepath)
225
py_source_file = None
227
py_source_file = os.path.splitext(c_file)[0] + '.py'
228
if not os.path.exists(py_source_file):
229
py_source_file = None
230
if not is_cython_generated_file(c_file, if_not_found=False):
231
if py_source_file and os.path.exists(c_file):
234
py_source_file = None
237
return c_file, py_source_file
239
def _find_c_source_files(self, dir_path, source_file):
241
Desperately parse all C files in the directory or its package parents
242
(not re-descending) to find the (included) source file in one of them.
244
if not os.path.isdir(dir_path):
246
splitext = os.path.splitext
247
for filename in os.listdir(dir_path):
248
ext = splitext(filename)[1].lower()
249
if ext in C_FILE_EXTENSIONS:
250
self._read_source_lines(os.path.join(dir_path, filename), source_file)
251
if source_file in self._c_files_map:
254
if is_package_dir(dir_path):
255
self._find_c_source_files(os.path.dirname(dir_path), source_file)
257
def _read_source_lines(self, c_file, sourcefile):
259
Parse a Cython generated C/C++ source file and find the executable lines.
260
Each executable line starts with a comment header that states source file
261
and line number, as well as the surrounding range of source code lines.
263
if self._parsed_c_files is None:
264
self._parsed_c_files = {}
265
if c_file in self._parsed_c_files:
266
code_lines = self._parsed_c_files[c_file]
268
code_lines = self._parse_cfile_lines(c_file)
269
self._parsed_c_files[c_file] = code_lines
271
if self._c_files_map is None:
272
self._c_files_map = {}
274
for filename, code in code_lines.items():
275
abs_path = _find_dep_file_path(c_file, filename,
276
relative_path_search=True)
277
self._c_files_map[abs_path] = (c_file, filename, code)
279
if sourcefile not in self._c_files_map:
281
return self._c_files_map[sourcefile][1:]
283
def _parse_cfile_lines(self, c_file):
285
Parse a C file and extract all source file lines that generated executable code.
287
match_source_path_line = re.compile(r' */[*] +"(.*)":([0-9]+)$').match
288
match_current_code_line = re.compile(r' *[*] (.*) # <<<<<<+$').match
289
match_comment_end = re.compile(r' *[*]/$').match
290
match_trace_line = re.compile(r' *__Pyx_TraceLine\(([0-9]+),').match
291
not_executable = re.compile(
292
r'\s*c(?:type)?def\s+'
293
r'(?:(?:public|external)\s+)?'
294
r'(?:struct|union|enum|class)'
297
if self._excluded_line_patterns:
298
line_is_excluded = re.compile("|".join(["(?:%s)" % regex for regex in self._excluded_line_patterns])).search
300
line_is_excluded = lambda line: False
302
code_lines = defaultdict(dict)
303
executable_lines = defaultdict(set)
304
current_filename = None
305
if self._excluded_lines_map is None:
306
self._excluded_lines_map = defaultdict(set)
308
with open(c_file, encoding='utf8') as lines:
311
match = match_source_path_line(line)
313
if '__Pyx_TraceLine(' in line and current_filename is not None:
314
trace_line = match_trace_line(line)
316
lineno = int(trace_line.group(1))
317
executable_lines[current_filename].add(lineno)
319
filename, lineno = match.groups()
320
current_filename = filename
322
for comment_line in lines:
323
match = match_current_code_line(comment_line)
325
code_line = match.group(1).rstrip()
326
if not_executable(code_line):
328
if line_is_excluded(code_line):
329
self._excluded_lines_map[filename].add(lineno)
331
code_lines[filename][lineno] = code_line
333
elif match_comment_end(comment_line):
338
for filename, lines in code_lines.items():
339
dead_lines = set(lines).difference(executable_lines.get(filename, ()))
340
for lineno in dead_lines:
345
class CythonModuleTracer(FileTracer):
347
Find the Python/Cython source file for a Cython module.
349
def __init__(self, module_file, py_file, c_file, c_files_map, file_path_map):
351
self.module_file = module_file
352
self.py_file = py_file
354
self._c_files_map = c_files_map
355
self._file_path_map = file_path_map
357
def has_dynamic_source_filename(self):
360
def dynamic_source_filename(self, filename, frame):
362
Determine source file path. Called by the function call tracer.
364
source_file = frame.f_code.co_filename
366
return self._file_path_map[source_file]
369
abs_path = _find_dep_file_path(filename, source_file)
371
if self.py_file and source_file[-3:].lower() == '.py':
373
self._file_path_map[source_file] = self.py_file
376
assert self._c_files_map is not None
377
if abs_path not in self._c_files_map:
378
self._c_files_map[abs_path] = (self.c_file, source_file, None)
379
self._file_path_map[source_file] = abs_path
383
class CythonModuleReporter(FileReporter):
385
Provide detailed trace information for one source file to coverage.py.
387
def __init__(self, c_file, source_file, rel_file_path, code, excluded_lines):
388
super().__init__(source_file)
389
self.name = rel_file_path
392
self._excluded_lines = excluded_lines
396
Return set of line numbers that are possibly executable.
398
return set(self._code)
400
def excluded_lines(self):
402
Return set of line numbers that are excluded from coverage.
404
return self._excluded_lines
406
def _iter_source_tokens(self):
408
for line_no, code_line in sorted(self._code.items()):
409
while line_no > current_line:
412
yield [('txt', code_line)]
417
Return the source code of the file as a string.
419
if os.path.exists(self.filename):
420
with open_source_file(self.filename) as f:
424
(tokens[0][1] if tokens else '')
425
for tokens in self._iter_source_tokens())
427
def source_token_lines(self):
429
Iterate over the source code tokens.
431
if os.path.exists(self.filename):
432
with open_source_file(self.filename) as f:
434
yield [('txt', line.rstrip('\n'))]
436
for line in self._iter_source_tokens():
437
yield [('txt', line)]
440
def coverage_init(reg, options):
442
reg.add_configurer(plugin)
443
reg.add_file_tracer(plugin)