You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

325 lines
12 KiB

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