Skip to content

Add Cython coverage plugin #188

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Aug 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
[run]
plugins = Cython.Coverage
plugins = coverage_plugin
6 changes: 2 additions & 4 deletions .github/workflows/buildwheel.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
43 changes: 11 additions & 32 deletions bin/coverage.sh
Original file line number Diff line number Diff line change
@@ -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
184 changes: 184 additions & 0 deletions coverage_plugin.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
cython
ninja
spin
meson
meson-python
Expand Down
Loading