Skip to content

Commit 9814860

Browse files
committed
Monkeypatch Cython for C parsing
1 parent bb331cf commit 9814860

File tree

1 file changed

+28
-161
lines changed

1 file changed

+28
-161
lines changed

coverage_plugin.py

+28-161
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def get_cython_build_rules():
5858

5959

6060
@cache
61-
def parse_all_cfile_lines(exclude_lines):
61+
def parse_all_cfile_lines():
6262
"""Parse all generated C files from the build directory."""
6363
#
6464
# Each .c file can include code generated from multiple Cython files (e.g.
@@ -76,7 +76,7 @@ def parse_all_cfile_lines(exclude_lines):
7676

7777
for c_file, _ in get_cython_build_rules():
7878

79-
cfile_lines = parse_cfile_lines(c_file, exclude_lines)
79+
cfile_lines = parse_cfile_lines(c_file)
8080

8181
for cython_file, line_map in cfile_lines.items():
8282
if cython_file == '(tree fragment)':
@@ -90,157 +90,38 @@ def parse_all_cfile_lines(exclude_lines):
9090
return all_code_lines
9191

9292

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
93+
def parse_cfile_lines(c_file):
94+
"""Use Cython's coverage plugin to parse the C code."""
95+
from Cython.Coverage import Plugin
96+
return Plugin()._parse_cfile_lines(c_file)
15797

15898

15999
class Plugin(CoveragePlugin):
160100
"""
161101
A Cython coverage plugin for coverage.py suitable for a spin/meson project.
162102
"""
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-
168103
def file_tracer(self, filename):
169104
"""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."""
205105
path = Path(filename)
206106

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:
107+
if path.suffix in ('.pyx', '.pxd') and root_dir in path.parents:
211108
# A .pyx file from the src directory. The path has src
212109
# stripped out and is not a real absolute path but it looks
213110
# like one. Remove the root prefix and then we have a path
214111
# relative to src_dir.
215-
return path.relative_to(root_dir)
112+
srcpath = path.relative_to(root_dir)
113+
return CyFileTracer(srcpath)
216114
else:
115+
# All sorts of paths come here and we reject them
217116
return None
218117

219-
def get_source_file_reporter(self, filename):
220-
"""Map from coverage path to srcpath."""
221-
path = Path(filename)
118+
def file_reporter(self, filename):
119+
"""Return a file reporter for filename."""
120+
srcfile = Path(filename).relative_to(src_dir)
121+
return CyFileReporter(srcfile)
222122

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):
123+
124+
class CyFileTracer(FileTracer):
244125
"""File tracer for Cython or Python files (.pyx,.pxd,.py)."""
245126

246127
def __init__(self, srcpath):
@@ -256,23 +137,24 @@ def has_dynamic_source_filename(self):
256137
def dynamic_source_filename(self, filename, frame):
257138
"""Get filename from frame and return abspath to file."""
258139
# What is returned here needs to match MyFileReporter.filename
259-
srcpath = frame.f_code.co_filename
260-
return self.srcpath_to_abs(srcpath)
140+
path = frame.f_code.co_filename
141+
return self.get_source_filename(path)
261142

262143
# This is called for every traced line. Cache it:
263144
@staticmethod
264145
@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)
146+
def get_source_filename(filename):
147+
"""Get src-relative path for filename from trace event."""
148+
path = src_dir / filename
149+
assert src_dir in path.parents
150+
assert path.exists()
151+
return str(path)
270152

271153

272-
class MyFileReporter(FileReporter):
154+
class CyFileReporter(FileReporter):
273155
"""File reporter for Cython or Python files (.pyx,.pxd,.py)."""
274156

275-
def __init__(self, srcpath, *, exclude_lines):
157+
def __init__(self, srcpath):
276158
abspath = (src_dir / srcpath)
277159
assert abspath.exists()
278160

@@ -282,32 +164,17 @@ def __init__(self, srcpath, *, exclude_lines):
282164

283165
self.srcpath = srcpath
284166
self.abspath = abspath
285-
self.exclude_lines = exclude_lines
286167

287168
def relative_filename(self):
288169
"""Path displayed in the coverage reports."""
289170
return str(self.srcpath)
290171

291172
def lines(self):
292173
"""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."""
307174
srcpath = str(self.srcpath)
308-
all_line_maps = parse_all_cfile_lines(self.exclude_lines)
175+
all_line_maps = parse_all_cfile_lines()
309176
line_map = all_line_maps[srcpath]
310-
return line_map
177+
return set(line_map)
311178

312179

313180
def coverage_init(reg, options):

0 commit comments

Comments
 (0)