Coverage for /opt/homebrew/lib/python3.11/site-packages/pytest_cov/plugin.py: 29%

223 statements  

« prev     ^ index     » next       coverage.py v7.2.3, created at 2023-05-04 13:14 +0700

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 

17class PytestCovWarning(pytest.PytestWarning): 

18 """ 

19 The base for all pytest-cov warnings, never raised directly 

20 """ 

21 

22 

23class CovDisabledWarning(PytestCovWarning): 

24 """Indicates that Coverage was manually disabled""" 

25 

26 

27class CovReportWarning(PytestCovWarning): 

28 """Indicates that we failed to generate a report""" 

29 

30 

31def validate_report(arg): 

32 file_choices = ['annotate', 'html', 'xml', 'lcov'] 

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

34 term_modifier_choices = ['skip-covered'] 

35 all_choices = term_choices + file_choices 

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

37 report_type = values[0] 

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

39 msg = f'invalid choice: "{arg}" (choose from "{all_choices}")' 

40 raise argparse.ArgumentTypeError(msg) 

41 

42 if report_type == 'lcov' and coverage.version_info <= (6, 3): 

43 raise argparse.ArgumentTypeError('LCOV output is only supported with coverage.py >= 6.3') 

44 

45 if len(values) == 1: 

46 return report_type, None 

47 

48 report_modifier = values[1] 

49 if report_type in term_choices and report_modifier in term_modifier_choices: 

50 return report_type, report_modifier 

51 

52 if report_type not in file_choices: 

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

54 file_choices) 

55 raise argparse.ArgumentTypeError(msg) 

56 

57 return values 

58 

59 

60def validate_fail_under(num_str): 

61 try: 

62 value = int(num_str) 

63 except ValueError: 

64 try: 

65 value = float(num_str) 

66 except ValueError: 

67 raise argparse.ArgumentTypeError('An integer or float value is required.') 

68 if value > 100: 

69 raise argparse.ArgumentTypeError('Your desire for over-achievement is admirable but misplaced. ' 

70 'The maximum value is 100. Perhaps write more integration tests?') 

71 return value 

72 

73 

74def validate_context(arg): 

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

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

77 if arg != "test": 

78 raise argparse.ArgumentTypeError('The only supported value is "test".') 

79 return arg 

80 

81 

82class StoreReport(argparse.Action): 

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

84 report_type, file = values 

85 namespace.cov_report[report_type] = file 

86 

87 

88def pytest_addoption(parser): 

89 """Add options to control coverage.""" 

90 

91 group = parser.getgroup( 

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

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

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

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

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

97 group.addoption('--cov-reset', action='store_const', const=[], dest='cov_source', 

98 help='Reset cov sources accumulated in options so far. ') 

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

100 metavar='TYPE', type=validate_report, 

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

102 'annotate, html, xml, lcov (multi-allowed). ' 

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

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

105 'where DEST specifies the output location. ' 

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

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

108 metavar='PATH', 

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

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

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

112 'Default: False') 

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

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

115 'Default: False') 

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

117 type=validate_fail_under, 

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

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

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

121 'Default: False') 

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

123 help='Enable branch coverage.') 

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

125 type=validate_context, 

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

127 

128 

129def _prepare_cov_source(cov_source): 

130 """ 

131 Prepare cov_source so that: 

132 

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

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

135 """ 

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

137 

138 

139@pytest.hookimpl(tryfirst=True) 

140def pytest_load_initial_conftests(early_config, parser, args): 

141 options = early_config.known_args_namespace 

142 no_cov = options.no_cov_should_warn = False 

143 for arg in args: 

144 arg = str(arg) 

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

146 no_cov = True 

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

148 options.no_cov_should_warn = True 

149 break 

150 

151 if early_config.known_args_namespace.cov_source: 

152 plugin = CovPlugin(options, early_config.pluginmanager) 

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

154 

155 

156class CovPlugin: 

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

158 

159 Delegates all work to a particular implementation based on whether 

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

161 distributed worker. 

162 """ 

163 

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

165 """Creates a coverage pytest plugin. 

166 

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

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

169 the data file name. 

170 """ 

171 

172 # Our implementation is unknown at this time. 

173 self.pid = None 

174 self.cov_controller = None 

175 self.cov_report = compat.StringIO() 

176 self.cov_total = None 

177 self.failed = False 

178 self._started = False 

179 self._start_path = None 

180 self._disabled = False 

181 self.options = options 

182 

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

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

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

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

187 self._disabled = True 

188 return 

189 

190 if not self.options.cov_report: 

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

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

193 self.options.cov_report = {} 

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

195 

196 # import engine lazily here to avoid importing 

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

198 from . import engine 

199 

200 if is_dist and start: 

201 self.start(engine.DistMaster) 

202 elif start: 

203 self.start(engine.Central) 

204 

205 # worker is started in pytest hook 

206 

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

208 

209 if config is None: 

210 # fake config option for engine 

211 class Config: 

212 option = self.options 

213 

214 config = Config() 

215 

216 self.cov_controller = controller_cls( 

217 self.options.cov_source, 

218 self.options.cov_report, 

219 self.options.cov_config, 

220 self.options.cov_append, 

221 self.options.cov_branch, 

222 config, 

223 nodeid 

224 ) 

225 self.cov_controller.start() 

226 self._started = True 

227 self._start_path = os.getcwd() 

228 cov_config = self.cov_controller.cov.config 

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

230 self.options.cov_fail_under = cov_config.fail_under 

231 

232 def _is_worker(self, session): 

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

234 

235 def pytest_sessionstart(self, session): 

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

237 

238 if self.options.no_cov: 

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

240 self._disabled = True 

241 return 

242 

243 # import engine lazily here to avoid importing 

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

245 from . import engine 

246 

247 self.pid = os.getpid() 

248 if self._is_worker(session): 

249 nodeid = ( 

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

251 ) 

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

253 elif not self._started: 

254 self.start(engine.Central) 

255 

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

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

258 

259 @pytest.hookimpl(optionalhook=True) 

260 def pytest_configure_node(self, node): 

261 """Delegate to our implementation. 

262 

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

264 """ 

265 if not self._disabled: 

266 self.cov_controller.configure_node(node) 

267 

268 @pytest.hookimpl(optionalhook=True) 

269 def pytest_testnodedown(self, node, error): 

270 """Delegate to our implementation. 

271 

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

273 """ 

274 if not self._disabled: 

275 self.cov_controller.testnodedown(node, error) 

276 

277 def _should_report(self): 

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

279 

280 def _failed_cov_total(self): 

281 cov_fail_under = self.options.cov_fail_under 

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

283 

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

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

286 @pytest.hookimpl(hookwrapper=True) 

287 def pytest_runtestloop(self, session): 

288 yield 

289 

290 if self._disabled: 

291 return 

292 

293 compat_session = compat.SessionWrapper(session) 

294 

295 self.failed = bool(compat_session.testsfailed) 

296 if self.cov_controller is not None: 

297 self.cov_controller.finish() 

298 

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

300 

301 # import coverage lazily here to avoid importing 

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

303 from coverage.misc import CoverageException 

304 

305 try: 

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

307 except CoverageException as exc: 

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

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

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

311 warnings.warn(CovReportWarning(message)) 

312 self.cov_total = 0 

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

314 if self._failed_cov_total() and not self.options.collectonly: 

315 # make sure we get the EXIT_TESTSFAILED exit code 

316 compat_session.testsfailed += 1 

317 

318 def pytest_terminal_summary(self, terminalreporter): 

319 if self._disabled: 

320 if self.options.no_cov_should_warn: 

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

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

323 warnings.warn(CovDisabledWarning(message)) 

324 return 

325 if self.cov_controller is None: 

326 return 

327 

328 if self.cov_total is None: 

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

330 return 

331 

332 report = self.cov_report.getvalue() 

333 

334 # Avoid undesirable new lines when output is disabled with "--cov-report=". 

335 if report: 

336 terminalreporter.write('\n' + report + '\n') 

337 

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

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

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

341 message = ( 

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

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

344 .format( 

345 required=self.options.cov_fail_under, 

346 actual=self.cov_total, 

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

348 reached="not reached" if failed else "reached" 

349 ) 

350 ) 

351 terminalreporter.write(message, **markup) 

352 

353 def pytest_runtest_setup(self, item): 

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

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

356 # coverage manually 

357 embed.init() 

358 

359 def pytest_runtest_teardown(self, item): 

360 embed.cleanup() 

361 

362 @pytest.hookimpl(hookwrapper=True) 

363 def pytest_runtest_call(self, item): 

364 if (item.get_closest_marker('no_cover') 

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

366 self.cov_controller.pause() 

367 yield 

368 self.cov_controller.resume() 

369 else: 

370 yield 

371 

372 

373class TestContextPlugin: 

374 def __init__(self, cov): 

375 self.cov = cov 

376 

377 def pytest_runtest_setup(self, item): 

378 self.switch_context(item, 'setup') 

379 

380 def pytest_runtest_teardown(self, item): 

381 self.switch_context(item, 'teardown') 

382 

383 def pytest_runtest_call(self, item): 

384 self.switch_context(item, 'run') 

385 

386 def switch_context(self, item, when): 

387 context = f"{item.nodeid}|{when}" 

388 self.cov.switch_context(context) 

389 os.environ['COV_CORE_CONTEXT'] = context 

390 

391 

392@pytest.fixture 

393def no_cover(): 

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

395 pass 

396 

397 

398@pytest.fixture 

399def cov(request): 

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

401 

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

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

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

405 if plugin.cov_controller: 

406 return plugin.cov_controller.cov 

407 return None 

408 

409 

410def pytest_configure(config): 

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