Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1"""Coverage plugin for pytest.""" 

2import argparse 

3import os 

4import warnings 

5 

6import coverage 

7import pytest 

8 

9from . import compat 

10from . import embed 

11 

12 

13class CoverageError(Exception): 

14 """Indicates that our coverage is too low""" 

15 

16 

17def validate_report(arg): 

18 file_choices = ['annotate', 'html', 'xml'] 

19 term_choices = ['term', 'term-missing'] 

20 term_modifier_choices = ['skip-covered'] 

21 all_choices = term_choices + file_choices 

22 values = arg.split(":", 1) 

23 report_type = values[0] 

24 if report_type not in all_choices + ['']: 

25 msg = 'invalid choice: "{}" (choose from "{}")'.format(arg, all_choices) 

26 raise argparse.ArgumentTypeError(msg) 

27 

28 if len(values) == 1: 

29 return report_type, None 

30 

31 report_modifier = values[1] 

32 if report_type in term_choices and report_modifier in term_modifier_choices: 

33 return report_type, report_modifier 

34 

35 if report_type not in file_choices: 

36 msg = 'output specifier not supported for: "{}" (choose from "{}")'.format(arg, 

37 file_choices) 

38 raise argparse.ArgumentTypeError(msg) 

39 

40 return values 

41 

42 

43def validate_fail_under(num_str): 

44 try: 

45 return int(num_str) 

46 except ValueError: 

47 return float(num_str) 

48 

49 

50def validate_context(arg): 

51 if coverage.version_info <= (5, 0): 

52 raise argparse.ArgumentTypeError('Contexts are only supported with coverage.py >= 5.x') 

53 if arg != "test": 

54 raise argparse.ArgumentTypeError('--cov-context=test is the only supported value') 

55 return arg 

56 

57 

58class StoreReport(argparse.Action): 

59 def __call__(self, parser, namespace, values, option_string=None): 

60 report_type, file = values 

61 namespace.cov_report[report_type] = file 

62 

63 

64def pytest_addoption(parser): 

65 """Add options to control coverage.""" 

66 

67 group = parser.getgroup( 

68 'cov', 'coverage reporting with distributed testing support') 

69 group.addoption('--cov', action='append', default=[], metavar='SOURCE', 

70 nargs='?', const=True, dest='cov_source', 

71 help='Path or package name to measure during execution (multi-allowed). ' 

72 'Use --cov= to not do any source filtering and record everything.') 

73 group.addoption('--cov-report', action=StoreReport, default={}, 

74 metavar='TYPE', type=validate_report, 

75 help='Type of report to generate: term, term-missing, ' 

76 'annotate, html, xml (multi-allowed). ' 

77 'term, term-missing may be followed by ":skip-covered". ' 

78 'annotate, html and xml may be followed by ":DEST" ' 

79 'where DEST specifies the output location. ' 

80 'Use --cov-report= to not generate any output.') 

81 group.addoption('--cov-config', action='store', default='.coveragerc', 

82 metavar='PATH', 

83 help='Config file for coverage. Default: .coveragerc') 

84 group.addoption('--no-cov-on-fail', action='store_true', default=False, 

85 help='Do not report coverage if test run fails. ' 

86 'Default: False') 

87 group.addoption('--no-cov', action='store_true', default=False, 

88 help='Disable coverage report completely (useful for debuggers). ' 

89 'Default: False') 

90 group.addoption('--cov-fail-under', action='store', metavar='MIN', 

91 type=validate_fail_under, 

92 help='Fail if the total coverage is less than MIN.') 

93 group.addoption('--cov-append', action='store_true', default=False, 

94 help='Do not delete coverage but append to current. ' 

95 'Default: False') 

96 group.addoption('--cov-branch', action='store_true', default=None, 

97 help='Enable branch coverage.') 

98 group.addoption('--cov-context', action='store', metavar='CONTEXT', 

99 type=validate_context, 

100 help='Dynamic contexts to use. "test" for now.') 

101 

102 

103def _prepare_cov_source(cov_source): 

104 """ 

105 Prepare cov_source so that: 

106 

107 --cov --cov=foobar is equivalent to --cov (cov_source=None) 

108 --cov=foo --cov=bar is equivalent to cov_source=['foo', 'bar'] 

109 """ 

110 return None if True in cov_source else [path for path in cov_source if path is not True] 

111 

112 

113@pytest.mark.tryfirst 

114def pytest_load_initial_conftests(early_config, parser, args): 

115 options = early_config.known_args_namespace 

116 no_cov = options.no_cov_should_warn = False 

117 for arg in args: 

118 arg = str(arg) 

119 if arg == '--no-cov': 

120 no_cov = True 

121 elif arg.startswith('--cov') and no_cov: 

122 options.no_cov_should_warn = True 

123 break 

124 

125 if early_config.known_args_namespace.cov_source: 

126 plugin = CovPlugin(options, early_config.pluginmanager) 

127 early_config.pluginmanager.register(plugin, '_cov') 

128 

129 

130class CovPlugin(object): 

131 """Use coverage package to produce code coverage reports. 

132 

133 Delegates all work to a particular implementation based on whether 

134 this test process is centralised, a distributed master or a 

135 distributed worker. 

136 """ 

137 

138 def __init__(self, options, pluginmanager, start=True, no_cov_should_warn=False): 

139 """Creates a coverage pytest plugin. 

140 

141 We read the rc file that coverage uses to get the data file 

142 name. This is needed since we give coverage through it's API 

143 the data file name. 

144 """ 

145 

146 # Our implementation is unknown at this time. 

147 self.pid = None 

148 self.cov_controller = None 

149 self.cov_report = compat.StringIO() 

150 self.cov_total = None 

151 self.failed = False 

152 self._started = False 

153 self._start_path = None 

154 self._disabled = False 

155 self.options = options 

156 

157 is_dist = (getattr(options, 'numprocesses', False) or 

158 getattr(options, 'distload', False) or 

159 getattr(options, 'dist', 'no') != 'no') 

160 if getattr(options, 'no_cov', False): 

161 self._disabled = True 

162 return 

163 

164 if not self.options.cov_report: 

165 self.options.cov_report = ['term'] 

166 elif len(self.options.cov_report) == 1 and '' in self.options.cov_report: 

167 self.options.cov_report = {} 

168 self.options.cov_source = _prepare_cov_source(self.options.cov_source) 

169 

170 # import engine lazily here to avoid importing 

171 # it for unit tests that don't need it 

172 from . import engine 

173 

174 if is_dist and start: 

175 self.start(engine.DistMaster) 

176 elif start: 

177 self.start(engine.Central) 

178 

179 # worker is started in pytest hook 

180 

181 def start(self, controller_cls, config=None, nodeid=None): 

182 

183 if config is None: 

184 # fake config option for engine 

185 class Config(object): 

186 option = self.options 

187 

188 config = Config() 

189 

190 self.cov_controller = controller_cls( 

191 self.options.cov_source, 

192 self.options.cov_report, 

193 self.options.cov_config, 

194 self.options.cov_append, 

195 self.options.cov_branch, 

196 config, 

197 nodeid 

198 ) 

199 self.cov_controller.start() 

200 self._started = True 

201 self._start_path = os.getcwd() 

202 cov_config = self.cov_controller.cov.config 

203 if self.options.cov_fail_under is None and hasattr(cov_config, 'fail_under'): 

204 self.options.cov_fail_under = cov_config.fail_under 

205 

206 def _is_worker(self, session): 

207 return getattr(session.config, 'workerinput', None) is not None 

208 

209 def pytest_sessionstart(self, session): 

210 """At session start determine our implementation and delegate to it.""" 

211 

212 if self.options.no_cov: 

213 # Coverage can be disabled because it does not cooperate with debuggers well. 

214 self._disabled = True 

215 return 

216 

217 # import engine lazily here to avoid importing 

218 # it for unit tests that don't need it 

219 from . import engine 

220 

221 self.pid = os.getpid() 

222 if self._is_worker(session): 

223 nodeid = ( 

224 session.config.workerinput.get('workerid', getattr(session, 'nodeid')) 

225 ) 

226 self.start(engine.DistWorker, session.config, nodeid) 

227 elif not self._started: 

228 self.start(engine.Central) 

229 

230 if self.options.cov_context == 'test': 

231 session.config.pluginmanager.register(TestContextPlugin(self.cov_controller.cov), '_cov_contexts') 

232 

233 def pytest_configure_node(self, node): 

234 """Delegate to our implementation. 

235 

236 Mark this hook as optional in case xdist is not installed. 

237 """ 

238 if not self._disabled: 

239 self.cov_controller.configure_node(node) 

240 pytest_configure_node.optionalhook = True 

241 

242 def pytest_testnodedown(self, node, error): 

243 """Delegate to our implementation. 

244 

245 Mark this hook as optional in case xdist is not installed. 

246 """ 

247 if not self._disabled: 

248 self.cov_controller.testnodedown(node, error) 

249 pytest_testnodedown.optionalhook = True 

250 

251 def _should_report(self): 

252 return not (self.failed and self.options.no_cov_on_fail) 

253 

254 def _failed_cov_total(self): 

255 cov_fail_under = self.options.cov_fail_under 

256 return cov_fail_under is not None and self.cov_total < cov_fail_under 

257 

258 # we need to wrap pytest_runtestloop. by the time pytest_sessionfinish 

259 # runs, it's too late to set testsfailed 

260 @compat.hookwrapper 

261 def pytest_runtestloop(self, session): 

262 yield 

263 

264 if self._disabled: 

265 return 

266 

267 compat_session = compat.SessionWrapper(session) 

268 

269 self.failed = bool(compat_session.testsfailed) 

270 if self.cov_controller is not None: 

271 self.cov_controller.finish() 

272 

273 if not self._is_worker(session) and self._should_report(): 

274 

275 # import coverage lazily here to avoid importing 

276 # it for unit tests that don't need it 

277 from coverage.misc import CoverageException 

278 

279 try: 

280 self.cov_total = self.cov_controller.summary(self.cov_report) 

281 except CoverageException as exc: 

282 message = 'Failed to generate report: %s\n' % exc 

283 session.config.pluginmanager.getplugin("terminalreporter").write( 

284 'WARNING: %s\n' % message, red=True, bold=True) 

285 warnings.warn(pytest.PytestWarning(message)) 

286 self.cov_total = 0 

287 assert self.cov_total is not None, 'Test coverage should never be `None`' 

288 if self._failed_cov_total(): 

289 # make sure we get the EXIT_TESTSFAILED exit code 

290 compat_session.testsfailed += 1 

291 

292 def pytest_terminal_summary(self, terminalreporter): 

293 if self._disabled: 

294 if self.options.no_cov_should_warn: 

295 message = 'Coverage disabled via --no-cov switch!' 

296 terminalreporter.write('WARNING: %s\n' % message, red=True, bold=True) 

297 warnings.warn(pytest.PytestWarning(message)) 

298 return 

299 if self.cov_controller is None: 

300 return 

301 

302 if self.cov_total is None: 

303 # we shouldn't report, or report generation failed (error raised above) 

304 return 

305 

306 terminalreporter.write('\n' + self.cov_report.getvalue() + '\n') 

307 

308 if self.options.cov_fail_under is not None and self.options.cov_fail_under > 0: 

309 failed = self.cov_total < self.options.cov_fail_under 

310 markup = {'red': True, 'bold': True} if failed else {'green': True} 

311 message = ( 

312 '{fail}Required test coverage of {required}% {reached}. ' 

313 'Total coverage: {actual:.2f}%\n' 

314 .format( 

315 required=self.options.cov_fail_under, 

316 actual=self.cov_total, 

317 fail="FAIL " if failed else "", 

318 reached="not reached" if failed else "reached" 

319 ) 

320 ) 

321 terminalreporter.write(message, **markup) 

322 

323 def pytest_runtest_setup(self, item): 

324 if os.getpid() != self.pid: 

325 # test is run in another process than session, run 

326 # coverage manually 

327 embed.init() 

328 

329 def pytest_runtest_teardown(self, item): 

330 embed.cleanup() 

331 

332 @compat.hookwrapper 

333 def pytest_runtest_call(self, item): 

334 if (item.get_closest_marker('no_cover') 

335 or 'no_cover' in getattr(item, 'fixturenames', ())): 

336 self.cov_controller.pause() 

337 yield 

338 self.cov_controller.resume() 

339 else: 

340 yield 

341 

342 

343class TestContextPlugin(object): 

344 def __init__(self, cov): 

345 self.cov = cov 

346 

347 def pytest_runtest_setup(self, item): 

348 self.switch_context(item, 'setup') 

349 

350 def pytest_runtest_teardown(self, item): 

351 self.switch_context(item, 'teardown') 

352 

353 def pytest_runtest_call(self, item): 

354 self.switch_context(item, 'run') 

355 

356 def switch_context(self, item, when): 

357 context = "{item.nodeid}|{when}".format(item=item, when=when) 

358 self.cov.switch_context(context) 

359 os.environ['COV_CORE_CONTEXT'] = context 

360 

361 

362@pytest.fixture 

363def no_cover(): 

364 """A pytest fixture to disable coverage.""" 

365 pass 

366 

367 

368@pytest.fixture 

369def cov(request): 

370 """A pytest fixture to provide access to the underlying coverage object.""" 

371 

372 # Check with hasplugin to avoid getplugin exception in older pytest. 

373 if request.config.pluginmanager.hasplugin('_cov'): 

374 plugin = request.config.pluginmanager.getplugin('_cov') 

375 if plugin.cov_controller: 

376 return plugin.cov_controller.cov 

377 return None 

378 

379 

380def pytest_configure(config): 

381 config.addinivalue_line("markers", "no_cover: disable coverage for this test.")