Skip to content

Commit 30c023b

Browse files
committed
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.
1 parent 22fe2eb commit 30c023b

14 files changed

+183
-89
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ Unreleased
3333
imported a file that will be measured" warnings about Django itself. These
3434
have been fixed, closing `issue 1150`_.
3535

36+
- Warnings generated by coverage.py are now real Python warnings.
37+
3638
- The ``COVERAGE_DEBUG_FILE`` environment variable now accepts ``stdout`` and
3739
``stderr`` to write to those destinations.
3840

coverage/control.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import platform
1212
import sys
1313
import time
14+
import warnings
1415

1516
from coverage import env
1617
from coverage.annotate import AnnotateReporter
@@ -20,7 +21,7 @@
2021
from coverage.data import CoverageData, combine_parallel_data
2122
from coverage.debug import DebugControl, short_stack, write_formatted_info
2223
from coverage.disposition import disposition_debug_msg
23-
from coverage.exceptions import CoverageException
24+
from coverage.exceptions import CoverageException, CoverageWarning
2425
from coverage.files import PathAliases, abs_file, relative_filename, set_relative_directory
2526
from coverage.html import HtmlReporter
2627
from coverage.inorout import InOrOut
@@ -362,7 +363,7 @@ def _warn(self, msg, slug=None, once=False):
362363
msg = f"{msg} ({slug})"
363364
if self._debug.should('pid'):
364365
msg = f"[{os.getpid()}] {msg}"
365-
sys.stderr.write(f"Coverage.py warning: {msg}\n")
366+
warnings.warn(msg, category=CoverageWarning, stacklevel=2)
366367

367368
if once:
368369
self._no_warn_slugs.append(slug)

coverage/exceptions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,8 @@ class StopEverything(BaseCoverageException):
4646
4747
"""
4848
pass
49+
50+
51+
class CoverageWarning(Warning):
52+
"""A warning from Coverage.py."""
53+
pass

coverage/inorout.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -356,10 +356,9 @@ def nope(disp, reason):
356356
)
357357
break
358358
except Exception:
359-
self.warn(
360-
"Disabling plug-in %r due to an exception:" % (plugin._coverage_plugin_name)
361-
)
362-
traceback.print_exc()
359+
plugin_name = plugin._coverage_plugin_name
360+
tb = traceback.format_exc()
361+
self.warn(f"Disabling plug-in {plugin_name!r} due to an exception:\n{tb}")
363362
plugin._coverage_enabled = False
364363
continue
365364
else:
@@ -503,10 +502,8 @@ def _warn_about_unmeasured_code(self, pkg):
503502
# The module was in sys.modules, and seems like a module with code, but
504503
# we never measured it. I guess that means it was imported before
505504
# coverage even started.
506-
self.warn(
507-
"Module %s was previously imported, but not measured" % pkg,
508-
slug="module-not-measured",
509-
)
505+
msg = f"Module {pkg} was previously imported, but not measured"
506+
self.warn(msg, slug="module-not-measured")
510507

511508
def find_possibly_unexecuted_files(self):
512509
"""Find files in the areas of interest that might be untraced.

coverage/pytracer.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -254,10 +254,8 @@ def stop(self):
254254
# has changed to None.
255255
dont_warn = (env.PYPY and env.PYPYVERSION >= (5, 4) and self.in_atexit and tf is None)
256256
if (not dont_warn) and tf != self._trace: # pylint: disable=comparison-with-callable
257-
self.warn(
258-
f"Trace function changed, measurement is likely wrong: {tf!r}",
259-
slug="trace-changed",
260-
)
257+
msg = f"Trace function changed, measurement is likely wrong: {tf!r}"
258+
self.warn(msg, slug="trace-changed")
261259

262260
def activity(self):
263261
"""Has there been any activity?"""

tests/helpers.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from unittest import mock
1616

17+
from coverage.exceptions import CoverageWarning
1718
from coverage.misc import output_encoding
1819

1920

@@ -262,3 +263,17 @@ def assert_count_equal(a, b):
262263
This only works for hashable elements.
263264
"""
264265
assert collections.Counter(list(a)) == collections.Counter(list(b))
266+
267+
268+
def assert_coverage_warnings(warns, *msgs):
269+
"""
270+
Assert that `warns` are all CoverageWarning's, and have `msgs` as messages.
271+
"""
272+
assert msgs # don't call this without some messages.
273+
assert len(warns) == len(msgs)
274+
assert all(w.category == CoverageWarning for w in warns)
275+
for actual, expected in zip((w.message.args[0] for w in warns), msgs):
276+
if hasattr(expected, "search"):
277+
assert expected.search(actual), f"{actual!r} didn't match {expected!r}"
278+
else:
279+
assert expected == actual

tests/test_api.py

Lines changed: 40 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from coverage.misc import import_local_file
2424

2525
from tests.coveragetest import CoverageTest, TESTS_DIR, UsingModulesMixin
26-
from tests.helpers import assert_count_equal, change_dir, nice_file
26+
from tests.helpers import assert_count_equal, assert_coverage_warnings, change_dir, nice_file
2727

2828

2929
class ApiTest(CoverageTest):
@@ -300,9 +300,11 @@ def test_completely_zero_reporting(self):
300300
# If nothing was measured, the file-touching didn't happen properly.
301301
self.make_file("foo/bar.py", "print('Never run')")
302302
self.make_file("test.py", "assert True")
303-
cov = coverage.Coverage(source=["foo"])
304-
self.start_import_stop(cov, "test")
305-
cov.report()
303+
with pytest.warns(Warning) as warns:
304+
cov = coverage.Coverage(source=["foo"])
305+
self.start_import_stop(cov, "test")
306+
cov.report()
307+
assert_coverage_warnings(warns, "No data was collected. (no-data-collected)")
306308
# Name Stmts Miss Cover
307309
# --------------------------------
308310
# foo/bar.py 1 1 0%
@@ -517,18 +519,19 @@ def test_warnings(self):
517519
import sys, os
518520
print("Hello")
519521
""")
520-
cov = coverage.Coverage(source=["sys", "xyzzy", "quux"])
521-
self.start_import_stop(cov, "hello")
522-
cov.get_data()
523-
524-
out, err = self.stdouterr()
525-
assert "Hello\n" in out
526-
assert textwrap.dedent("""\
527-
Coverage.py warning: Module sys has no Python source. (module-not-python)
528-
Coverage.py warning: Module xyzzy was never imported. (module-not-imported)
529-
Coverage.py warning: Module quux was never imported. (module-not-imported)
530-
Coverage.py warning: No data was collected. (no-data-collected)
531-
""") in err
522+
with pytest.warns(Warning) as warns:
523+
cov = coverage.Coverage(source=["sys", "xyzzy", "quux"])
524+
self.start_import_stop(cov, "hello")
525+
cov.get_data()
526+
527+
assert "Hello\n" == self.stdout()
528+
assert_coverage_warnings(
529+
warns,
530+
"Module sys has no Python source. (module-not-python)",
531+
"Module xyzzy was never imported. (module-not-imported)",
532+
"Module quux was never imported. (module-not-imported)",
533+
"No data was collected. (no-data-collected)",
534+
)
532535

533536
def test_warnings_suppressed(self):
534537
self.make_file("hello.py", """\
@@ -539,24 +542,25 @@ def test_warnings_suppressed(self):
539542
[run]
540543
disable_warnings = no-data-collected, module-not-imported
541544
""")
542-
cov = coverage.Coverage(source=["sys", "xyzzy", "quux"])
543-
self.start_import_stop(cov, "hello")
544-
cov.get_data()
545+
with pytest.warns(Warning) as warns:
546+
cov = coverage.Coverage(source=["sys", "xyzzy", "quux"])
547+
self.start_import_stop(cov, "hello")
548+
cov.get_data()
545549

546-
out, err = self.stdouterr()
547-
assert "Hello\n" in out
548-
assert "Coverage.py warning: Module sys has no Python source. (module-not-python)" in err
549-
assert "module-not-imported" not in err
550-
assert "no-data-collected" not in err
550+
assert "Hello\n" == self.stdout()
551+
assert_coverage_warnings(warns, "Module sys has no Python source. (module-not-python)")
552+
# No "module-not-imported" in warns
553+
# No "no-data-collected" in warns
551554

552555
def test_warn_once(self):
553-
cov = coverage.Coverage()
554-
cov.load()
555-
cov._warn("Warning, warning 1!", slug="bot", once=True)
556-
cov._warn("Warning, warning 2!", slug="bot", once=True)
557-
err = self.stderr()
558-
assert "Warning, warning 1!" in err
559-
assert "Warning, warning 2!" not in err
556+
with pytest.warns(Warning) as warns:
557+
cov = coverage.Coverage()
558+
cov.load()
559+
cov._warn("Warning, warning 1!", slug="bot", once=True)
560+
cov._warn("Warning, warning 2!", slug="bot", once=True)
561+
562+
assert_coverage_warnings(warns, "Warning, warning 1! (bot)")
563+
# No "Warning, warning 2!" in warns
560564

561565
def test_source_and_include_dont_conflict(self):
562566
# A bad fix made this case fail: https://github.com/nedbat/coveragepy/issues/541
@@ -683,12 +687,12 @@ def test_dynamic_context_conflict(self):
683687
cov = coverage.Coverage(source=["."])
684688
cov.set_option("run:dynamic_context", "test_function")
685689
cov.start()
686-
# Switch twice, but only get one warning.
687-
cov.switch_context("test1") # pragma: nested
688-
cov.switch_context("test2") # pragma: nested
689-
expected = "Coverage.py warning: Conflicting dynamic contexts (dynamic-conflict)\n"
690-
assert expected == self.stderr()
690+
with pytest.warns(Warning) as warns:
691+
# Switch twice, but only get one warning.
692+
cov.switch_context("test1") # pragma: nested
693+
cov.switch_context("test2") # pragma: nested
691694
cov.stop() # pragma: nested
695+
assert_coverage_warnings(warns, "Conflicting dynamic contexts (dynamic-conflict)")
692696

693697
def test_switch_context_unstarted(self):
694698
# Coverage must be started to switch context

tests/test_html.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from tests.coveragetest import CoverageTest, TESTS_DIR
2525
from tests.goldtest import gold_path
2626
from tests.goldtest import compare, contains, doesnt_contain, contains_any
27-
from tests.helpers import change_dir
27+
from tests.helpers import assert_coverage_warnings, change_dir
2828

2929

3030
class HtmlTestHelpers(CoverageTest):
@@ -341,13 +341,14 @@ def test_dotpy_not_python_ignored(self):
341341
self.make_file("innocuous.py", "a = 2")
342342
cov = coverage.Coverage()
343343
self.start_import_stop(cov, "main")
344+
344345
self.make_file("innocuous.py", "<h1>This isn't python!</h1>")
345-
cov.html_report(ignore_errors=True)
346-
msg = "Expected a warning to be thrown when an invalid python file is parsed"
347-
assert 1 == len(cov._warnings), msg
348-
msg = "Warning message should be in 'invalid file' warning"
349-
assert "Couldn't parse Python file" in cov._warnings[0], msg
350-
assert "innocuous.py" in cov._warnings[0], "Filename should be in 'invalid file' warning"
346+
with pytest.warns(Warning) as warns:
347+
cov.html_report(ignore_errors=True)
348+
assert_coverage_warnings(
349+
warns,
350+
re.compile(r"Couldn't parse Python file '.*innocuous.py' \(couldnt-parse\)"),
351+
)
351352
self.assert_exists("htmlcov/index.html")
352353
# This would be better as a glob, if the HTML layout changes:
353354
self.assert_doesnt_exist("htmlcov/innocuous.html")

tests/test_oddball.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -81,17 +81,18 @@ def recur(n):
8181
def test_long_recursion(self):
8282
# We can't finish a very deep recursion, but we don't crash.
8383
with pytest.raises(RuntimeError):
84-
self.check_coverage("""\
85-
def recur(n):
86-
if n == 0:
87-
return 0
88-
else:
89-
return recur(n-1)+1
90-
91-
recur(100000) # This is definitely too many frames.
92-
""",
93-
[1, 2, 3, 5, 7], ""
94-
)
84+
with pytest.warns(None):
85+
self.check_coverage("""\
86+
def recur(n):
87+
if n == 0:
88+
return 0
89+
else:
90+
return recur(n-1)+1
91+
92+
recur(100000) # This is definitely too many frames.
93+
""",
94+
[1, 2, 3, 5, 7], ""
95+
)
9596

9697
def test_long_recursion_recovery(self):
9798
# Test the core of bug 93: https://github.com/nedbat/coveragepy/issues/93
@@ -117,7 +118,8 @@ def recur(n):
117118
""")
118119

119120
cov = coverage.Coverage()
120-
self.start_import_stop(cov, "recur")
121+
with pytest.warns(None):
122+
self.start_import_stop(cov, "recur")
121123

122124
pytrace = (cov._collector.tracer_name() == "PyTracer")
123125
expected_missing = [3]

tests/test_plugins.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from coverage import env
1515
from coverage.control import Plugins
1616
from coverage.data import line_counts
17-
from coverage.exceptions import CoverageException
17+
from coverage.exceptions import CoverageException, CoverageWarning
1818
from coverage.misc import import_local_file
1919

2020
import coverage.plugin
@@ -193,7 +193,9 @@ def coverage_init(reg, options):
193193
cov = coverage.Coverage(debug=["sys"])
194194
cov._debug_file = debug_out
195195
cov.set_option("run:plugins", ["plugin_sys_info"])
196-
cov.start()
196+
with pytest.warns(None):
197+
# Catch warnings so we don't see "plugins aren't supported on PyTracer"
198+
cov.start()
197199
cov.stop() # pragma: nested
198200

199201
out_lines = [line.strip() for line in debug_out.getvalue().splitlines()]
@@ -631,28 +633,29 @@ def run_bad_plugin(self, module_name, plugin_name, our_error=True, excmsg=None,
631633
explaining why.
632634
633635
"""
634-
self.run_plugin(module_name)
636+
with pytest.warns(Warning) as warns:
637+
self.run_plugin(module_name)
635638

636639
stderr = self.stderr()
637-
640+
stderr += "".join(w.message.args[0] for w in warns)
638641
if our_error:
639-
errors = stderr.count("# Oh noes!")
640642
# The exception we're causing should only appear once.
641-
assert errors == 1
643+
assert stderr.count("# Oh noes!") == 1
642644

643645
# There should be a warning explaining what's happening, but only one.
644646
# The message can be in two forms:
645647
# Disabling plug-in '...' due to previous exception
646648
# or:
647649
# Disabling plug-in '...' due to an exception:
648-
msg = f"Disabling plug-in '{module_name}.{plugin_name}' due to "
649-
warnings = stderr.count(msg)
650-
assert warnings == 1
650+
assert len(warns) == 1
651+
assert issubclass(warns[0].category, CoverageWarning)
652+
warnmsg = warns[0].message.args[0]
653+
assert f"Disabling plug-in '{module_name}.{plugin_name}' due to " in warnmsg
651654

652655
if excmsg:
653656
assert excmsg in stderr
654657
if excmsgs:
655-
assert any(em in stderr for em in excmsgs), "expected one of %r" % excmsgs
658+
assert any(em in stderr for em in excmsgs), f"expected one of {excmsgs} in stderr"
656659

657660
def test_file_tracer_has_no_file_tracer_method(self):
658661
self.make_file("bad_plugin.py", """\

0 commit comments

Comments
 (0)