blob: ce378e976b20024c5d7057d51a30c166746878b4 [file] [log] [blame] [edit]
# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
"""Tests for plugins."""
from __future__ import annotations
import inspect
import io
import math
import os.path
from typing import Any, Iterable
from xml.etree import ElementTree
import pytest
import coverage
from coverage import Coverage
from coverage.plugin_support import Plugins
from coverage.data import line_counts, sorted_lines
from coverage.exceptions import CoverageWarning, NoSource, PluginError
from coverage.misc import import_local_file
from coverage.types import TConfigSectionOut, TLineNo, TPluginConfig
import coverage.plugin
from tests import testenv
from tests.coveragetest import CoverageTest
from tests.helpers import CheckUniqueFilenames, swallow_warnings
class NullConfig(TPluginConfig):
"""A plugin configure thing when we don't really need one."""
def get_plugin_options(self, plugin: str) -> TConfigSectionOut:
return {} # pragma: never called
class FakeConfig(TPluginConfig):
"""A fake config for use in tests."""
def __init__(self, plugin: str, options: dict[str, Any]) -> None:
self.plugin = plugin
self.options = options
self.asked_for: list[str] = []
def get_plugin_options(self, plugin: str) -> TConfigSectionOut:
"""Just return the options for `plugin` if this is the right module."""
self.asked_for.append(plugin)
if plugin == self.plugin:
return self.options
else:
return {}
def make_plugins(
modules: Iterable[str],
config: TPluginConfig,
) -> Plugins:
"""Construct a Plugins and call plugins.load_from_config() for convenience."""
plugins = Plugins()
plugins.load_from_config(modules, config)
return plugins
class LoadPluginsTest(CoverageTest):
"""Test Plugins construction."""
def test_implicit_boolean(self) -> None:
self.make_file(
"plugin1.py",
"""\
from coverage import CoveragePlugin
class Plugin(CoveragePlugin):
pass
def coverage_init(reg, options):
reg.add_file_tracer(Plugin())
""",
)
config = FakeConfig("plugin1", {})
plugins = make_plugins([], config)
assert not plugins
plugins = make_plugins(["plugin1"], config)
assert plugins
def test_importing_and_configuring(self) -> None:
self.make_file(
"plugin1.py",
"""\
from coverage import CoveragePlugin
class Plugin(CoveragePlugin):
def __init__(self, options):
self.options = options
self.this_is = "me"
def coverage_init(reg, options):
reg.add_file_tracer(Plugin(options))
""",
)
config = FakeConfig("plugin1", {"a": "hello"})
plugins = list(make_plugins(["plugin1"], config))
assert len(plugins) == 1
assert plugins[0].this_is == "me" # type: ignore
assert plugins[0].options == {"a": "hello"} # type: ignore
assert config.asked_for == ["plugin1"]
def test_importing_and_configuring_more_than_one(self) -> None:
self.make_file(
"plugin1.py",
"""\
from coverage import CoveragePlugin
class Plugin(CoveragePlugin):
def __init__(self, options):
self.options = options
self.this_is = "me"
def coverage_init(reg, options):
reg.add_file_tracer(Plugin(options))
""",
)
self.make_file(
"plugin2.py",
"""\
from coverage import CoveragePlugin
class Plugin(CoveragePlugin):
def __init__(self, options):
self.options = options
def coverage_init(reg, options):
reg.add_file_tracer(Plugin(options))
""",
)
config = FakeConfig("plugin1", {"a": "hello"})
plugins = list(make_plugins(["plugin1", "plugin2"], config))
assert len(plugins) == 2
assert plugins[0].this_is == "me" # type: ignore
assert plugins[0].options == {"a": "hello"} # type: ignore
assert plugins[1].options == {} # type: ignore
assert config.asked_for == ["plugin1", "plugin2"]
# The order matters...
config = FakeConfig("plugin1", {"a": "second"})
plugins = list(make_plugins(["plugin2", "plugin1"], config))
assert len(plugins) == 2
assert plugins[0].options == {} # type: ignore
assert plugins[1].this_is == "me" # type: ignore
assert plugins[1].options == {"a": "second"} # type: ignore
def test_cant_import(self) -> None:
with pytest.raises(ImportError, match="No module named '?plugin_not_there'?"):
_ = make_plugins(["plugin_not_there"], NullConfig())
def test_plugin_must_define_coverage_init(self) -> None:
self.make_file(
"no_plugin.py",
"""\
from coverage import CoveragePlugin
Nothing = 0
""",
)
msg_pat = "Plugin module 'no_plugin' didn't define a coverage_init function"
with pytest.raises(PluginError, match=msg_pat):
list(make_plugins(["no_plugin"], NullConfig()))
class PluginTest(CoverageTest):
"""Test plugins through the Coverage class."""
def test_plugin_imported(self) -> None:
# Prove that a plugin will be imported.
self.make_file(
"my_plugin.py",
"""\
from coverage import CoveragePlugin
class Plugin(CoveragePlugin):
pass
def coverage_init(reg, options):
reg.add_noop(Plugin())
with open("evidence.out", "w", encoding="utf-8") as f:
f.write("we are here!")
""",
)
self.assert_doesnt_exist("evidence.out")
cov = coverage.Coverage()
cov.set_option("run:plugins", ["my_plugin"])
cov.start()
cov.stop() # pragma: nested
with open("evidence.out", encoding="utf-8") as f:
assert f.read() == "we are here!"
def test_missing_plugin_raises_import_error(self) -> None:
# Prove that a missing plugin will raise an ImportError.
with pytest.raises(ImportError, match="No module named '?does_not_exist_woijwoicweo'?"):
cov = coverage.Coverage()
cov.set_option("run:plugins", ["does_not_exist_woijwoicweo"])
cov.start()
cov.stop()
def test_bad_plugin_isnt_hidden(self) -> None:
# Prove that a plugin with an error in it will raise the error.
self.make_file("plugin_over_zero.py", "1/0")
with pytest.raises(ZeroDivisionError):
cov = coverage.Coverage()
cov.set_option("run:plugins", ["plugin_over_zero"])
cov.start()
cov.stop()
def test_plugin_sys_info(self) -> None:
self.make_file(
"plugin_sys_info.py",
"""\
import coverage
class Plugin(coverage.CoveragePlugin):
def sys_info(self):
return [("hello", "world")]
def coverage_init(reg, options):
reg.add_file_tracer(Plugin())
""",
)
debug_out = io.StringIO()
cov = coverage.Coverage(debug=["sys"])
cov._debug_file = debug_out
cov.set_option("run:plugins", ["plugin_sys_info"])
with swallow_warnings(
r"Plugin file tracers \(plugin_sys_info.Plugin\) aren't supported with .*",
):
cov.start()
cov.stop() # pragma: nested
out_lines = [line.strip() for line in debug_out.getvalue().splitlines()]
if testenv.C_TRACER:
assert "plugins.file_tracers: plugin_sys_info.Plugin" in out_lines
else:
assert "plugins.file_tracers: plugin_sys_info.Plugin (disabled)" in out_lines
assert "plugins.configurers: -none-" in out_lines
expected_end = [
"-- sys: plugin_sys_info.Plugin -------------------------------",
"hello: world",
"-- end -------------------------------------------------------",
]
assert expected_end == out_lines[-len(expected_end) :]
def test_plugin_with_no_sys_info(self) -> None:
self.make_file(
"plugin_no_sys_info.py",
"""\
import coverage
class Plugin(coverage.CoveragePlugin):
pass
def coverage_init(reg, options):
reg.add_configurer(Plugin())
""",
)
debug_out = io.StringIO()
cov = coverage.Coverage(debug=["sys"])
cov._debug_file = debug_out
cov.set_option("run:plugins", ["plugin_no_sys_info"])
cov.start()
cov.stop() # pragma: nested
out_lines = [line.strip() for line in debug_out.getvalue().splitlines()]
assert "plugins.file_tracers: -none-" in out_lines
assert "plugins.configurers: plugin_no_sys_info.Plugin" in out_lines
expected_end = [
"-- sys: plugin_no_sys_info.Plugin ----------------------------",
"-- end -------------------------------------------------------",
]
assert expected_end == out_lines[-len(expected_end) :]
def test_local_files_are_importable(self) -> None:
self.make_file(
"importing_plugin.py",
"""\
from coverage import CoveragePlugin
import local_module
class MyPlugin(CoveragePlugin):
pass
def coverage_init(reg, options):
reg.add_noop(MyPlugin())
""",
)
self.make_file("local_module.py", "CONST = 1")
self.make_file(
".coveragerc",
"""\
[run]
plugins = importing_plugin
""",
)
self.make_file("main_file.py", "print('MAIN')")
out = self.run_command("coverage run main_file.py")
assert out == "MAIN\n"
out = self.run_command("coverage html -q") # sneak in a test of -q
assert out == ""
def test_coverage_init_plugins(self) -> None:
called = False
def coverage_init(reg: Plugins) -> None: # pylint: disable=unused-argument
nonlocal called
called = True
cov = coverage.Coverage(plugins=[coverage_init])
# Calls _init() and loads plugins
cov.sys_info()
assert called
@pytest.mark.skipif(testenv.PLUGINS, reason="This core doesn't support plugins.")
class PluginWarningOnPyTracerTest(CoverageTest):
"""Test that we get a controlled exception when plugins aren't supported."""
def test_exception_if_plugins_on_pytracer(self) -> None:
self.make_file("simple.py", "a = 1")
cov = coverage.Coverage()
cov.set_option("run:plugins", ["tests.plugin1"])
if testenv.PY_TRACER:
core = "PyTracer"
else:
assert testenv.SYS_MON
core = "SysMonitor"
expected_warnings = [
rf"Plugin file tracers \(tests.plugin1.Plugin\) aren't supported with {core}",
]
with self.assert_warnings(cov, expected_warnings):
self.start_import_stop(cov, "simple")
@pytest.mark.skipif(not testenv.PLUGINS, reason="Plugins are not supported with this core.")
class FileTracerTest(CoverageTest):
"""Tests of plugins that implement file_tracer."""
class GoodFileTracerTest(FileTracerTest):
"""Tests of file tracer plugin happy paths."""
def test_plugin1(self) -> None:
self.make_file(
"simple.py",
"""\
import try_xyz
a = 1
b = 2
""",
)
self.make_file(
"try_xyz.py",
"""\
c = 3
d = 4
""",
)
cov = coverage.Coverage()
CheckUniqueFilenames.hook(cov, "_should_trace")
CheckUniqueFilenames.hook(cov, "_check_include_omit_etc")
cov.set_option("run:plugins", ["tests.plugin1"])
# Import the Python file, executing it.
self.start_import_stop(cov, "simple")
_, statements, missing, _ = cov.analysis("simple.py")
assert statements == [1, 2, 3]
assert missing == []
zzfile = os.path.abspath(os.path.join("/src", "try_ABC.zz"))
_, statements, _, _ = cov.analysis(zzfile)
assert statements == [105, 106, 107, 205, 206, 207]
def make_render_and_caller(self) -> None:
"""Make the render.py and caller.py files we need."""
# plugin2 emulates a dynamic tracing plugin: the caller's locals
# are examined to determine the source file and line number.
# The plugin is in tests/plugin2.py.
self.make_file(
"render.py",
"""\
def render(filename, linenum):
# This function emulates a template renderer. The plugin
# will examine the `filename` and `linenum` locals to
# determine the source file and line number.
fiddle_around = 1 # not used, just chaff.
return "[{} @ {}]".format(filename, linenum)
def helper(x):
# This function is here just to show that not all code in
# this file will be part of the dynamic tracing.
return x+1
""",
)
self.make_file(
"caller.py",
"""\
import sys
from render import helper, render
assert render("foo_7.html", 4) == "[foo_7.html @ 4]"
# Render foo_7.html again to try the CheckUniqueFilenames asserts.
render("foo_7.html", 4)
assert helper(42) == 43
assert render("bar_4.html", 2) == "[bar_4.html @ 2]"
assert helper(76) == 77
# quux_5.html will be omitted from the results.
assert render("quux_5.html", 3) == "[quux_5.html @ 3]"
""",
)
# will try to read the actual source files, so make some
# source files.
def lines(n: int) -> str:
"""Make a string with n lines of text."""
return "".join("line %d\n" % i for i in range(n))
self.make_file("bar_4.html", lines(4))
self.make_file("foo_7.html", lines(7))
def test_plugin2(self) -> None:
self.make_render_and_caller()
cov = coverage.Coverage(omit=["*quux*"])
CheckUniqueFilenames.hook(cov, "_should_trace")
CheckUniqueFilenames.hook(cov, "_check_include_omit_etc")
cov.set_option("run:plugins", ["tests.plugin2"])
self.start_import_stop(cov, "caller")
# The way plugin2 works, a file named foo_7.html will be claimed to
# have 7 lines in it. If render() was called with line number 4,
# then the plugin will claim that lines 4 and 5 were executed.
_, statements, missing, _ = cov.analysis("foo_7.html")
assert statements == [1, 2, 3, 4, 5, 6, 7]
assert missing == [1, 2, 3, 6, 7]
assert "foo_7.html" in line_counts(cov.get_data())
_, statements, missing, _ = cov.analysis("bar_4.html")
assert statements == [1, 2, 3, 4]
assert missing == [1, 4]
assert "bar_4.html" in line_counts(cov.get_data())
assert "quux_5.html" not in line_counts(cov.get_data())
def test_plugin2_with_branch(self) -> None:
self.make_render_and_caller()
cov = coverage.Coverage(branch=True, omit=["*quux*"])
CheckUniqueFilenames.hook(cov, "_should_trace")
CheckUniqueFilenames.hook(cov, "_check_include_omit_etc")
cov.set_option("run:plugins", ["tests.plugin2"])
self.start_import_stop(cov, "caller")
# The way plugin2 works, a file named foo_7.html will be claimed to
# have 7 lines in it. If render() was called with line number 4,
# then the plugin will claim that lines 4 and 5 were executed.
analysis = cov._analyze("foo_7.html")
assert analysis.statements == {1, 2, 3, 4, 5, 6, 7}
# Plugins don't do branch coverage yet.
assert analysis.has_arcs is True
assert analysis.arc_possibilities == []
assert analysis.missing == {1, 2, 3, 6, 7}
def test_plugin2_with_text_report(self) -> None:
self.make_render_and_caller()
cov = coverage.Coverage(branch=True, omit=["*quux*"])
cov.set_option("run:plugins", ["tests.plugin2"])
self.start_import_stop(cov, "caller")
repout = io.StringIO()
total = cov.report(file=repout, include=["*.html"], omit=["uni*.html"], show_missing=True)
report = repout.getvalue().splitlines()
expected = [
"Name Stmts Miss Branch BrPart Cover Missing",
"--------------------------------------------------------",
"bar_4.html 4 2 0 0 50% 1, 4",
"foo_7.html 7 5 0 0 29% 1-3, 6-7",
"--------------------------------------------------------",
"TOTAL 11 7 0 0 36%",
]
assert expected == report
assert math.isclose(total, 4 / 11 * 100)
def test_plugin2_with_html_report(self) -> None:
self.make_render_and_caller()
cov = coverage.Coverage(branch=True, omit=["*quux*"])
cov.set_option("run:plugins", ["tests.plugin2"])
self.start_import_stop(cov, "caller")
total = cov.html_report(include=["*.html"], omit=["uni*.html"])
assert math.isclose(total, 4 / 11 * 100)
self.assert_exists("htmlcov/index.html")
self.assert_exists("htmlcov/bar_4_html.html")
self.assert_exists("htmlcov/foo_7_html.html")
def test_plugin2_with_xml_report(self) -> None:
self.make_render_and_caller()
cov = coverage.Coverage(branch=True, omit=["*quux*"])
cov.set_option("run:plugins", ["tests.plugin2"])
self.start_import_stop(cov, "caller")
total = cov.xml_report(include=["*.html"], omit=["uni*.html"])
assert math.isclose(total, 4 / 11 * 100)
dom = ElementTree.parse("coverage.xml")
classes = {}
for elt in dom.findall(".//class"):
classes[elt.get("name")] = elt
assert classes["bar_4.html"].attrib == {
"branch-rate": "1",
"complexity": "0",
"filename": "bar_4.html",
"line-rate": "0.5",
"name": "bar_4.html",
}
assert classes["foo_7.html"].attrib == {
"branch-rate": "1",
"complexity": "0",
"filename": "foo_7.html",
"line-rate": "0.2857",
"name": "foo_7.html",
}
def test_defer_to_python(self) -> None:
# A plugin that measures, but then wants built-in python reporting.
self.make_file(
"fairly_odd_plugin.py",
"""\
# A plugin that claims all the odd lines are executed, and none of
# the even lines, and then punts reporting off to the built-in
# Python reporting.
import coverage.plugin
class Plugin(coverage.CoveragePlugin):
def file_tracer(self, filename):
return OddTracer(filename)
def file_reporter(self, filename):
return "python"
class OddTracer(coverage.plugin.FileTracer):
def __init__(self, filename):
self.filename = filename
def source_filename(self):
return self.filename
def line_number_range(self, frame):
lineno = frame.f_lineno
if lineno % 2:
return (lineno, lineno)
else:
return (-1, -1)
def coverage_init(reg, options):
reg.add_file_tracer(Plugin())
""",
)
self.make_file(
"unsuspecting.py",
"""\
a = 1
b = 2
c = 3
d = 4
e = 5
f = 6
""",
)
cov = coverage.Coverage(include=["unsuspecting.py"])
cov.set_option("run:plugins", ["fairly_odd_plugin"])
self.start_import_stop(cov, "unsuspecting")
repout = io.StringIO()
total = cov.report(file=repout, show_missing=True)
report = repout.getvalue().splitlines()
expected = [
"Name Stmts Miss Cover Missing",
"-----------------------------------------------",
"unsuspecting.py 6 3 50% 2, 4, 6",
"-----------------------------------------------",
"TOTAL 6 3 50%",
]
assert expected == report
assert total == 50
def test_find_unexecuted(self) -> None:
self.make_file(
"unexecuted_plugin.py",
"""\
import os
import coverage.plugin
class Plugin(coverage.CoveragePlugin):
def file_tracer(self, filename):
if filename.endswith("foo.py"):
return MyTracer(filename)
def file_reporter(self, filename):
return MyReporter(filename)
def find_executable_files(self, src_dir):
# Check that src_dir is the right value
files = os.listdir(src_dir)
assert "foo.py" in files
assert "unexecuted_plugin.py" in files
return ["chimera.py"]
class MyTracer(coverage.plugin.FileTracer):
def __init__(self, filename):
self.filename = filename
def source_filename(self):
return self.filename
def line_number_range(self, frame):
return (999, 999)
class MyReporter(coverage.FileReporter):
def lines(self):
return {99, 999, 9999}
def coverage_init(reg, options):
reg.add_file_tracer(Plugin())
""",
)
self.make_file("foo.py", "a = 1")
cov = coverage.Coverage(source=["."])
cov.set_option("run:plugins", ["unexecuted_plugin"])
self.start_import_stop(cov, "foo")
# The file we executed claims to have run line 999.
_, statements, missing, _ = cov.analysis("foo.py")
assert statements == [99, 999, 9999]
assert missing == [99, 9999]
# The completely missing file is in the results.
_, statements, missing, _ = cov.analysis("chimera.py")
assert statements == [99, 999, 9999]
assert missing == [99, 999, 9999]
# But completely new filenames are not in the results.
assert len(cov.get_data().measured_files()) == 3
with pytest.raises(NoSource):
cov.analysis("fictional.py")
class BadFileTracerTest(FileTracerTest):
"""Test error handling around file tracer plugins."""
def run_plugin(self, module_name: str) -> Coverage:
"""Run a plugin with the given module_name.
Uses a few fixed Python files.
Returns the Coverage object.
"""
self.make_file(
"simple.py",
"""\
import other, another
a = other.f(2)
b = other.f(3)
c = another.g(4)
d = another.g(5)
""",
)
# The names of these files are important: some plugins apply themselves
# to "*other.py".
self.make_file(
"other.py",
"""\
def f(x):
return x+1
""",
)
self.make_file(
"another.py",
"""\
def g(x):
return x-1
""",
)
cov = coverage.Coverage()
cov.set_option("run:plugins", [module_name])
self.start_import_stop(cov, "simple")
cov.save() # pytest-cov does a save after stop, so we'll do it too.
return cov
def run_bad_plugin(
self,
module_name: str,
plugin_name: str,
our_error: bool = True,
excmsg: str | None = None,
excmsgs: list[str] | None = None,
) -> None:
"""Run a file, and see that the plugin failed.
`module_name` and `plugin_name` is the module and name of the plugin to
use.
`our_error` is True if the error reported to the user will be an
explicit error in our test code, marked with an '# Oh noes!' comment.
`excmsg`, if provided, is text that must appear in the stderr.
`excmsgs`, if provided, is a list of messages, one of which must
appear in the stderr.
The plugin will be disabled, and we check that a warning is output
explaining why.
"""
with pytest.warns(Warning) as warns:
self.run_plugin(module_name)
stderr = self.stderr()
stderr += "".join(str(w.message) for w in warns)
if our_error:
# The exception we're causing should only appear once.
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:
print([str(w) for w in warns.list])
warnings = [w for w in warns.list if issubclass(w.category, CoverageWarning)]
assert len(warnings) == 1
warnmsg = str(warnings[0].message)
assert f"Disabling plug-in '{module_name}.{plugin_name}' due to " in warnmsg
if excmsg:
assert excmsg in stderr
if excmsgs:
found_exc = any(em in stderr for em in excmsgs) # pragma: part covered
assert found_exc, f"expected one of {excmsgs} in stderr"
def test_file_tracer_has_no_file_tracer_method(self) -> None:
self.make_file(
"bad_plugin.py",
"""\
class Plugin(object):
pass
def coverage_init(reg, options):
reg.add_file_tracer(Plugin())
""",
)
self.run_bad_plugin("bad_plugin", "Plugin", our_error=False)
def test_file_tracer_has_inherited_sourcefilename_method(self) -> None:
self.make_file(
"bad_plugin.py",
"""\
import coverage
class Plugin(coverage.CoveragePlugin):
def file_tracer(self, filename):
# Just grab everything.
return FileTracer()
class FileTracer(coverage.FileTracer):
pass
def coverage_init(reg, options):
reg.add_file_tracer(Plugin())
""",
)
self.run_bad_plugin(
"bad_plugin",
"Plugin",
our_error=False,
excmsg="Class 'bad_plugin.FileTracer' needs to implement source_filename()",
)
def test_plugin_has_inherited_filereporter_method(self) -> None:
self.make_file(
"bad_plugin.py",
"""\
import coverage
class Plugin(coverage.CoveragePlugin):
def file_tracer(self, filename):
# Just grab everything.
return FileTracer()
class FileTracer(coverage.FileTracer):
def source_filename(self):
return "foo.xxx"
def coverage_init(reg, options):
reg.add_file_tracer(Plugin())
""",
)
cov = self.run_plugin("bad_plugin")
expected_msg = "Plugin 'bad_plugin.Plugin' needs to implement file_reporter()"
with pytest.raises(NotImplementedError, match=expected_msg):
cov.report()
def test_file_tracer_fails(self) -> None:
self.make_file(
"bad_plugin.py",
"""\
import coverage.plugin
class Plugin(coverage.plugin.CoveragePlugin):
def file_tracer(self, filename):
17/0 # Oh noes!
def coverage_init(reg, options):
reg.add_file_tracer(Plugin())
""",
)
self.run_bad_plugin("bad_plugin", "Plugin")
def test_file_tracer_fails_eventually(self) -> None:
# Django coverage plugin can report on a few files and then fail.
# https://github.com/nedbat/coveragepy/issues/1011
self.make_file(
"bad_plugin.py",
"""\
import os.path
import coverage.plugin
class Plugin(coverage.plugin.CoveragePlugin):
def __init__(self):
self.calls = 0
def file_tracer(self, filename):
print(filename)
self.calls += 1
if self.calls <= 2:
return FileTracer(filename)
else:
17/0 # Oh noes!
class FileTracer(coverage.FileTracer):
def __init__(self, filename):
self.filename = filename
def source_filename(self):
return os.path.basename(self.filename).replace(".py", ".foo")
def line_number_range(self, frame):
return -1, -1
def coverage_init(reg, options):
reg.add_file_tracer(Plugin())
""",
)
self.run_bad_plugin("bad_plugin", "Plugin")
def test_file_tracer_returns_wrong(self) -> None:
self.make_file(
"bad_plugin.py",
"""\
import coverage.plugin
class Plugin(coverage.plugin.CoveragePlugin):
def file_tracer(self, filename):
return 3.14159
def coverage_init(reg, options):
reg.add_file_tracer(Plugin())
""",
)
self.run_bad_plugin(
"bad_plugin",
"Plugin",
our_error=False,
excmsg="'float' object has no attribute",
)
def test_has_dynamic_source_filename_fails(self) -> None:
self.make_file(
"bad_plugin.py",
"""\
import coverage.plugin
class Plugin(coverage.plugin.CoveragePlugin):
def file_tracer(self, filename):
return BadFileTracer()
class BadFileTracer(coverage.plugin.FileTracer):
def has_dynamic_source_filename(self):
23/0 # Oh noes!
def coverage_init(reg, options):
reg.add_file_tracer(Plugin())
""",
)
self.run_bad_plugin("bad_plugin", "Plugin")
def test_source_filename_fails(self) -> None:
self.make_file(
"bad_plugin.py",
"""\
import coverage.plugin
class Plugin(coverage.plugin.CoveragePlugin):
def file_tracer(self, filename):
return BadFileTracer()
class BadFileTracer(coverage.plugin.FileTracer):
def source_filename(self):
42/0 # Oh noes!
def coverage_init(reg, options):
reg.add_file_tracer(Plugin())
""",
)
self.run_bad_plugin("bad_plugin", "Plugin")
def test_source_filename_returns_wrong(self) -> None:
self.make_file(
"bad_plugin.py",
"""\
import coverage.plugin
class Plugin(coverage.plugin.CoveragePlugin):
def file_tracer(self, filename):
return BadFileTracer()
class BadFileTracer(coverage.plugin.FileTracer):
def source_filename(self):
return 17.3
def coverage_init(reg, options):
reg.add_file_tracer(Plugin())
""",
)
self.run_bad_plugin(
"bad_plugin",
"Plugin",
our_error=False,
excmsgs=[
"expected str, bytes or os.PathLike object, not float",
"'float' object has no attribute",
"object of type 'float' has no len()",
"'float' object is unsubscriptable",
],
)
def test_dynamic_source_filename_fails(self) -> None:
self.make_file(
"bad_plugin.py",
"""\
import coverage.plugin
class Plugin(coverage.plugin.CoveragePlugin):
def file_tracer(self, filename):
if filename.endswith("other.py"):
return BadFileTracer()
class BadFileTracer(coverage.plugin.FileTracer):
def has_dynamic_source_filename(self):
return True
def dynamic_source_filename(self, filename, frame):
101/0 # Oh noes!
def coverage_init(reg, options):
reg.add_file_tracer(Plugin())
""",
)
self.run_bad_plugin("bad_plugin", "Plugin")
def test_line_number_range_raises_error(self) -> None:
self.make_file(
"bad_plugin.py",
"""\
import coverage.plugin
class Plugin(coverage.plugin.CoveragePlugin):
def file_tracer(self, filename):
if filename.endswith("other.py"):
return BadFileTracer()
class BadFileTracer(coverage.plugin.FileTracer):
def source_filename(self):
return "something.foo"
def line_number_range(self, frame):
raise Exception("borked!")
def coverage_init(reg, options):
reg.add_file_tracer(Plugin())
""",
)
self.run_bad_plugin(
"bad_plugin",
"Plugin",
our_error=False,
excmsg="borked!",
)
def test_line_number_range_returns_non_tuple(self) -> None:
self.make_file(
"bad_plugin.py",
"""\
import coverage.plugin
class Plugin(coverage.plugin.CoveragePlugin):
def file_tracer(self, filename):
if filename.endswith("other.py"):
return BadFileTracer()
class BadFileTracer(coverage.plugin.FileTracer):
def source_filename(self):
return "something.foo"
def line_number_range(self, frame):
return 42.23
def coverage_init(reg, options):
reg.add_file_tracer(Plugin())
""",
)
self.run_bad_plugin(
"bad_plugin",
"Plugin",
our_error=False,
excmsg="line_number_range must return 2-tuple",
)
def test_line_number_range_returns_triple(self) -> None:
self.make_file(
"bad_plugin.py",
"""\
import coverage.plugin
class Plugin(coverage.plugin.CoveragePlugin):
def file_tracer(self, filename):
if filename.endswith("other.py"):
return BadFileTracer()
class BadFileTracer(coverage.plugin.FileTracer):
def source_filename(self):
return "something.foo"
def line_number_range(self, frame):
return (1, 2, 3)
def coverage_init(reg, options):
reg.add_file_tracer(Plugin())
""",
)
self.run_bad_plugin(
"bad_plugin",
"Plugin",
our_error=False,
excmsg="line_number_range must return 2-tuple",
)
def test_line_number_range_returns_pair_of_strings(self) -> None:
self.make_file(
"bad_plugin.py",
"""\
import coverage.plugin
class Plugin(coverage.plugin.CoveragePlugin):
def file_tracer(self, filename):
if filename.endswith("other.py"):
return BadFileTracer()
class BadFileTracer(coverage.plugin.FileTracer):
def source_filename(self):
return "something.foo"
def line_number_range(self, frame):
return ("5", "7")
def coverage_init(reg, options):
reg.add_file_tracer(Plugin())
""",
)
self.run_bad_plugin(
"bad_plugin",
"Plugin",
our_error=False,
excmsgs=[
"an integer is required",
"cannot be interpreted as an integer",
],
)
class ConfigurerPluginTest(CoverageTest):
"""Test configuring plugins."""
run_in_temp_dir = False
def test_configurer_plugin(self) -> None:
cov = coverage.Coverage()
cov.set_option("run:plugins", ["tests.plugin_config"])
cov.start()
cov.stop() # pragma: nested
excluded = cov.get_option("report:exclude_lines")
assert isinstance(excluded, list)
assert "pragma: custom" in excluded
assert "pragma: or whatever" in excluded
@pytest.mark.skipif(not testenv.DYN_CONTEXTS, reason="No dynamic contexts with this core")
class DynamicContextPluginTest(CoverageTest):
"""Tests of plugins that implement `dynamic_context`."""
def make_plugin_capitalized_testnames(self, filename: str) -> None:
"""Create a dynamic context plugin that capitalizes the part after 'test_'."""
self.make_file(
filename,
"""\
from coverage import CoveragePlugin
class Plugin(CoveragePlugin):
def dynamic_context(self, frame):
name = frame.f_code.co_name
if name.startswith(("test_", "doctest_")):
parts = name.split("_", 1)
return "%s:%s" % (parts[0], parts[1].upper())
return None
def coverage_init(reg, options):
reg.add_dynamic_context(Plugin())
""",
)
def make_plugin_track_render(self, filename: str) -> None:
"""Make a dynamic context plugin that tracks 'render_' functions."""
self.make_file(
filename,
"""\
from coverage import CoveragePlugin
class Plugin(CoveragePlugin):
def dynamic_context(self, frame):
name = frame.f_code.co_name
if name.startswith("render_"):
return 'renderer:' + name[7:]
return None
def coverage_init(reg, options):
reg.add_dynamic_context(Plugin())
""",
)
def make_test_files(self) -> None:
"""Make some files to use while testing dynamic context plugins."""
self.make_file(
"rendering.py",
"""\
def html_tag(tag, content):
return f'<{tag}>{content}</{tag}>'
def render_paragraph(text):
return html_tag('p', text)
def render_span(text):
return html_tag('span', text)
def render_bold(text):
return html_tag('b', text)
""",
)
self.make_file(
"testsuite.py",
"""\
import rendering
def test_html_tag() -> None:
assert rendering.html_tag('b', 'hello') == '<b>hello</b>'
def doctest_html_tag():
assert eval('''
rendering.html_tag('i', 'text') == '<i>text</i>'
'''.strip())
def test_renderers() -> None:
assert rendering.render_paragraph('hello') == '<p>hello</p>'
assert rendering.render_bold('wide') == '<b>wide</b>'
assert rendering.render_span('world') == '<span>world</span>'
def build_full_html():
html = '<html><body>%s</body></html>' % (
rendering.render_paragraph(
rendering.render_span('hello')))
return html
""",
)
def run_all_functions(self, cov: Coverage, suite_name: str) -> None: # pragma: nested
"""Run all functions in `suite_name` under coverage."""
cov.start()
suite = import_local_file(suite_name)
try:
# Call all functions in this module
for name in dir(suite):
variable = getattr(suite, name)
if inspect.isfunction(variable):
variable()
finally:
cov.stop()
def test_plugin_standalone(self) -> None:
self.make_plugin_capitalized_testnames("plugin_tests.py")
self.make_test_files()
# Enable dynamic context plugin
cov = coverage.Coverage()
cov.set_option("run:plugins", ["plugin_tests"])
# Run the tests
self.run_all_functions(cov, "testsuite")
# Labeled coverage is collected
data = cov.get_data()
filenames = self.get_measured_filenames(data)
expected = ["", "doctest:HTML_TAG", "test:HTML_TAG", "test:RENDERERS"]
assert expected == sorted(data.measured_contexts())
data.set_query_context("doctest:HTML_TAG")
assert [2] == sorted_lines(data, filenames["rendering.py"])
data.set_query_context("test:HTML_TAG")
assert [2] == sorted_lines(data, filenames["rendering.py"])
data.set_query_context("test:RENDERERS")
assert [2, 5, 8, 11] == sorted_lines(data, filenames["rendering.py"])
def test_static_context(self) -> None:
self.make_plugin_capitalized_testnames("plugin_tests.py")
self.make_test_files()
# Enable dynamic context plugin for coverage with named context
cov = coverage.Coverage(context="mytests")
cov.set_option("run:plugins", ["plugin_tests"])
# Run the tests
self.run_all_functions(cov, "testsuite")
# Static context prefix is preserved
data = cov.get_data()
expected = [
"mytests",
"mytests|doctest:HTML_TAG",
"mytests|test:HTML_TAG",
"mytests|test:RENDERERS",
]
assert expected == sorted(data.measured_contexts())
def test_plugin_with_test_function(self) -> None:
self.make_plugin_capitalized_testnames("plugin_tests.py")
self.make_test_files()
# Enable both a plugin and test_function dynamic context
cov = coverage.Coverage()
cov.set_option("run:plugins", ["plugin_tests"])
cov.set_option("run:dynamic_context", "test_function")
# Run the tests
self.run_all_functions(cov, "testsuite")
# test_function takes precedence over plugins - only
# functions that are not labeled by test_function are
# labeled by plugin_tests.
data = cov.get_data()
filenames = self.get_measured_filenames(data)
expected = [
"",
"doctest:HTML_TAG",
"testsuite.test_html_tag",
"testsuite.test_renderers",
]
assert expected == sorted(data.measured_contexts())
def assert_context_lines(context: str, lines: list[TLineNo]) -> None:
data.set_query_context(context)
assert lines == sorted_lines(data, filenames["rendering.py"])
assert_context_lines("doctest:HTML_TAG", [2])
assert_context_lines("testsuite.test_html_tag", [2])
assert_context_lines("testsuite.test_renderers", [2, 5, 8, 11])
def test_multiple_plugins(self) -> None:
self.make_plugin_capitalized_testnames("plugin_tests.py")
self.make_plugin_track_render("plugin_renderers.py")
self.make_test_files()
# Enable two plugins
cov = coverage.Coverage()
cov.set_option("run:plugins", ["plugin_renderers", "plugin_tests"])
self.run_all_functions(cov, "testsuite")
# It is important to note, that line 11 (render_bold function) is never
# labeled as renderer:bold context, because it is only called from
# test_renderers function - so it already falls under test:RENDERERS
# context.
#
# render_paragraph and render_span (lines 5, 8) are directly called by
# testsuite.build_full_html, so they get labeled by renderers plugin.
data = cov.get_data()
filenames = self.get_measured_filenames(data)
expected = [
"",
"doctest:HTML_TAG",
"renderer:paragraph",
"renderer:span",
"test:HTML_TAG",
"test:RENDERERS",
]
assert expected == sorted(data.measured_contexts())
def assert_context_lines(context: str, lines: list[TLineNo]) -> None:
data.set_query_context(context)
assert lines == sorted_lines(data, filenames["rendering.py"])
assert_context_lines("test:HTML_TAG", [2])
assert_context_lines("test:RENDERERS", [2, 5, 8, 11])
assert_context_lines("doctest:HTML_TAG", [2])
assert_context_lines("renderer:paragraph", [2, 5])
assert_context_lines("renderer:span", [2, 8])