This commit is contained in:
Iliyan Angelov
2025-12-06 03:27:35 +02:00
parent 7667eb5eda
commit 5a8ca3c475
2211 changed files with 28086 additions and 37066 deletions

View File

@@ -1,36 +1,47 @@
"""Coverage plugin for pytest."""
import argparse
import os
import re
import warnings
from io import StringIO
from pathlib import Path
from typing import TYPE_CHECKING
import coverage
import pytest
from . import CovDisabledWarning
from . import CovReportWarning
from . import PytestCovWarning
from . import compat
from . import embed
if TYPE_CHECKING:
from .engine import CovController
COVERAGE_SQLITE_WARNING_RE = re.compile('unclosed database in <sqlite3.Connection object at', re.I)
class CoverageError(Exception):
"""Indicates that our coverage is too low"""
class PytestCovWarning(pytest.PytestWarning):
"""
The base for all pytest-cov warnings, never raised directly
"""
class CovDisabledWarning(PytestCovWarning):
"""Indicates that Coverage was manually disabled"""
class CovReportWarning(PytestCovWarning):
"""Indicates that we failed to generate a report"""
def validate_report(arg):
file_choices = ['annotate', 'html', 'xml', 'json', 'markdown', 'markdown-append', 'lcov']
file_choices = ['annotate', 'html', 'xml', 'json', 'lcov']
term_choices = ['term', 'term-missing']
term_modifier_choices = ['skip-covered']
all_choices = term_choices + file_choices
values = arg.split(':', 1)
values = arg.split(":", 1)
report_type = values[0]
if report_type not in [*all_choices, '']:
if report_type not in all_choices + ['']:
msg = f'invalid choice: "{arg}" (choose from "{all_choices}")'
raise argparse.ArgumentTypeError(msg)
if report_type == 'lcov' and coverage.version_info <= (6, 3):
raise argparse.ArgumentTypeError('LCOV output is only supported with coverage.py >= 6.3')
if len(values) == 1:
return report_type, None
@@ -39,7 +50,8 @@ def validate_report(arg):
return report_type, report_modifier
if report_type not in file_choices:
msg = f'output specifier not supported for: "{arg}" (choose from "{file_choices}")'
msg = 'output specifier not supported for: "{}" (choose from "{}")'.format(arg,
file_choices)
raise argparse.ArgumentTypeError(msg)
return values
@@ -52,16 +64,17 @@ def validate_fail_under(num_str):
try:
value = float(num_str)
except ValueError:
raise argparse.ArgumentTypeError('An integer or float value is required.') from None
raise argparse.ArgumentTypeError('An integer or float value is required.')
if value > 100:
raise argparse.ArgumentTypeError(
'Your desire for over-achievement is admirable but misplaced. The maximum value is 100. Perhaps write more integration tests?'
)
raise argparse.ArgumentTypeError('Your desire for over-achievement is admirable but misplaced. '
'The maximum value is 100. Perhaps write more integration tests?')
return value
def validate_context(arg):
if arg != 'test':
if coverage.version_info <= (5, 0):
raise argparse.ArgumentTypeError('Contexts are only supported with coverage.py >= 5.x')
if arg != "test":
raise argparse.ArgumentTypeError('The only supported value is "test".')
return arg
@@ -71,107 +84,46 @@ class StoreReport(argparse.Action):
report_type, file = values
namespace.cov_report[report_type] = file
# coverage.py doesn't set a default file for markdown output_format
if report_type in ['markdown', 'markdown-append'] and file is None:
namespace.cov_report[report_type] = 'coverage.md'
if all(x in namespace.cov_report for x in ['markdown', 'markdown-append']):
self._validate_markdown_dest_files(namespace.cov_report, parser)
def _validate_markdown_dest_files(self, cov_report_options, parser):
markdown_file = cov_report_options['markdown']
markdown_append_file = cov_report_options['markdown-append']
if markdown_file == markdown_append_file:
error_message = f"markdown and markdown-append options cannot point to the same file: '{markdown_file}'."
error_message += ' Please redirect one of them using :DEST (e.g. --cov-report=markdown:dest_file.md)'
parser.error(error_message)
def pytest_addoption(parser):
"""Add options to control coverage."""
group = parser.getgroup('cov', 'coverage reporting with distributed testing support')
group.addoption(
'--cov',
action='append',
default=[],
metavar='SOURCE',
nargs='?',
const=True,
dest='cov_source',
help='Path or package name to measure during execution (multi-allowed). '
'Use --cov= to not do any source filtering and record everything.',
)
group.addoption(
'--cov-reset',
action='store_const',
const=[],
dest='cov_source',
help='Reset cov sources accumulated in options so far. ',
)
group.addoption(
'--cov-report',
action=StoreReport,
default={},
metavar='TYPE',
type=validate_report,
help='Type of report to generate: term, term-missing, '
'annotate, html, xml, json, markdown, markdown-append, lcov (multi-allowed). '
'term, term-missing may be followed by ":skip-covered". '
'annotate, html, xml, json, markdown, markdown-append and lcov may be followed by ":DEST" '
'where DEST specifies the output location. '
'Use --cov-report= to not generate any output.',
)
group.addoption(
'--cov-config',
action='store',
default='.coveragerc',
metavar='PATH',
help='Config file for coverage. Default: .coveragerc',
)
group.addoption(
'--no-cov-on-fail',
action='store_true',
default=False,
help='Do not report coverage if test run fails. Default: False',
)
group.addoption(
'--no-cov',
action='store_true',
default=False,
help='Disable coverage report completely (useful for debuggers). Default: False',
)
group.addoption(
'--cov-fail-under',
action='store',
metavar='MIN',
type=validate_fail_under,
help='Fail if the total coverage is less than MIN.',
)
group.addoption(
'--cov-append',
action='store_true',
default=False,
help='Do not delete coverage but append to current. Default: False',
)
group.addoption(
'--cov-branch',
action='store_true',
default=None,
help='Enable branch coverage.',
)
group.addoption(
'--cov-precision',
type=int,
default=None,
help='Override the reporting precision.',
)
group.addoption(
'--cov-context',
action='store',
metavar='CONTEXT',
type=validate_context,
help='Dynamic contexts to use. "test" for now.',
)
group = parser.getgroup(
'cov', 'coverage reporting with distributed testing support')
group.addoption('--cov', action='append', default=[], metavar='SOURCE',
nargs='?', const=True, dest='cov_source',
help='Path or package name to measure during execution (multi-allowed). '
'Use --cov= to not do any source filtering and record everything.')
group.addoption('--cov-reset', action='store_const', const=[], dest='cov_source',
help='Reset cov sources accumulated in options so far. ')
group.addoption('--cov-report', action=StoreReport, default={},
metavar='TYPE', type=validate_report,
help='Type of report to generate: term, term-missing, '
'annotate, html, xml, json, lcov (multi-allowed). '
'term, term-missing may be followed by ":skip-covered". '
'annotate, html, xml, json and lcov may be followed by ":DEST" '
'where DEST specifies the output location. '
'Use --cov-report= to not generate any output.')
group.addoption('--cov-config', action='store', default='.coveragerc',
metavar='PATH',
help='Config file for coverage. Default: .coveragerc')
group.addoption('--no-cov-on-fail', action='store_true', default=False,
help='Do not report coverage if test run fails. '
'Default: False')
group.addoption('--no-cov', action='store_true', default=False,
help='Disable coverage report completely (useful for debuggers). '
'Default: False')
group.addoption('--cov-fail-under', action='store', metavar='MIN',
type=validate_fail_under,
help='Fail if the total coverage is less than MIN.')
group.addoption('--cov-append', action='store_true', default=False,
help='Do not delete coverage but append to current. '
'Default: False')
group.addoption('--cov-branch', action='store_true', default=None,
help='Enable branch coverage.')
group.addoption('--cov-context', action='store', metavar='CONTEXT',
type=validate_context,
help='Dynamic contexts to use. "test" for now.')
def _prepare_cov_source(cov_source):
@@ -209,7 +161,7 @@ class CovPlugin:
distributed worker.
"""
def __init__(self, options: argparse.Namespace, pluginmanager, start=True, no_cov_should_warn=False):
def __init__(self, options, pluginmanager, start=True, no_cov_should_warn=False):
"""Creates a coverage pytest plugin.
We read the rc file that coverage uses to get the data file
@@ -220,16 +172,17 @@ class CovPlugin:
# Our implementation is unknown at this time.
self.pid = None
self.cov_controller = None
self.cov_report = StringIO()
self.cov_report = compat.StringIO()
self.cov_total = None
self.failed = False
self._started = False
self._start_path = None
self._disabled = False
self.options = options
self._wrote_heading = False
is_dist = getattr(options, 'numprocesses', False) or getattr(options, 'distload', False) or getattr(options, 'dist', 'no') != 'no'
is_dist = (getattr(options, 'numprocesses', False) or
getattr(options, 'distload', False) or
getattr(options, 'dist', 'no') != 'no')
if getattr(options, 'no_cov', False):
self._disabled = True
return
@@ -251,7 +204,8 @@ class CovPlugin:
# worker is started in pytest hook
def start(self, controller_cls: type['CovController'], config=None, nodeid=None):
def start(self, controller_cls, config=None, nodeid=None):
if config is None:
# fake config option for engine
class Config:
@@ -259,15 +213,21 @@ class CovPlugin:
config = Config()
self.cov_controller = controller_cls(self.options, config, nodeid)
self.cov_controller = controller_cls(
self.options.cov_source,
self.options.cov_report,
self.options.cov_config,
self.options.cov_append,
self.options.cov_branch,
config,
nodeid
)
self.cov_controller.start()
self._started = True
self._start_path = Path.cwd()
self._start_path = os.getcwd()
cov_config = self.cov_controller.cov.config
if self.options.cov_fail_under is None and hasattr(cov_config, 'fail_under'):
self.options.cov_fail_under = cov_config.fail_under
if self.options.cov_precision is None:
self.options.cov_precision = getattr(cov_config, 'precision', 0)
def _is_worker(self, session):
return getattr(session.config, 'workerinput', None) is not None
@@ -286,13 +246,15 @@ class CovPlugin:
self.pid = os.getpid()
if self._is_worker(session):
nodeid = session.config.workerinput.get('workerid', session.nodeid)
nodeid = (
session.config.workerinput.get('workerid', getattr(session, 'nodeid'))
)
self.start(engine.DistWorker, session.config, nodeid)
elif not self._started:
self.start(engine.Central)
if self.options.cov_context == 'test':
session.config.pluginmanager.register(TestContextPlugin(self.cov_controller), '_cov_contexts')
session.config.pluginmanager.register(TestContextPlugin(self.cov_controller.cov), '_cov_contexts')
@pytest.hookimpl(optionalhook=True)
def pytest_configure_node(self, node):
@@ -316,81 +278,50 @@ class CovPlugin:
needed = self.options.cov_report or self.options.cov_fail_under
return needed and not (self.failed and self.options.no_cov_on_fail)
def _failed_cov_total(self):
cov_fail_under = self.options.cov_fail_under
return cov_fail_under is not None and self.cov_total < cov_fail_under
# we need to wrap pytest_runtestloop. by the time pytest_sessionfinish
# runs, it's too late to set testsfailed
@pytest.hookimpl(wrapper=True)
@pytest.hookimpl(hookwrapper=True)
def pytest_runtestloop(self, session):
yield
if self._disabled:
return (yield)
return
# we add default warning configuration to prevent certain warnings to bubble up as errors due to rigid filterwarnings configuration
for _, message, category, _, _ in warnings.filters:
if category is ResourceWarning and message == COVERAGE_SQLITE_WARNING_RE:
break
else:
warnings.filterwarnings('default', 'unclosed database in <sqlite3.Connection object at', ResourceWarning)
for _, _, category, _, _ in warnings.filters:
if category is PytestCovWarning:
break
else:
warnings.simplefilter('once', PytestCovWarning)
from coverage.exceptions import CoverageWarning
compat_session = compat.SessionWrapper(session)
for _, _, category, _, _ in warnings.filters:
if category is CoverageWarning:
break
else:
warnings.simplefilter('once', CoverageWarning)
result = yield
self.failed = bool(session.testsfailed)
self.failed = bool(compat_session.testsfailed)
if self.cov_controller is not None:
self.cov_controller.finish()
if not self._is_worker(session) and self._should_report():
# import coverage lazily here to avoid importing
# it for unit tests that don't need it
from coverage.misc import CoverageException
from coverage.results import display_covered
from coverage.results import should_fail_under
try:
self.cov_total = self.cov_controller.summary(self.cov_report)
except CoverageException as exc:
message = f'Failed to generate report: {exc}\n'
session.config.pluginmanager.getplugin('terminalreporter').write(f'\nWARNING: {message}\n', red=True, bold=True)
warnings.warn(CovReportWarning(message), stacklevel=1)
session.config.pluginmanager.getplugin("terminalreporter").write(
f'WARNING: {message}\n', red=True, bold=True)
warnings.warn(CovReportWarning(message))
self.cov_total = 0
assert self.cov_total is not None, 'Test coverage should never be `None`'
cov_fail_under = self.options.cov_fail_under
cov_precision = self.options.cov_precision
if cov_fail_under is None or self.options.collectonly:
return
if should_fail_under(self.cov_total, cov_fail_under, cov_precision):
message = 'Coverage failure: total of {total} is less than fail-under={fail_under:.{p}f}'.format(
total=display_covered(self.cov_total, cov_precision),
fail_under=cov_fail_under,
p=cov_precision,
)
session.config.pluginmanager.getplugin('terminalreporter').write(f'\nERROR: {message}\n', red=True, bold=True)
if self._failed_cov_total() and not self.options.collectonly:
# make sure we get the EXIT_TESTSFAILED exit code
session.testsfailed += 1
return result
def write_heading(self, terminalreporter):
if not self._wrote_heading:
terminalreporter.write_sep('=', 'tests coverage')
self._wrote_heading = True
compat_session.testsfailed += 1
def pytest_terminal_summary(self, terminalreporter):
if self._disabled:
if self.options.no_cov_should_warn:
self.write_heading(terminalreporter)
message = 'Coverage disabled via --no-cov switch!'
terminalreporter.write(f'WARNING: {message}\n', red=True, bold=True)
warnings.warn(CovDisabledWarning(message), stacklevel=1)
warnings.warn(CovDisabledWarning(message))
return
if self.cov_controller is None:
return
@@ -401,25 +332,38 @@ class CovPlugin:
report = self.cov_report.getvalue()
# Avoid undesirable new lines when output is disabled with "--cov-report=".
if report:
self.write_heading(terminalreporter)
terminalreporter.write(report)
terminalreporter.write('\n' + report + '\n')
if self.options.cov_fail_under is not None and self.options.cov_fail_under > 0:
self.write_heading(terminalreporter)
failed = self.cov_total < self.options.cov_fail_under
markup = {'red': True, 'bold': True} if failed else {'green': True}
message = '{fail}Required test coverage of {required}% {reached}. Total coverage: {actual:.2f}%\n'.format(
required=self.options.cov_fail_under,
actual=self.cov_total,
fail='FAIL ' if failed else '',
reached='not reached' if failed else 'reached',
message = (
'{fail}Required test coverage of {required}% {reached}. '
'Total coverage: {actual:.2f}%\n'
.format(
required=self.options.cov_fail_under,
actual=self.cov_total,
fail="FAIL " if failed else "",
reached="not reached" if failed else "reached"
)
)
terminalreporter.write(message, **markup)
def pytest_runtest_setup(self, item):
if os.getpid() != self.pid:
# test is run in another process than session, run
# coverage manually
embed.init()
def pytest_runtest_teardown(self, item):
embed.cleanup()
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(self, item):
if item.get_closest_marker('no_cover') or 'no_cover' in getattr(item, 'fixturenames', ()):
if (item.get_closest_marker('no_cover')
or 'no_cover' in getattr(item, 'fixturenames', ())):
self.cov_controller.pause()
yield
self.cov_controller.resume()
@@ -428,10 +372,8 @@ class CovPlugin:
class TestContextPlugin:
cov_controller: 'CovController'
def __init__(self, cov_controller):
self.cov_controller = cov_controller
def __init__(self, cov):
self.cov = cov
def pytest_runtest_setup(self, item):
self.switch_context(item, 'setup')
@@ -443,13 +385,15 @@ class TestContextPlugin:
self.switch_context(item, 'run')
def switch_context(self, item, when):
if self.cov_controller.started:
self.cov_controller.cov.switch_context(f'{item.nodeid}|{when}')
context = f"{item.nodeid}|{when}"
self.cov.switch_context(context)
os.environ['COV_CORE_CONTEXT'] = context
@pytest.fixture
def no_cover():
"""A pytest fixture to disable coverage."""
pass
@pytest.fixture
@@ -465,4 +409,4 @@ def cov(request):
def pytest_configure(config):
config.addinivalue_line('markers', 'no_cover: disable coverage for this test.')
config.addinivalue_line("markers", "no_cover: disable coverage for this test.")