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,41 +1,2 @@
"""pytest-cov: avoid already-imported warning: PYTEST_DONT_REWRITE."""
__version__ = '7.0.0'
import pytest
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.
"""
class CentralCovContextWarning(PytestCovWarning):
"""
Indicates that dynamic_context was set to test_function instead of using the builtin --cov-context.
"""
class DistCovError(Exception):
"""
Raised when dynamic_context is set to test_function and xdist is also used.
See: https://github.com/pytest-dev/pytest-cov/issues/604
"""
__version__ = '4.1.0'

View File

@@ -0,0 +1,24 @@
try:
from StringIO import StringIO
except ImportError:
from io import StringIO
StringIO # pyflakes, this is for re-export
class SessionWrapper:
def __init__(self, session):
self._session = session
if hasattr(session, 'testsfailed'):
self._attr = 'testsfailed'
else:
self._attr = '_testsfailed'
@property
def testsfailed(self):
return getattr(self._session, self._attr)
@testsfailed.setter
def testsfailed(self, value):
setattr(self._session, self._attr, value)

View File

@@ -0,0 +1,122 @@
"""Activate coverage at python startup if appropriate.
The python site initialisation will ensure that anything we import
will be removed and not visible at the end of python startup. However
we minimise all work by putting these init actions in this separate
module and only importing what is needed when needed.
For normal python startup when coverage should not be activated the pth
file checks a single env var and does not import or call the init fn
here.
For python startup when an ancestor process has set the env indicating
that code coverage is being collected we activate coverage based on
info passed via env vars.
"""
import atexit
import os
import signal
_active_cov = None
def init():
# Only continue if ancestor process has set everything needed in
# the env.
global _active_cov
cov_source = os.environ.get('COV_CORE_SOURCE')
cov_config = os.environ.get('COV_CORE_CONFIG')
cov_datafile = os.environ.get('COV_CORE_DATAFILE')
cov_branch = True if os.environ.get('COV_CORE_BRANCH') == 'enabled' else None
cov_context = os.environ.get('COV_CORE_CONTEXT')
if cov_datafile:
if _active_cov:
cleanup()
# Import what we need to activate coverage.
import coverage
# Determine all source roots.
if cov_source in os.pathsep:
cov_source = None
else:
cov_source = cov_source.split(os.pathsep)
if cov_config == os.pathsep:
cov_config = True
# Activate coverage for this process.
cov = _active_cov = coverage.Coverage(
source=cov_source,
branch=cov_branch,
data_suffix=True,
config_file=cov_config,
auto_data=True,
data_file=cov_datafile
)
cov.load()
cov.start()
if cov_context:
cov.switch_context(cov_context)
cov._warn_no_data = False
cov._warn_unimported_source = False
return cov
def _cleanup(cov):
if cov is not None:
cov.stop()
cov.save()
cov._auto_save = False # prevent autosaving from cov._atexit in case the interpreter lacks atexit.unregister
try:
atexit.unregister(cov._atexit)
except Exception:
pass
def cleanup():
global _active_cov
global _cleanup_in_progress
global _pending_signal
_cleanup_in_progress = True
_cleanup(_active_cov)
_active_cov = None
_cleanup_in_progress = False
if _pending_signal:
pending_signal = _pending_signal
_pending_signal = None
_signal_cleanup_handler(*pending_signal)
_previous_handlers = {}
_pending_signal = None
_cleanup_in_progress = False
def _signal_cleanup_handler(signum, frame):
global _pending_signal
if _cleanup_in_progress:
_pending_signal = signum, frame
return
cleanup()
_previous_handler = _previous_handlers.get(signum)
if _previous_handler == signal.SIG_IGN:
return
elif _previous_handler and _previous_handler is not _signal_cleanup_handler:
_previous_handler(signum, frame)
elif signum == signal.SIGTERM:
os._exit(128 + signum)
elif signum == signal.SIGINT:
raise KeyboardInterrupt()
def cleanup_on_signal(signum):
previous = signal.getsignal(signum)
if previous is not _signal_cleanup_handler:
_previous_handlers[signum] = previous
signal.signal(signum, _signal_cleanup_handler)
def cleanup_on_sigterm():
cleanup_on_signal(signal.SIGTERM)

View File

@@ -1,28 +1,17 @@
"""Coverage controllers for use by pytest-cov and nose-cov."""
import argparse
import contextlib
import copy
import functools
import os
import random
import shutil
import socket
import sys
import warnings
from pathlib import Path
from typing import Union
import coverage
from coverage.data import CoverageData
from coverage.sqldata import filename_suffix
from . import CentralCovContextWarning
from . import DistCovError
class BrokenCovConfigError(Exception):
pass
from .compat import StringIO
from .embed import cleanup
class _NullFile:
@@ -45,7 +34,7 @@ def _ensure_topdir(meth):
@functools.wraps(meth)
def ensure_topdir_wrapper(self, *args, **kwargs):
try:
original_cwd = Path.cwd()
original_cwd = os.getcwd()
except OSError:
# Looks like it's gone, this is non-ideal because a side-effect will
# be introduced in the tests here but we can't do anything about it.
@@ -63,14 +52,13 @@ def _ensure_topdir(meth):
class CovController:
"""Base class for different plugin implementations."""
def __init__(self, options: argparse.Namespace, config: Union[None, object], nodeid: Union[None, str]):
def __init__(self, cov_source, cov_report, cov_config, cov_append, cov_branch, config=None, nodeid=None):
"""Get some common config used by multiple derived classes."""
self.cov_source = options.cov_source
self.cov_report = options.cov_report
self.cov_config = options.cov_config
self.cov_append = options.cov_append
self.cov_branch = options.cov_branch
self.cov_precision = options.cov_precision
self.cov_source = cov_source
self.cov_report = cov_report
self.cov_config = cov_config
self.cov_append = cov_append
self.cov_branch = cov_branch
self.config = config
self.nodeid = nodeid
@@ -79,73 +67,67 @@ class CovController:
self.data_file = None
self.node_descs = set()
self.failed_workers = []
self.topdir = os.fspath(Path.cwd())
self.topdir = os.getcwd()
self.is_collocated = None
self.started = False
@contextlib.contextmanager
def ensure_topdir(self):
original_cwd = Path.cwd()
original_cwd = os.getcwd()
os.chdir(self.topdir)
yield
os.chdir(original_cwd)
@_ensure_topdir
def pause(self):
self.started = False
self.cov.stop()
self.unset_env()
@_ensure_topdir
def resume(self):
self.cov.start()
self.started = True
self.set_env()
def start(self):
self.started = True
@_ensure_topdir
def set_env(self):
"""Put info about coverage into the env so that subprocesses can activate coverage."""
if self.cov_source is None:
os.environ['COV_CORE_SOURCE'] = os.pathsep
else:
os.environ['COV_CORE_SOURCE'] = os.pathsep.join(self.cov_source)
config_file = os.path.abspath(self.cov_config)
if os.path.exists(config_file):
os.environ['COV_CORE_CONFIG'] = config_file
else:
os.environ['COV_CORE_CONFIG'] = os.pathsep
os.environ['COV_CORE_DATAFILE'] = os.path.abspath(self.cov.config.data_file)
if self.cov_branch:
os.environ['COV_CORE_BRANCH'] = 'enabled'
def finish(self):
self.started = False
@staticmethod
def unset_env():
"""Remove coverage info from env."""
os.environ.pop('COV_CORE_SOURCE', None)
os.environ.pop('COV_CORE_CONFIG', None)
os.environ.pop('COV_CORE_DATAFILE', None)
os.environ.pop('COV_CORE_BRANCH', None)
os.environ.pop('COV_CORE_CONTEXT', None)
@staticmethod
def get_node_desc(platform, version_info):
"""Return a description of this node."""
return 'platform {}, python {}'.format(platform, '{}.{}.{}-{}-{}'.format(*version_info[:5]))
return 'platform {}, python {}'.format(platform, '%s.%s.%s-%s-%s' % version_info[:5])
@staticmethod
def get_width():
# taken from https://github.com/pytest-dev/pytest/blob/33c7b05a/src/_pytest/_io/terminalwriter.py#L26
width, _ = shutil.get_terminal_size(fallback=(80, 24))
# The Windows get_terminal_size may be bogus, let's sanify a bit.
if width < 40:
width = 80
return width
def sep(self, stream, s, txt):
def sep(stream, s, txt):
if hasattr(stream, 'sep'):
stream.sep(s, txt)
else:
fullwidth = self.get_width()
# taken from https://github.com/pytest-dev/pytest/blob/33c7b05a/src/_pytest/_io/terminalwriter.py#L126
# The goal is to have the line be as long as possible
# under the condition that len(line) <= fullwidth.
if sys.platform == 'win32':
# If we print in the last column on windows we are on a
# new line but there is no way to verify/neutralize this
# (we may not know the exact line width).
# So let's be defensive to avoid empty lines in the output.
fullwidth -= 1
N = max((fullwidth - len(txt) - 2) // (2 * len(s)), 1)
fill = s * N
line = f'{fill} {txt} {fill}'
# In some situations there is room for an extra sepchar at the right,
# in particular if we consider that with a sepchar like "_ " the
# trailing space is not important at the end of the line.
if len(line) + len(s.rstrip()) <= fullwidth:
line += s.rstrip()
# (end of terminalwriter borrowed code)
line += '\n\n'
stream.write(line)
sep_total = max((70 - 2 - len(txt)), 2)
sep_len = sep_total // 2
sep_extra = sep_total % 2
out = f'{s * sep_len} {txt} {s * (sep_len + sep_extra)}\n'
stream.write(out)
@_ensure_topdir
def summary(self, stream):
@@ -153,21 +135,22 @@ class CovController:
total = None
if not self.cov_report:
with _backup(self.cov, 'config'):
with _backup(self.cov, "config"):
return self.cov.report(show_missing=True, ignore_errors=True, file=_NullFile)
# Output coverage section header.
if len(self.node_descs) == 1:
self.sep(stream, '_', f'coverage: {"".join(self.node_descs)}')
self.sep(stream, '-', f"coverage: {''.join(self.node_descs)}")
else:
self.sep(stream, '_', 'coverage')
self.sep(stream, '-', 'coverage')
for node_desc in sorted(self.node_descs):
self.sep(stream, ' ', f'{node_desc}')
# Report on any failed workers.
if self.failed_workers:
self.sep(stream, '_', 'coverage: failed workers')
stream.write('The following workers failed to return coverage data, ensure that pytest-cov is installed on these workers.\n')
self.sep(stream, '-', 'coverage: failed workers')
stream.write('The following workers failed to return coverage data, '
'ensure that pytest-cov is installed on these workers.\n')
for node in self.failed_workers:
stream.write(f'{node.gateway.id}\n')
@@ -177,23 +160,22 @@ class CovController:
'show_missing': ('term-missing' in self.cov_report) or None,
'ignore_errors': True,
'file': stream,
'precision': self.cov_precision,
}
skip_covered = isinstance(self.cov_report, dict) and 'skip-covered' in self.cov_report.values()
options.update({'skip_covered': skip_covered or None})
with _backup(self.cov, 'config'):
with _backup(self.cov, "config"):
total = self.cov.report(**options)
# Produce annotated source code report if wanted.
if 'annotate' in self.cov_report:
annotate_dir = self.cov_report['annotate']
with _backup(self.cov, 'config'):
with _backup(self.cov, "config"):
self.cov.annotate(ignore_errors=True, directory=annotate_dir)
# We need to call Coverage.report here, just to get the total
# Coverage.annotate don't return any total and we need it for --cov-fail-under.
with _backup(self.cov, 'config'):
with _backup(self.cov, "config"):
total = self.cov.report(ignore_errors=True, file=_NullFile)
if annotate_dir:
stream.write(f'Coverage annotated source written to dir {annotate_dir}\n')
@@ -203,44 +185,28 @@ class CovController:
# Produce html report if wanted.
if 'html' in self.cov_report:
output = self.cov_report['html']
with _backup(self.cov, 'config'):
with _backup(self.cov, "config"):
total = self.cov.html_report(ignore_errors=True, directory=output)
stream.write(f'Coverage HTML written to dir {self.cov.config.html_dir if output is None else output}\n')
# Produce xml report if wanted.
if 'xml' in self.cov_report:
output = self.cov_report['xml']
with _backup(self.cov, 'config'):
with _backup(self.cov, "config"):
total = self.cov.xml_report(ignore_errors=True, outfile=output)
stream.write(f'Coverage XML written to file {self.cov.config.xml_output if output is None else output}\n')
# Produce json report if wanted
if 'json' in self.cov_report:
output = self.cov_report['json']
with _backup(self.cov, 'config'):
with _backup(self.cov, "config"):
total = self.cov.json_report(ignore_errors=True, outfile=output)
stream.write('Coverage JSON written to file %s\n' % (self.cov.config.json_output if output is None else output))
# Produce Markdown report if wanted.
if 'markdown' in self.cov_report:
output = self.cov_report['markdown']
with _backup(self.cov, 'config'):
with Path(output).open('w') as output_file:
total = self.cov.report(ignore_errors=True, file=output_file, output_format='markdown')
stream.write(f'Coverage Markdown information written to file {output}\n')
# Produce Markdown report if wanted, appending to output file
if 'markdown-append' in self.cov_report:
output = self.cov_report['markdown-append']
with _backup(self.cov, 'config'):
with Path(output).open('a') as output_file:
total = self.cov.report(ignore_errors=True, file=output_file, output_format='markdown')
stream.write(f'Coverage Markdown information appended to file {output}\n')
# Produce lcov report if wanted.
if 'lcov' in self.cov_report:
output = self.cov_report['lcov']
with _backup(self.cov, 'config'):
with _backup(self.cov, "config"):
self.cov.lcov_report(ignore_errors=True, outfile=output)
# We need to call Coverage.report here, just to get the total
@@ -257,39 +223,29 @@ class Central(CovController):
@_ensure_topdir
def start(self):
self.cov = coverage.Coverage(
source=self.cov_source,
branch=self.cov_branch,
data_suffix=True,
config_file=self.cov_config,
)
if self.cov.config.dynamic_context == 'test_function':
message = (
'Detected dynamic_context=test_function in coverage configuration. '
'This is unnecessary as this plugin provides the more complete --cov-context option.'
)
warnings.warn(CentralCovContextWarning(message), stacklevel=1)
cleanup()
self.combining_cov = coverage.Coverage(
source=self.cov_source,
branch=self.cov_branch,
data_suffix=f'{filename_suffix(True)}.combine',
data_file=os.path.abspath(self.cov.config.data_file), # noqa: PTH100
config_file=self.cov_config,
)
self.cov = coverage.Coverage(source=self.cov_source,
branch=self.cov_branch,
data_suffix=True,
config_file=self.cov_config)
self.combining_cov = coverage.Coverage(source=self.cov_source,
branch=self.cov_branch,
data_suffix=True,
data_file=os.path.abspath(self.cov.config.data_file),
config_file=self.cov_config)
# Erase or load any previous coverage data and start coverage.
if not self.cov_append:
self.cov.erase()
self.cov.start()
super().start()
self.set_env()
@_ensure_topdir
def finish(self):
"""Stop coverage, save data to file and set the list of coverage objects to report on."""
super().finish()
self.unset_env()
self.cov.stop()
self.cov.save()
@@ -307,28 +263,26 @@ class DistMaster(CovController):
@_ensure_topdir
def start(self):
self.cov = coverage.Coverage(
source=self.cov_source,
branch=self.cov_branch,
data_suffix=True,
config_file=self.cov_config,
)
if self.cov.config.dynamic_context == 'test_function':
raise DistCovError(
'Detected dynamic_context=test_function in coverage configuration. '
'This is known to cause issues when using xdist, see: https://github.com/pytest-dev/pytest-cov/issues/604\n'
'It is recommended to use --cov-context instead.'
)
cleanup()
# Ensure coverage rc file rsynced if appropriate.
if self.cov_config and os.path.exists(self.cov_config):
# rsyncdir is going away in pytest-xdist 4.0, already deprecated
if hasattr(self.config.option, 'rsyncdir'):
self.config.option.rsyncdir.append(self.cov_config)
self.cov = coverage.Coverage(source=self.cov_source,
branch=self.cov_branch,
data_suffix=True,
config_file=self.cov_config)
self.cov._warn_no_data = False
self.cov._warn_unimported_source = False
self.cov._warn_preimported_source = False
self.combining_cov = coverage.Coverage(
source=self.cov_source,
branch=self.cov_branch,
data_suffix=f'{filename_suffix(True)}.combine',
data_file=os.path.abspath(self.cov.config.data_file), # noqa: PTH100
config_file=self.cov_config,
)
self.combining_cov = coverage.Coverage(source=self.cov_source,
branch=self.cov_branch,
data_suffix=True,
data_file=os.path.abspath(self.cov.config.data_file),
config_file=self.cov_config)
if not self.cov_append:
self.cov.erase()
self.cov.start()
@@ -337,13 +291,11 @@ class DistMaster(CovController):
def configure_node(self, node):
"""Workers need to know if they are collocated and what files have moved."""
node.workerinput.update(
{
'cov_master_host': socket.gethostname(),
'cov_master_topdir': self.topdir,
'cov_master_rsync_roots': [str(root) for root in node.nodemanager.roots],
}
)
node.workerinput.update({
'cov_master_host': socket.gethostname(),
'cov_master_topdir': self.topdir,
'cov_master_rsync_roots': [str(root) for root in node.nodemanager.roots],
})
def testnodedown(self, node, error):
"""Collect data file name from worker."""
@@ -358,17 +310,27 @@ class DistMaster(CovController):
# If worker is not collocated then we must save the data file
# that it returns to us.
if 'cov_worker_data' in output:
data_suffix = '%s.%s.%06d.%s' % ( # noqa: UP031
socket.gethostname(),
os.getpid(),
random.randint(0, 999999), # noqa: S311
output['cov_worker_node_id'],
data_suffix = '%s.%s.%06d.%s' % (
socket.gethostname(), os.getpid(),
random.randint(0, 999999),
output['cov_worker_node_id']
)
cov_data = CoverageData(
suffix=data_suffix,
)
cov_data.loads(output['cov_worker_data'])
cov = coverage.Coverage(source=self.cov_source,
branch=self.cov_branch,
data_suffix=data_suffix,
config_file=self.cov_config)
cov.start()
if coverage.version_info < (5, 0):
data = CoverageData()
data.read_fileobj(StringIO(output['cov_worker_data']))
cov.data.update(data)
else:
data = CoverageData(no_disk=True)
data.loads(output['cov_worker_data'])
cov.get_data().update(data)
cov.stop()
cov.save()
path = output['cov_worker_path']
self.cov.config.paths['source'].append(path)
@@ -395,38 +357,34 @@ class DistWorker(CovController):
@_ensure_topdir
def start(self):
# Determine whether we are collocated with master.
self.is_collocated = (
socket.gethostname() == self.config.workerinput['cov_master_host']
and self.topdir == self.config.workerinput['cov_master_topdir']
)
# If we are not collocated, then rewrite master paths to worker paths.
cleanup()
# Determine whether we are collocated with master.
self.is_collocated = (socket.gethostname() == self.config.workerinput['cov_master_host'] and
self.topdir == self.config.workerinput['cov_master_topdir'])
# If we are not collocated then rewrite master paths to worker paths.
if not self.is_collocated:
master_topdir = self.config.workerinput['cov_master_topdir']
worker_topdir = self.topdir
if self.cov_source is not None:
self.cov_source = [source.replace(master_topdir, worker_topdir) for source in self.cov_source]
self.cov_source = [source.replace(master_topdir, worker_topdir)
for source in self.cov_source]
self.cov_config = self.cov_config.replace(master_topdir, worker_topdir)
# Erase any previous data and start coverage.
self.cov = coverage.Coverage(
source=self.cov_source,
branch=self.cov_branch,
data_suffix=True,
config_file=self.cov_config,
)
# Prevent workers from issuing module-not-measured type of warnings (expected for a workers to not have coverage in all the files).
self.cov._warn_unimported_source = False
self.cov = coverage.Coverage(source=self.cov_source,
branch=self.cov_branch,
data_suffix=True,
config_file=self.cov_config)
self.cov.start()
super().start()
self.set_env()
@_ensure_topdir
def finish(self):
"""Stop coverage and send relevant info back to the master."""
super().finish()
self.unset_env()
self.cov.stop()
if self.is_collocated:
@@ -446,15 +404,20 @@ class DistWorker(CovController):
# it on the master node.
# Send all the data to the master over the channel.
data = self.cov.get_data().dumps()
if coverage.version_info < (5, 0):
buff = StringIO()
self.cov.data.write_fileobj(buff)
data = buff.getvalue()
else:
data = self.cov.get_data().dumps()
self.config.workeroutput.update(
{
'cov_worker_path': self.topdir,
'cov_worker_node_id': self.nodeid,
'cov_worker_data': data,
}
)
self.config.workeroutput.update({
'cov_worker_path': self.topdir,
'cov_worker_node_id': self.nodeid,
'cov_worker_data': data,
})
def summary(self, stream):
"""Only the master reports so do nothing."""
pass

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.")