Coverage for /home/martinb/.local/share/virtualenvs/camcops/lib/python3.6/site-packages/pytest_cov/plugin.py : 9%

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
6import coverage
7import pytest
9from . import compat
10from . import embed
13class CoverageError(Exception):
14 """Indicates that our coverage is too low"""
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)
28 if len(values) == 1:
29 return report_type, None
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
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)
40 return values
43def validate_fail_under(num_str):
44 try:
45 return int(num_str)
46 except ValueError:
47 return float(num_str)
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
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
64def pytest_addoption(parser):
65 """Add options to control coverage."""
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.')
103def _prepare_cov_source(cov_source):
104 """
105 Prepare cov_source so that:
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]
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
125 if early_config.known_args_namespace.cov_source:
126 plugin = CovPlugin(options, early_config.pluginmanager)
127 early_config.pluginmanager.register(plugin, '_cov')
130class CovPlugin(object):
131 """Use coverage package to produce code coverage reports.
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 """
138 def __init__(self, options, pluginmanager, start=True, no_cov_should_warn=False):
139 """Creates a coverage pytest plugin.
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 """
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
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
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)
170 # import engine lazily here to avoid importing
171 # it for unit tests that don't need it
172 from . import engine
174 if is_dist and start:
175 self.start(engine.DistMaster)
176 elif start:
177 self.start(engine.Central)
179 # worker is started in pytest hook
181 def start(self, controller_cls, config=None, nodeid=None):
183 if config is None:
184 # fake config option for engine
185 class Config(object):
186 option = self.options
188 config = Config()
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
206 def _is_worker(self, session):
207 return getattr(session.config, 'workerinput', None) is not None
209 def pytest_sessionstart(self, session):
210 """At session start determine our implementation and delegate to it."""
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
217 # import engine lazily here to avoid importing
218 # it for unit tests that don't need it
219 from . import engine
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)
230 if self.options.cov_context == 'test':
231 session.config.pluginmanager.register(TestContextPlugin(self.cov_controller.cov), '_cov_contexts')
233 def pytest_configure_node(self, node):
234 """Delegate to our implementation.
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
242 def pytest_testnodedown(self, node, error):
243 """Delegate to our implementation.
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
251 def _should_report(self):
252 return not (self.failed and self.options.no_cov_on_fail)
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
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
264 if self._disabled:
265 return
267 compat_session = compat.SessionWrapper(session)
269 self.failed = bool(compat_session.testsfailed)
270 if self.cov_controller is not None:
271 self.cov_controller.finish()
273 if not self._is_worker(session) and self._should_report():
275 # import coverage lazily here to avoid importing
276 # it for unit tests that don't need it
277 from coverage.misc import CoverageException
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
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
302 if self.cov_total is None:
303 # we shouldn't report, or report generation failed (error raised above)
304 return
306 terminalreporter.write('\n' + self.cov_report.getvalue() + '\n')
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)
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()
329 def pytest_runtest_teardown(self, item):
330 embed.cleanup()
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
343class TestContextPlugin(object):
344 def __init__(self, cov):
345 self.cov = cov
347 def pytest_runtest_setup(self, item):
348 self.switch_context(item, 'setup')
350 def pytest_runtest_teardown(self, item):
351 self.switch_context(item, 'teardown')
353 def pytest_runtest_call(self, item):
354 self.switch_context(item, 'run')
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
362@pytest.fixture
363def no_cover():
364 """A pytest fixture to disable coverage."""
365 pass
368@pytest.fixture
369def cov(request):
370 """A pytest fixture to provide access to the underlying coverage object."""
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
380def pytest_configure(config):
381 config.addinivalue_line("markers", "no_cover: disable coverage for this test.")