summaryrefslogtreecommitdiff
path: root/Cython/Coverage.py
diff options
context:
space:
mode:
Diffstat (limited to 'Cython/Coverage.py')
-rw-r--r--Cython/Coverage.py111
1 files changed, 101 insertions, 10 deletions
diff --git a/Cython/Coverage.py b/Cython/Coverage.py
index 269896e13..147df8050 100644
--- a/Cython/Coverage.py
+++ b/Cython/Coverage.py
@@ -2,6 +2,48 @@
A Cython plugin for coverage.py
Requires the coverage package at least in version 4.0 (which added the plugin API).
+
+This plugin requires the generated C sources to be available, next to the extension module.
+It parses the C file and reads the original source files from it, which are stored in C comments.
+It then reports a source file to coverage.py when it hits one of its lines during line tracing.
+
+Basically, Cython can (on request) emit explicit trace calls into the C code that it generates,
+and as a general human debugging helper, it always copies the current source code line
+(and its surrounding context) into the C files before it generates code for that line, e.g.
+
+::
+
+ /* "line_trace.pyx":147
+ * def cy_add_with_nogil(a,b):
+ * cdef int z, x=a, y=b # 1
+ * with nogil: # 2 # <<<<<<<<<<<<<<
+ * z = 0 # 3
+ * z += cy_add_nogil(x, y) # 4
+ */
+ __Pyx_TraceLine(147,1,__PYX_ERR(0, 147, __pyx_L4_error))
+ [C code generated for file line_trace.pyx, line 147, follows here]
+
+The crux is that multiple source files can contribute code to a single C (or C++) file
+(and thus, to a single extension module) besides the main module source file (.py/.pyx),
+usually shared declaration files (.pxd) but also literally included files (.pxi).
+
+Therefore, the coverage plugin doesn't actually try to look at the file that happened
+to contribute the current source line for the trace call, but simply looks up the single
+.c file from which the extension was compiled (which usually lies right next to it after
+the build, having the same name), and parses the code copy comments from that .c file
+to recover the original source files and their code as a line-to-file mapping.
+
+That mapping is then used to report the ``__Pyx_TraceLine()`` calls to the coverage tool.
+The plugin also reports the line of source code that it found in the C file to the coverage
+tool to support annotated source representations. For this, again, it does not look at the
+actual source files but only reports the source code that it found in the C code comments.
+
+Apart from simplicity (read one file instead of finding and parsing many), part of the
+reasoning here is that any line in the original sources for which there is no comment line
+(and trace call) in the generated C code cannot count as executed, really, so the C code
+comments are a very good source for coverage reporting. They already filter out purely
+declarative code lines that do not contribute executable code, and such (missing) lines
+can then be marked as excluded from coverage analysis.
"""
from __future__ import absolute_import
@@ -14,7 +56,7 @@ from collections import defaultdict
from coverage.plugin import CoveragePlugin, FileTracer, FileReporter # requires coverage.py 4.0+
from coverage.files import canonical_filename
-from .Utils import find_root_package_dir, is_package_dir, open_source_file
+from .Utils import find_root_package_dir, is_package_dir, is_cython_generated_file, open_source_file
from . import __version__
@@ -41,6 +83,23 @@ def _find_dep_file_path(main_file, file_path, relative_path_search=False):
rel_file_path = os.path.join(os.path.dirname(main_file), file_path)
if os.path.exists(rel_file_path):
abs_path = os.path.abspath(rel_file_path)
+
+ abs_no_ext = os.path.splitext(abs_path)[0]
+ file_no_ext, extension = os.path.splitext(file_path)
+ # We check if the paths match by matching the directories in reverse order.
+ # pkg/module.pyx /long/absolute_path/bla/bla/site-packages/pkg/module.c should match.
+ # this will match the pairs: module-module and pkg-pkg. After which there is nothing left to zip.
+ abs_no_ext = os.path.normpath(abs_no_ext)
+ file_no_ext = os.path.normpath(file_no_ext)
+ matching_paths = zip(reversed(abs_no_ext.split(os.sep)), reversed(file_no_ext.split(os.sep)))
+ for one, other in matching_paths:
+ if one != other:
+ break
+ else: # No mismatches detected
+ matching_abs_path = os.path.splitext(main_file)[0] + extension
+ if os.path.exists(matching_abs_path):
+ return canonical_filename(matching_abs_path)
+
# search sys.path for external locations if a valid file hasn't been found
if not os.path.exists(abs_path):
for sys_path in sys.path:
@@ -57,10 +116,19 @@ class Plugin(CoveragePlugin):
_c_files_map = None
# map from parsed C files to their content
_parsed_c_files = None
+ # map from traced files to lines that are excluded from coverage
+ _excluded_lines_map = None
+ # list of regex patterns for lines to exclude
+ _excluded_line_patterns = ()
def sys_info(self):
return [('Cython version', __version__)]
+ def configure(self, config):
+ # Entry point for coverage "configurer".
+ # Read the regular expressions from the coverage config that match lines to be excluded from coverage.
+ self._excluded_line_patterns = config.get_option("report:exclude_lines")
+
def file_tracer(self, filename):
"""
Try to find a C source file for a file path found by the tracer.
@@ -108,7 +176,13 @@ class Plugin(CoveragePlugin):
rel_file_path, code = self._read_source_lines(c_file, filename)
if code is None:
return None # no source found
- return CythonModuleReporter(c_file, filename, rel_file_path, code)
+ return CythonModuleReporter(
+ c_file,
+ filename,
+ rel_file_path,
+ code,
+ self._excluded_lines_map.get(rel_file_path, frozenset())
+ )
def _find_source_files(self, filename):
basename, ext = os.path.splitext(filename)
@@ -150,12 +224,11 @@ class Plugin(CoveragePlugin):
py_source_file = os.path.splitext(c_file)[0] + '.py'
if not os.path.exists(py_source_file):
py_source_file = None
-
- try:
- with open(c_file, 'rb') as f:
- if b'/* Generated by Cython ' not in f.read(30):
- return None, None # not a Cython file
- except (IOError, OSError):
+ if not is_cython_generated_file(c_file, if_not_found=False):
+ if py_source_file and os.path.exists(c_file):
+ # if we did not generate the C file,
+ # then we probably also shouldn't care about the .py file.
+ py_source_file = None
c_file = None
return c_file, py_source_file
@@ -218,10 +291,16 @@ class Plugin(CoveragePlugin):
r'(?:struct|union|enum|class)'
r'(\s+[^:]+|)\s*:'
).match
+ if self._excluded_line_patterns:
+ line_is_excluded = re.compile("|".join(["(?:%s)" % regex for regex in self._excluded_line_patterns])).search
+ else:
+ line_is_excluded = lambda line: False
code_lines = defaultdict(dict)
executable_lines = defaultdict(set)
current_filename = None
+ if self._excluded_lines_map is None:
+ self._excluded_lines_map = defaultdict(set)
with open(c_file) as lines:
lines = iter(lines)
@@ -242,6 +321,9 @@ class Plugin(CoveragePlugin):
code_line = match.group(1).rstrip()
if not_executable(code_line):
break
+ if line_is_excluded(code_line):
+ self._excluded_lines_map[filename].add(lineno)
+ break
code_lines[filename][lineno] = code_line
break
elif match_comment_end(comment_line):
@@ -298,11 +380,12 @@ class CythonModuleReporter(FileReporter):
"""
Provide detailed trace information for one source file to coverage.py.
"""
- def __init__(self, c_file, source_file, rel_file_path, code):
+ def __init__(self, c_file, source_file, rel_file_path, code, excluded_lines):
super(CythonModuleReporter, self).__init__(source_file)
self.name = rel_file_path
self.c_file = c_file
self._code = code
+ self._excluded_lines = excluded_lines
def lines(self):
"""
@@ -310,6 +393,12 @@ class CythonModuleReporter(FileReporter):
"""
return set(self._code)
+ def excluded_lines(self):
+ """
+ Return set of line numbers that are excluded from coverage.
+ """
+ return self._excluded_lines
+
def _iter_source_tokens(self):
current_line = 1
for line_no, code_line in sorted(self._code.items()):
@@ -345,4 +434,6 @@ class CythonModuleReporter(FileReporter):
def coverage_init(reg, options):
- reg.add_file_tracer(Plugin())
+ plugin = Plugin()
+ reg.add_configurer(plugin)
+ reg.add_file_tracer(plugin)