| | """ |
| | A Cython plugin for coverage.py |
| | |
| | Requires the coverage package at least in version 4.0 (which added the plugin API). |
| | """ |
| |
|
| | from __future__ import absolute_import |
| |
|
| | import re |
| | import os.path |
| | import sys |
| | from collections import defaultdict |
| |
|
| | from coverage.plugin import CoveragePlugin, FileTracer, FileReporter |
| | from coverage.files import canonical_filename |
| |
|
| | from .Utils import find_root_package_dir, is_package_dir, open_source_file |
| |
|
| |
|
| | from . import __version__ |
| |
|
| |
|
| | C_FILE_EXTENSIONS = ['.c', '.cpp', '.cc', '.cxx'] |
| | MODULE_FILE_EXTENSIONS = set(['.py', '.pyx', '.pxd'] + C_FILE_EXTENSIONS) |
| |
|
| |
|
| | def _find_c_source(base_path): |
| | file_exists = os.path.exists |
| | for ext in C_FILE_EXTENSIONS: |
| | file_name = base_path + ext |
| | if file_exists(file_name): |
| | return file_name |
| | return None |
| |
|
| |
|
| | def _find_dep_file_path(main_file, file_path, relative_path_search=False): |
| | abs_path = os.path.abspath(file_path) |
| | if not os.path.exists(abs_path) and (file_path.endswith('.pxi') or |
| | relative_path_search): |
| | |
| | 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) |
| | |
| | if not os.path.exists(abs_path): |
| | for sys_path in sys.path: |
| | test_path = os.path.realpath(os.path.join(sys_path, file_path)) |
| | if os.path.exists(test_path): |
| | return canonical_filename(test_path) |
| | return canonical_filename(abs_path) |
| |
|
| |
|
| | class Plugin(CoveragePlugin): |
| | |
| | _file_path_map = None |
| | |
| | _c_files_map = None |
| | |
| | _parsed_c_files = 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. |
| | """ |
| | if filename.startswith('<') or filename.startswith('memory:'): |
| | return None |
| | c_file = py_file = None |
| | filename = canonical_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 |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | _, code = self._read_source_lines(c_file, filename) |
| | if code is None: |
| | return None |
| |
|
| | if self._file_path_map is None: |
| | self._file_path_map = {} |
| | return CythonModuleTracer(filename, py_file, c_file, self._c_files_map, self._file_path_map) |
| |
|
| | def file_reporter(self, filename): |
| | |
| | |
| | |
| | |
| | |
| |
|
| | filename = canonical_filename(os.path.abspath(filename)) |
| | if self._c_files_map and filename in self._c_files_map: |
| | c_file, rel_file_path, code = self._c_files_map[filename] |
| | else: |
| | c_file, _ = self._find_source_files(filename) |
| | if not c_file: |
| | return None |
| | rel_file_path, code = self._read_source_lines(c_file, filename) |
| | if code is None: |
| | return None |
| | return CythonModuleReporter(c_file, filename, rel_file_path, code) |
| |
|
| | def _find_source_files(self, filename): |
| | basename, ext = os.path.splitext(filename) |
| | ext = ext.lower() |
| | if ext in MODULE_FILE_EXTENSIONS: |
| | pass |
| | elif ext == '.pyd': |
| | |
| | platform_suffix = re.search(r'[.]cp[0-9]+-win[_a-z0-9]*$', basename, re.I) |
| | if platform_suffix: |
| | basename = basename[:platform_suffix.start()] |
| | elif ext == '.so': |
| | |
| | platform_suffix = re.search(r'[.](?:cpython|pypy)-[0-9]+[-_a-z0-9]*$', basename, re.I) |
| | if platform_suffix: |
| | basename = basename[:platform_suffix.start()] |
| | elif ext == '.pxi': |
| | |
| | |
| | |
| | self._find_c_source_files(os.path.dirname(filename), filename) |
| | if filename in self._c_files_map: |
| | return self._c_files_map[filename][0], None |
| | else: |
| | |
| | return None, None |
| |
|
| | c_file = filename if ext in C_FILE_EXTENSIONS else _find_c_source(basename) |
| | if c_file is None: |
| | |
| | package_root = find_root_package_dir.uncached(filename) |
| | package_path = os.path.relpath(basename, package_root).split(os.path.sep) |
| | if len(package_path) > 1: |
| | test_basepath = os.path.join(os.path.dirname(filename), '.'.join(package_path)) |
| | c_file = _find_c_source(test_basepath) |
| |
|
| | 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 |
| |
|
| | try: |
| | with open(c_file, 'rb') as f: |
| | if b'/* Generated by Cython ' not in f.read(30): |
| | return None, None |
| | except (IOError, OSError): |
| | c_file = None |
| |
|
| | return c_file, py_source_file |
| |
|
| | def _find_c_source_files(self, dir_path, source_file): |
| | """ |
| | Desperately parse all C files in the directory or its package parents |
| | (not re-descending) to find the (included) source file in one of them. |
| | """ |
| | if not os.path.isdir(dir_path): |
| | return |
| | splitext = os.path.splitext |
| | for filename in os.listdir(dir_path): |
| | ext = splitext(filename)[1].lower() |
| | if ext in C_FILE_EXTENSIONS: |
| | self._read_source_lines(os.path.join(dir_path, filename), source_file) |
| | if source_file in self._c_files_map: |
| | return |
| | |
| | if is_package_dir(dir_path): |
| | self._find_c_source_files(os.path.dirname(dir_path), source_file) |
| |
|
| | def _read_source_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. |
| | """ |
| | if self._parsed_c_files is None: |
| | self._parsed_c_files = {} |
| | if c_file in self._parsed_c_files: |
| | code_lines = self._parsed_c_files[c_file] |
| | else: |
| | code_lines = self._parse_cfile_lines(c_file) |
| | self._parsed_c_files[c_file] = code_lines |
| |
|
| | if self._c_files_map is None: |
| | self._c_files_map = {} |
| |
|
| | for filename, code in code_lines.items(): |
| | abs_path = _find_dep_file_path(c_file, filename, |
| | relative_path_search=True) |
| | self._c_files_map[abs_path] = (c_file, filename, code) |
| |
|
| | if sourcefile not in self._c_files_map: |
| | return (None,) * 2 |
| | return self._c_files_map[sourcefile][1:] |
| |
|
| | def _parse_cfile_lines(self, c_file): |
| | """ |
| | Parse a C file and extract all source file lines that generated executable 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 |
| |
|
| | 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 |
| | code_lines[filename][lineno] = code_line |
| | break |
| | elif match_comment_end(comment_line): |
| | |
| | break |
| |
|
| | |
| | for filename, lines in code_lines.items(): |
| | dead_lines = set(lines).difference(executable_lines.get(filename, ())) |
| | for lineno in dead_lines: |
| | del lines[lineno] |
| | return code_lines |
| |
|
| |
|
| | 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, file_path_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 |
| | self._file_path_map = file_path_map |
| |
|
| | def has_dynamic_source_filename(self): |
| | return True |
| |
|
| | def dynamic_source_filename(self, filename, frame): |
| | """ |
| | Determine source file path. Called by the function call tracer. |
| | """ |
| | source_file = frame.f_code.co_filename |
| | try: |
| | return self._file_path_map[source_file] |
| | except KeyError: |
| | pass |
| | abs_path = _find_dep_file_path(filename, source_file) |
| |
|
| | if self.py_file and source_file[-3:].lower() == '.py': |
| | |
| | self._file_path_map[source_file] = self.py_file |
| | 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) |
| | self._file_path_map[source_file] = abs_path |
| | 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): |
| | super(CythonModuleReporter, self).__init__(source_file) |
| | self.name = rel_file_path |
| | self.c_file = c_file |
| | self._code = code |
| |
|
| | def lines(self): |
| | """ |
| | Return set of line numbers that are possibly executable. |
| | """ |
| | return set(self._code) |
| |
|
| | def _iter_source_tokens(self): |
| | current_line = 1 |
| | for line_no, code_line in sorted(self._code.items()): |
| | while line_no > current_line: |
| | yield [] |
| | current_line += 1 |
| | yield [('txt', code_line)] |
| | current_line += 1 |
| |
|
| | def source(self): |
| | """ |
| | Return the source code of the file as a string. |
| | """ |
| | if os.path.exists(self.filename): |
| | with open_source_file(self.filename) as f: |
| | return f.read() |
| | else: |
| | return '\n'.join( |
| | (tokens[0][1] if tokens else '') |
| | for tokens in self._iter_source_tokens()) |
| |
|
| | def source_token_lines(self): |
| | """ |
| | Iterate over the source code tokens. |
| | """ |
| | if os.path.exists(self.filename): |
| | with open_source_file(self.filename) as f: |
| | for line in f: |
| | yield [('txt', line.rstrip('\n'))] |
| | else: |
| | for line in self._iter_source_tokens(): |
| | yield [('txt', line)] |
| |
|
| |
|
| | def coverage_init(reg, options): |
| | reg.add_file_tracer(Plugin()) |
| |
|