From a6107be1888601304257a6c9055d75da997ef366 Mon Sep 17 00:00:00 2001 From: Oscar Benjamin Date: Thu, 15 Aug 2024 16:50:02 +0100 Subject: [PATCH 1/7] Add Cython coverage plugin --- .coveragerc | 2 +- coverage_plugin.py | 316 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 coverage_plugin.py 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/coverage_plugin.py b/coverage_plugin.py new file mode 100644 index 00000000..62eabe5c --- /dev/null +++ b/coverage_plugin.py @@ -0,0 +1,316 @@ +""" +A Cython plugin for coverage.py suitable for a spin/meson project. + +This is derived from Cython's coverage plugin. + +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 + + +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(exclude_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, exclude_lines) + + 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, exclude_lines): + """Parse a C file and extract all source file lines.""" + # + # The C code has comments that refer to the Cython source files. We want to + # parse those comments and match them up with the __Pyx_TraceLine() calls + # in the C code. The __Pyx_TraceLine calls generate the trace events which + # coverage feeds through to our plugin. If we can pair them up back to the + # Cython source files then we measure coverage of the original Cython code. + # + match_source_path_line = re.compile(r' */[*] +"(.*)":([0-9]+)$').match + match_current_code_line = re.compile(r' *[*] (.*) # <<<<<<+$').match + match_comment_end = re.compile(r' *[*]/$').match + match_trace_line = re.compile(r' *__Pyx_TraceLine\(([0-9]+),').match + not_executable = re.compile( + r'\s*c(?:type)?def\s+' + r'(?:(?:public|external)\s+)?' + r'(?:struct|union|enum|class)' + r'(\s+[^:]+|)\s*:' + ).match + + # Exclude e.g. # pragma: nocover + exclude_pats = [f"(?:{regex})" for regex in exclude_lines] + line_is_excluded = re.compile("|".join(exclude_pats)).search + + code_lines = defaultdict(dict) + executable_lines = defaultdict(set) + current_filename = None + + with open(c_file) as lines: + lines = iter(lines) + for line in lines: + match = match_source_path_line(line) + if not match: + if '__Pyx_TraceLine(' in line and current_filename is not None: + trace_line = match_trace_line(line) + if trace_line: + executable_lines[current_filename].add(int(trace_line.group(1))) + continue + filename, lineno = match.groups() + current_filename = filename + lineno = int(lineno) + for comment_line in lines: + match = match_current_code_line(comment_line) + if match: + code_line = match.group(1).rstrip() + if not_executable(code_line): + break + if line_is_excluded(code_line): + break + code_lines[filename][lineno] = code_line + break + elif match_comment_end(comment_line): + # unexpected comment format - false positive? + break + + exe_code_lines = {} + + for fname in code_lines: + # Remove lines that generated code but are not traceable. + exe_lines = set(executable_lines.get(fname, ())) + line_map = {n: c for n, c in code_lines[fname].items() if n in exe_lines} + exe_code_lines[fname] = line_map + + return exe_code_lines + + +class Plugin(CoveragePlugin): + """ + A Cython coverage plugin for coverage.py suitable for a spin/meson project. + """ + def configure(self, config): + """Configure the plugin based on .coveragerc/pyproject.toml.""" + # Read the regular expressions from the coverage config + self.exclude_lines = tuple(config.get_option("report:exclude_lines")) + + def file_tracer(self, filename): + """Find a tracer for filename as reported in trace events.""" + # All sorts of paths come here and we discard them if they do not begin + # with the path to this directory. Otherwise we return a tracer. + srcfile = self.get_source_file_tracer(filename) + + if srcfile is None: + return None + + return MyFileTracer(srcfile) + + def file_reporter(self, filename): + """Return a file reporter for filename.""" + srcfile = self.get_source_file_reporter(filename) + + return MyFileReporter(srcfile, exclude_lines=self.exclude_lines) + + # + # It is important not to mix up get_source_file_tracer and + # get_source_file_reporter. On the face of it these two functions do the + # same thing i.e. you give a path and they return a path relative to src. + # However the inputs they receive are different. For get_source_file_tracer + # the inputs are semi-garbage paths from coverage. In particular the Cython + # trace events use src-relative paths but coverage merges those with CWD to + # make paths that look absolute but do not really exist. The paths sent to + # get_source_file_reporter come indirectly from + # MyFileTracer.dynamic_source_filename which we control and so those paths + # are real absolute paths to the source files in the src dir. + # + # We make sure that get_source_file_tracer is the only place that needs to + # deal with garbage paths. It also needs to filter out all of the + # irrelevant paths that coverage sends our way. Once that data cleaning is + # done we can work with real paths sanely. + # + + def get_source_file_tracer(self, filename): + """Map from coverage path to srcpath.""" + path = Path(filename) + + if build_install_dir in path.parents: + # A .py file in the build-install directory. + return self.get_source_file_build_install(path) + elif 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. + return path.relative_to(root_dir) + else: + return None + + def get_source_file_reporter(self, filename): + """Map from coverage path to srcpath.""" + path = Path(filename) + + if build_install_dir in path.parents: + # A .py file in the build-install directory. + return self.get_source_file_build_install(path) + else: + # An absolute path to a file in src dir. + return path.relative_to(src_dir) + + def get_source_file_build_install(self, path): + """Get src-relative path for file in build-install directory.""" + # A .py file in the build-install directory. We want to find its + # relative path from the src directory. One of path.parents is on + # sys.path and the relpath from there is also the relpath from src. + for pkgdir in path.parents: + init = pkgdir / '__init__.py' + if not init.exists(): + sys_path_dir = pkgdir + return path.relative_to(sys_path_dir) + assert False + + +class MyFileTracer(FileTracer): + """File tracer for Cython or Python files (.pyx,.pxd,.py).""" + + 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 MyFileReporter.filename + srcpath = frame.f_code.co_filename + return self.srcpath_to_abs(srcpath) + + # This is called for every traced line. Cache it: + @staticmethod + @cache + def srcpath_to_abs(srcpath): + """Get absolute path string from src-relative path.""" + abspath = (src_dir / srcpath).absolute() + assert abspath.exists() + return str(abspath) + + +class MyFileReporter(FileReporter): + """File reporter for Cython or Python files (.pyx,.pxd,.py).""" + + def __init__(self, srcpath, *, exclude_lines): + abspath = (src_dir / srcpath) + assert abspath.exists() + + # filepath here needs to match dynamic_source_filename + filepath = str(abspath) + super().__init__(filepath) + + self.srcpath = srcpath + self.abspath = abspath + self.exclude_lines = exclude_lines + + 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.""" + if self.srcpath.suffix == '.py': + line_map = self.get_pyfile_line_map() + else: + line_map = self.get_cyfile_line_map() + return set(line_map) + + def get_pyfile_line_map(self): + """Return all lines from .py file.""" + with open(self.abspath) as pyfile: + line_map = dict(enumerate(pyfile)) + return line_map + + def get_cyfile_line_map(self): + """Get all traceable code lines for this file.""" + srcpath = str(self.srcpath) + all_line_maps = parse_all_cfile_lines(self.exclude_lines) + line_map = all_line_maps[srcpath] + return line_map + + +def coverage_init(reg, options): + plugin = Plugin() + reg.add_configurer(plugin) + reg.add_file_tracer(plugin) From 4d12d36ff477c3facbeada240bf404d3543cd47d Mon Sep 17 00:00:00 2001 From: Oscar Benjamin Date: Thu, 15 Aug 2024 17:42:27 +0100 Subject: [PATCH 2/7] Update bin/coverage.sh to use meson --- .github/workflows/buildwheel.yml | 6 ++--- bin/coverage.sh | 40 ++++++-------------------------- 2 files changed, 9 insertions(+), 37 deletions(-) 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..057fb123 100755 --- a/bin/coverage.sh +++ b/bin/coverage.sh @@ -1,42 +1,16 @@ #!/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 # -# - 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 configure build -Dcoverage=true +spin run -- coverage run -m flint.test +coverage report -m coverage html From 1150eb0b587ba16d1f37ff7dce0710fa9864c5e7 Mon Sep 17 00:00:00 2001 From: Oscar Benjamin Date: Thu, 15 Aug 2024 17:47:14 +0100 Subject: [PATCH 3/7] Fix coverage.sh --- bin/coverage.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/coverage.sh b/bin/coverage.sh index 057fb123..d0b9775f 100755 --- a/bin/coverage.sh +++ b/bin/coverage.sh @@ -10,7 +10,7 @@ # set -o errexit -meson configure build -Dcoverage=true +meson setup build -Dcoverage=true spin run -- coverage run -m flint.test coverage report -m coverage html From b2ebde3f1e662883788e787205728b1b4f389e92 Mon Sep 17 00:00:00 2001 From: Oscar Benjamin Date: Thu, 15 Aug 2024 18:05:05 +0100 Subject: [PATCH 4/7] Add ninja to requirements --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) 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 From bb331cf72d0460a40eed0a178be98f34c02c4133 Mon Sep 17 00:00:00 2001 From: Oscar Benjamin Date: Thu, 15 Aug 2024 18:16:27 +0100 Subject: [PATCH 5/7] Pass arguments to flint.test in bin/coverage.sh --- bin/coverage.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bin/coverage.sh b/bin/coverage.sh index d0b9775f..1c729377 100755 --- a/bin/coverage.sh +++ b/bin/coverage.sh @@ -8,9 +8,14 @@ # # 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 meson setup build -Dcoverage=true -spin run -- coverage run -m flint.test -coverage report -m +spin run -- coverage run -m flint.test $@ +coverage report -m --sort=cover coverage html From 98148600c9eb71045ec639e7d48085b680aca893 Mon Sep 17 00:00:00 2001 From: Oscar Benjamin Date: Thu, 15 Aug 2024 20:51:14 +0100 Subject: [PATCH 6/7] Monkeypatch Cython for C parsing --- coverage_plugin.py | 189 +++++++-------------------------------------- 1 file changed, 28 insertions(+), 161 deletions(-) diff --git a/coverage_plugin.py b/coverage_plugin.py index 62eabe5c..0c4203dd 100644 --- a/coverage_plugin.py +++ b/coverage_plugin.py @@ -58,7 +58,7 @@ def get_cython_build_rules(): @cache -def parse_all_cfile_lines(exclude_lines): +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. @@ -76,7 +76,7 @@ def parse_all_cfile_lines(exclude_lines): for c_file, _ in get_cython_build_rules(): - cfile_lines = parse_cfile_lines(c_file, exclude_lines) + cfile_lines = parse_cfile_lines(c_file) for cython_file, line_map in cfile_lines.items(): if cython_file == '(tree fragment)': @@ -90,157 +90,38 @@ def parse_all_cfile_lines(exclude_lines): return all_code_lines -def parse_cfile_lines(c_file, exclude_lines): - """Parse a C file and extract all source file lines.""" - # - # The C code has comments that refer to the Cython source files. We want to - # parse those comments and match them up with the __Pyx_TraceLine() calls - # in the C code. The __Pyx_TraceLine calls generate the trace events which - # coverage feeds through to our plugin. If we can pair them up back to the - # Cython source files then we measure coverage of the original Cython code. - # - match_source_path_line = re.compile(r' */[*] +"(.*)":([0-9]+)$').match - match_current_code_line = re.compile(r' *[*] (.*) # <<<<<<+$').match - match_comment_end = re.compile(r' *[*]/$').match - match_trace_line = re.compile(r' *__Pyx_TraceLine\(([0-9]+),').match - not_executable = re.compile( - r'\s*c(?:type)?def\s+' - r'(?:(?:public|external)\s+)?' - r'(?:struct|union|enum|class)' - r'(\s+[^:]+|)\s*:' - ).match - - # Exclude e.g. # pragma: nocover - exclude_pats = [f"(?:{regex})" for regex in exclude_lines] - line_is_excluded = re.compile("|".join(exclude_pats)).search - - code_lines = defaultdict(dict) - executable_lines = defaultdict(set) - current_filename = None - - with open(c_file) as lines: - lines = iter(lines) - for line in lines: - match = match_source_path_line(line) - if not match: - if '__Pyx_TraceLine(' in line and current_filename is not None: - trace_line = match_trace_line(line) - if trace_line: - executable_lines[current_filename].add(int(trace_line.group(1))) - continue - filename, lineno = match.groups() - current_filename = filename - lineno = int(lineno) - for comment_line in lines: - match = match_current_code_line(comment_line) - if match: - code_line = match.group(1).rstrip() - if not_executable(code_line): - break - if line_is_excluded(code_line): - break - code_lines[filename][lineno] = code_line - break - elif match_comment_end(comment_line): - # unexpected comment format - false positive? - break - - exe_code_lines = {} - - for fname in code_lines: - # Remove lines that generated code but are not traceable. - exe_lines = set(executable_lines.get(fname, ())) - line_map = {n: c for n, c in code_lines[fname].items() if n in exe_lines} - exe_code_lines[fname] = line_map - - return exe_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 Cython coverage plugin for coverage.py suitable for a spin/meson project. """ - def configure(self, config): - """Configure the plugin based on .coveragerc/pyproject.toml.""" - # Read the regular expressions from the coverage config - self.exclude_lines = tuple(config.get_option("report:exclude_lines")) - def file_tracer(self, filename): """Find a tracer for filename as reported in trace events.""" - # All sorts of paths come here and we discard them if they do not begin - # with the path to this directory. Otherwise we return a tracer. - srcfile = self.get_source_file_tracer(filename) - - if srcfile is None: - return None - - return MyFileTracer(srcfile) - - def file_reporter(self, filename): - """Return a file reporter for filename.""" - srcfile = self.get_source_file_reporter(filename) - - return MyFileReporter(srcfile, exclude_lines=self.exclude_lines) - - # - # It is important not to mix up get_source_file_tracer and - # get_source_file_reporter. On the face of it these two functions do the - # same thing i.e. you give a path and they return a path relative to src. - # However the inputs they receive are different. For get_source_file_tracer - # the inputs are semi-garbage paths from coverage. In particular the Cython - # trace events use src-relative paths but coverage merges those with CWD to - # make paths that look absolute but do not really exist. The paths sent to - # get_source_file_reporter come indirectly from - # MyFileTracer.dynamic_source_filename which we control and so those paths - # are real absolute paths to the source files in the src dir. - # - # We make sure that get_source_file_tracer is the only place that needs to - # deal with garbage paths. It also needs to filter out all of the - # irrelevant paths that coverage sends our way. Once that data cleaning is - # done we can work with real paths sanely. - # - - def get_source_file_tracer(self, filename): - """Map from coverage path to srcpath.""" path = Path(filename) - if build_install_dir in path.parents: - # A .py file in the build-install directory. - return self.get_source_file_build_install(path) - elif root_dir in path.parents: + 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. - return path.relative_to(root_dir) + srcpath = path.relative_to(root_dir) + return CyFileTracer(srcpath) else: + # All sorts of paths come here and we reject them return None - def get_source_file_reporter(self, filename): - """Map from coverage path to srcpath.""" - path = Path(filename) + def file_reporter(self, filename): + """Return a file reporter for filename.""" + srcfile = Path(filename).relative_to(src_dir) + return CyFileReporter(srcfile) - if build_install_dir in path.parents: - # A .py file in the build-install directory. - return self.get_source_file_build_install(path) - else: - # An absolute path to a file in src dir. - return path.relative_to(src_dir) - - def get_source_file_build_install(self, path): - """Get src-relative path for file in build-install directory.""" - # A .py file in the build-install directory. We want to find its - # relative path from the src directory. One of path.parents is on - # sys.path and the relpath from there is also the relpath from src. - for pkgdir in path.parents: - init = pkgdir / '__init__.py' - if not init.exists(): - sys_path_dir = pkgdir - return path.relative_to(sys_path_dir) - assert False - - -class MyFileTracer(FileTracer): + +class CyFileTracer(FileTracer): """File tracer for Cython or Python files (.pyx,.pxd,.py).""" def __init__(self, srcpath): @@ -256,23 +137,24 @@ def has_dynamic_source_filename(self): def dynamic_source_filename(self, filename, frame): """Get filename from frame and return abspath to file.""" # What is returned here needs to match MyFileReporter.filename - srcpath = frame.f_code.co_filename - return self.srcpath_to_abs(srcpath) + path = frame.f_code.co_filename + return self.get_source_filename(path) # This is called for every traced line. Cache it: @staticmethod @cache - def srcpath_to_abs(srcpath): - """Get absolute path string from src-relative path.""" - abspath = (src_dir / srcpath).absolute() - assert abspath.exists() - return str(abspath) + 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 MyFileReporter(FileReporter): +class CyFileReporter(FileReporter): """File reporter for Cython or Python files (.pyx,.pxd,.py).""" - def __init__(self, srcpath, *, exclude_lines): + def __init__(self, srcpath): abspath = (src_dir / srcpath) assert abspath.exists() @@ -282,7 +164,6 @@ def __init__(self, srcpath, *, exclude_lines): self.srcpath = srcpath self.abspath = abspath - self.exclude_lines = exclude_lines def relative_filename(self): """Path displayed in the coverage reports.""" @@ -290,24 +171,10 @@ def relative_filename(self): def lines(self): """Set of line numbers for possibly traceable lines.""" - if self.srcpath.suffix == '.py': - line_map = self.get_pyfile_line_map() - else: - line_map = self.get_cyfile_line_map() - return set(line_map) - - def get_pyfile_line_map(self): - """Return all lines from .py file.""" - with open(self.abspath) as pyfile: - line_map = dict(enumerate(pyfile)) - return line_map - - def get_cyfile_line_map(self): - """Get all traceable code lines for this file.""" srcpath = str(self.srcpath) - all_line_maps = parse_all_cfile_lines(self.exclude_lines) + all_line_maps = parse_all_cfile_lines() line_map = all_line_maps[srcpath] - return line_map + return set(line_map) def coverage_init(reg, options): From 3c51217be07090e403c3d4b6ba447fd98aaf6d53 Mon Sep 17 00:00:00 2001 From: Oscar Benjamin Date: Thu, 15 Aug 2024 21:04:00 +0100 Subject: [PATCH 7/7] Tweaks in coverage_plugin --- coverage_plugin.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/coverage_plugin.py b/coverage_plugin.py index 0c4203dd..8382dc26 100644 --- a/coverage_plugin.py +++ b/coverage_plugin.py @@ -1,7 +1,10 @@ """ A Cython plugin for coverage.py suitable for a spin/meson project. -This is derived from Cython's coverage plugin. +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 @@ -15,6 +18,7 @@ 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' @@ -97,11 +101,10 @@ def parse_cfile_lines(c_file): class Plugin(CoveragePlugin): - """ - A Cython coverage plugin for coverage.py suitable for a spin/meson project. - """ + """A coverage plugin for a spin/meson project with Cython code.""" + def file_tracer(self, filename): - """Find a tracer for filename as reported in trace events.""" + """Find a tracer for filename to handle trace events.""" path = Path(filename) if path.suffix in ('.pyx', '.pxd') and root_dir in path.parents: @@ -122,7 +125,7 @@ def file_reporter(self, filename): class CyFileTracer(FileTracer): - """File tracer for Cython or Python files (.pyx,.pxd,.py).""" + """File tracer for Cython files (.pyx,.pxd).""" def __init__(self, srcpath): assert (src_dir / srcpath).exists() @@ -136,7 +139,7 @@ def has_dynamic_source_filename(self): def dynamic_source_filename(self, filename, frame): """Get filename from frame and return abspath to file.""" - # What is returned here needs to match MyFileReporter.filename + # What is returned here needs to match CyFileReporter.filename path = frame.f_code.co_filename return self.get_source_filename(path) @@ -159,11 +162,9 @@ def __init__(self, srcpath): assert abspath.exists() # filepath here needs to match dynamic_source_filename - filepath = str(abspath) - super().__init__(filepath) + super().__init__(str(abspath)) self.srcpath = srcpath - self.abspath = abspath def relative_filename(self): """Path displayed in the coverage reports."""