-
Notifications
You must be signed in to change notification settings - Fork 27
/
coverage_plugin.py
184 lines (143 loc) · 5.96 KB
/
coverage_plugin.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
"""
A Cython plugin for coverage.py suitable for a spin/meson project.
This follows the same general approach as Cython's coverage plugin and uses the
Cython plugin for parsing the C files. The difference here is that files are
laid out very differently in a meson project. Assuming meson makes it a lot
easier to find all the C files because we can just parse the build.ninja file.
https://coverage.readthedocs.io/en/latest/api_plugin.html
https://github.com/cython/cython/blob/master/Cython/Coverage.py
"""
import re
from collections import defaultdict
from coverage.plugin import CoveragePlugin, FileTracer, FileReporter
from functools import cache
from pathlib import Path
# Paths used by spin/meson in a src-layout:
root_dir = Path(__file__).parent
build_dir = root_dir / 'build'
build_install_dir = root_dir / 'build-install'
src_dir = root_dir / 'src'
def get_ninja_build_rules():
"""Read all build rules from build.ninja."""
rules = []
with open(build_dir / 'build.ninja') as build_ninja:
for line in build_ninja:
line = line.strip()
if line.startswith('build '):
line = line[len('build '):]
target, rule = line.split(': ')
if target == 'PHONY':
continue
compiler, *srcfiles = rule.split(' ')
# target is a path relative to the build directory. We will
# turn that into an absolute path so that all paths in target
# and srcfiles are absolute.
target = str(build_dir / target)
rule = (target, compiler, srcfiles)
rules.append(rule)
return rules
def get_cython_build_rules():
"""Get all Cython build rules."""
cython_rules = []
for target, compiler, srcfiles in get_ninja_build_rules():
if compiler == 'cython_COMPILER':
assert target.endswith('.c')
assert len(srcfiles) == 1 and srcfiles[0].endswith('.pyx')
c_file = target
[cython_file] = srcfiles
cython_rules.append((c_file, cython_file))
return cython_rules
@cache
def parse_all_cfile_lines():
"""Parse all generated C files from the build directory."""
#
# Each .c file can include code generated from multiple Cython files (e.g.
# because of .pxd files) being cimported. Each Cython file can contribute
# to more than one .c file. Here we parse all .c files and then collect
# together all the executable lines from all of the Cython files into a
# dict like this:
#
# {filename: {lineno: linestr, ...}, ...}
#
# This function is cached because it only needs calling once and is
# expensive.
#
all_code_lines = {}
for c_file, _ in get_cython_build_rules():
cfile_lines = parse_cfile_lines(c_file)
for cython_file, line_map in cfile_lines.items():
if cython_file == '(tree fragment)':
continue
elif cython_file in all_code_lines:
# Possibly need to merge the lines?
assert all_code_lines[cython_file] == line_map
else:
all_code_lines[cython_file] = line_map
return all_code_lines
def parse_cfile_lines(c_file):
"""Use Cython's coverage plugin to parse the C code."""
from Cython.Coverage import Plugin
return Plugin()._parse_cfile_lines(c_file)
class Plugin(CoveragePlugin):
"""A coverage plugin for a spin/meson project with Cython code."""
def file_tracer(self, filename):
"""Find a tracer for filename to handle trace events."""
path = Path(filename)
if path.suffix in ('.pyx', '.pxd') and root_dir in path.parents:
# A .pyx file from the src directory. The path has src
# stripped out and is not a real absolute path but it looks
# like one. Remove the root prefix and then we have a path
# relative to src_dir.
srcpath = path.relative_to(root_dir)
return CyFileTracer(srcpath)
else:
# All sorts of paths come here and we reject them
return None
def file_reporter(self, filename):
"""Return a file reporter for filename."""
srcfile = Path(filename).relative_to(src_dir)
return CyFileReporter(srcfile)
class CyFileTracer(FileTracer):
"""File tracer for Cython files (.pyx,.pxd)."""
def __init__(self, srcpath):
assert (src_dir / srcpath).exists()
self.srcpath = srcpath
def source_filename(self):
return self.srcpath
def has_dynamic_source_filename(self):
return True
def dynamic_source_filename(self, filename, frame):
"""Get filename from frame and return abspath to file."""
# What is returned here needs to match CyFileReporter.filename
path = frame.f_code.co_filename
return self.get_source_filename(path)
# This is called for every traced line. Cache it:
@staticmethod
@cache
def get_source_filename(filename):
"""Get src-relative path for filename from trace event."""
path = src_dir / filename
assert src_dir in path.parents
assert path.exists()
return str(path)
class CyFileReporter(FileReporter):
"""File reporter for Cython or Python files (.pyx,.pxd,.py)."""
def __init__(self, srcpath):
abspath = (src_dir / srcpath)
assert abspath.exists()
# filepath here needs to match dynamic_source_filename
super().__init__(str(abspath))
self.srcpath = srcpath
def relative_filename(self):
"""Path displayed in the coverage reports."""
return str(self.srcpath)
def lines(self):
"""Set of line numbers for possibly traceable lines."""
srcpath = str(self.srcpath)
all_line_maps = parse_all_cfile_lines()
line_map = all_line_maps[srcpath]
return set(line_map)
def coverage_init(reg, options):
plugin = Plugin()
reg.add_configurer(plugin)
reg.add_file_tracer(plugin)