From f2c8159cebe14167ce36830bf5f858cec3e17c4d Mon Sep 17 00:00:00 2001 From: Hezarfen Builder Date: Sun, 26 Jul 2026 13:28:09 +0300 Subject: [PATCH 1/2] Make the fail-under summary agree with the exit code pytest-cov decides the --cov-fail-under verdict twice with two different comparisons. pytest_sessionfinish uses coverage.py's should_fail_under, which compares the total rounded to [report] precision. The terminal summary compares the raw total with a bare `<`. When the raw total is below the threshold but rounds up to meet it, the two disagree: the run exits 0 while the summary prints a red "FAIL Required test coverage of N% not reached". Use the same predicate in both places. The exit code is unchanged; only the banner and its colour move, so no currently-passing build starts failing. Adds two regression tests alongside the existing test_cov_min_float_value group. Both fail on master and pass with this change; the rest of the fail-under tests are unaffected because their thresholds do not sit in the divergent band. Refs #638. #728 reports the same inconsistency from the opposite direction -- see the PR description. --- CHANGELOG.rst | 11 + src/pytest_cov/plugin.py | 948 ++++++++++++++++++++------------------- tests/test_pytest_cov.py | 25 ++ 3 files changed, 513 insertions(+), 471 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 15a028dd..ac21f5d4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,17 @@ Changelog ========= +* Fixed the terminal summary contradicting the exit code when ``--cov-fail-under`` is used. + + The exit code is decided with coverage.py's ``should_fail_under``, which compares the total rounded to + ``[report] precision``, but the summary banner compared the raw total. When the raw total was below the + threshold and rounded up to meet it, the run exited 0 while printing a red + ``FAIL Required test coverage of N% not reached``. + The banner now uses the same predicate as the exit code, so ``[report] precision`` is honoured in both. + See `#638 `_ + (`#728 `_ reports the same inconsistency + from the opposite direction). + 7.1.0 (2026-03-21) ------------------ diff --git a/src/pytest_cov/plugin.py b/src/pytest_cov/plugin.py index 9f5c3f76..d78f20df 100644 --- a/src/pytest_cov/plugin.py +++ b/src/pytest_cov/plugin.py @@ -1,471 +1,477 @@ -"""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 pytest - -from . import CovDisabledWarning -from . import CovReportWarning -from . import PytestCovWarning - -if TYPE_CHECKING: - from .engine import CovController - -# The message is unescaped if it comes from configuration file, and escaped if -# it comes from command line option (-W) or PYTHONWARNINGS envvar. -COVERAGE_SQLITE_WARNING_RE = re.compile('unclosed database in 100: - 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': - raise argparse.ArgumentTypeError('The only supported value is "test".') - return arg - - -class StoreReport(argparse.Action): - def __call__(self, parser, namespace, values, option_string=None): - 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.', - ) - - -def _prepare_cov_source(cov_source): - """ - Prepare cov_source so that: - - --cov --cov=foobar is equivalent to --cov (cov_source=None) - --cov=foo --cov=bar is equivalent to cov_source=['foo', 'bar'] - """ - return None if True in cov_source else [path for path in cov_source if path is not True] - - -@pytest.hookimpl(tryfirst=True) -def pytest_load_initial_conftests(early_config, parser, args): - options = early_config.known_args_namespace - no_cov = options.no_cov_should_warn = False - for arg in args: - arg = str(arg) - if arg == '--no-cov': - no_cov = True - elif arg.startswith('--cov') and no_cov: - options.no_cov_should_warn = True - break - - if early_config.known_args_namespace.cov_source: - plugin = CovPlugin(options, early_config.pluginmanager) - early_config.pluginmanager.register(plugin, '_cov') - - -class CovPlugin: - """Use coverage package to produce code coverage reports. - - Delegates all work to a particular implementation based on whether - this test process is centralised, a distributed master or a - distributed worker. - """ - - def __init__(self, options: argparse.Namespace, 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 - name. This is needed since we give coverage through it's API - the data file name. - """ - - # Our implementation is unknown at this time. - self.pid = None - self.cov_controller = None - self.cov_report = 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' - if getattr(options, 'no_cov', False): - self._disabled = True - return - - if not self.options.cov_report: - self.options.cov_report = ['term'] - elif len(self.options.cov_report) == 1 and '' in self.options.cov_report: - self.options.cov_report = {} - self.options.cov_source = _prepare_cov_source(self.options.cov_source) - - # import engine lazily here to avoid importing - # it for unit tests that don't need it - from . import engine - - if is_dist and start: - self.start(engine.DistMaster) - elif start: - self.start(engine.Central) - - # worker is started in pytest hook - - def start(self, controller_cls: type['CovController'], config=None, nodeid=None): - if config is None: - # fake config option for engine - class Config: - option = self.options - - config = Config() - - self.cov_controller = controller_cls(self.options, config, nodeid) - self.cov_controller.start() - self._started = True - self._start_path = Path.cwd() - 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 - - def pytest_sessionstart(self, session): - """At session start determine our implementation and delegate to it.""" - - if self.options.no_cov: - # Coverage can be disabled because it does not cooperate with debuggers well. - self._disabled = True - return - - # import engine lazily here to avoid importing - # it for unit tests that don't need it - from . import engine - - self.pid = os.getpid() - if self._is_worker(session): - nodeid = session.config.workerinput.get('workerid', 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') - - @pytest.hookimpl(optionalhook=True) - def pytest_configure_node(self, node): - """Delegate to our implementation. - - Mark this hook as optional in case xdist is not installed. - """ - if not self._disabled: - self.cov_controller.configure_node(node) - - @pytest.hookimpl(optionalhook=True) - def pytest_testnodedown(self, node, error): - """Delegate to our implementation. - - Mark this hook as optional in case xdist is not installed. - """ - if not self._disabled: - self.cov_controller.testnodedown(node, error) - - def _should_report(self): - needed = self.options.cov_report or self.options.cov_fail_under - return needed and not (self.failed and self.options.no_cov_on_fail) - - # we need to wrap pytest_runtestloop. by the time pytest_sessionfinish - # runs, it's too late to set testsfailed - @pytest.hookimpl(wrapper=True) - def pytest_runtestloop(self, session): - if self._disabled: - return (yield) - - # 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 in (COVERAGE_SQLITE_WARNING_RE, COVERAGE_SQLITE_WARNING_RE2): - break - else: - warnings.filterwarnings('default', 'unclosed database in 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', - ) - terminalreporter.write(message, **markup) - - @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_call(self, item): - if item.get_closest_marker('no_cover') or 'no_cover' in getattr(item, 'fixturenames', ()): - self.cov_controller.pause() - yield - self.cov_controller.resume() - else: - yield - - -class TestContextPlugin: - cov_controller: 'CovController' - - def __init__(self, cov_controller): - self.cov_controller = cov_controller - - def pytest_runtest_setup(self, item): - self.switch_context(item, 'setup') - - def pytest_runtest_teardown(self, item): - self.switch_context(item, 'teardown') - - def pytest_runtest_call(self, item): - 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}') - - -@pytest.fixture -def no_cover(): - """A pytest fixture to disable coverage.""" - - -@pytest.fixture -def cov(request): - """A pytest fixture to provide access to the underlying coverage object.""" - - # Check with hasplugin to avoid getplugin exception in older pytest. - if request.config.pluginmanager.hasplugin('_cov'): - plugin = request.config.pluginmanager.getplugin('_cov') - if plugin.cov_controller: - return plugin.cov_controller.cov - return None - - -def pytest_configure(config): - config.addinivalue_line('markers', 'no_cover: disable coverage for this test.') +"""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 pytest + +from . import CovDisabledWarning +from . import CovReportWarning +from . import PytestCovWarning + +if TYPE_CHECKING: + from .engine import CovController + +# The message is unescaped if it comes from configuration file, and escaped if +# it comes from command line option (-W) or PYTHONWARNINGS envvar. +COVERAGE_SQLITE_WARNING_RE = re.compile('unclosed database in 100: + 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': + raise argparse.ArgumentTypeError('The only supported value is "test".') + return arg + + +class StoreReport(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + 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.', + ) + + +def _prepare_cov_source(cov_source): + """ + Prepare cov_source so that: + + --cov --cov=foobar is equivalent to --cov (cov_source=None) + --cov=foo --cov=bar is equivalent to cov_source=['foo', 'bar'] + """ + return None if True in cov_source else [path for path in cov_source if path is not True] + + +@pytest.hookimpl(tryfirst=True) +def pytest_load_initial_conftests(early_config, parser, args): + options = early_config.known_args_namespace + no_cov = options.no_cov_should_warn = False + for arg in args: + arg = str(arg) + if arg == '--no-cov': + no_cov = True + elif arg.startswith('--cov') and no_cov: + options.no_cov_should_warn = True + break + + if early_config.known_args_namespace.cov_source: + plugin = CovPlugin(options, early_config.pluginmanager) + early_config.pluginmanager.register(plugin, '_cov') + + +class CovPlugin: + """Use coverage package to produce code coverage reports. + + Delegates all work to a particular implementation based on whether + this test process is centralised, a distributed master or a + distributed worker. + """ + + def __init__(self, options: argparse.Namespace, 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 + name. This is needed since we give coverage through it's API + the data file name. + """ + + # Our implementation is unknown at this time. + self.pid = None + self.cov_controller = None + self.cov_report = 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' + if getattr(options, 'no_cov', False): + self._disabled = True + return + + if not self.options.cov_report: + self.options.cov_report = ['term'] + elif len(self.options.cov_report) == 1 and '' in self.options.cov_report: + self.options.cov_report = {} + self.options.cov_source = _prepare_cov_source(self.options.cov_source) + + # import engine lazily here to avoid importing + # it for unit tests that don't need it + from . import engine + + if is_dist and start: + self.start(engine.DistMaster) + elif start: + self.start(engine.Central) + + # worker is started in pytest hook + + def start(self, controller_cls: type['CovController'], config=None, nodeid=None): + if config is None: + # fake config option for engine + class Config: + option = self.options + + config = Config() + + self.cov_controller = controller_cls(self.options, config, nodeid) + self.cov_controller.start() + self._started = True + self._start_path = Path.cwd() + 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 + + def pytest_sessionstart(self, session): + """At session start determine our implementation and delegate to it.""" + + if self.options.no_cov: + # Coverage can be disabled because it does not cooperate with debuggers well. + self._disabled = True + return + + # import engine lazily here to avoid importing + # it for unit tests that don't need it + from . import engine + + self.pid = os.getpid() + if self._is_worker(session): + nodeid = session.config.workerinput.get('workerid', 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') + + @pytest.hookimpl(optionalhook=True) + def pytest_configure_node(self, node): + """Delegate to our implementation. + + Mark this hook as optional in case xdist is not installed. + """ + if not self._disabled: + self.cov_controller.configure_node(node) + + @pytest.hookimpl(optionalhook=True) + def pytest_testnodedown(self, node, error): + """Delegate to our implementation. + + Mark this hook as optional in case xdist is not installed. + """ + if not self._disabled: + self.cov_controller.testnodedown(node, error) + + def _should_report(self): + needed = self.options.cov_report or self.options.cov_fail_under + return needed and not (self.failed and self.options.no_cov_on_fail) + + # we need to wrap pytest_runtestloop. by the time pytest_sessionfinish + # runs, it's too late to set testsfailed + @pytest.hookimpl(wrapper=True) + def pytest_runtestloop(self, session): + if self._disabled: + return (yield) + + # 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 in (COVERAGE_SQLITE_WARNING_RE, COVERAGE_SQLITE_WARNING_RE2): + break + else: + warnings.filterwarnings('default', 'unclosed database in 0: + # import coverage lazily here to avoid importing + # it for unit tests that don't need it + from coverage.results import should_fail_under + + self.write_heading(terminalreporter) + # Use the same predicate that decided the exit code in + # pytest_sessionfinish, so this banner cannot contradict it. + failed = should_fail_under(self.cov_total, self.options.cov_fail_under, self.options.cov_precision) + 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', + ) + terminalreporter.write(message, **markup) + + @pytest.hookimpl(hookwrapper=True) + def pytest_runtest_call(self, item): + if item.get_closest_marker('no_cover') or 'no_cover' in getattr(item, 'fixturenames', ()): + self.cov_controller.pause() + yield + self.cov_controller.resume() + else: + yield + + +class TestContextPlugin: + cov_controller: 'CovController' + + def __init__(self, cov_controller): + self.cov_controller = cov_controller + + def pytest_runtest_setup(self, item): + self.switch_context(item, 'setup') + + def pytest_runtest_teardown(self, item): + self.switch_context(item, 'teardown') + + def pytest_runtest_call(self, item): + 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}') + + +@pytest.fixture +def no_cover(): + """A pytest fixture to disable coverage.""" + + +@pytest.fixture +def cov(request): + """A pytest fixture to provide access to the underlying coverage object.""" + + # Check with hasplugin to avoid getplugin exception in older pytest. + if request.config.pluginmanager.hasplugin('_cov'): + plugin = request.config.pluginmanager.getplugin('_cov') + if plugin.cov_controller: + return plugin.cov_controller.cov + return None + + +def pytest_configure(config): + config.addinivalue_line('markers', 'no_cover: disable coverage for this test.') diff --git a/tests/test_pytest_cov.py b/tests/test_pytest_cov.py index b291f432..1a0f40a7 100644 --- a/tests/test_pytest_cov.py +++ b/tests/test_pytest_cov.py @@ -535,6 +535,31 @@ def test_cov_min_float_value_not_reached(testdir): result.stdout.fnmatch_lines(['FAIL Required test coverage of 88.89% not reached. Total coverage: 88.89%']) +def test_cov_min_float_value_rounds_up_to_threshold(testdir): + # The summary banner must agree with the exit code. SCRIPT covers + # 88.888...%, which rounds to 89% at the default precision of 0, so the + # run passes. The banner used to compare the raw total instead and + # printed a red FAIL on a passing run. See #638 and #728. + script = testdir.makepyfile(SCRIPT) + + result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', '--cov-fail-under=88.9', script) + assert result.ret == 0 + result.stdout.fnmatch_lines(['Required test coverage of 88.9% reached. Total coverage: 88.89%']) + + +def test_cov_min_float_value_rounds_up_to_threshold_with_precision(testdir): + # Same divergence, driven by an explicit [report] precision as in #638. + script = testdir.makepyfile(SCRIPT) + testdir.tmpdir.join('.coveragerc').write(""" +[report] +precision = 1 +""") + + result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', '--cov-fail-under=88.9', script) + assert result.ret == 0 + result.stdout.fnmatch_lines(['Required test coverage of 88.9% reached. Total coverage: 88.89%']) + + def test_cov_min_float_value_not_reached_cli(testdir): script = testdir.makepyfile(SCRIPT) result = testdir.runpytest( From f3121a1da1431dce6332961939fc8069b7a608dd Mon Sep 17 00:00:00 2001 From: Hezarfen Builder Date: Mon, 27 Jul 2026 10:40:25 +0300 Subject: [PATCH 2/2] Restore LF line endings in plugin.py The file was committed with CRLF endings, which rewrote all 477 lines and made this pull request look like a full-file rewrite (+477 -471) instead of the seven-line change it is. That is what the review question was about. Endings are back to LF, matching upstream. The diff against master is now only the fail-under predicate change and its two comments; nothing else in this file differs. --- src/pytest_cov/plugin.py | 954 +++++++++++++++++++-------------------- 1 file changed, 477 insertions(+), 477 deletions(-) diff --git a/src/pytest_cov/plugin.py b/src/pytest_cov/plugin.py index d78f20df..d70294b9 100644 --- a/src/pytest_cov/plugin.py +++ b/src/pytest_cov/plugin.py @@ -1,477 +1,477 @@ -"""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 pytest - -from . import CovDisabledWarning -from . import CovReportWarning -from . import PytestCovWarning - -if TYPE_CHECKING: - from .engine import CovController - -# The message is unescaped if it comes from configuration file, and escaped if -# it comes from command line option (-W) or PYTHONWARNINGS envvar. -COVERAGE_SQLITE_WARNING_RE = re.compile('unclosed database in 100: - 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': - raise argparse.ArgumentTypeError('The only supported value is "test".') - return arg - - -class StoreReport(argparse.Action): - def __call__(self, parser, namespace, values, option_string=None): - 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.', - ) - - -def _prepare_cov_source(cov_source): - """ - Prepare cov_source so that: - - --cov --cov=foobar is equivalent to --cov (cov_source=None) - --cov=foo --cov=bar is equivalent to cov_source=['foo', 'bar'] - """ - return None if True in cov_source else [path for path in cov_source if path is not True] - - -@pytest.hookimpl(tryfirst=True) -def pytest_load_initial_conftests(early_config, parser, args): - options = early_config.known_args_namespace - no_cov = options.no_cov_should_warn = False - for arg in args: - arg = str(arg) - if arg == '--no-cov': - no_cov = True - elif arg.startswith('--cov') and no_cov: - options.no_cov_should_warn = True - break - - if early_config.known_args_namespace.cov_source: - plugin = CovPlugin(options, early_config.pluginmanager) - early_config.pluginmanager.register(plugin, '_cov') - - -class CovPlugin: - """Use coverage package to produce code coverage reports. - - Delegates all work to a particular implementation based on whether - this test process is centralised, a distributed master or a - distributed worker. - """ - - def __init__(self, options: argparse.Namespace, 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 - name. This is needed since we give coverage through it's API - the data file name. - """ - - # Our implementation is unknown at this time. - self.pid = None - self.cov_controller = None - self.cov_report = 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' - if getattr(options, 'no_cov', False): - self._disabled = True - return - - if not self.options.cov_report: - self.options.cov_report = ['term'] - elif len(self.options.cov_report) == 1 and '' in self.options.cov_report: - self.options.cov_report = {} - self.options.cov_source = _prepare_cov_source(self.options.cov_source) - - # import engine lazily here to avoid importing - # it for unit tests that don't need it - from . import engine - - if is_dist and start: - self.start(engine.DistMaster) - elif start: - self.start(engine.Central) - - # worker is started in pytest hook - - def start(self, controller_cls: type['CovController'], config=None, nodeid=None): - if config is None: - # fake config option for engine - class Config: - option = self.options - - config = Config() - - self.cov_controller = controller_cls(self.options, config, nodeid) - self.cov_controller.start() - self._started = True - self._start_path = Path.cwd() - 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 - - def pytest_sessionstart(self, session): - """At session start determine our implementation and delegate to it.""" - - if self.options.no_cov: - # Coverage can be disabled because it does not cooperate with debuggers well. - self._disabled = True - return - - # import engine lazily here to avoid importing - # it for unit tests that don't need it - from . import engine - - self.pid = os.getpid() - if self._is_worker(session): - nodeid = session.config.workerinput.get('workerid', 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') - - @pytest.hookimpl(optionalhook=True) - def pytest_configure_node(self, node): - """Delegate to our implementation. - - Mark this hook as optional in case xdist is not installed. - """ - if not self._disabled: - self.cov_controller.configure_node(node) - - @pytest.hookimpl(optionalhook=True) - def pytest_testnodedown(self, node, error): - """Delegate to our implementation. - - Mark this hook as optional in case xdist is not installed. - """ - if not self._disabled: - self.cov_controller.testnodedown(node, error) - - def _should_report(self): - needed = self.options.cov_report or self.options.cov_fail_under - return needed and not (self.failed and self.options.no_cov_on_fail) - - # we need to wrap pytest_runtestloop. by the time pytest_sessionfinish - # runs, it's too late to set testsfailed - @pytest.hookimpl(wrapper=True) - def pytest_runtestloop(self, session): - if self._disabled: - return (yield) - - # 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 in (COVERAGE_SQLITE_WARNING_RE, COVERAGE_SQLITE_WARNING_RE2): - break - else: - warnings.filterwarnings('default', 'unclosed database in 0: - # import coverage lazily here to avoid importing - # it for unit tests that don't need it - from coverage.results import should_fail_under - - self.write_heading(terminalreporter) - # Use the same predicate that decided the exit code in - # pytest_sessionfinish, so this banner cannot contradict it. - failed = should_fail_under(self.cov_total, self.options.cov_fail_under, self.options.cov_precision) - 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', - ) - terminalreporter.write(message, **markup) - - @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_call(self, item): - if item.get_closest_marker('no_cover') or 'no_cover' in getattr(item, 'fixturenames', ()): - self.cov_controller.pause() - yield - self.cov_controller.resume() - else: - yield - - -class TestContextPlugin: - cov_controller: 'CovController' - - def __init__(self, cov_controller): - self.cov_controller = cov_controller - - def pytest_runtest_setup(self, item): - self.switch_context(item, 'setup') - - def pytest_runtest_teardown(self, item): - self.switch_context(item, 'teardown') - - def pytest_runtest_call(self, item): - 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}') - - -@pytest.fixture -def no_cover(): - """A pytest fixture to disable coverage.""" - - -@pytest.fixture -def cov(request): - """A pytest fixture to provide access to the underlying coverage object.""" - - # Check with hasplugin to avoid getplugin exception in older pytest. - if request.config.pluginmanager.hasplugin('_cov'): - plugin = request.config.pluginmanager.getplugin('_cov') - if plugin.cov_controller: - return plugin.cov_controller.cov - return None - - -def pytest_configure(config): - config.addinivalue_line('markers', 'no_cover: disable coverage for this test.') +"""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 pytest + +from . import CovDisabledWarning +from . import CovReportWarning +from . import PytestCovWarning + +if TYPE_CHECKING: + from .engine import CovController + +# The message is unescaped if it comes from configuration file, and escaped if +# it comes from command line option (-W) or PYTHONWARNINGS envvar. +COVERAGE_SQLITE_WARNING_RE = re.compile('unclosed database in 100: + 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': + raise argparse.ArgumentTypeError('The only supported value is "test".') + return arg + + +class StoreReport(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + 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.', + ) + + +def _prepare_cov_source(cov_source): + """ + Prepare cov_source so that: + + --cov --cov=foobar is equivalent to --cov (cov_source=None) + --cov=foo --cov=bar is equivalent to cov_source=['foo', 'bar'] + """ + return None if True in cov_source else [path for path in cov_source if path is not True] + + +@pytest.hookimpl(tryfirst=True) +def pytest_load_initial_conftests(early_config, parser, args): + options = early_config.known_args_namespace + no_cov = options.no_cov_should_warn = False + for arg in args: + arg = str(arg) + if arg == '--no-cov': + no_cov = True + elif arg.startswith('--cov') and no_cov: + options.no_cov_should_warn = True + break + + if early_config.known_args_namespace.cov_source: + plugin = CovPlugin(options, early_config.pluginmanager) + early_config.pluginmanager.register(plugin, '_cov') + + +class CovPlugin: + """Use coverage package to produce code coverage reports. + + Delegates all work to a particular implementation based on whether + this test process is centralised, a distributed master or a + distributed worker. + """ + + def __init__(self, options: argparse.Namespace, 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 + name. This is needed since we give coverage through it's API + the data file name. + """ + + # Our implementation is unknown at this time. + self.pid = None + self.cov_controller = None + self.cov_report = 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' + if getattr(options, 'no_cov', False): + self._disabled = True + return + + if not self.options.cov_report: + self.options.cov_report = ['term'] + elif len(self.options.cov_report) == 1 and '' in self.options.cov_report: + self.options.cov_report = {} + self.options.cov_source = _prepare_cov_source(self.options.cov_source) + + # import engine lazily here to avoid importing + # it for unit tests that don't need it + from . import engine + + if is_dist and start: + self.start(engine.DistMaster) + elif start: + self.start(engine.Central) + + # worker is started in pytest hook + + def start(self, controller_cls: type['CovController'], config=None, nodeid=None): + if config is None: + # fake config option for engine + class Config: + option = self.options + + config = Config() + + self.cov_controller = controller_cls(self.options, config, nodeid) + self.cov_controller.start() + self._started = True + self._start_path = Path.cwd() + 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 + + def pytest_sessionstart(self, session): + """At session start determine our implementation and delegate to it.""" + + if self.options.no_cov: + # Coverage can be disabled because it does not cooperate with debuggers well. + self._disabled = True + return + + # import engine lazily here to avoid importing + # it for unit tests that don't need it + from . import engine + + self.pid = os.getpid() + if self._is_worker(session): + nodeid = session.config.workerinput.get('workerid', 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') + + @pytest.hookimpl(optionalhook=True) + def pytest_configure_node(self, node): + """Delegate to our implementation. + + Mark this hook as optional in case xdist is not installed. + """ + if not self._disabled: + self.cov_controller.configure_node(node) + + @pytest.hookimpl(optionalhook=True) + def pytest_testnodedown(self, node, error): + """Delegate to our implementation. + + Mark this hook as optional in case xdist is not installed. + """ + if not self._disabled: + self.cov_controller.testnodedown(node, error) + + def _should_report(self): + needed = self.options.cov_report or self.options.cov_fail_under + return needed and not (self.failed and self.options.no_cov_on_fail) + + # we need to wrap pytest_runtestloop. by the time pytest_sessionfinish + # runs, it's too late to set testsfailed + @pytest.hookimpl(wrapper=True) + def pytest_runtestloop(self, session): + if self._disabled: + return (yield) + + # 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 in (COVERAGE_SQLITE_WARNING_RE, COVERAGE_SQLITE_WARNING_RE2): + break + else: + warnings.filterwarnings('default', 'unclosed database in 0: + # import coverage lazily here to avoid importing + # it for unit tests that don't need it + from coverage.results import should_fail_under + + self.write_heading(terminalreporter) + # Use the same predicate that decided the exit code in + # pytest_sessionfinish, so this banner cannot contradict it. + failed = should_fail_under(self.cov_total, self.options.cov_fail_under, self.options.cov_precision) + 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', + ) + terminalreporter.write(message, **markup) + + @pytest.hookimpl(hookwrapper=True) + def pytest_runtest_call(self, item): + if item.get_closest_marker('no_cover') or 'no_cover' in getattr(item, 'fixturenames', ()): + self.cov_controller.pause() + yield + self.cov_controller.resume() + else: + yield + + +class TestContextPlugin: + cov_controller: 'CovController' + + def __init__(self, cov_controller): + self.cov_controller = cov_controller + + def pytest_runtest_setup(self, item): + self.switch_context(item, 'setup') + + def pytest_runtest_teardown(self, item): + self.switch_context(item, 'teardown') + + def pytest_runtest_call(self, item): + 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}') + + +@pytest.fixture +def no_cover(): + """A pytest fixture to disable coverage.""" + + +@pytest.fixture +def cov(request): + """A pytest fixture to provide access to the underlying coverage object.""" + + # Check with hasplugin to avoid getplugin exception in older pytest. + if request.config.pluginmanager.hasplugin('_cov'): + plugin = request.config.pluginmanager.getplugin('_cov') + if plugin.cov_controller: + return plugin.cov_controller.cov + return None + + +def pytest_configure(config): + config.addinivalue_line('markers', 'no_cover: disable coverage for this test.')