Commit 29eb0238 authored by Stefan Behnel's avatar Stefan Behnel

implement coverage analysis support as a plugin for the coverage.py tool

parent 32d32d03
......@@ -5,6 +5,15 @@ Cython Changelog
Latest changes
==============
Features added
--------------
* Support for coverage.py 4.0+ can be enabled by adding the plugin
"Cython.Coverage" to the ".coveragerc" config file.
Bugs fixed
----------
0.22 (2015-02-11)
=================
......
"""
A Cython plugin for coverage.py
Requires the coverage package at least in version 4.0 (which added the plugin API).
"""
import re
import os.path
from collections import defaultdict
try:
from coverage.plugin import CoveragePlugin, FileTracer, FileReporter
except ImportError:
# version too old?
CoveragePlugin = FileTracer = FileReporter = object
from . import __version__
class Plugin(CoveragePlugin):
_c_files_map = None
def sys_info(self):
return [('Cython version', __version__)]
def file_tracer(self, filename):
"""
Try to find a C source file for a file path found by the tracer.
"""
c_file = py_file = None
filename = os.path.abspath(filename)
if self._c_files_map and filename in self._c_files_map:
c_file = self._c_files_map[filename][0]
if c_file is None:
c_file, py_file = self._find_source_files(filename)
if not c_file:
return None
try:
with open(c_file, 'rb') as f:
if b'/* Generated by Cython ' not in f.read(30):
return None # not a Cython file
except (IOError, OSError):
return None
# parse all source file paths and lines from C file
# to learn about all relevant source files right away (pyx/pxi/pxd)
# FIXME: this might already be too late if the first executed line
# is not from the main .pyx file but a file with a different
# name than the .c file (which prevents us from finding the
# .c file)
self._parse_lines(c_file, filename)
return CythonModuleTracer(filename, py_file, c_file, self._c_files_map)
def file_reporter(self, filename):
if os.path.splitext(filename)[1].lower() not in ('.pyx', '.pxi', '.pxd'):
return None # let coverage.py handle it (e.g. .py files)
filename = os.path.abspath(filename)
if not self._c_files_map or filename not in self._c_files_map:
return None # unknown file
c_file, rel_file_path, code, excluded = self._c_files_map[filename]
if code is None:
return None # unknown file
return CythonModuleReporter(c_file, filename, rel_file_path, code, excluded)
def _find_source_files(self, filename):
basename, ext = os.path.splitext(filename)
if ext.lower() not in ('.so', '.dll', '.c', '.cpp'):
return None, None
if os.path.exists(basename + '.c'):
c_file = basename + '.c'
elif os.path.exists(basename + '.cpp'):
c_file = basename + '.cpp'
else:
c_file = None
py_source_file = None
if c_file:
py_source_file = os.path.splitext(c_file)[0] + '.py'
if not os.path.exists(py_source_file):
py_source_file = None
return c_file, py_source_file
def _parse_lines(self, c_file, sourcefile):
"""
Parse a Cython generated C/C++ source file and find the executable lines.
Each executable line starts with a comment header that states source file
and line number, as well as the surrounding range of source code lines.
"""
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
code_lines = defaultdict(dict)
max_line = defaultdict(int)
filenames = set()
with open(c_file) as lines:
lines = iter(lines)
for line in lines:
match = match_source_path_line(line)
if not match:
continue
filename, lineno = match.groups()
filenames.add(filename)
lineno = int(lineno)
max_line[filename] = max(max_line[filename], lineno)
for comment_line in lines:
match = match_current_code_line(comment_line)
if match:
code_lines[filename][lineno] = match.group(1).rstrip()
break
elif match_comment_end(comment_line):
# unexpected comment format - false positive?
break
excluded_lines = dict(
(filename, set(range(1, max_line[filename] + 1)) - set(lines))
for filename, lines in code_lines.iteritems()
)
if self._c_files_map is None:
self._c_files_map = {}
for filename in filenames:
self._c_files_map[os.path.abspath(filename)] = (
c_file, filename, code_lines[filename], excluded_lines[filename])
if sourcefile not in self._c_files_map:
return None, None # shouldn't happen ...
return self._c_files_map[sourcefile][1:]
class CythonModuleTracer(FileTracer):
"""
Find the Python/Cython source file for a Cython module.
"""
def __init__(self, module_file, py_file, c_file, c_files_map):
super(CythonModuleTracer, self).__init__()
self.module_file = module_file
self.py_file = py_file
self.c_file = c_file
self._c_files_map = c_files_map
def has_dynamic_source_filename(self):
return True
def dynamic_source_filename(self, filename, frame):
source_file = frame.f_code.co_filename
abs_path = os.path.abspath(source_file)
if self.py_file and source_file.lower().endswith('.py'):
# always let coverage.py handle this case itself
return self.py_file
assert self._c_files_map is not None
if abs_path not in self._c_files_map:
self._c_files_map[abs_path] = (self.c_file, source_file, None, None)
return abs_path
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, excluded):
super(CythonModuleReporter, self).__init__(source_file)
self.name = rel_file_path
self.c_file = c_file
self._code = code
self._excluded = excluded
def statements(self):
return self._code.viewkeys()
def excluded_statements(self):
return self._excluded
......@@ -1937,6 +1937,13 @@ def runtests(options, cmd_args, coverage=None):
except (ImportError, AttributeError, TypeError):
exclude_selectors.append(RegExSelector('Jedi'))
try:
import coverage
if list(map(int, re.findall('[0-9]+', coverage.__version__ or '0'))) < [4, 0]:
raise ImportError
except (ImportError, AttributeError, TypeError):
exclude_selectors.append(RegExSelector('coverage'))
if options.exclude:
exclude_selectors += [ string_selector(r) for r in options.exclude ]
......
......@@ -14,8 +14,6 @@ inherited_final_method
tryfinallychaining # also see FIXME in "yield_from_pep380" test
cimport_alias_subclass
coverage # depends on newer coverage.py version
# CPython regression tests that don't current work:
pyregr.test_signal
pyregr.test_capi
......
......@@ -2,6 +2,7 @@
# tag: coverage,trace
"""
PYTHON -c 'import shutil; shutil.copy("pkg/coverage_test_pyx.pyx", "pkg/coverage_test_pyx.pxi")'
PYTHON setup.py build_ext -i
PYTHON coverage_test.py
"""
......@@ -11,10 +12,20 @@ PYTHON coverage_test.py
from distutils.core import setup
from Cython.Build import cythonize
setup(ext_modules = cythonize('coverage_test_cy.py'))
setup(ext_modules = cythonize([
'coverage_test_*.py*',
'pkg/coverage_test_*.py*'
]))
######## coverage_test_cy.py ########
######## .coveragerc ########
[run]
plugins = Cython.Coverage
######## pkg/__init__.py ########
######## pkg/coverage_test_py.py ########
# cython: linetrace=True
# distutils: define_macros=CYTHON_TRACE=1
......@@ -28,8 +39,38 @@ def func2(a):
return a * 2 # 11
######## pkg/coverage_test_pyx.pyx ########
# cython: linetrace=True
# distutils: define_macros=CYTHON_TRACE=1
def func1(int a, int b):
cdef int x = 1 # 5
c = func2(a) + b # 6
return x + c # 7
def func2(int a):
return a * 2 # 11
######## coverage_test_include_pyx.pyx ########
# cython: linetrace=True
# distutils: define_macros=CYTHON_TRACE=1
cdef int x = 5 # 4
cdef int cfunc1(int x): # 6
return x * 3 # 7
include "pkg/coverage_test_pyx.pxi" # 9
def main_func(int x): # 11
return cfunc1(x) + func1(x, 4) + func2(x) # 12
######## coverage_test.py ########
import os.path
try:
# io.StringIO in Py2.x cannot handle str ...
from StringIO import StringIO
......@@ -38,27 +79,52 @@ except ImportError:
from coverage import coverage
import coverage_test_cy
from pkg import coverage_test_py
from pkg import coverage_test_pyx
import coverage_test_include_pyx
for module in [coverage_test_py, coverage_test_pyx, coverage_test_include_pyx]:
assert not any(module.__file__.endswith(ext) for ext in '.py .pyc .pyo .pyw .pyx .pxi'.split()), \
module.__file__
def run_coverage():
def run_coverage(module):
module_name = module.__name__
module_path = module_name.replace('.', os.path.sep) + '.' + module_name.rsplit('_', 1)[-1]
cov = coverage()
cov.start()
assert coverage_test_cy.func1(1, 2) == 5
assert coverage_test_cy.func2(2) == 4
assert module.func1(1, 2) == (1 * 2) + 2 + 1
assert module.func2(2) == 2 * 2
if '_include_' in module_name:
assert module.main_func(2) == (2 * 3) + ((2 * 2) + 4 + 1) + (2 * 2)
cov.stop()
out = StringIO()
cov.report([coverage_test_cy], file=out)
cov.report(file=out)
#cov.report([module], file=out)
lines = out.getvalue().splitlines()
assert any('coverage_test_cy' in line for line in lines), "coverage_test_cy not found in coverage"
assert any(module_path in line for line in lines), "'%s' not found in coverage report:\n\n%s" % (
module_path, out.getvalue())
mod_file, exec_lines, excl_lines, missing_lines, _ = cov.analysis2(
os.path.splitext(module.__file__)[0] + '.' + module_name.rsplit('_', 1)[-1])
assert module_path in mod_file
if '_include_' in module_name:
executed = set(exec_lines) - set(missing_lines)
assert all(line in executed for line in [7, 12]), '%s / %s' % (exec_lines, missing_lines)
mod_file, exec_lines, excl_lines, missing_lines, _ = cov.analysis2(coverage_test_cy)
assert 'coverage_test_cy' in mod_file
# rest of test if for include file
mod_file, exec_lines, excl_lines, missing_lines, _ = cov.analysis2(
os.path.join(os.path.dirname(module.__file__), "pkg", "coverage_test_pyx.pxi"))
executed = set(exec_lines) - set(missing_lines)
assert all(line in executed for line in [5, 6, 7, 11]), '%s / %s' % (exec_lines, missing_lines)
if __name__ == '__main__':
run_coverage()
run_coverage(coverage_test_py)
run_coverage(coverage_test_pyx)
run_coverage(coverage_test_include_pyx)
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment