Skip to content

Commit 23e1ed3

Browse files
Merge pull request #188 from oscarbenjamin/pr_coverage_plugin
Add Cython coverage plugin
2 parents 5445684 + 3c51217 commit 23e1ed3

File tree

5 files changed

+199
-37
lines changed

5 files changed

+199
-37
lines changed

.coveragerc

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
[run]
2-
plugins = Cython.Coverage
2+
plugins = coverage_plugin

.github/workflows/buildwheel.yml

+2-4
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,9 @@ jobs:
149149
python-version: '3.12'
150150
- run: sudo apt-get update
151151
- run: sudo apt-get install libflint-dev
152-
- run: pip install cython setuptools coverage
152+
- run: pip install git+https://github.com/oscarbenjamin/cython.git@pr_relative_paths
153+
- run: pip install -r requirements-dev.txt
153154
- run: bin/coverage.sh
154-
env:
155-
PYTHONPATH: src
156-
- run: coverage report --sort=cover
157155

158156
# Run SymPy test suite against python-flint master
159157
test_sympy:

bin/coverage.sh

+11-32
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,21 @@
11
#!/bin/bash
22
#
3-
# Note: cython's Cython/Coverage.py fails for pyx files that are included in
4-
# other pyx files. This gives the following error:
3+
# This needs a patched Cython:
54
#
6-
# $ coverage report -m
7-
# Plugin 'Cython.Coverage.Plugin' did not provide a file reporter for
8-
# '.../python-flint/src/flint/fmpz.pyx'.
5+
# pip install git+https://github.com/oscarbenjamin/cython.git@pr_relative_paths
96
#
10-
# A patch to the file is needed:
7+
# That patch has been submitted as a pull request:
118
#
12-
# --- Coverage.py.backup 2022-12-09 17:36:35.387690467 +0000
13-
# +++ Coverage.py 2022-12-09 17:08:06.282516837 +0000
14-
# @@ -172,7 +172,9 @@ class Plugin(CoveragePlugin):
15-
# else:
16-
# c_file, _ = self._find_source_files(filename)
17-
# if not c_file:
18-
# - return None
19-
# + c_file = os.path.join(os.path.dirname(filename), 'pyflint.c')
20-
# + if not os.path.exists(c_file):
21-
# + return None
22-
# rel_file_path, code = self._read_source_lines(c_file, filename)
23-
# if code is None:
24-
# return None # no source found
9+
# https://github.com/cython/cython/pull/6341
2510
#
11+
# Arguments to this script are passed to python -m flint.test e.g. to skip
12+
# doctests and run in quiet mode:
13+
#
14+
# bin/coverage.sh -qt
2615
#
27-
2816
set -o errexit
2917

30-
source bin/activate
31-
32-
export PYTHON_FLINT_COVERAGE=true
33-
34-
# Force a rebuild of everything with coverage tracing enabled:
35-
# touch src/flint/flintlib/*
36-
37-
python setup.py build_ext --inplace
38-
39-
coverage run -m flint.test $@
40-
41-
#coverage report -m
18+
meson setup build -Dcoverage=true
19+
spin run -- coverage run -m flint.test $@
20+
coverage report -m --sort=cover
4221
coverage html

coverage_plugin.py

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

requirements-dev.txt

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
cython
2+
ninja
23
spin
34
meson
45
meson-python

0 commit comments

Comments
 (0)