From c85eaba206e1bf98302e0997e32c079e2c231f4b Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 24 Dec 2024 10:16:26 -0500 Subject: [PATCH] fix: multi-line statements no longer confuse branch target descriptions. #1874 #1875 --- CHANGES.rst | 6 ++++++ coverage/parser.py | 14 +++++--------- coverage/results.py | 3 ++- tests/test_lcov.py | 30 ++++++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index e88494645..59f881af1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -23,6 +23,10 @@ upgrading your version of coverage.py. Unreleased ---------- +- Fix: some descriptions of missing branches in HTML and LCOV reports were + incorrect when multi-line statements were involved (`issue 1874`_ and `issue + 1875`_). These are now fixed. + - Fix: Python 3.14 `defers evaluation of annotations `_ by moving them into separate code objects. That code is rarely executed, so coverage.py would mark them as missing, as reported in `issue 1908`_. Now they are @@ -33,6 +37,8 @@ Unreleased understand the problem or the solution, but ``git bisect`` helped find it, and now it's fixed. +.. _issue 1874: https://github.com/nedbat/coveragepy/issues/1874 +.. _issue 1875: https://github.com/nedbat/coveragepy/issues/1875 .. _issue 1902: https://github.com/nedbat/coveragepy/issues/1902 .. _issue 1908: https://github.com/nedbat/coveragepy/issues/1908 .. _pep649: https://docs.python.org/3.14/whatsnew/3.14.html#pep-649-deferred-evaluation-of-annotations diff --git a/coverage/parser.py b/coverage/parser.py index 96318c547..fb74ea9e0 100644 --- a/coverage/parser.py +++ b/coverage/parser.py @@ -684,7 +684,6 @@ def __init__( ) -> None: self.filename = filename self.root_node = root_node - # TODO: I think this is happening in too many places. self.statements = {multiline.get(l, l) for l in statements} self.multiline = multiline @@ -812,9 +811,10 @@ def line_for_node(self, node: ast.AST) -> TLineNo: getattr(self, "_line__" + node_name, None), ) if handler is not None: - return handler(node) + line = handler(node) else: - return node.lineno # type: ignore[attr-defined, no-any-return] + line = node.lineno # type: ignore[attr-defined] + return self.multiline.get(line, line) # First lines: _line__* # @@ -936,8 +936,7 @@ def process_body( # the next node. for body_node in body: lineno = self.line_for_node(body_node) - first_line = self.multiline.get(lineno, lineno) - if first_line not in self.statements: + if lineno not in self.statements: maybe_body_node = self.find_non_missing_node(body_node) if maybe_body_node is None: continue @@ -961,8 +960,7 @@ def find_non_missing_node(self, node: ast.AST) -> ast.AST | None: # means we can avoid a function call in the 99.9999% case of not # optimizing away statements. lineno = self.line_for_node(node) - first_line = self.multiline.get(lineno, lineno) - if first_line in self.statements: + if lineno in self.statements: return node missing_fn = cast( @@ -1110,8 +1108,6 @@ def _handle_decorated(self, node: ast.FunctionDef) -> set[ArcStart]: # not what we'd think of as the first line in the statement, so map # it to the first one. assert node.body, f"Oops: {node.body = } in {self.filename}@{node.lineno}" - body_start = self.line_for_node(node.body[0]) - body_start = self.multiline.get(body_start, body_start) # The body is handled in collect_arcs. assert last is not None return {ArcStart(last)} diff --git a/coverage/results.py b/coverage/results.py index e61ad5736..43e5b523c 100644 --- a/coverage/results.py +++ b/coverage/results.py @@ -34,7 +34,8 @@ def analysis_from_file_reporter( if has_arcs: arc_possibilities_set = file_reporter.arcs() - arcs = data.arcs(filename) or [] + arcs: Iterable[TArc] = data.arcs(filename) or [] + arcs = file_reporter.translate_arcs(arcs) # Reduce the set of arcs to the ones that could be branches. dests = collections.defaultdict(set) diff --git a/tests/test_lcov.py b/tests/test_lcov.py index fe6016da2..76e99e91d 100644 --- a/tests/test_lcov.py +++ b/tests/test_lcov.py @@ -558,3 +558,33 @@ def test_always_raise(self) -> None: """) actual_result = self.get_lcov_report_content() assert expected_result == actual_result + + def test_multiline_conditions(self) -> None: + self.make_file("multi.py", """\ + def fun(x): + if ( + x + ): + print("got here") + """) + cov = coverage.Coverage(source=".", branch=True) + self.start_import_stop(cov, "multi") + cov.lcov_report() + lcov = self.get_lcov_report_content() + assert "BRDA:2,0,return from function 'fun',-" in lcov + + def test_module_exit(self) -> None: + self.make_file("modexit.py", """\ + #! /usr/bin/env python + def foo(): + return bar( + ) + if "x" == "y": # line 5 + foo() + """) + cov = coverage.Coverage(source=".", branch=True) + self.start_import_stop(cov, "modexit") + cov.lcov_report() + lcov = self.get_lcov_report_content() + print(lcov) + assert "BRDA:5,0,exit the module,1" in lcov