diff options
| author | Ned Batchelder <ned@nedbatchelder.com> | 2021-05-30 17:39:20 -0400 |
|---|---|---|
| committer | Ned Batchelder <ned@nedbatchelder.com> | 2021-05-30 18:03:05 -0400 |
| commit | 30c023b5b74f9c798645cbb3f35362ae046a4c25 (patch) | |
| tree | e86df1a4c044ec9b2919068297dfd91a382eeb84 | |
| parent | 22fe2eb167a18dda8fd3e14cbf9166a1c7331fb9 (diff) | |
| download | python-coveragepy-git-30c023b5b74f9c798645cbb3f35362ae046a4c25.tar.gz | |
feat: warnings are now real warnings
This makes coverage warnings visible when running test suites under
pytest. But it also means some uninteresting warnings would show up in
our own test suite, so we had to catch or suppress those.
| -rw-r--r-- | CHANGES.rst | 2 | ||||
| -rw-r--r-- | coverage/control.py | 5 | ||||
| -rw-r--r-- | coverage/exceptions.py | 5 | ||||
| -rw-r--r-- | coverage/inorout.py | 13 | ||||
| -rw-r--r-- | coverage/pytracer.py | 6 | ||||
| -rw-r--r-- | tests/helpers.py | 15 | ||||
| -rw-r--r-- | tests/test_api.py | 76 | ||||
| -rw-r--r-- | tests/test_html.py | 15 | ||||
| -rw-r--r-- | tests/test_oddball.py | 26 | ||||
| -rw-r--r-- | tests/test_plugins.py | 23 | ||||
| -rw-r--r-- | tests/test_process.py | 14 | ||||
| -rw-r--r-- | tests/test_summary.py | 5 | ||||
| -rw-r--r-- | tests/test_testing.py | 58 | ||||
| -rw-r--r-- | tests/test_xml.py | 9 |
14 files changed, 183 insertions, 89 deletions
diff --git a/CHANGES.rst b/CHANGES.rst index 1dd5c175..205ef0ab 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -33,6 +33,8 @@ Unreleased imported a file that will be measured" warnings about Django itself. These have been fixed, closing `issue 1150`_. +- Warnings generated by coverage.py are now real Python warnings. + - The ``COVERAGE_DEBUG_FILE`` environment variable now accepts ``stdout`` and ``stderr`` to write to those destinations. diff --git a/coverage/control.py b/coverage/control.py index bf91d447..b13acf45 100644 --- a/coverage/control.py +++ b/coverage/control.py @@ -11,6 +11,7 @@ import os.path import platform import sys import time +import warnings from coverage import env from coverage.annotate import AnnotateReporter @@ -20,7 +21,7 @@ from coverage.context import should_start_context_test_function, combine_context from coverage.data import CoverageData, combine_parallel_data from coverage.debug import DebugControl, short_stack, write_formatted_info from coverage.disposition import disposition_debug_msg -from coverage.exceptions import CoverageException +from coverage.exceptions import CoverageException, CoverageWarning from coverage.files import PathAliases, abs_file, relative_filename, set_relative_directory from coverage.html import HtmlReporter from coverage.inorout import InOrOut @@ -362,7 +363,7 @@ class Coverage: msg = f"{msg} ({slug})" if self._debug.should('pid'): msg = f"[{os.getpid()}] {msg}" - sys.stderr.write(f"Coverage.py warning: {msg}\n") + warnings.warn(msg, category=CoverageWarning, stacklevel=2) if once: self._no_warn_slugs.append(slug) diff --git a/coverage/exceptions.py b/coverage/exceptions.py index ed96fb21..6631e1ad 100644 --- a/coverage/exceptions.py +++ b/coverage/exceptions.py @@ -46,3 +46,8 @@ class StopEverything(BaseCoverageException): """ pass + + +class CoverageWarning(Warning): + """A warning from Coverage.py.""" + pass diff --git a/coverage/inorout.py b/coverage/inorout.py index fae9ef18..32eb9079 100644 --- a/coverage/inorout.py +++ b/coverage/inorout.py @@ -356,10 +356,9 @@ class InOrOut: ) break except Exception: - self.warn( - "Disabling plug-in %r due to an exception:" % (plugin._coverage_plugin_name) - ) - traceback.print_exc() + plugin_name = plugin._coverage_plugin_name + tb = traceback.format_exc() + self.warn(f"Disabling plug-in {plugin_name!r} due to an exception:\n{tb}") plugin._coverage_enabled = False continue else: @@ -503,10 +502,8 @@ class InOrOut: # The module was in sys.modules, and seems like a module with code, but # we never measured it. I guess that means it was imported before # coverage even started. - self.warn( - "Module %s was previously imported, but not measured" % pkg, - slug="module-not-measured", - ) + msg = f"Module {pkg} was previously imported, but not measured" + self.warn(msg, slug="module-not-measured") def find_possibly_unexecuted_files(self): """Find files in the areas of interest that might be untraced. diff --git a/coverage/pytracer.py b/coverage/pytracer.py index 51f08a1b..540df68c 100644 --- a/coverage/pytracer.py +++ b/coverage/pytracer.py @@ -254,10 +254,8 @@ class PyTracer: # has changed to None. dont_warn = (env.PYPY and env.PYPYVERSION >= (5, 4) and self.in_atexit and tf is None) if (not dont_warn) and tf != self._trace: # pylint: disable=comparison-with-callable - self.warn( - f"Trace function changed, measurement is likely wrong: {tf!r}", - slug="trace-changed", - ) + msg = f"Trace function changed, measurement is likely wrong: {tf!r}" + self.warn(msg, slug="trace-changed") def activity(self): """Has there been any activity?""" diff --git a/tests/helpers.py b/tests/helpers.py index 21459cd4..369875b9 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -14,6 +14,7 @@ import textwrap from unittest import mock +from coverage.exceptions import CoverageWarning from coverage.misc import output_encoding @@ -262,3 +263,17 @@ def assert_count_equal(a, b): This only works for hashable elements. """ assert collections.Counter(list(a)) == collections.Counter(list(b)) + + +def assert_coverage_warnings(warns, *msgs): + """ + Assert that `warns` are all CoverageWarning's, and have `msgs` as messages. + """ + assert msgs # don't call this without some messages. + assert len(warns) == len(msgs) + assert all(w.category == CoverageWarning for w in warns) + for actual, expected in zip((w.message.args[0] for w in warns), msgs): + if hasattr(expected, "search"): + assert expected.search(actual), f"{actual!r} didn't match {expected!r}" + else: + assert expected == actual diff --git a/tests/test_api.py b/tests/test_api.py index d6a9c08a..885f3370 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -23,7 +23,7 @@ from coverage.files import abs_file, relative_filename from coverage.misc import import_local_file from tests.coveragetest import CoverageTest, TESTS_DIR, UsingModulesMixin -from tests.helpers import assert_count_equal, change_dir, nice_file +from tests.helpers import assert_count_equal, assert_coverage_warnings, change_dir, nice_file class ApiTest(CoverageTest): @@ -300,9 +300,11 @@ class ApiTest(CoverageTest): # If nothing was measured, the file-touching didn't happen properly. self.make_file("foo/bar.py", "print('Never run')") self.make_file("test.py", "assert True") - cov = coverage.Coverage(source=["foo"]) - self.start_import_stop(cov, "test") - cov.report() + with pytest.warns(Warning) as warns: + cov = coverage.Coverage(source=["foo"]) + self.start_import_stop(cov, "test") + cov.report() + assert_coverage_warnings(warns, "No data was collected. (no-data-collected)") # Name Stmts Miss Cover # -------------------------------- # foo/bar.py 1 1 0% @@ -517,18 +519,19 @@ class ApiTest(CoverageTest): import sys, os print("Hello") """) - cov = coverage.Coverage(source=["sys", "xyzzy", "quux"]) - self.start_import_stop(cov, "hello") - cov.get_data() - - out, err = self.stdouterr() - assert "Hello\n" in out - assert textwrap.dedent("""\ - Coverage.py warning: Module sys has no Python source. (module-not-python) - Coverage.py warning: Module xyzzy was never imported. (module-not-imported) - Coverage.py warning: Module quux was never imported. (module-not-imported) - Coverage.py warning: No data was collected. (no-data-collected) - """) in err + with pytest.warns(Warning) as warns: + cov = coverage.Coverage(source=["sys", "xyzzy", "quux"]) + self.start_import_stop(cov, "hello") + cov.get_data() + + assert "Hello\n" == self.stdout() + assert_coverage_warnings( + warns, + "Module sys has no Python source. (module-not-python)", + "Module xyzzy was never imported. (module-not-imported)", + "Module quux was never imported. (module-not-imported)", + "No data was collected. (no-data-collected)", + ) def test_warnings_suppressed(self): self.make_file("hello.py", """\ @@ -539,24 +542,25 @@ class ApiTest(CoverageTest): [run] disable_warnings = no-data-collected, module-not-imported """) - cov = coverage.Coverage(source=["sys", "xyzzy", "quux"]) - self.start_import_stop(cov, "hello") - cov.get_data() + with pytest.warns(Warning) as warns: + cov = coverage.Coverage(source=["sys", "xyzzy", "quux"]) + self.start_import_stop(cov, "hello") + cov.get_data() - out, err = self.stdouterr() - assert "Hello\n" in out - assert "Coverage.py warning: Module sys has no Python source. (module-not-python)" in err - assert "module-not-imported" not in err - assert "no-data-collected" not in err + assert "Hello\n" == self.stdout() + assert_coverage_warnings(warns, "Module sys has no Python source. (module-not-python)") + # No "module-not-imported" in warns + # No "no-data-collected" in warns def test_warn_once(self): - cov = coverage.Coverage() - cov.load() - cov._warn("Warning, warning 1!", slug="bot", once=True) - cov._warn("Warning, warning 2!", slug="bot", once=True) - err = self.stderr() - assert "Warning, warning 1!" in err - assert "Warning, warning 2!" not in err + with pytest.warns(Warning) as warns: + cov = coverage.Coverage() + cov.load() + cov._warn("Warning, warning 1!", slug="bot", once=True) + cov._warn("Warning, warning 2!", slug="bot", once=True) + + assert_coverage_warnings(warns, "Warning, warning 1! (bot)") + # No "Warning, warning 2!" in warns def test_source_and_include_dont_conflict(self): # A bad fix made this case fail: https://github.com/nedbat/coveragepy/issues/541 @@ -683,12 +687,12 @@ class ApiTest(CoverageTest): cov = coverage.Coverage(source=["."]) cov.set_option("run:dynamic_context", "test_function") cov.start() - # Switch twice, but only get one warning. - cov.switch_context("test1") # pragma: nested - cov.switch_context("test2") # pragma: nested - expected = "Coverage.py warning: Conflicting dynamic contexts (dynamic-conflict)\n" - assert expected == self.stderr() + with pytest.warns(Warning) as warns: + # Switch twice, but only get one warning. + cov.switch_context("test1") # pragma: nested + cov.switch_context("test2") # pragma: nested cov.stop() # pragma: nested + assert_coverage_warnings(warns, "Conflicting dynamic contexts (dynamic-conflict)") def test_switch_context_unstarted(self): # Coverage must be started to switch context diff --git a/tests/test_html.py b/tests/test_html.py index c9dbacc8..56519a64 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -24,7 +24,7 @@ from coverage.report import get_analysis_to_report from tests.coveragetest import CoverageTest, TESTS_DIR from tests.goldtest import gold_path from tests.goldtest import compare, contains, doesnt_contain, contains_any -from tests.helpers import change_dir +from tests.helpers import assert_coverage_warnings, change_dir class HtmlTestHelpers(CoverageTest): @@ -341,13 +341,14 @@ class HtmlWithUnparsableFilesTest(HtmlTestHelpers, CoverageTest): self.make_file("innocuous.py", "a = 2") cov = coverage.Coverage() self.start_import_stop(cov, "main") + self.make_file("innocuous.py", "<h1>This isn't python!</h1>") - cov.html_report(ignore_errors=True) - msg = "Expected a warning to be thrown when an invalid python file is parsed" - assert 1 == len(cov._warnings), msg - msg = "Warning message should be in 'invalid file' warning" - assert "Couldn't parse Python file" in cov._warnings[0], msg - assert "innocuous.py" in cov._warnings[0], "Filename should be in 'invalid file' warning" + with pytest.warns(Warning) as warns: + cov.html_report(ignore_errors=True) + assert_coverage_warnings( + warns, + re.compile(r"Couldn't parse Python file '.*innocuous.py' \(couldnt-parse\)"), + ) self.assert_exists("htmlcov/index.html") # This would be better as a glob, if the HTML layout changes: self.assert_doesnt_exist("htmlcov/innocuous.html") diff --git a/tests/test_oddball.py b/tests/test_oddball.py index 52f80734..a97fc190 100644 --- a/tests/test_oddball.py +++ b/tests/test_oddball.py @@ -81,17 +81,18 @@ class RecursionTest(CoverageTest): def test_long_recursion(self): # We can't finish a very deep recursion, but we don't crash. with pytest.raises(RuntimeError): - self.check_coverage("""\ - def recur(n): - if n == 0: - return 0 - else: - return recur(n-1)+1 - - recur(100000) # This is definitely too many frames. - """, - [1, 2, 3, 5, 7], "" - ) + with pytest.warns(None): + self.check_coverage("""\ + def recur(n): + if n == 0: + return 0 + else: + return recur(n-1)+1 + + recur(100000) # This is definitely too many frames. + """, + [1, 2, 3, 5, 7], "" + ) def test_long_recursion_recovery(self): # Test the core of bug 93: https://github.com/nedbat/coveragepy/issues/93 @@ -117,7 +118,8 @@ class RecursionTest(CoverageTest): """) cov = coverage.Coverage() - self.start_import_stop(cov, "recur") + with pytest.warns(None): + self.start_import_stop(cov, "recur") pytrace = (cov._collector.tracer_name() == "PyTracer") expected_missing = [3] diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 3401895b..b15ee45b 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -14,7 +14,7 @@ import coverage from coverage import env from coverage.control import Plugins from coverage.data import line_counts -from coverage.exceptions import CoverageException +from coverage.exceptions import CoverageException, CoverageWarning from coverage.misc import import_local_file import coverage.plugin @@ -193,7 +193,9 @@ class PluginTest(CoverageTest): cov = coverage.Coverage(debug=["sys"]) cov._debug_file = debug_out cov.set_option("run:plugins", ["plugin_sys_info"]) - cov.start() + with pytest.warns(None): + # Catch warnings so we don't see "plugins aren't supported on PyTracer" + cov.start() cov.stop() # pragma: nested out_lines = [line.strip() for line in debug_out.getvalue().splitlines()] @@ -631,28 +633,29 @@ class BadFileTracerTest(FileTracerTest): explaining why. """ - self.run_plugin(module_name) + with pytest.warns(Warning) as warns: + self.run_plugin(module_name) stderr = self.stderr() - + stderr += "".join(w.message.args[0] for w in warns) if our_error: - errors = stderr.count("# Oh noes!") # The exception we're causing should only appear once. - assert errors == 1 + assert stderr.count("# Oh noes!") == 1 # There should be a warning explaining what's happening, but only one. # The message can be in two forms: # Disabling plug-in '...' due to previous exception # or: # Disabling plug-in '...' due to an exception: - msg = f"Disabling plug-in '{module_name}.{plugin_name}' due to " - warnings = stderr.count(msg) - assert warnings == 1 + assert len(warns) == 1 + assert issubclass(warns[0].category, CoverageWarning) + warnmsg = warns[0].message.args[0] + assert f"Disabling plug-in '{module_name}.{plugin_name}' due to " in warnmsg if excmsg: assert excmsg in stderr if excmsgs: - assert any(em in stderr for em in excmsgs), "expected one of %r" % excmsgs + assert any(em in stderr for em in excmsgs), f"expected one of {excmsgs} in stderr" def test_file_tracer_has_no_file_tracer_method(self): self.make_file("bad_plugin.py", """\ diff --git a/tests/test_process.py b/tests/test_process.py index a912debb..54bf345d 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -130,7 +130,7 @@ class ProcessTest(CoverageTest): self.assert_exists(".coverage") self.assert_exists(".coverage.bad") warning_regex = ( - r"Coverage.py warning: Couldn't use data file '.*\.coverage\.bad': " + r"CoverageWarning: Couldn't use data file '.*\.coverage\.bad': " r"file (is encrypted or )?is not a database" ) assert re.search(warning_regex, out) @@ -163,7 +163,7 @@ class ProcessTest(CoverageTest): for n in "12": self.assert_exists(f".coverage.bad{n}") warning_regex = ( - r"Coverage.py warning: Couldn't use data file '.*\.coverage.bad{}': " + r"CoverageWarning: Couldn't use data file '.*\.coverage.bad{}': " r"file (is encrypted or )?is not a database" .format(n) ) @@ -725,7 +725,7 @@ class ProcessTest(CoverageTest): assert "Goodbye!" in out msg = ( - "Coverage.py warning: " + "CoverageWarning: " "Already imported a file that will be measured: {} " "(already-imported)").format(goodbye_path) assert msg in out @@ -815,10 +815,14 @@ class ProcessTest(CoverageTest): inst.save() """) out = self.run_command("python run_twice.py") + # Remove the file location and source line from the warning. + out = re.sub(r"(?m)^[\\/\w.:~_-]+:\d+: CoverageWarning: ", "f:d: CoverageWarning: ", out) + out = re.sub(r"(?m)^\s+self.warn.*$\n", "", out) + print("out:", repr(out)) expected = ( "Run 1\n" + "Run 2\n" + - "Coverage.py warning: Module foo was previously imported, but not measured " + + "f:d: CoverageWarning: Module foo was previously imported, but not measured " + "(module-not-measured)\n" ) assert expected == out @@ -920,7 +924,7 @@ class EnvironmentTest(CoverageTest): def test_coverage_run_dashm_superset_of_doubledashsource(self): """Edge case: --source foo -m foo.bar""" # Ugh: without this config file, we'll get a warning about - # Coverage.py warning: Module process_test was previously imported, + # CoverageWarning: Module process_test was previously imported, # but not measured (module-not-measured) # # This is because process_test/__init__.py is imported while looking diff --git a/tests/test_summary.py b/tests/test_summary.py index a6384c46..b71921c7 100644 --- a/tests/test_summary.py +++ b/tests/test_summary.py @@ -595,12 +595,13 @@ class SummaryTest(UsingModulesMixin, CoverageTest): self.make_file("mycode.py", "This isn't python at all!") report = self.report_from_command("coverage report -i mycode.py") - # Coverage.py warning: Couldn't parse Python file blah_blah/mycode.py (couldnt-parse) + # CoverageWarning: Couldn't parse Python file blah_blah/mycode.py (couldnt-parse) + # (source line) # Name Stmts Miss Cover # ---------------------------- # No data to report. - assert self.line_count(report) == 4 + assert self.line_count(report) == 5 assert 'No data to report.' in report assert '(couldnt-parse)' in report diff --git a/tests/test_testing.py b/tests/test_testing.py index 3a563efe..7219ff0b 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -7,16 +7,18 @@ import datetime import os import re import sys +import warnings import pytest import coverage from coverage import tomlconfig +from coverage.exceptions import CoverageWarning from coverage.files import actual_path from tests.coveragetest import CoverageTest from tests.helpers import ( - arcs_to_arcz_repr, arcz_to_arcs, assert_count_equal, + arcs_to_arcz_repr, arcz_to_arcs, assert_count_equal, assert_coverage_warnings, CheckUniqueFilenames, re_lines, re_line, without_module, ) @@ -383,3 +385,57 @@ class ArczTest(CoverageTest): ]) def test_arcs_to_arcz_repr(self, arcs, arcz_repr): assert arcs_to_arcz_repr(arcs) == arcz_repr + + +class AssertCoverageWarningsTest(CoverageTest): + """Tests of assert_coverage_warnings""" + + def test_one_warning(self): + with pytest.warns(Warning) as warns: + warnings.warn("Hello there", category=CoverageWarning) + assert_coverage_warnings(warns, "Hello there") + + def test_many_warnings(self): + with pytest.warns(Warning) as warns: + warnings.warn("The first", category=CoverageWarning) + warnings.warn("The second", category=CoverageWarning) + warnings.warn("The third", category=CoverageWarning) + assert_coverage_warnings(warns, "The first", "The second", "The third") + + def test_wrong_type(self): + with pytest.warns(Warning) as warns: + warnings.warn("Not ours", category=Warning) + with pytest.raises(AssertionError): + assert_coverage_warnings(warns, "Not ours") + + def test_wrong_message(self): + with pytest.warns(Warning) as warns: + warnings.warn("Goodbye", category=CoverageWarning) + with pytest.raises(AssertionError): + assert_coverage_warnings(warns, "Hello there") + + def test_wrong_number_too_many(self): + with pytest.warns(Warning) as warns: + warnings.warn("The first", category=CoverageWarning) + warnings.warn("The second", category=CoverageWarning) + with pytest.raises(AssertionError): + assert_coverage_warnings(warns, "The first", "The second", "The third") + + def test_wrong_number_too_few(self): + with pytest.warns(Warning) as warns: + warnings.warn("The first", category=CoverageWarning) + warnings.warn("The second", category=CoverageWarning) + warnings.warn("The third", category=CoverageWarning) + with pytest.raises(AssertionError): + assert_coverage_warnings(warns, "The first", "The second") + + def test_regex_matches(self): + with pytest.warns(Warning) as warns: + warnings.warn("The first", category=CoverageWarning) + assert_coverage_warnings(warns, re.compile("f?rst")) + + def test_regex_doesnt_match(self): + with pytest.warns(Warning) as warns: + warnings.warn("The first", category=CoverageWarning) + with pytest.raises(AssertionError): + assert_coverage_warnings(warns, re.compile("second")) diff --git a/tests/test_xml.py b/tests/test_xml.py index 9c6cfb58..a03257a2 100644 --- a/tests/test_xml.py +++ b/tests/test_xml.py @@ -16,7 +16,7 @@ from coverage.misc import import_local_file from tests.coveragetest import CoverageTest from tests.goldtest import compare, gold_path -from tests.helpers import change_dir +from tests.helpers import assert_coverage_warnings, change_dir class XmlTestHelpers(CoverageTest): @@ -213,7 +213,12 @@ class XmlReportTest(XmlTestHelpers, CoverageTest): mod_foo = import_local_file("foo", "src/main/foo.py") # pragma: nested mod_bar = import_local_file("bar", "also/over/there/bar.py") # pragma: nested cov.stop() # pragma: nested - cov.xml_report([mod_foo, mod_bar]) + with pytest.warns(Warning) as warns: + cov.xml_report([mod_foo, mod_bar]) + assert_coverage_warnings( + warns, + "Module not/really was never imported. (module-not-imported)", + ) dom = ElementTree.parse("coverage.xml") self.assert_source(dom, "src/main") |
