| # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 |
| # For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt |
| |
| """Oddball cases for testing coverage.py""" |
| |
| from __future__ import annotations |
| |
| import os.path |
| import re |
| import sys |
| import warnings |
| |
| import pytest |
| |
| import coverage |
| from coverage import env |
| from coverage.data import sorted_lines |
| from coverage.files import abs_file |
| from coverage.misc import import_local_file |
| |
| from tests import osinfo, testenv |
| from tests.coveragetest import CoverageTest |
| from tests.helpers import swallow_warnings |
| |
| |
| class ThreadingTest(CoverageTest): |
| """Tests of the threading support.""" |
| |
| def test_threading(self) -> None: |
| self.check_coverage( |
| """\ |
| import threading |
| |
| def fromMainThread(): |
| return "called from main thread" |
| |
| def fromOtherThread(): |
| return "called from other thread" |
| |
| def neverCalled(): |
| return "no one calls me" |
| |
| other = threading.Thread(target=fromOtherThread) |
| other.start() |
| fromMainThread() |
| other.join() |
| """, |
| lines=[1, 3, 4, 6, 7, 9, 10, 12, 13, 14, 15], |
| missing="10", |
| ) |
| |
| def test_thread_run(self) -> None: |
| self.check_coverage( |
| """\ |
| import threading |
| |
| class TestThread(threading.Thread): |
| def run(self): |
| self.a = 5 |
| self.do_work() |
| self.a = 7 |
| |
| def do_work(self): |
| self.a = 10 |
| |
| thd = TestThread() |
| thd.start() |
| thd.join() |
| """, |
| lines=[1, 3, 4, 5, 6, 7, 9, 10, 12, 13, 14], |
| missing="", |
| ) |
| |
| |
| class RecursionTest(CoverageTest): |
| """Check what happens when recursive code gets near limits.""" |
| |
| def test_short_recursion(self) -> None: |
| # We can definitely get close to 500 stack frames. |
| self.check_coverage( |
| """\ |
| def recur(n): |
| if n == 0: |
| return 0 |
| else: |
| return recur(n-1)+1 |
| |
| recur(495) # We can get at least this many stack frames. |
| i = 8 # and this line will be traced |
| """, |
| lines=[1, 2, 3, 5, 7, 8], |
| missing="", |
| ) |
| |
| @pytest.mark.flaky(max_runs=3) |
| def test_long_recursion(self) -> None: |
| # We can't finish a very deep recursion, but we don't crash. |
| with pytest.raises(RuntimeError): |
| with swallow_warnings("Trace function changed, data is likely wrong: 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. |
| """, |
| lines=[1, 2, 3, 5, 7], |
| missing="", |
| ) |
| |
| def test_long_recursion_recovery(self) -> None: |
| # Test the core of bug 93: https://github.com/nedbat/coveragepy/issues/93 |
| # When recovering from a stack overflow, the Python trace function is |
| # disabled, but the C trace function is not. So if we're using a |
| # Python trace function, we won't trace anything after the stack |
| # overflow, and there should be a warning about it. If we're using |
| # the C trace function, only line 3 will be missing, and all else |
| # will be traced. |
| |
| self.make_file( |
| "recur.py", |
| """\ |
| import sys #; sys.setrecursionlimit(70) |
| def recur(n): |
| if n == 0: |
| return 0 # never hit |
| else: |
| return recur(n-1)+1 |
| |
| try: |
| recur(100000) # This is definitely too many frames. |
| except RuntimeError: |
| i = 11 |
| i = 12 |
| """, |
| ) |
| |
| cov = coverage.Coverage() |
| with swallow_warnings("Trace function changed, data is likely wrong: None"): |
| self.start_import_stop(cov, "recur") |
| |
| assert cov._collector is not None |
| pytrace = (cov._collector.tracer_name() == "PyTracer") # fmt: skip |
| expected_missing = [4] |
| if pytrace: # pragma: partial metacov |
| expected_missing += [10, 11, 12] |
| |
| _, statements, missing, _ = cov.analysis("recur.py") |
| assert statements == [1, 2, 3, 4, 6, 8, 9, 10, 11, 12] |
| assert expected_missing == missing |
| |
| # Get a warning about the stackoverflow effect on the tracing function. |
| if pytrace and not env.METACOV: # pragma: partial metacov |
| assert len(cov._warnings) == 1 |
| assert re.fullmatch( |
| r"Trace function changed, data is likely wrong: None != " |
| + r"<bound method PyTracer._trace of " |
| + "<PyTracer at 0x[0-9a-fA-F]+: 6 data points in 1 files>>", |
| cov._warnings[0], |
| ) |
| else: |
| assert not cov._warnings |
| |
| |
| class MemoryLeakTest(CoverageTest): |
| """Attempt the impossible: test that memory doesn't leak. |
| |
| Note: this test is truly unusual, and has had a colorful history. See |
| for example: https://github.com/nedbat/coveragepy/issues/186 |
| |
| It may still fail occasionally, especially on PyPy. |
| |
| """ |
| |
| @pytest.mark.flaky |
| @pytest.mark.skipif(not testenv.C_TRACER, reason="Only the C tracer has refcounting issues") |
| def test_for_leaks(self) -> None: |
| # Our original bad memory leak only happened on line numbers > 255, so |
| # make a code object with more lines than that. Ugly string mumbo |
| # jumbo to get 300 blank lines at the beginning.. |
| code = ( |
| """\ |
| # blank line\n""" |
| * 300 |
| + """\ |
| def once(x): # line 301 |
| if x % 100 == 0: |
| raise Exception("100!") |
| elif x % 2: |
| return 10 |
| else: # line 306 |
| return 11 |
| i = 0 # Portable loop without alloc'ing memory. |
| while i < ITERS: |
| try: |
| once(i) |
| except: |
| pass |
| i += 1 # line 315 |
| """ |
| ) |
| lines = list(range(301, 315)) |
| lines.remove(306) # Line 306 is the "else". |
| |
| # This is a non-deterministic test, so try it a few times, and fail it |
| # only if it predominantly fails. |
| fails = 0 |
| for _ in range(10): |
| ram_0 = osinfo.process_ram() |
| self.check_coverage(code.replace("ITERS", "10"), lines=lines, missing="") |
| ram_10 = osinfo.process_ram() |
| self.check_coverage(code.replace("ITERS", "10000"), lines=lines, missing="") |
| ram_10k = osinfo.process_ram() |
| # Running the code 10k times shouldn't grow the ram much more than |
| # running it 10 times. |
| ram_growth = (ram_10k - ram_10) - (ram_10 - ram_0) |
| if ram_growth > 100000: |
| fails += 1 # pragma: only failure |
| |
| if fails > 8: |
| pytest.fail("RAM grew by %d" % (ram_growth)) # pragma: only failure |
| |
| @pytest.mark.skipif( |
| not testenv.C_TRACER, |
| reason="Only the C tracer has refcounting issues", |
| # In fact, sysmon explicitly holds onto all code objects, |
| # so this will definitely fail with sysmon. |
| ) |
| @pytest.mark.skipif( |
| env.PYVERSION[:2] == (3, 13) and not env.GIL, |
| reason="3.13t never frees code objects: https://github.com/python/cpython/pull/131989", |
| ) |
| @pytest.mark.parametrize("branch", [False, True]) |
| def test_eval_codeobject_leak(self, branch: bool) -> None: |
| # https://github.com/nedbat/coveragepy/issues/1924 |
| code = """\ |
| for i in range(10_000): |
| r = eval("'a' + '1'") |
| assert r == 'a1' |
| """ |
| # Looking for leaks is hard. We consider the leak fixed if at least |
| # one of our loops only increased the footprint by a small amount. |
| base = osinfo.process_ram() |
| deltas = [] |
| for _ in range(30): |
| self.check_coverage(code, lines=[1, 2, 3], missing="", branch=branch) |
| now = osinfo.process_ram() |
| deltas.append(now - base) |
| print(f"Mem delta: {(now - base) // 1024}") |
| base = now |
| assert any(d < 50 * 1024 for d in deltas) |
| |
| |
| class MemoryFumblingTest(CoverageTest): |
| """Test that we properly manage the None refcount.""" |
| |
| @pytest.mark.skipif(not testenv.C_TRACER, reason="Only the C tracer has refcounting issues") |
| def test_dropping_none(self) -> None: # pragma: not covered |
| # TODO: Mark this so it will only be run sometimes. |
| pytest.skip("This is too expensive for now (30s)") |
| # Start and stop coverage thousands of times to flush out bad |
| # reference counting, maybe. |
| _ = "this is just here to put a type comment on" # type: ignore[unreachable] |
| self.make_file( |
| "the_code.py", |
| """\ |
| import random |
| def f(): |
| if random.random() > .5: |
| x = 1 |
| else: |
| x = 2 |
| """, |
| ) |
| self.make_file( |
| "main.py", |
| """\ |
| import coverage |
| import sys |
| from the_code import f |
| for i in range(10000): |
| cov = coverage.Coverage(branch=True) |
| cov.start() |
| f() |
| cov.stop() |
| cov.erase() |
| print("Final None refcount: %d" % (sys.getrefcount(None))) |
| """, |
| ) |
| status, out = self.run_command_status("python main.py") |
| assert status == 0 |
| assert "Final None refcount" in out |
| assert "Fatal" not in out |
| |
| |
| class PyexpatTest(CoverageTest): |
| """Pyexpat screws up tracing. Make sure we've counter-defended properly.""" |
| |
| def test_pyexpat(self) -> None: |
| # pyexpat calls the trace function explicitly (inexplicably), and does |
| # it wrong for exceptions. Parsing a DOCTYPE for some reason throws |
| # an exception internally, and triggers its wrong behavior. This test |
| # checks that our fake PyTrace_RETURN hack in tracer.c works. It will |
| # also detect if the pyexpat bug is fixed unbeknownst to us, meaning |
| # we'd see two RETURNs where there should only be one. |
| |
| self.make_file( |
| "trydom.py", |
| """\ |
| import xml.dom.minidom |
| |
| XML = '''\\ |
| <!DOCTYPE fooey SYSTEM "http://www.example.com/example.dtd"> |
| <root><child/><child/></root> |
| ''' |
| |
| def foo(): |
| dom = xml.dom.minidom.parseString(XML) |
| assert len(dom.getElementsByTagName('child')) == 2 |
| a = 11 |
| |
| foo() |
| """, |
| ) |
| |
| self.make_file("outer.py", "\n" * 100 + "import trydom\na = 102\n") |
| |
| cov = coverage.Coverage() |
| cov.erase() |
| |
| # Import the Python file, executing it. |
| self.start_import_stop(cov, "outer") |
| |
| _, statements, missing, _ = cov.analysis("trydom.py") |
| assert statements == [1, 3, 8, 9, 10, 11, 13] |
| assert missing == [] |
| |
| _, statements, missing, _ = cov.analysis("outer.py") |
| assert statements == [101, 102] |
| assert missing == [] |
| |
| # Make sure pyexpat isn't recorded as a source file. |
| # https://github.com/nedbat/coveragepy/issues/419 |
| files = cov.get_data().measured_files() |
| msg = f"Pyexpat.c is in the measured files!: {files!r}:" |
| assert not any(f.endswith("pyexpat.c") for f in files), msg |
| |
| |
| class ExceptionTest(CoverageTest): |
| """I suspect different versions of Python deal with exceptions differently |
| in the trace function. |
| """ |
| |
| def test_exception(self) -> None: |
| # Python 2.3's trace function doesn't get called with "return" if the |
| # scope is exiting due to an exception. This confounds our trace |
| # function which relies on scope announcements to track which files to |
| # trace. |
| # |
| # This test is designed to sniff this out. Each function in the call |
| # stack is in a different file, to try to trip up the tracer. Each |
| # file has active lines in a different range so we'll see if the lines |
| # get attributed to the wrong file. |
| |
| self.make_file( |
| "oops.py", |
| """\ |
| def oops(args): |
| a = 2 |
| raise Exception("oops") |
| a = 4 |
| """, |
| ) |
| |
| self.make_file( |
| "fly.py", |
| "\n" * 100 |
| + """\ |
| def fly(calls): |
| a = 2 |
| calls[0](calls[1:]) |
| a = 4 |
| """, |
| ) |
| |
| self.make_file( |
| "catch.py", |
| "\n" * 200 |
| + """\ |
| def catch(calls): |
| try: |
| a = 3 |
| calls[0](calls[1:]) |
| a = 5 |
| except: |
| a = 7 |
| """, |
| ) |
| |
| self.make_file( |
| "doit.py", |
| "\n" * 300 |
| + """\ |
| def doit(calls): |
| try: |
| calls[0](calls[1:]) |
| except: |
| a = 5 |
| """, |
| ) |
| |
| # Import all the modules before starting coverage, so the def lines |
| # won't be in all the results. |
| for mod in "oops fly catch doit".split(): |
| import_local_file(mod) |
| |
| # Each run nests the functions differently to get different |
| # combinations of catching exceptions and letting them fly. |
| runs = [ |
| ( |
| "doit fly oops", |
| { |
| "doit.py": [302, 303, 304, 305], |
| "fly.py": [102, 103], |
| "oops.py": [2, 3], |
| }, |
| ), |
| ( |
| "doit catch oops", |
| { |
| "doit.py": [302, 303], |
| "catch.py": [202, 203, 204, 206, 207], |
| "oops.py": [2, 3], |
| }, |
| ), |
| ( |
| "doit fly catch oops", |
| { |
| "doit.py": [302, 303], |
| "fly.py": [102, 103, 104], |
| "catch.py": [202, 203, 204, 206, 207], |
| "oops.py": [2, 3], |
| }, |
| ), |
| ( |
| "doit catch fly oops", |
| { |
| "doit.py": [302, 303], |
| "catch.py": [202, 203, 204, 206, 207], |
| "fly.py": [102, 103], |
| "oops.py": [2, 3], |
| }, |
| ), |
| ] |
| |
| for callnames, lines_expected in runs: |
| # Make the list of functions we'll call for this test. |
| callnames_list = callnames.split() |
| calls = [getattr(sys.modules[cn], cn) for cn in callnames_list] |
| |
| cov = coverage.Coverage() |
| with cov.collect(): |
| # Call our list of functions: invoke the first, with the rest as |
| # an argument. |
| calls[0](calls[1:]) |
| |
| # Clean the line data and compare to expected results. |
| # The file names are absolute, so keep just the base. |
| clean_lines = {} |
| data = cov.get_data() |
| for callname in callnames_list: |
| filename = callname + ".py" |
| clean_lines[filename] = sorted_lines(data, abs_file(filename)) |
| |
| assert clean_lines == lines_expected |
| |
| |
| class DoctestTest(CoverageTest): |
| """Tests invoked with doctest should measure properly.""" |
| |
| def test_doctest(self) -> None: |
| # Doctests used to be traced, with their line numbers credited to the |
| # file they were in. Below, one of the doctests has four lines (1-4), |
| # which would incorrectly claim that lines 1-4 of the file were |
| # executed. In this file, line 2 is not executed. |
| self.make_file( |
| "the_doctest.py", |
| '''\ |
| if "x" in "abc": |
| print("hello") |
| def return_arg_or_void(arg): |
| """If <arg> is None, return "Void"; otherwise return <arg> |
| |
| >>> return_arg_or_void(None) |
| 'Void' |
| >>> return_arg_or_void("arg") |
| 'arg' |
| >>> return_arg_or_void("None") |
| 'None' |
| >>> if "x" in "xyz": # line 1 |
| ... if "a" in "aswed": # line 2 |
| ... if "a" in "abc": # line 3 |
| ... return_arg_or_void(12) # line 4 |
| 12 |
| """ |
| if arg is None: |
| return "Void" |
| else: |
| return arg |
| |
| import doctest, sys |
| doctest.testmod(sys.modules[__name__]) # we're not __main__ :( |
| ''', |
| ) |
| cov = coverage.Coverage() |
| with warnings.catch_warnings(): |
| # Doctest calls pdb which opens ~/.pdbrc without an encoding argument, |
| # but we don't care. PYVERSIONS: this was needed for 3.10 only. |
| warnings.filterwarnings("ignore", r".*'encoding' argument not specified.*") |
| self.start_import_stop(cov, "the_doctest") |
| data = cov.get_data() |
| assert len(data.measured_files()) == 1 |
| lines = sorted_lines(data, data.measured_files().pop()) |
| assert lines == [1, 3, 18, 19, 21, 23, 24] |
| |
| |
| class GettraceTest(CoverageTest): |
| """Tests that we work properly with `sys.gettrace()`.""" |
| |
| def test_round_trip_in_untraced_function(self) -> None: |
| # https://github.com/nedbat/coveragepy/issues/575 |
| self.make_file("main.py", """import sample""") |
| self.make_file( |
| "sample.py", |
| """\ |
| from swap import swap_it |
| def doit(): |
| print(3) |
| swap_it() |
| print(5) |
| def doit_soon(): |
| print(7) |
| doit() |
| print(9) |
| print(10) |
| doit_soon() |
| print(12) |
| """, |
| ) |
| self.make_file( |
| "swap.py", |
| """\ |
| import sys |
| def swap_it(): |
| sys.settrace(sys.gettrace()) |
| """, |
| ) |
| |
| # Use --source=sample to prevent measurement of swap.py. |
| cov = coverage.Coverage(source=["sample"]) |
| self.start_import_stop(cov, "main") |
| |
| assert self.stdout() == "10\n7\n3\n5\n9\n12\n" |
| |
| _, statements, missing, _ = cov.analysis("sample.py") |
| assert statements == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] |
| assert missing == [] |
| |
| def test_setting_new_trace_function(self) -> None: |
| # https://github.com/nedbat/coveragepy/issues/436 |
| if testenv.SETTRACE_CORE or not env.PYBEHAVIOR.branch_right_left: |
| missing = "5-7, 13-14" |
| else: |
| missing = "5-7" |
| self.check_coverage( |
| """\ |
| import os.path |
| import sys |
| |
| def tracer(frame, event, arg): |
| filename = os.path.basename(frame.f_code.co_filename) # 5 |
| print(f"{event}: {filename} @ {frame.f_lineno}") # 6 |
| return tracer # 7 |
| |
| def begin(): |
| sys.settrace(tracer) |
| |
| def collect(): |
| t = sys.gettrace() # 13 |
| assert t is tracer, t # 14 |
| |
| def test_unsets_trace() -> None: |
| begin() |
| collect() |
| |
| old = sys.gettrace() |
| test_unsets_trace() |
| sys.settrace(old) |
| a = 21 |
| b = 22 |
| """, |
| lines=[1, 2, 4, 5, 6, 7, 9, 10, 12, 13, 14, 16, 17, 18, 20, 21, 22, 23, 24], |
| missing=missing, |
| ) |
| |
| assert self.last_module_name is not None |
| out = self.stdout().replace(self.last_module_name, "coverage_test") |
| expected = ( |
| "call: coverage_test.py @ 12\n" |
| + "line: coverage_test.py @ 13\n" |
| + "line: coverage_test.py @ 14\n" |
| + "return: coverage_test.py @ 14\n" |
| ) |
| assert expected == out |
| |
| @pytest.mark.skipif(env.METACOV, reason="Can't set trace functions during meta-coverage") |
| def test_atexit_gettrace(self) -> None: |
| # This is not a test of coverage at all, but of our understanding |
| # of this edge-case behavior in various Pythons. |
| |
| self.make_file( |
| "atexit_gettrace.py", |
| """\ |
| import atexit, sys |
| |
| def trace_function(frame, event, arg): |
| return trace_function |
| sys.settrace(trace_function) |
| |
| def show_trace_function(): |
| tfn = sys.gettrace() |
| if tfn is not None: |
| tfn = tfn.__name__ |
| print(tfn) |
| atexit.register(show_trace_function) |
| |
| # This will show what the trace function is at the end of the program. |
| """, |
| ) |
| status, out = self.run_command_status("python atexit_gettrace.py") |
| assert status == 0 |
| if env.PYPY: |
| # PyPy clears the trace function before atexit runs. |
| assert out == "None\n" |
| else: |
| # Other Pythons leave the trace function in place. |
| assert out == "trace_function\n" |
| |
| |
| class ExecTest(CoverageTest): |
| """Tests of exec.""" |
| |
| def test_correct_filename(self) -> None: |
| # https://github.com/nedbat/coveragepy/issues/380 |
| # Bug was that exec'd files would have their lines attributed to the |
| # calling file. Make two files, both with ~30 lines, but no lines in |
| # common. Line 30 in to_exec.py was recorded as line 30 in main.py, |
| # but now it's fixed. :) |
| self.make_file( |
| "to_exec.py", |
| """\ |
| \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n |
| print("var is {}".format(var)) # line 31 |
| """, |
| ) |
| self.make_file( |
| "main.py", |
| """\ |
| namespace = {'var': 17} |
| with open("to_exec.py", encoding="utf-8") as to_exec_py: |
| code = compile(to_exec_py.read(), 'to_exec.py', 'exec') |
| exec(code, globals(), namespace) |
| \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n |
| print("done") # line 35 |
| """, |
| ) |
| |
| cov = coverage.Coverage() |
| self.start_import_stop(cov, "main") |
| |
| _, statements, missing, _ = cov.analysis("main.py") |
| assert statements == [1, 2, 3, 4, 35] |
| assert missing == [] |
| _, statements, missing, _ = cov.analysis("to_exec.py") |
| assert statements == [31] |
| assert missing == [] |
| |
| def test_unencodable_filename(self) -> None: |
| # https://github.com/nedbat/coveragepy/issues/891 |
| self.make_file("bug891.py", r"""exec(compile("pass", "\udcff.py", "exec"))""") |
| cov = coverage.Coverage() |
| self.start_import_stop(cov, "bug891") |
| # Saving would fail trying to encode \udcff.py |
| cov.save() |
| files = [os.path.basename(f) for f in cov.get_data().measured_files()] |
| assert "bug891.py" in files |
| |
| |
| class MockingProtectionTest(CoverageTest): |
| """Tests about protecting ourselves from aggressive mocking. |
| |
| https://github.com/nedbat/coveragepy/issues/416 |
| |
| """ |
| |
| def test_os_path_exists(self) -> None: |
| # To see if this test still detects the problem, change isolate_module |
| # in misc.py to simply return its argument. It should fail with a |
| # StopIteration error. |
| self.make_file( |
| "bug416.py", |
| """\ |
| import os.path |
| from unittest import mock |
| |
| @mock.patch('os.path.exists') |
| def test_path_exists(mock_exists): |
| mock_exists.side_effect = [17] |
| print("in test") |
| import bug416a |
| print(bug416a.foo) |
| print(os.path.exists(".")) |
| |
| test_path_exists() |
| """, |
| ) |
| self.make_file( |
| "bug416a.py", |
| """\ |
| print("bug416a.py") |
| foo = 23 |
| """, |
| ) |
| |
| import py_compile |
| |
| py_compile.compile("bug416a.py") |
| out = self.run_command("coverage run bug416.py") |
| assert out == "in test\nbug416a.py\n23\n17\n" |