Coverage.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. """
  2. A Cython plugin for coverage.py
  3. Requires the coverage package at least in version 4.0 (which added the plugin API).
  4. """
  5. from __future__ import absolute_import
  6. import re
  7. import os.path
  8. import sys
  9. from collections import defaultdict
  10. from coverage.plugin import CoveragePlugin, FileTracer, FileReporter # requires coverage.py 4.0+
  11. from coverage.files import canonical_filename
  12. from .Utils import find_root_package_dir, is_package_dir, open_source_file
  13. from . import __version__
  14. C_FILE_EXTENSIONS = ['.c', '.cpp', '.cc', '.cxx']
  15. MODULE_FILE_EXTENSIONS = set(['.py', '.pyx', '.pxd'] + C_FILE_EXTENSIONS)
  16. def _find_c_source(base_path):
  17. file_exists = os.path.exists
  18. for ext in C_FILE_EXTENSIONS:
  19. file_name = base_path + ext
  20. if file_exists(file_name):
  21. return file_name
  22. return None
  23. def _find_dep_file_path(main_file, file_path, relative_path_search=False):
  24. abs_path = os.path.abspath(file_path)
  25. if not os.path.exists(abs_path) and (file_path.endswith('.pxi') or
  26. relative_path_search):
  27. # files are looked up relative to the main source file
  28. rel_file_path = os.path.join(os.path.dirname(main_file), file_path)
  29. if os.path.exists(rel_file_path):
  30. abs_path = os.path.abspath(rel_file_path)
  31. # search sys.path for external locations if a valid file hasn't been found
  32. if not os.path.exists(abs_path):
  33. for sys_path in sys.path:
  34. test_path = os.path.realpath(os.path.join(sys_path, file_path))
  35. if os.path.exists(test_path):
  36. return canonical_filename(test_path)
  37. return canonical_filename(abs_path)
  38. class Plugin(CoveragePlugin):
  39. # map from traced file paths to absolute file paths
  40. _file_path_map = None
  41. # map from traced file paths to corresponding C files
  42. _c_files_map = None
  43. # map from parsed C files to their content
  44. _parsed_c_files = None
  45. def sys_info(self):
  46. return [('Cython version', __version__)]
  47. def file_tracer(self, filename):
  48. """
  49. Try to find a C source file for a file path found by the tracer.
  50. """
  51. if filename.startswith('<') or filename.startswith('memory:'):
  52. return None
  53. c_file = py_file = None
  54. filename = canonical_filename(os.path.abspath(filename))
  55. if self._c_files_map and filename in self._c_files_map:
  56. c_file = self._c_files_map[filename][0]
  57. if c_file is None:
  58. c_file, py_file = self._find_source_files(filename)
  59. if not c_file:
  60. return None # unknown file
  61. # parse all source file paths and lines from C file
  62. # to learn about all relevant source files right away (pyx/pxi/pxd)
  63. # FIXME: this might already be too late if the first executed line
  64. # is not from the main .pyx file but a file with a different
  65. # name than the .c file (which prevents us from finding the
  66. # .c file)
  67. _, code = self._read_source_lines(c_file, filename)
  68. if code is None:
  69. return None # no source found
  70. if self._file_path_map is None:
  71. self._file_path_map = {}
  72. return CythonModuleTracer(filename, py_file, c_file, self._c_files_map, self._file_path_map)
  73. def file_reporter(self, filename):
  74. # TODO: let coverage.py handle .py files itself
  75. #ext = os.path.splitext(filename)[1].lower()
  76. #if ext == '.py':
  77. # from coverage.python import PythonFileReporter
  78. # return PythonFileReporter(filename)
  79. filename = canonical_filename(os.path.abspath(filename))
  80. if self._c_files_map and filename in self._c_files_map:
  81. c_file, rel_file_path, code = self._c_files_map[filename]
  82. else:
  83. c_file, _ = self._find_source_files(filename)
  84. if not c_file:
  85. return None # unknown file
  86. rel_file_path, code = self._read_source_lines(c_file, filename)
  87. if code is None:
  88. return None # no source found
  89. return CythonModuleReporter(c_file, filename, rel_file_path, code)
  90. def _find_source_files(self, filename):
  91. basename, ext = os.path.splitext(filename)
  92. ext = ext.lower()
  93. if ext in MODULE_FILE_EXTENSIONS:
  94. pass
  95. elif ext == '.pyd':
  96. # Windows extension module
  97. platform_suffix = re.search(r'[.]cp[0-9]+-win[_a-z0-9]*$', basename, re.I)
  98. if platform_suffix:
  99. basename = basename[:platform_suffix.start()]
  100. elif ext == '.so':
  101. # Linux/Unix/Mac extension module
  102. platform_suffix = re.search(r'[.](?:cpython|pypy)-[0-9]+[-_a-z0-9]*$', basename, re.I)
  103. if platform_suffix:
  104. basename = basename[:platform_suffix.start()]
  105. elif ext == '.pxi':
  106. # if we get here, it means that the first traced line of a Cython module was
  107. # not in the main module but in an include file, so try a little harder to
  108. # find the main source file
  109. self._find_c_source_files(os.path.dirname(filename), filename)
  110. if filename in self._c_files_map:
  111. return self._c_files_map[filename][0], None
  112. else:
  113. # none of our business
  114. return None, None
  115. c_file = filename if ext in C_FILE_EXTENSIONS else _find_c_source(basename)
  116. if c_file is None:
  117. # a module "pkg/mod.so" can have a source file "pkg/pkg.mod.c"
  118. package_root = find_root_package_dir.uncached(filename)
  119. package_path = os.path.relpath(basename, package_root).split(os.path.sep)
  120. if len(package_path) > 1:
  121. test_basepath = os.path.join(os.path.dirname(filename), '.'.join(package_path))
  122. c_file = _find_c_source(test_basepath)
  123. py_source_file = None
  124. if c_file:
  125. py_source_file = os.path.splitext(c_file)[0] + '.py'
  126. if not os.path.exists(py_source_file):
  127. py_source_file = None
  128. try:
  129. with open(c_file, 'rb') as f:
  130. if b'/* Generated by Cython ' not in f.read(30):
  131. return None, None # not a Cython file
  132. except (IOError, OSError):
  133. c_file = None
  134. return c_file, py_source_file
  135. def _find_c_source_files(self, dir_path, source_file):
  136. """
  137. Desperately parse all C files in the directory or its package parents
  138. (not re-descending) to find the (included) source file in one of them.
  139. """
  140. if not os.path.isdir(dir_path):
  141. return
  142. splitext = os.path.splitext
  143. for filename in os.listdir(dir_path):
  144. ext = splitext(filename)[1].lower()
  145. if ext in C_FILE_EXTENSIONS:
  146. self._read_source_lines(os.path.join(dir_path, filename), source_file)
  147. if source_file in self._c_files_map:
  148. return
  149. # not found? then try one package up
  150. if is_package_dir(dir_path):
  151. self._find_c_source_files(os.path.dirname(dir_path), source_file)
  152. def _read_source_lines(self, c_file, sourcefile):
  153. """
  154. Parse a Cython generated C/C++ source file and find the executable lines.
  155. Each executable line starts with a comment header that states source file
  156. and line number, as well as the surrounding range of source code lines.
  157. """
  158. if self._parsed_c_files is None:
  159. self._parsed_c_files = {}
  160. if c_file in self._parsed_c_files:
  161. code_lines = self._parsed_c_files[c_file]
  162. else:
  163. code_lines = self._parse_cfile_lines(c_file)
  164. self._parsed_c_files[c_file] = code_lines
  165. if self._c_files_map is None:
  166. self._c_files_map = {}
  167. for filename, code in code_lines.items():
  168. abs_path = _find_dep_file_path(c_file, filename,
  169. relative_path_search=True)
  170. self._c_files_map[abs_path] = (c_file, filename, code)
  171. if sourcefile not in self._c_files_map:
  172. return (None,) * 2 # e.g. shared library file
  173. return self._c_files_map[sourcefile][1:]
  174. def _parse_cfile_lines(self, c_file):
  175. """
  176. Parse a C file and extract all source file lines that generated executable code.
  177. """
  178. match_source_path_line = re.compile(r' */[*] +"(.*)":([0-9]+)$').match
  179. match_current_code_line = re.compile(r' *[*] (.*) # <<<<<<+$').match
  180. match_comment_end = re.compile(r' *[*]/$').match
  181. match_trace_line = re.compile(r' *__Pyx_TraceLine\(([0-9]+),').match
  182. not_executable = re.compile(
  183. r'\s*c(?:type)?def\s+'
  184. r'(?:(?:public|external)\s+)?'
  185. r'(?:struct|union|enum|class)'
  186. r'(\s+[^:]+|)\s*:'
  187. ).match
  188. code_lines = defaultdict(dict)
  189. executable_lines = defaultdict(set)
  190. current_filename = None
  191. with open(c_file) as lines:
  192. lines = iter(lines)
  193. for line in lines:
  194. match = match_source_path_line(line)
  195. if not match:
  196. if '__Pyx_TraceLine(' in line and current_filename is not None:
  197. trace_line = match_trace_line(line)
  198. if trace_line:
  199. executable_lines[current_filename].add(int(trace_line.group(1)))
  200. continue
  201. filename, lineno = match.groups()
  202. current_filename = filename
  203. lineno = int(lineno)
  204. for comment_line in lines:
  205. match = match_current_code_line(comment_line)
  206. if match:
  207. code_line = match.group(1).rstrip()
  208. if not_executable(code_line):
  209. break
  210. code_lines[filename][lineno] = code_line
  211. break
  212. elif match_comment_end(comment_line):
  213. # unexpected comment format - false positive?
  214. break
  215. # Remove lines that generated code but are not traceable.
  216. for filename, lines in code_lines.items():
  217. dead_lines = set(lines).difference(executable_lines.get(filename, ()))
  218. for lineno in dead_lines:
  219. del lines[lineno]
  220. return code_lines
  221. class CythonModuleTracer(FileTracer):
  222. """
  223. Find the Python/Cython source file for a Cython module.
  224. """
  225. def __init__(self, module_file, py_file, c_file, c_files_map, file_path_map):
  226. super(CythonModuleTracer, self).__init__()
  227. self.module_file = module_file
  228. self.py_file = py_file
  229. self.c_file = c_file
  230. self._c_files_map = c_files_map
  231. self._file_path_map = file_path_map
  232. def has_dynamic_source_filename(self):
  233. return True
  234. def dynamic_source_filename(self, filename, frame):
  235. """
  236. Determine source file path. Called by the function call tracer.
  237. """
  238. source_file = frame.f_code.co_filename
  239. try:
  240. return self._file_path_map[source_file]
  241. except KeyError:
  242. pass
  243. abs_path = _find_dep_file_path(filename, source_file)
  244. if self.py_file and source_file[-3:].lower() == '.py':
  245. # always let coverage.py handle this case itself
  246. self._file_path_map[source_file] = self.py_file
  247. return self.py_file
  248. assert self._c_files_map is not None
  249. if abs_path not in self._c_files_map:
  250. self._c_files_map[abs_path] = (self.c_file, source_file, None)
  251. self._file_path_map[source_file] = abs_path
  252. return abs_path
  253. class CythonModuleReporter(FileReporter):
  254. """
  255. Provide detailed trace information for one source file to coverage.py.
  256. """
  257. def __init__(self, c_file, source_file, rel_file_path, code):
  258. super(CythonModuleReporter, self).__init__(source_file)
  259. self.name = rel_file_path
  260. self.c_file = c_file
  261. self._code = code
  262. def lines(self):
  263. """
  264. Return set of line numbers that are possibly executable.
  265. """
  266. return set(self._code)
  267. def _iter_source_tokens(self):
  268. current_line = 1
  269. for line_no, code_line in sorted(self._code.items()):
  270. while line_no > current_line:
  271. yield []
  272. current_line += 1
  273. yield [('txt', code_line)]
  274. current_line += 1
  275. def source(self):
  276. """
  277. Return the source code of the file as a string.
  278. """
  279. if os.path.exists(self.filename):
  280. with open_source_file(self.filename) as f:
  281. return f.read()
  282. else:
  283. return '\n'.join(
  284. (tokens[0][1] if tokens else '')
  285. for tokens in self._iter_source_tokens())
  286. def source_token_lines(self):
  287. """
  288. Iterate over the source code tokens.
  289. """
  290. if os.path.exists(self.filename):
  291. with open_source_file(self.filename) as f:
  292. for line in f:
  293. yield [('txt', line.rstrip('\n'))]
  294. else:
  295. for line in self._iter_source_tokens():
  296. yield [('txt', line)]
  297. def coverage_init(reg, options):
  298. reg.add_file_tracer(Plugin())