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

2 import argparse

3 import os

4 import warnings

5

6 import coverage

7 import pytest

8

9 from . import compat

10 from . import embed

11

12

13 class CoverageError(Exception):

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

15

16

17 class PytestCovWarning(pytest.PytestWarning):

18 """

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

20 """

21

22

23 class CovDisabledWarning(PytestCovWarning):

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

25

26

27 class CovReportWarning(PytestCovWarning):

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

29

30

31 def 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

60 def 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

74 def 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

82 class 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

88 def 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

129 def _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)

140 def 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

156 class 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

373 class 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

393 def no_cover():

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

395 pass

396

397

398@pytest.fixture

399 def 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

410 def pytest_configure(config):

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