engine.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. """Coverage controllers for use by pytest-cov and nose-cov."""
  2. import os
  3. import random
  4. import socket
  5. import sys
  6. import coverage
  7. from coverage.data import CoverageData
  8. from .compat import StringIO
  9. class CovController(object):
  10. """Base class for different plugin implementations."""
  11. def __init__(self, cov_source, cov_report, cov_config, cov_append, cov_branch, config=None, nodeid=None):
  12. """Get some common config used by multiple derived classes."""
  13. self.cov_source = cov_source
  14. self.cov_report = cov_report
  15. self.cov_config = cov_config
  16. self.cov_append = cov_append
  17. self.cov_branch = cov_branch
  18. self.config = config
  19. self.nodeid = nodeid
  20. self.cov = None
  21. self.combining_cov = None
  22. self.data_file = None
  23. self.node_descs = set()
  24. self.failed_slaves = []
  25. self.topdir = os.getcwd()
  26. def pause(self):
  27. self.cov.stop()
  28. self.unset_env()
  29. def resume(self):
  30. self.cov.start()
  31. self.set_env()
  32. def set_env(self):
  33. """Put info about coverage into the env so that subprocesses can activate coverage."""
  34. if self.cov_source is None:
  35. os.environ['COV_CORE_SOURCE'] = os.pathsep
  36. else:
  37. os.environ['COV_CORE_SOURCE'] = os.pathsep.join(self.cov_source)
  38. config_file = os.path.abspath(self.cov_config)
  39. if os.path.exists(config_file):
  40. os.environ['COV_CORE_CONFIG'] = config_file
  41. else:
  42. os.environ['COV_CORE_CONFIG'] = os.pathsep
  43. os.environ['COV_CORE_DATAFILE'] = os.path.abspath(self.cov.config.data_file)
  44. if self.cov_branch:
  45. os.environ['COV_CORE_BRANCH'] = 'enabled'
  46. @staticmethod
  47. def unset_env():
  48. """Remove coverage info from env."""
  49. os.environ.pop('COV_CORE_SOURCE', None)
  50. os.environ.pop('COV_CORE_CONFIG', None)
  51. os.environ.pop('COV_CORE_DATAFILE', None)
  52. os.environ.pop('COV_CORE_BRANCH', None)
  53. @staticmethod
  54. def get_node_desc(platform, version_info):
  55. """Return a description of this node."""
  56. return 'platform %s, python %s' % (platform, '%s.%s.%s-%s-%s' % version_info[:5])
  57. @staticmethod
  58. def sep(stream, s, txt):
  59. if hasattr(stream, 'sep'):
  60. stream.sep(s, txt)
  61. else:
  62. sep_total = max((70 - 2 - len(txt)), 2)
  63. sep_len = sep_total // 2
  64. sep_extra = sep_total % 2
  65. out = '%s %s %s\n' % (s * sep_len, txt, s * (sep_len + sep_extra))
  66. stream.write(out)
  67. def summary(self, stream):
  68. """Produce coverage reports."""
  69. total = 0
  70. if not self.cov_report:
  71. with open(os.devnull, 'w') as null:
  72. total = self.cov.report(show_missing=True, ignore_errors=True, file=null)
  73. return total
  74. # Output coverage section header.
  75. if len(self.node_descs) == 1:
  76. self.sep(stream, '-', 'coverage: %s' % ''.join(self.node_descs))
  77. else:
  78. self.sep(stream, '-', 'coverage')
  79. for node_desc in sorted(self.node_descs):
  80. self.sep(stream, ' ', '%s' % node_desc)
  81. # Produce terminal report if wanted.
  82. if any(x in self.cov_report for x in ['term', 'term-missing']):
  83. options = {
  84. 'show_missing': ('term-missing' in self.cov_report) or None,
  85. 'ignore_errors': True,
  86. 'file': stream,
  87. }
  88. skip_covered = isinstance(self.cov_report, dict) and 'skip-covered' in self.cov_report.values()
  89. if hasattr(coverage, 'version_info') and coverage.version_info[0] >= 4:
  90. options.update({'skip_covered': skip_covered or None})
  91. total = self.cov.report(**options)
  92. # Produce annotated source code report if wanted.
  93. if 'annotate' in self.cov_report:
  94. annotate_dir = self.cov_report['annotate']
  95. self.cov.annotate(ignore_errors=True, directory=annotate_dir)
  96. # We need to call Coverage.report here, just to get the total
  97. # Coverage.annotate don't return any total and we need it for --cov-fail-under.
  98. total = self.cov.report(ignore_errors=True, file=StringIO())
  99. if annotate_dir:
  100. stream.write('Coverage annotated source written to dir %s\n' % annotate_dir)
  101. else:
  102. stream.write('Coverage annotated source written next to source\n')
  103. # Produce html report if wanted.
  104. if 'html' in self.cov_report:
  105. total = self.cov.html_report(ignore_errors=True, directory=self.cov_report['html'])
  106. stream.write('Coverage HTML written to dir %s\n' % self.cov.config.html_dir)
  107. # Produce xml report if wanted.
  108. if 'xml' in self.cov_report:
  109. total = self.cov.xml_report(ignore_errors=True, outfile=self.cov_report['xml'])
  110. stream.write('Coverage XML written to file %s\n' % self.cov.config.xml_output)
  111. # Report on any failed slaves.
  112. if self.failed_slaves:
  113. self.sep(stream, '-', 'coverage: failed slaves')
  114. stream.write('The following slaves failed to return coverage data, '
  115. 'ensure that pytest-cov is installed on these slaves.\n')
  116. for node in self.failed_slaves:
  117. stream.write('%s\n' % node.gateway.id)
  118. return total
  119. class Central(CovController):
  120. """Implementation for centralised operation."""
  121. def start(self):
  122. """Erase any previous coverage data and start coverage."""
  123. self.cov = coverage.coverage(source=self.cov_source,
  124. branch=self.cov_branch,
  125. config_file=self.cov_config)
  126. self.combining_cov = coverage.coverage(source=self.cov_source,
  127. branch=self.cov_branch,
  128. data_file=os.path.abspath(self.cov.config.data_file),
  129. config_file=self.cov_config)
  130. if self.cov_append:
  131. self.cov.load()
  132. else:
  133. self.cov.erase()
  134. self.cov.start()
  135. self.set_env()
  136. def finish(self):
  137. """Stop coverage, save data to file and set the list of coverage objects to report on."""
  138. self.unset_env()
  139. self.cov.stop()
  140. self.cov.save()
  141. self.cov = self.combining_cov
  142. self.cov.load()
  143. self.cov.combine()
  144. self.cov.save()
  145. node_desc = self.get_node_desc(sys.platform, sys.version_info)
  146. self.node_descs.add(node_desc)
  147. class DistMaster(CovController):
  148. """Implementation for distributed master."""
  149. def start(self):
  150. """Ensure coverage rc file rsynced if appropriate."""
  151. if self.cov_config and os.path.exists(self.cov_config):
  152. self.config.option.rsyncdir.append(self.cov_config)
  153. self.cov = coverage.coverage(source=self.cov_source,
  154. branch=self.cov_branch,
  155. config_file=self.cov_config)
  156. self.combining_cov = coverage.coverage(source=self.cov_source,
  157. branch=self.cov_branch,
  158. data_file=os.path.abspath(self.cov.config.data_file),
  159. config_file=self.cov_config)
  160. if self.cov_append:
  161. self.cov.load()
  162. else:
  163. self.cov.erase()
  164. self.cov.start()
  165. self.cov.config.paths['source'] = [self.topdir]
  166. def configure_node(self, node):
  167. """Slaves need to know if they are collocated and what files have moved."""
  168. node.slaveinput['cov_master_host'] = socket.gethostname()
  169. node.slaveinput['cov_master_topdir'] = self.topdir
  170. node.slaveinput['cov_master_rsync_roots'] = [str(root) for root in node.nodemanager.roots]
  171. def testnodedown(self, node, error):
  172. """Collect data file name from slave."""
  173. # If slave doesn't return any data then it is likely that this
  174. # plugin didn't get activated on the slave side.
  175. if not (hasattr(node, 'slaveoutput') and 'cov_slave_node_id' in node.slaveoutput):
  176. self.failed_slaves.append(node)
  177. return
  178. # If slave is not collocated then we must save the data file
  179. # that it returns to us.
  180. if 'cov_slave_data' in node.slaveoutput:
  181. data_suffix = '%s.%s.%06d.%s' % (
  182. socket.gethostname(), os.getpid(),
  183. random.randint(0, 999999),
  184. node.slaveoutput['cov_slave_node_id']
  185. )
  186. cov = coverage.coverage(source=self.cov_source,
  187. branch=self.cov_branch,
  188. data_suffix=data_suffix,
  189. config_file=self.cov_config)
  190. cov.start()
  191. data = CoverageData()
  192. data.read_fileobj(StringIO(node.slaveoutput['cov_slave_data']))
  193. cov.data.update(data)
  194. cov.stop()
  195. cov.save()
  196. path = node.slaveoutput['cov_slave_path']
  197. self.cov.config.paths['source'].append(path)
  198. # Record the slave types that contribute to the data file.
  199. rinfo = node.gateway._rinfo()
  200. node_desc = self.get_node_desc(rinfo.platform, rinfo.version_info)
  201. self.node_descs.add(node_desc)
  202. def finish(self):
  203. """Combines coverage data and sets the list of coverage objects to report on."""
  204. # Combine all the suffix files into the data file.
  205. self.cov.stop()
  206. self.cov.save()
  207. self.cov = self.combining_cov
  208. self.cov.load()
  209. self.cov.combine()
  210. self.cov.save()
  211. class DistSlave(CovController):
  212. """Implementation for distributed slaves."""
  213. def start(self):
  214. """Determine what data file and suffix to contribute to and start coverage."""
  215. # Determine whether we are collocated with master.
  216. self.is_collocated = (socket.gethostname() == self.config.slaveinput['cov_master_host'] and
  217. self.topdir == self.config.slaveinput['cov_master_topdir'])
  218. # If we are not collocated then rewrite master paths to slave paths.
  219. if not self.is_collocated:
  220. master_topdir = self.config.slaveinput['cov_master_topdir']
  221. slave_topdir = self.topdir
  222. if self.cov_source is not None:
  223. self.cov_source = [source.replace(master_topdir, slave_topdir)
  224. for source in self.cov_source]
  225. self.cov_config = self.cov_config.replace(master_topdir, slave_topdir)
  226. # Erase any previous data and start coverage.
  227. self.cov = coverage.coverage(source=self.cov_source,
  228. branch=self.cov_branch,
  229. data_suffix=True,
  230. config_file=self.cov_config)
  231. if self.cov_append:
  232. self.cov.load()
  233. else:
  234. self.cov.erase()
  235. self.cov.start()
  236. self.set_env()
  237. def finish(self):
  238. """Stop coverage and send relevant info back to the master."""
  239. self.unset_env()
  240. self.cov.stop()
  241. if self.is_collocated:
  242. # We don't combine data if we're collocated - we can get
  243. # race conditions in the .combine() call (it's not atomic)
  244. # The data is going to be combined in the master.
  245. self.cov.save()
  246. # If we are collocated then just inform the master of our
  247. # data file to indicate that we have finished.
  248. self.config.slaveoutput['cov_slave_node_id'] = self.nodeid
  249. else:
  250. self.cov.combine()
  251. self.cov.save()
  252. # If we are not collocated then add the current path
  253. # and coverage data to the output so we can combine
  254. # it on the master node.
  255. # Send all the data to the master over the channel.
  256. self.config.slaveoutput['cov_slave_path'] = self.topdir
  257. self.config.slaveoutput['cov_slave_node_id'] = self.nodeid
  258. buff = StringIO()
  259. self.cov.data.write_fileobj(buff)
  260. self.config.slaveoutput['cov_slave_data'] = buff.getvalue()
  261. def summary(self, stream):
  262. """Only the master reports so do nothing."""
  263. pass