""" 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)