Skip to content

Commit a6107be

Browse files
committed
Add Cython coverage plugin
1 parent 5445684 commit a6107be

File tree

2 files changed

+317
-1
lines changed

2 files changed

+317
-1
lines changed

.coveragerc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
[run]
2-
plugins = Cython.Coverage
2+
plugins = coverage_plugin

coverage_plugin.py

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
"""
2+
A Cython plugin for coverage.py suitable for a spin/meson project.
3+
4+
This is derived from Cython's coverage plugin.
5+
6+
https://coverage.readthedocs.io/en/latest/api_plugin.html
7+
https://github.com/cython/cython/blob/master/Cython/Coverage.py
8+
"""
9+
import re
10+
from collections import defaultdict
11+
12+
from coverage.plugin import CoveragePlugin, FileTracer, FileReporter
13+
14+
from functools import cache
15+
from pathlib import Path
16+
17+
18+
root_dir = Path(__file__).parent
19+
build_dir = root_dir / 'build'
20+
build_install_dir = root_dir / 'build-install'
21+
src_dir = root_dir / 'src'
22+
23+
24+
def get_ninja_build_rules():
25+
"""Read all build rules from build.ninja."""
26+
rules = []
27+
with open(build_dir / 'build.ninja') as build_ninja:
28+
for line in build_ninja:
29+
line = line.strip()
30+
if line.startswith('build '):
31+
line = line[len('build '):]
32+
target, rule = line.split(': ')
33+
if target == 'PHONY':
34+
continue
35+
compiler, *srcfiles = rule.split(' ')
36+
# target is a path relative to the build directory. We will
37+
# turn that into an absolute path so that all paths in target
38+
# and srcfiles are absolute.
39+
target = str(build_dir / target)
40+
rule = (target, compiler, srcfiles)
41+
rules.append(rule)
42+
return rules
43+
44+
45+
def get_cython_build_rules():
46+
"""Get all Cython build rules."""
47+
cython_rules = []
48+
49+
for target, compiler, srcfiles in get_ninja_build_rules():
50+
if compiler == 'cython_COMPILER':
51+
assert target.endswith('.c')
52+
assert len(srcfiles) == 1 and srcfiles[0].endswith('.pyx')
53+
c_file = target
54+
[cython_file] = srcfiles
55+
cython_rules.append((c_file, cython_file))
56+
57+
return cython_rules
58+
59+
60+
@cache
61+
def parse_all_cfile_lines(exclude_lines):
62+
"""Parse all generated C files from the build directory."""
63+
#
64+
# Each .c file can include code generated from multiple Cython files (e.g.
65+
# because of .pxd files) being cimported. Each Cython file can contribute
66+
# to more than one .c file. Here we parse all .c files and then collect
67+
# together all the executable lines from all of the Cython files into a
68+
# dict like this:
69+
#
70+
# {filename: {lineno: linestr, ...}, ...}
71+
#
72+
# This function is cached because it only needs calling once and is
73+
# expensive.
74+
#
75+
all_code_lines = {}
76+
77+
for c_file, _ in get_cython_build_rules():
78+
79+
cfile_lines = parse_cfile_lines(c_file, exclude_lines)
80+
81+
for cython_file, line_map in cfile_lines.items():
82+
if cython_file == '(tree fragment)':
83+
continue
84+
elif cython_file in all_code_lines:
85+
# Possibly need to merge the lines?
86+
assert all_code_lines[cython_file] == line_map
87+
else:
88+
all_code_lines[cython_file] = line_map
89+
90+
return all_code_lines
91+
92+
93+
def parse_cfile_lines(c_file, exclude_lines):
94+
"""Parse a C file and extract all source file lines."""
95+
#
96+
# The C code has comments that refer to the Cython source files. We want to
97+
# parse those comments and match them up with the __Pyx_TraceLine() calls
98+
# in the C code. The __Pyx_TraceLine calls generate the trace events which
99+
# coverage feeds through to our plugin. If we can pair them up back to the
100+
# Cython source files then we measure coverage of the original Cython code.
101+
#
102+
match_source_path_line = re.compile(r' */[*] +"(.*)":([0-9]+)$').match
103+
match_current_code_line = re.compile(r' *[*] (.*) # <<<<<<+$').match
104+
match_comment_end = re.compile(r' *[*]/$').match
105+
match_trace_line = re.compile(r' *__Pyx_TraceLine\(([0-9]+),').match
106+
not_executable = re.compile(
107+
r'\s*c(?:type)?def\s+'
108+
r'(?:(?:public|external)\s+)?'
109+
r'(?:struct|union|enum|class)'
110+
r'(\s+[^:]+|)\s*:'
111+
).match
112+
113+
# Exclude e.g. # pragma: nocover
114+
exclude_pats = [f"(?:{regex})" for regex in exclude_lines]
115+
line_is_excluded = re.compile("|".join(exclude_pats)).search
116+
117+
code_lines = defaultdict(dict)
118+
executable_lines = defaultdict(set)
119+
current_filename = None
120+
121+
with open(c_file) as lines:
122+
lines = iter(lines)
123+
for line in lines:
124+
match = match_source_path_line(line)
125+
if not match:
126+
if '__Pyx_TraceLine(' in line and current_filename is not None:
127+
trace_line = match_trace_line(line)
128+
if trace_line:
129+
executable_lines[current_filename].add(int(trace_line.group(1)))
130+
continue
131+
filename, lineno = match.groups()
132+
current_filename = filename
133+
lineno = int(lineno)
134+
for comment_line in lines:
135+
match = match_current_code_line(comment_line)
136+
if match:
137+
code_line = match.group(1).rstrip()
138+
if not_executable(code_line):
139+
break
140+
if line_is_excluded(code_line):
141+
break
142+
code_lines[filename][lineno] = code_line
143+
break
144+
elif match_comment_end(comment_line):
145+
# unexpected comment format - false positive?
146+
break
147+
148+
exe_code_lines = {}
149+
150+
for fname in code_lines:
151+
# Remove lines that generated code but are not traceable.
152+
exe_lines = set(executable_lines.get(fname, ()))
153+
line_map = {n: c for n, c in code_lines[fname].items() if n in exe_lines}
154+
exe_code_lines[fname] = line_map
155+
156+
return exe_code_lines
157+
158+
159+
class Plugin(CoveragePlugin):
160+
"""
161+
A Cython coverage plugin for coverage.py suitable for a spin/meson project.
162+
"""
163+
def configure(self, config):
164+
"""Configure the plugin based on .coveragerc/pyproject.toml."""
165+
# Read the regular expressions from the coverage config
166+
self.exclude_lines = tuple(config.get_option("report:exclude_lines"))
167+
168+
def file_tracer(self, filename):
169+
"""Find a tracer for filename as reported in trace events."""
170+
# All sorts of paths come here and we discard them if they do not begin
171+
# with the path to this directory. Otherwise we return a tracer.
172+
srcfile = self.get_source_file_tracer(filename)
173+
174+
if srcfile is None:
175+
return None
176+
177+
return MyFileTracer(srcfile)
178+
179+
def file_reporter(self, filename):
180+
"""Return a file reporter for filename."""
181+
srcfile = self.get_source_file_reporter(filename)
182+
183+
return MyFileReporter(srcfile, exclude_lines=self.exclude_lines)
184+
185+
#
186+
# It is important not to mix up get_source_file_tracer and
187+
# get_source_file_reporter. On the face of it these two functions do the
188+
# same thing i.e. you give a path and they return a path relative to src.
189+
# However the inputs they receive are different. For get_source_file_tracer
190+
# the inputs are semi-garbage paths from coverage. In particular the Cython
191+
# trace events use src-relative paths but coverage merges those with CWD to
192+
# make paths that look absolute but do not really exist. The paths sent to
193+
# get_source_file_reporter come indirectly from
194+
# MyFileTracer.dynamic_source_filename which we control and so those paths
195+
# are real absolute paths to the source files in the src dir.
196+
#
197+
# We make sure that get_source_file_tracer is the only place that needs to
198+
# deal with garbage paths. It also needs to filter out all of the
199+
# irrelevant paths that coverage sends our way. Once that data cleaning is
200+
# done we can work with real paths sanely.
201+
#
202+
203+
def get_source_file_tracer(self, filename):
204+
"""Map from coverage path to srcpath."""
205+
path = Path(filename)
206+
207+
if build_install_dir in path.parents:
208+
# A .py file in the build-install directory.
209+
return self.get_source_file_build_install(path)
210+
elif root_dir in path.parents:
211+
# A .pyx file from the src directory. The path has src
212+
# stripped out and is not a real absolute path but it looks
213+
# like one. Remove the root prefix and then we have a path
214+
# relative to src_dir.
215+
return path.relative_to(root_dir)
216+
else:
217+
return None
218+
219+
def get_source_file_reporter(self, filename):
220+
"""Map from coverage path to srcpath."""
221+
path = Path(filename)
222+
223+
if build_install_dir in path.parents:
224+
# A .py file in the build-install directory.
225+
return self.get_source_file_build_install(path)
226+
else:
227+
# An absolute path to a file in src dir.
228+
return path.relative_to(src_dir)
229+
230+
def get_source_file_build_install(self, path):
231+
"""Get src-relative path for file in build-install directory."""
232+
# A .py file in the build-install directory. We want to find its
233+
# relative path from the src directory. One of path.parents is on
234+
# sys.path and the relpath from there is also the relpath from src.
235+
for pkgdir in path.parents:
236+
init = pkgdir / '__init__.py'
237+
if not init.exists():
238+
sys_path_dir = pkgdir
239+
return path.relative_to(sys_path_dir)
240+
assert False
241+
242+
243+
class MyFileTracer(FileTracer):
244+
"""File tracer for Cython or Python files (.pyx,.pxd,.py)."""
245+
246+
def __init__(self, srcpath):
247+
assert (src_dir / srcpath).exists()
248+
self.srcpath = srcpath
249+
250+
def source_filename(self):
251+
return self.srcpath
252+
253+
def has_dynamic_source_filename(self):
254+
return True
255+
256+
def dynamic_source_filename(self, filename, frame):
257+
"""Get filename from frame and return abspath to file."""
258+
# What is returned here needs to match MyFileReporter.filename
259+
srcpath = frame.f_code.co_filename
260+
return self.srcpath_to_abs(srcpath)
261+
262+
# This is called for every traced line. Cache it:
263+
@staticmethod
264+
@cache
265+
def srcpath_to_abs(srcpath):
266+
"""Get absolute path string from src-relative path."""
267+
abspath = (src_dir / srcpath).absolute()
268+
assert abspath.exists()
269+
return str(abspath)
270+
271+
272+
class MyFileReporter(FileReporter):
273+
"""File reporter for Cython or Python files (.pyx,.pxd,.py)."""
274+
275+
def __init__(self, srcpath, *, exclude_lines):
276+
abspath = (src_dir / srcpath)
277+
assert abspath.exists()
278+
279+
# filepath here needs to match dynamic_source_filename
280+
filepath = str(abspath)
281+
super().__init__(filepath)
282+
283+
self.srcpath = srcpath
284+
self.abspath = abspath
285+
self.exclude_lines = exclude_lines
286+
287+
def relative_filename(self):
288+
"""Path displayed in the coverage reports."""
289+
return str(self.srcpath)
290+
291+
def lines(self):
292+
"""Set of line numbers for possibly traceable lines."""
293+
if self.srcpath.suffix == '.py':
294+
line_map = self.get_pyfile_line_map()
295+
else:
296+
line_map = self.get_cyfile_line_map()
297+
return set(line_map)
298+
299+
def get_pyfile_line_map(self):
300+
"""Return all lines from .py file."""
301+
with open(self.abspath) as pyfile:
302+
line_map = dict(enumerate(pyfile))
303+
return line_map
304+
305+
def get_cyfile_line_map(self):
306+
"""Get all traceable code lines for this file."""
307+
srcpath = str(self.srcpath)
308+
all_line_maps = parse_all_cfile_lines(self.exclude_lines)
309+
line_map = all_line_maps[srcpath]
310+
return line_map
311+
312+
313+
def coverage_init(reg, options):
314+
plugin = Plugin()
315+
reg.add_configurer(plugin)
316+
reg.add_file_tracer(plugin)

0 commit comments

Comments
 (0)