diff --git a/.coveragerc b/.coveragerc index d9a48b4b..a72fd709 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,2 @@ [run] -plugins = Cython.Coverage +plugins = coverage_plugin diff --git a/.github/workflows/buildwheel.yml b/.github/workflows/buildwheel.yml index 90bf7810..a285cfed 100644 --- a/.github/workflows/buildwheel.yml +++ b/.github/workflows/buildwheel.yml @@ -149,11 +149,9 @@ jobs: python-version: '3.12' - run: sudo apt-get update - run: sudo apt-get install libflint-dev - - run: pip install cython setuptools coverage + - run: pip install git+https://github.com/oscarbenjamin/cython.git@pr_relative_paths + - run: pip install -r requirements-dev.txt - run: bin/coverage.sh - env: - PYTHONPATH: src - - run: coverage report --sort=cover # Run SymPy test suite against python-flint master test_sympy: diff --git a/bin/coverage.sh b/bin/coverage.sh index 65f189a8..1c729377 100755 --- a/bin/coverage.sh +++ b/bin/coverage.sh @@ -1,42 +1,21 @@ #!/bin/bash # -# Note: cython's Cython/Coverage.py fails for pyx files that are included in -# other pyx files. This gives the following error: +# This needs a patched Cython: # -# $ coverage report -m -# Plugin 'Cython.Coverage.Plugin' did not provide a file reporter for -# '.../python-flint/src/flint/fmpz.pyx'. +# pip install git+https://github.com/oscarbenjamin/cython.git@pr_relative_paths # -# A patch to the file is needed: +# That patch has been submitted as a pull request: # -# --- Coverage.py.backup 2022-12-09 17:36:35.387690467 +0000 -# +++ Coverage.py 2022-12-09 17:08:06.282516837 +0000 -# @@ -172,7 +172,9 @@ class Plugin(CoveragePlugin): -# else: -# c_file, _ = self._find_source_files(filename) -# if not c_file: -# - return None -# + c_file = os.path.join(os.path.dirname(filename), 'pyflint.c') -# + if not os.path.exists(c_file): -# + return None -# rel_file_path, code = self._read_source_lines(c_file, filename) -# if code is None: -# return None # no source found +# https://github.com/cython/cython/pull/6341 # +# Arguments to this script are passed to python -m flint.test e.g. to skip +# doctests and run in quiet mode: +# +# bin/coverage.sh -qt # - set -o errexit -source bin/activate - -export PYTHON_FLINT_COVERAGE=true - -# Force a rebuild of everything with coverage tracing enabled: -# touch src/flint/flintlib/* - -python setup.py build_ext --inplace - -coverage run -m flint.test $@ - -#coverage report -m +meson setup build -Dcoverage=true +spin run -- coverage run -m flint.test $@ +coverage report -m --sort=cover coverage html diff --git a/coverage_plugin.py b/coverage_plugin.py new file mode 100644 index 00000000..8382dc26 --- /dev/null +++ b/coverage_plugin.py @@ -0,0 +1,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) diff --git a/requirements-dev.txt b/requirements-dev.txt index 536a010f..1429700e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,5 @@ cython +ninja spin meson meson-python