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
« 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
6import coverage
7import pytest
9from . import compat
10from . import embed
13class CoverageError(Exception):
14 """Indicates that our coverage is too low"""
17class PytestCovWarning(pytest.PytestWarning):
18 """
19 The base for all pytest-cov warnings, never raised directly
20 """
23class CovDisabledWarning(PytestCovWarning):
24 """Indicates that Coverage was manually disabled"""
27class CovReportWarning(PytestCovWarning):
28 """Indicates that we failed to generate a report"""
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)
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')
45 if len(values) == 1:
46 return report_type, None
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
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)
57 return values
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
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
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
88def pytest_addoption(parser):
89 """Add options to control coverage."""
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.')
129def _prepare_cov_source(cov_source):
130 """
131 Prepare cov_source so that:
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]
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
151 if early_config.known_args_namespace.cov_source:
152 plugin = CovPlugin(options, early_config.pluginmanager)
153 early_config.pluginmanager.register(plugin, '_cov')
156class CovPlugin:
157 """Use coverage package to produce code coverage reports.
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 """
164 def __init__(self, options, pluginmanager, start=True, no_cov_should_warn=False):
165 """Creates a coverage pytest plugin.
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 """
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
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
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)
196 # import engine lazily here to avoid importing
197 # it for unit tests that don't need it
198 from . import engine
200 if is_dist and start:
201 self.start(engine.DistMaster)
202 elif start:
203 self.start(engine.Central)
205 # worker is started in pytest hook
207 def start(self, controller_cls, config=None, nodeid=None):
209 if config is None:
210 # fake config option for engine
211 class Config:
212 option = self.options
214 config = Config()
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
232 def _is_worker(self, session):
233 return getattr(session.config, 'workerinput', None) is not None
235 def pytest_sessionstart(self, session):
236 """At session start determine our implementation and delegate to it."""
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
243 # import engine lazily here to avoid importing
244 # it for unit tests that don't need it
245 from . import engine
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)
256 if self.options.cov_context == 'test':
257 session.config.pluginmanager.register(TestContextPlugin(self.cov_controller.cov), '_cov_contexts')
259 @pytest.hookimpl(optionalhook=True)
260 def pytest_configure_node(self, node):
261 """Delegate to our implementation.
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)
268 @pytest.hookimpl(optionalhook=True)
269 def pytest_testnodedown(self, node, error):
270 """Delegate to our implementation.
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)
277 def _should_report(self):
278 return not (self.failed and self.options.no_cov_on_fail)
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
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
290 if self._disabled:
291 return
293 compat_session = compat.SessionWrapper(session)
295 self.failed = bool(compat_session.testsfailed)
296 if self.cov_controller is not None:
297 self.cov_controller.finish()
299 if not self._is_worker(session) and self._should_report():
301 # import coverage lazily here to avoid importing
302 # it for unit tests that don't need it
303 from coverage.misc import CoverageException
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
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
328 if self.cov_total is None:
329 # we shouldn't report, or report generation failed (error raised above)
330 return
332 report = self.cov_report.getvalue()
334 # Avoid undesirable new lines when output is disabled with "--cov-report=".
335 if report:
336 terminalreporter.write('\n' + report + '\n')
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)
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()
359 def pytest_runtest_teardown(self, item):
360 embed.cleanup()
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
373class TestContextPlugin:
374 def __init__(self, cov):
375 self.cov = cov
377 def pytest_runtest_setup(self, item):
378 self.switch_context(item, 'setup')
380 def pytest_runtest_teardown(self, item):
381 self.switch_context(item, 'teardown')
383 def pytest_runtest_call(self, item):
384 self.switch_context(item, 'run')
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
392@pytest.fixture
393def no_cover():
394 """A pytest fixture to disable coverage."""
395 pass
398@pytest.fixture
399def cov(request):
400 """A pytest fixture to provide access to the underlying coverage object."""
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
410def pytest_configure(config):
411 config.addinivalue_line("markers", "no_cover: disable coverage for this test.")