/
Coverage.py
175 lines (145 loc) · 5.83 KB
/
Coverage.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
#
# Copyright (c) 2021 by Kristoffer Paulsson <kristoffer.paulsson@talenten.se>.
#
# This software is available under the terms of the MIT license. Parts are licensed under
# different terms if stated. The legal terms are attached to the LICENSE file and are
# made available on:
#
# https://opensource.org/licenses/MIT
#
# SPDX-License-Identifier: MIT
#
# Contributors:
# Kristoffer Paulsson - initial implementation
#
import glob
import os
import re
from coverage.plugin import CoveragePlugin, FileTracer, FileReporter
class Tracer(FileTracer):
cache = dict()
def __init__(self, filename, file, prefixes):
FileTracer.__init__(self)
self._filename = filename
self._file = file
self._prefixes = prefixes
def source_filename(self):
return self._filename
def has_dynamic_source_filename(self):
return self._file
def dynamic_source_filename(self, filename, frame):
current = frame.f_code.co_filename
if current in self.cache:
return self.cache[current]
for prefix in self._prefixes:
origin = prefix + current[3:]
if os.path.isfile(origin):
self.cache[current] = origin
return origin
raise OSError("didn't find real path for: {}".format(current))
class Reporter(FileReporter):
def __init__(self, filename, sources):
FileReporter.__init__(self, filename)
self._sources = sources
self._cfile = None
self._executable = set()
self._excluded = set()
for prefix in self._sources:
if filename.startswith(prefix):
prefix_len = len(prefix)
cfile = filename[:prefix_len - 3] + "build/" + filename[prefix_len - 3:-4] + ".c"
if os.path.isfile(cfile):
self._cfile = cfile
break
if not self._cfile:
with open(self.filename, "rb") as f:
count = 0
for _ in f:
count += 1
self._executable.add(count)
else:
self._executable = self._parse_cfile_lines(self._cfile)
def lines(self):
return self._executable
def excluded_lines(self):
return self._excluded
def _parse_cfile_lines(self, c_file):
"""
Parse a C file and extract all source file lines that generated executable code.
"""
# LICENSE: https://github.com/cython/cython/blob/master/LICENSE.txt
# Modified by Kristoffer Paulsson 2021-08-02 to fit this project with multiple namespace packages.
# Multiple lines has become unnecessary and been removed, also unnecessary complexity.
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 = set()
executable = 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.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.add(lineno)
break
elif match_comment_end(comment_line):
# unexpected comment format - false positive?
break
# Remove lines that generated code but are not traceable.
dead_lines = set(code_lines).difference(executable)
for lineno in dead_lines:
code_lines.remove(lineno)
return code_lines
class Coverage(CoveragePlugin):
_sources = list()
def file_tracer(self, filename):
return Tracer(filename, filename.startswith(self._sources) and filename.endswith((".pyx", ".pxd")),
self._sources)
def file_reporter(self, filename):
return Reporter(filename, self._sources)
def configure(self, config):
sources = config.get_option("run:source") or list()
sources += glob.glob(os.getcwd() + "/src")
sources += glob.glob(os.getcwd() + "/angelos-*/src")
sources = set(sources)
self._sources = tuple(sources)
config.set_option("run:source", list(sources))
def find_executable_files(self, src_dir):
map = set()
cache = dict()
for exe in glob.glob(src_dir + "/**", recursive=True):
if exe.endswith((".py", ".pyx", ".pxd")):
map.add(exe)
if exe.endswith((".pyx", ".pxd")):
key = exe[exe.rfind("src"):]
if key in cache:
raise OSError("Path already in cache: {}".format(key))
cache[key] = exe
Tracer.cache = cache
return list(map)
def coverage_init(reg, options):
plugin = Coverage()
reg.add_configurer(plugin)
reg.add_file_tracer(plugin)