From 0f3b2275ff5815c536f244dd2c27b7aed1ef489d Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Sun, 22 Sep 2024 12:01:48 -0400 Subject: [PATCH 01/15] refactor: don't need this aux variable anymore --- coverage/parser.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/coverage/parser.py b/coverage/parser.py index 89bc89dfc..dee7747bb 100644 --- a/coverage/parser.py +++ b/coverage/parser.py @@ -352,7 +352,6 @@ def missing_arc_description(self, start: TLineNo, end: TLineNo) -> str: self._analyze_ast() assert self._missing_arc_fragments is not None - actual_start = start fragment_pairs = self._missing_arc_fragments.get((start, end), [(None, None)]) msgs = [] @@ -364,9 +363,9 @@ def missing_arc_description(self, start: TLineNo, end: TLineNo) -> str: emsg = "didn't jump to line {lineno}" emsg = emsg.format(lineno=end) - msg = f"line {actual_start} {emsg}" + msg = f"line {start} {emsg}" if smsg is not None: - msg += f" because {smsg.format(lineno=actual_start)}" + msg += f" because {smsg.format(lineno=start)}" msgs.append(msg) From 9cc8fd0470611c31df3bb39472986658675dccec Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Sun, 22 Sep 2024 12:17:24 -0400 Subject: [PATCH 02/15] feature: add arc_description method for #1850 --- coverage/parser.py | 80 +++++++++++++++++++++++++++++----------------- coverage/plugin.py | 8 +++++ 2 files changed, 59 insertions(+), 29 deletions(-) diff --git a/coverage/parser.py b/coverage/parser.py index dee7747bb..49df1dafe 100644 --- a/coverage/parser.py +++ b/coverage/parser.py @@ -346,6 +346,16 @@ def exit_counts(self) -> dict[TLineNo, int]: return exit_counts + def _finish_action_msg(self, action_msg: str | None, end: TLineNo) -> str: + """Apply some defaulting and formatting to an arc's description.""" + if action_msg is None: + if end < 0: + action_msg = "jump to the function exit" + else: + action_msg = "jump to line {lineno}" + action_msg = action_msg.format(lineno=end) + return action_msg + def missing_arc_description(self, start: TLineNo, end: TLineNo) -> str: """Provide an English sentence describing a missing arc.""" if self._missing_arc_fragments is None: @@ -355,22 +365,26 @@ def missing_arc_description(self, start: TLineNo, end: TLineNo) -> str: fragment_pairs = self._missing_arc_fragments.get((start, end), [(None, None)]) msgs = [] - for smsg, emsg in fragment_pairs: - if emsg is None: - if end < 0: - emsg = "didn't jump to the function exit" - else: - emsg = "didn't jump to line {lineno}" - emsg = emsg.format(lineno=end) - - msg = f"line {start} {emsg}" - if smsg is not None: - msg += f" because {smsg.format(lineno=start)}" + for missing_cause_msg, action_msg in fragment_pairs: + action_msg = self._finish_action_msg(action_msg, end) + msg = f"line {start} didn't {action_msg}" + if missing_cause_msg is not None: + msg += f" because {missing_cause_msg.format(lineno=start)}" msgs.append(msg) return " or ".join(msgs) + def arc_description(self, start: TLineNo, end: TLineNo) -> str: + """Provide an English description of an arc's effect.""" + if self._missing_arc_fragments is None: + self._analyze_ast() + assert self._missing_arc_fragments is not None + + fragment_pairs = self._missing_arc_fragments.get((start, end), [(None, None)]) + action_msg = self._finish_action_msg(fragment_pairs[0][1], end) + return action_msg + class ByteParser: """Parse bytecode to understand the structure of code.""" @@ -451,7 +465,7 @@ class ArcStart: `lineno` is the line number the arc starts from. - `cause` is an English text fragment used as the `startmsg` for + `cause` is an English text fragment used as the `missing_cause_msg` for AstArcAnalyzer.missing_arc_fragments. It will be used to describe why an arc wasn't executed, so should fit well into a sentence of the form, "Line 17 didn't run because {cause}." The fragment can include "{lineno}" @@ -485,10 +499,21 @@ def __call__( self, start: TLineNo, end: TLineNo, - smsg: str | None = None, - emsg: str | None = None, + missing_cause_msg: str | None = None, + action_msg: str | None = None, ) -> None: - ... + """ + Record an arc from `start` to `end`. + + `missing_cause_msg` is a description of the reason the arc wasn't + taken if it wasn't taken. For example, "the condition on line 10 was + never true." + + `action_msg` is a description of what the arc does, like "jump to line + 10" or "exit from function 'fooey'." + + """ + TArcFragments = Dict[TArc, List[Tuple[Optional[str], Optional[str]]]] @@ -549,7 +574,7 @@ def process_raise_exits(self, exits: set[ArcStart], add_arc: TAddArcFn) -> bool: for xit in exits: add_arc( xit.lineno, -self.start, xit.cause, - f"didn't except from function {self.name!r}", + f"except from function {self.name!r}", ) return True @@ -557,7 +582,7 @@ def process_return_exits(self, exits: set[ArcStart], add_arc: TAddArcFn) -> bool for xit in exits: add_arc( xit.lineno, -self.start, xit.cause, - f"didn't return from function {self.name!r}", + f"return from function {self.name!r}", ) return True @@ -601,10 +626,10 @@ class AstArcAnalyzer: `missing_arc_fragments`: a dict mapping (from, to) arcs to lists of message fragments explaining why the arc is missing from execution:: - { (start, end): [(startmsg, endmsg), ...], } + { (start, end): [(missing_cause_msg, action_msg), ...], } For an arc starting from line 17, they should be usable to form complete - sentences like: "Line 17 {endmsg} because {startmsg}". + sentences like: "Line 17 didn't {action_msg} because {missing_cause_msg}". NOTE: Starting in July 2024, I've been whittling this down to only report arc that are part of true branches. It's not clear how far this work will @@ -715,30 +740,27 @@ def _code_object__FunctionDef(self, node: ast.FunctionDef) -> None: def _code_object__ClassDef(self, node: ast.ClassDef) -> None: start = self.line_for_node(node) - exits = self.process_body(node.body)#, from_start=ArcStart(start)) + exits = self.process_body(node.body) for xit in exits: - self.add_arc( - xit.lineno, -start, xit.cause, - f"didn't exit the body of class {node.name!r}", - ) + self.add_arc(xit.lineno, -start, xit.cause, f"exit class {node.name!r}") def add_arc( self, start: TLineNo, end: TLineNo, - smsg: str | None = None, - emsg: str | None = None, + missing_cause_msg: str | None = None, + action_msg: str | None = None, ) -> None: """Add an arc, including message fragments to use if it is missing.""" if self.debug: # pragma: debugging - print(f"Adding possible arc: ({start}, {end}): {smsg!r}, {emsg!r}") + print(f"Adding possible arc: ({start}, {end}): {missing_cause_msg!r}, {action_msg!r}") print(short_stack(), end="\n\n") self.arcs.add((start, end)) if start in self.current_with_starts: self.with_entries.add((start, end)) - if smsg is not None or emsg is not None: - self.missing_arc_fragments[(start, end)].append((smsg, emsg)) + if missing_cause_msg is not None or action_msg is not None: + self.missing_arc_fragments[(start, end)].append((missing_cause_msg, action_msg)) def nearest_blocks(self) -> Iterable[Block]: """Yield the blocks in nearest-to-farthest order.""" diff --git a/coverage/plugin.py b/coverage/plugin.py index 788b300ba..6386209c1 100644 --- a/coverage/plugin.py +++ b/coverage/plugin.py @@ -542,6 +542,14 @@ def missing_arc_description( """ return f"Line {start} didn't jump to line {end}" + def arc_description( + self, + start: TLineNo, # pylint: disable=unused-argument + end: TLineNo + ) -> str: + """Provide an English description of an arc's effect.""" + return f"jump to line {end}" + def source_token_lines(self) -> TSourceTokenLines: """Generate a series of tokenized lines, one for each line in `source`. From e72cb37c4f35696496188b633a5f9a756c6666f9 Mon Sep 17 00:00:00 2001 From: Zack Weinberg Date: Wed, 11 Sep 2024 13:48:56 -0400 Subject: [PATCH 03/15] [lcov] Refactor LcovReporter.lcov_file Split the bulk of the code in LcovReporter.lcov_file out into two free helper functions, lcov_lines and lcov_arcs. This is easier to read and will make it easier to do future planned changes in a type-safe manner. No functional changes in this commit. --- coverage/lcovreport.py | 145 +++++++++++++++++++++++------------------ 1 file changed, 82 insertions(+), 63 deletions(-) diff --git a/coverage/lcovreport.py b/coverage/lcovreport.py index 850bb6f93..d0e636b47 100644 --- a/coverage/lcovreport.py +++ b/coverage/lcovreport.py @@ -32,6 +32,82 @@ def line_hash(line: str) -> str: return base64.b64encode(hashed).decode("ascii").rstrip("=") +def lcov_lines( + analysis: Analysis, + lines: list[int], + source_lines: list[str], + outfile: IO[str], +) -> None: + """Emit line coverage records for an analyzed file.""" + hash_suffix = "" + for line in lines: + if source_lines: + hash_suffix = "," + line_hash(source_lines[line-1]) + # Q: can we get info about the number of times a statement is + # executed? If so, that should be recorded here. + hit = int(line not in analysis.missing) + outfile.write(f"DA:{line},{hit}{hash_suffix}\n") + + if analysis.numbers.n_statements > 0: + outfile.write(f"LF:{analysis.numbers.n_statements}\n") + outfile.write(f"LH:{analysis.numbers.n_executed}\n") + + +def lcov_arcs( + analysis: Analysis, + lines: list[int], + outfile: IO[str], +) -> None: + """Emit branch coverage records for an analyzed file.""" + branch_stats = analysis.branch_stats() + executed_arcs = analysis.executed_branch_arcs() + missing_arcs = analysis.missing_branch_arcs() + + for line in lines: + if line in branch_stats: + # The meaning of a BRDA: line is not well explained in the lcov + # documentation. Based on what genhtml does with them, however, + # the interpretation is supposed to be something like this: + # BRDA: , , , + # where is the source line number of the *origin* of the + # branch; is an arbitrary number which distinguishes multiple + # control flow operations on a single line; is an arbitrary + # number which distinguishes the possible destinations of the specific + # control flow operation identified by + ; and is + # either the hit count for + + or "-" meaning + # that + was never *reached*. must be >= 1, + # and , , must be >= 0. + + # This is only one possible way to map our sets of executed and + # not-executed arcs to BRDA codes. It seems to produce reasonable + # results when fed through genhtml. + + # Q: can we get counts of the number of times each arc was executed? + # branch_stats has "total" and "taken" counts for each branch, but it + # doesn't have "taken" broken down by destination. + destinations = {} + for dst in executed_arcs[line]: + destinations[(int(dst < 0), abs(dst))] = 1 + for dst in missing_arcs[line]: + destinations[(int(dst < 0), abs(dst))] = 0 + + if all(v == 0 for v in destinations.values()): + # When _none_ of the out arcs from 'line' were executed, presume + # 'line' was never reached. + for branch, _ in enumerate(sorted(destinations.keys())): + outfile.write(f"BRDA:{line},0,{branch},-\n") + else: + for branch, (_, hit) in enumerate(sorted(destinations.items())): + outfile.write(f"BRDA:{line},0,{branch},{hit}\n") + + # Summary of the branch coverage. + brf = sum(t for t, k in branch_stats.values()) + brh = brf - sum(t - k for t, k in branch_stats.values()) + if brf > 0: + outfile.write(f"BRF:{brf}\n") + outfile.write(f"BRH:{brh}\n") + + class LcovReporter: """A reporter for writing LCOV coverage reports.""" @@ -85,72 +161,15 @@ def lcov_file( outfile.write(f"SF:{rel_fname}\n") + lines = sorted(analysis.statements) if self.config.lcov_line_checksums: source_lines = fr.source().splitlines() + else: + source_lines = [] + + lcov_lines(analysis, lines, source_lines, outfile) - # Emit a DA: record for each line of the file. - lines = sorted(analysis.statements) - hash_suffix = "" - for line in lines: - if self.config.lcov_line_checksums: - hash_suffix = "," + line_hash(source_lines[line-1]) - # Q: can we get info about the number of times a statement is - # executed? If so, that should be recorded here. - hit = int(line not in analysis.missing) - outfile.write(f"DA:{line},{hit}{hash_suffix}\n") - - if analysis.numbers.n_statements > 0: - outfile.write(f"LF:{analysis.numbers.n_statements}\n") - outfile.write(f"LH:{analysis.numbers.n_executed}\n") - - # More information dense branch coverage data, if available. if analysis.has_arcs: - branch_stats = analysis.branch_stats() - executed_arcs = analysis.executed_branch_arcs() - missing_arcs = analysis.missing_branch_arcs() - - for line in lines: - if line in branch_stats: - # The meaning of a BRDA: line is not well explained in the lcov - # documentation. Based on what genhtml does with them, however, - # the interpretation is supposed to be something like this: - # BRDA: , , , - # where is the source line number of the *origin* of the - # branch; is an arbitrary number which distinguishes multiple - # control flow operations on a single line; is an arbitrary - # number which distinguishes the possible destinations of the specific - # control flow operation identified by + ; and is - # either the hit count for + + or "-" meaning - # that + was never *reached*. must be >= 1, - # and , , must be >= 0. - - # This is only one possible way to map our sets of executed and - # not-executed arcs to BRDA codes. It seems to produce reasonable - # results when fed through genhtml. - - # Q: can we get counts of the number of times each arc was executed? - # branch_stats has "total" and "taken" counts for each branch, but it - # doesn't have "taken" broken down by destination. - destinations = {} - for dst in executed_arcs[line]: - destinations[(int(dst < 0), abs(dst))] = 1 - for dst in missing_arcs[line]: - destinations[(int(dst < 0), abs(dst))] = 0 - - if all(v == 0 for v in destinations.values()): - # When _none_ of the out arcs from 'line' were executed, presume - # 'line' was never reached. - for branch, _ in enumerate(sorted(destinations.keys())): - outfile.write(f"BRDA:{line},0,{branch},-\n") - else: - for branch, (_, hit) in enumerate(sorted(destinations.items())): - outfile.write(f"BRDA:{line},0,{branch},{hit}\n") - - # Summary of the branch coverage. - brf = sum(t for t, k in branch_stats.values()) - brh = brf - sum(t - k for t, k in branch_stats.values()) - if brf > 0: - outfile.write(f"BRF:{brf}\n") - outfile.write(f"BRH:{brh}\n") + lcov_arcs(analysis, lines, outfile) outfile.write("end_of_record\n") From f45f2faaec9930f560875e7e28212691d8ac1fe0 Mon Sep 17 00:00:00 2001 From: Zack Weinberg Date: Wed, 11 Sep 2024 13:58:18 -0400 Subject: [PATCH 04/15] [lcov] Improve reporting of branch destinations. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch field of a BRDA: record can be an arbitrary textual label. Therefore, instead of emitting meaningless numbers, emit the string “to line ” for ordinary branches (where is the arc destination line, and “to exit” for branches that exit the function. When there is more than one exit arc from a single line, provide the negated arc destination as a disambiguator. Thanks to Henry Cox (@henry2cox), one of the LCOV maintainers, for clarifying the semantics of BRDA: records for us. --- coverage/lcovreport.py | 75 +++++++++++++++++++++----------------- tests/test_lcov.py | 83 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 117 insertions(+), 41 deletions(-) diff --git a/coverage/lcovreport.py b/coverage/lcovreport.py index d0e636b47..7f7953ddc 100644 --- a/coverage/lcovreport.py +++ b/coverage/lcovreport.py @@ -64,41 +64,50 @@ def lcov_arcs( missing_arcs = analysis.missing_branch_arcs() for line in lines: - if line in branch_stats: - # The meaning of a BRDA: line is not well explained in the lcov - # documentation. Based on what genhtml does with them, however, - # the interpretation is supposed to be something like this: - # BRDA: , , , - # where is the source line number of the *origin* of the - # branch; is an arbitrary number which distinguishes multiple - # control flow operations on a single line; is an arbitrary - # number which distinguishes the possible destinations of the specific - # control flow operation identified by + ; and is - # either the hit count for + + or "-" meaning - # that + was never *reached*. must be >= 1, - # and , , must be >= 0. - - # This is only one possible way to map our sets of executed and - # not-executed arcs to BRDA codes. It seems to produce reasonable - # results when fed through genhtml. - + if line not in branch_stats: + continue + + # This is only one of several possible ways to map our sets of executed + # and not-executed arcs to BRDA codes. It seems to produce reasonable + # results when fed through genhtml. + _, taken = branch_stats[line] + + if taken == 0: + # When _none_ of the out arcs from 'line' were executed, + # this probably means means 'line' was never executed at all. + # Cross-check with the line stats. + assert len(executed_arcs[line]) == 0 + if line in analysis.missing: + hit = "-" # indeed, never reached + else: + hit = "0" # I am not prepared to swear this is impossible + destinations = [ + (int(dst < 0), abs(dst), hit) for dst in missing_arcs[line] + ] + else: # Q: can we get counts of the number of times each arc was executed? - # branch_stats has "total" and "taken" counts for each branch, but it - # doesn't have "taken" broken down by destination. - destinations = {} - for dst in executed_arcs[line]: - destinations[(int(dst < 0), abs(dst))] = 1 - for dst in missing_arcs[line]: - destinations[(int(dst < 0), abs(dst))] = 0 - - if all(v == 0 for v in destinations.values()): - # When _none_ of the out arcs from 'line' were executed, presume - # 'line' was never reached. - for branch, _ in enumerate(sorted(destinations.keys())): - outfile.write(f"BRDA:{line},0,{branch},-\n") + # branch_stats has "total" and "taken" counts for each branch, + # but it doesn't have "taken" broken down by destination. + destinations = [ + (int(dst < 0), abs(dst), "1") for dst in executed_arcs[line] + ] + destinations.extend( + (int(dst < 0), abs(dst), "0") for dst in missing_arcs[line] + ) + + destinations.sort() + n_exits = sum( + 1 for is_exit, _, _ in destinations if is_exit + ) + for is_exit, dst, hit in destinations: + if is_exit: + if n_exits == 1: + branch = "to exit" + else: + branch = f"to exit {dst}" else: - for branch, (_, hit) in enumerate(sorted(destinations.items())): - outfile.write(f"BRDA:{line},0,{branch},{hit}\n") + branch = f"to line {dst}" + outfile.write(f"BRDA:{line},0,{branch},{hit}\n") # Summary of the branch coverage. brf = sum(t for t, k in branch_stats.values()) diff --git a/tests/test_lcov.py b/tests/test_lcov.py index 65671f3fc..5c2061dc2 100644 --- a/tests/test_lcov.py +++ b/tests/test_lcov.py @@ -160,8 +160,8 @@ def is_it_x(x): DA:5,0 LF:4 LH:1 - BRDA:2,0,0,- - BRDA:2,0,1,- + BRDA:2,0,to line 3,- + BRDA:2,0,to line 5,- BRF:2 BRH:0 end_of_record @@ -203,8 +203,8 @@ def test_is_it_x(self): DA:5,0 LF:4 LH:1 - BRDA:2,0,0,- - BRDA:2,0,1,- + BRDA:2,0,to line 3,- + BRDA:2,0,to line 5,- BRF:2 BRH:0 end_of_record @@ -247,8 +247,8 @@ def test_half_covered_branch(self) -> None: DA:6,0 LF:4 LH:3 - BRDA:3,0,0,1 - BRDA:3,0,1,0 + BRDA:3,0,to line 4,1 + BRDA:3,0,to line 6,0 BRF:2 BRH:1 end_of_record @@ -315,11 +315,78 @@ def test_excluded_lines(self) -> None: DA:6,1 LF:4 LH:3 - BRDA:3,0,0,0 - BRDA:3,0,1,1 + BRDA:3,0,to line 4,0 + BRDA:3,0,to line 6,1 BRF:2 BRH:1 end_of_record """) actual_result = self.get_lcov_report_content() assert expected_result == actual_result + + def test_exit_branches(self) -> None: + self.make_file("runme.py", """\ + def foo(a): + if a: + print(f"{a!r} is truthy") + foo(True) + foo(False) + foo([]) + foo([0]) + """) + cov = coverage.Coverage(source=".", branch=True) + self.start_import_stop(cov, "runme") + cov.lcov_report() + expected_result = textwrap.dedent("""\ + SF:runme.py + DA:1,1 + DA:2,1 + DA:3,1 + DA:4,1 + DA:5,1 + DA:6,1 + DA:7,1 + LF:7 + LH:7 + BRDA:2,0,to line 3,1 + BRDA:2,0,to exit,1 + BRF:2 + BRH:2 + end_of_record + """) + actual_result = self.get_lcov_report_content() + assert expected_result == actual_result + + def test_multiple_exit_branches(self) -> None: + self.make_file("runme.py", """\ + def foo(a): + if any(x > 0 for x in a): + print(f"{a!r} has positives") + foo([]) + foo([0]) + foo([0,1]) + foo([0,-1]) + """) + cov = coverage.Coverage(source=".", branch=True) + self.start_import_stop(cov, "runme") + cov.lcov_report() + expected_result = textwrap.dedent("""\ + SF:runme.py + DA:1,1 + DA:2,1 + DA:3,1 + DA:4,1 + DA:5,1 + DA:6,1 + DA:7,1 + LF:7 + LH:7 + BRDA:2,0,to line 3,1 + BRDA:2,0,to exit 1,1 + BRDA:2,0,to exit 2,1 + BRF:2 + BRH:2 + end_of_record + """) + actual_result = self.get_lcov_report_content() + assert expected_result == actual_result From 8c62e38b6a5e6fec9b5a03f698b5956af1879f45 Mon Sep 17 00:00:00 2001 From: Zack Weinberg Date: Wed, 11 Sep 2024 16:22:23 -0400 Subject: [PATCH 05/15] Implement function coverage reporting in lcov reports. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quite straightforward: a function has been executed if any of its region’s lines have been executed. --- coverage/lcovreport.py | 39 ++++++++++++++++++++++++++++++++++++++- tests/test_lcov.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/coverage/lcovreport.py b/coverage/lcovreport.py index 7f7953ddc..1b33665c1 100644 --- a/coverage/lcovreport.py +++ b/coverage/lcovreport.py @@ -53,6 +53,43 @@ def lcov_lines( outfile.write(f"LH:{analysis.numbers.n_executed}\n") +def lcov_functions( + fr: FileReporter, + file_analysis: Analysis, + outfile: IO[str], +) -> None: + """Emit function coverage records for an analyzed file.""" + # lcov 2.2 introduces a new format for function coverage records. + # We continue to generate the old format because we don't know what + # version of the lcov tools will be used to read this report. + + # suppressions because of https://github.com/pylint-dev/pylint/issues/9923 + functions = [ + (min(region.start, min(region.lines)), #pylint: disable=nested-min-max + max(region.start, max(region.lines)), #pylint: disable=nested-min-max + region) + for region in fr.code_regions() + if region.kind == "function" + ] + if not functions: + return + + functions.sort() + functions_hit = 0 + for first_line, last_line, region in functions: + # A function counts as having been executed if any of it has been + # executed. + analysis = file_analysis.narrow(region.lines) + hit = int(analysis.numbers.n_executed > 0) + functions_hit += hit + + outfile.write(f"FN:{first_line},{last_line},{region.name}\n") + outfile.write(f"FNDA:{hit},{region.name}\n") + + outfile.write(f"FNF:{len(functions)}\n") + outfile.write(f"FNH:{functions_hit}\n") + + def lcov_arcs( analysis: Analysis, lines: list[int], @@ -177,7 +214,7 @@ def lcov_file( source_lines = [] lcov_lines(analysis, lines, source_lines, outfile) - + lcov_functions(fr, analysis, outfile) if analysis.has_arcs: lcov_arcs(analysis, lines, outfile) diff --git a/tests/test_lcov.py b/tests/test_lcov.py index 5c2061dc2..17271cef0 100644 --- a/tests/test_lcov.py +++ b/tests/test_lcov.py @@ -64,6 +64,12 @@ def IsItTrue(): DA:5,0 LF:4 LH:2 + FN:1,2,cuboid_volume + FNDA:0,cuboid_volume + FN:4,5,IsItTrue + FNDA:0,IsItTrue + FNF:2 + FNH:0 end_of_record """) self.assert_doesnt_exist(".coverage") @@ -96,6 +102,12 @@ def IsItTrue(): DA:5,0,LWILTcvARcydjFFyo9qM0A LF:4 LH:2 + FN:1,2,cuboid_volume + FNDA:0,cuboid_volume + FN:4,5,IsItTrue + FNDA:0,IsItTrue + FNF:2 + FNH:0 end_of_record """) actual_result = self.get_lcov_report_content() @@ -120,6 +132,12 @@ def test_simple_line_coverage_two_files(self) -> None: DA:5,0 LF:4 LH:2 + FN:1,2,cuboid_volume + FNDA:0,cuboid_volume + FN:4,5,IsItTrue + FNDA:0,IsItTrue + FNF:2 + FNH:0 end_of_record SF:test_file.py DA:1,1 @@ -132,6 +150,10 @@ def test_simple_line_coverage_two_files(self) -> None: DA:9,0 LF:8 LH:4 + FN:5,9,TestCuboid.test_volume + FNDA:0,TestCuboid.test_volume + FNF:1 + FNH:0 end_of_record """) actual_result = self.get_lcov_report_content(filename="data.lcov") @@ -160,6 +182,10 @@ def is_it_x(x): DA:5,0 LF:4 LH:1 + FN:1,5,is_it_x + FNDA:0,is_it_x + FNF:1 + FNH:0 BRDA:2,0,to line 3,- BRDA:2,0,to line 5,- BRF:2 @@ -203,6 +229,10 @@ def test_is_it_x(self): DA:5,0 LF:4 LH:1 + FN:1,5,is_it_x + FNDA:0,is_it_x + FNF:1 + FNH:0 BRDA:2,0,to line 3,- BRDA:2,0,to line 5,- BRF:2 @@ -217,6 +247,10 @@ def test_is_it_x(self): DA:7,0 LF:6 LH:4 + FN:5,7,TestIsItX.test_is_it_x + FNDA:0,TestIsItX.test_is_it_x + FNF:1 + FNH:0 end_of_record """) actual_result = self.get_lcov_report_content() @@ -348,6 +382,10 @@ def foo(a): DA:7,1 LF:7 LH:7 + FN:1,3,foo + FNDA:1,foo + FNF:1 + FNH:1 BRDA:2,0,to line 3,1 BRDA:2,0,to exit,1 BRF:2 @@ -381,6 +419,10 @@ def foo(a): DA:7,1 LF:7 LH:7 + FN:1,3,foo + FNDA:1,foo + FNF:1 + FNH:1 BRDA:2,0,to line 3,1 BRDA:2,0,to exit 1,1 BRDA:2,0,to exit 2,1 From db579772becdc69bbc0d0483885215ca06cdc40c Mon Sep 17 00:00:00 2001 From: Zack Weinberg Date: Thu, 12 Sep 2024 14:02:02 -0400 Subject: [PATCH 06/15] [lcov] Ignore vacuous function regions. Should fix the test failures with pypy pretending to be python 3.8. --- coverage/lcovreport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coverage/lcovreport.py b/coverage/lcovreport.py index 1b33665c1..f6c0cc5b0 100644 --- a/coverage/lcovreport.py +++ b/coverage/lcovreport.py @@ -69,7 +69,7 @@ def lcov_functions( max(region.start, max(region.lines)), #pylint: disable=nested-min-max region) for region in fr.code_regions() - if region.kind == "function" + if region.kind == "function" and region.lines ] if not functions: return From 13059eeaab546d9a365c562ddd361839b6d5b4be Mon Sep 17 00:00:00 2001 From: Zack Weinberg Date: Mon, 16 Sep 2024 10:35:31 -0400 Subject: [PATCH 07/15] Adjust test expectations for lcov reports generated under PyPy 3.8. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is a bug somewhere, in which if we collect data in --branch mode under PyPy 3.8, regions for top-level functions come out of the analysis engine with empty lines arrays. The previous commit prevented this from crashing the lcov reporter; this commit adjusts the tests of the lcov reporter so that we expect the function records affected by the bug to be missing. I don’t think it’s worth trying to pin down the cause of the bug, since Python 3.8 is approaching end-of-life for both CPython and PyPy. --- coverage/lcovreport.py | 5 + tests/test_lcov.py | 202 +++++++++++++++++++++++++---------------- 2 files changed, 129 insertions(+), 78 deletions(-) diff --git a/coverage/lcovreport.py b/coverage/lcovreport.py index f6c0cc5b0..0ae462160 100644 --- a/coverage/lcovreport.py +++ b/coverage/lcovreport.py @@ -63,6 +63,11 @@ def lcov_functions( # We continue to generate the old format because we don't know what # version of the lcov tools will be used to read this report. + # "and region.lines" below avoids a crash due to a bug in PyPy 3.8 + # where, for whatever reason, when collecting data in --branch mode, + # top-level functions have an empty lines array. Instead we just don't + # emit function records for those. + # suppressions because of https://github.com/pylint-dev/pylint/issues/9923 functions = [ (min(region.start, min(region.lines)), #pylint: disable=nested-min-max diff --git a/tests/test_lcov.py b/tests/test_lcov.py index 17271cef0..c64249b96 100644 --- a/tests/test_lcov.py +++ b/tests/test_lcov.py @@ -11,6 +11,24 @@ from tests.coveragetest import CoverageTest import coverage +from coverage import env + + +def fn_coverage_missing_in_pypy_38( + line_records: str, + fn_records: str, + arc_records: str, +) -> str: + """ + Helper for tests that trip a bug in PyPy 3.8 that prevent us from + generating function coverage records for top-level functions when + coverage data was collected in --branch mode. See lcov_functions + in lcovreport.py for more detail. + """ + if env.PYPY and env.PYVERSION[:2] == (3, 8): + return line_records + arc_records + else: + return line_records + fn_records + arc_records class LcovTest(CoverageTest): @@ -174,24 +192,30 @@ def is_it_x(x): pct = cov.lcov_report() assert math.isclose(pct, 16.666666666666668) self.assert_exists("coverage.lcov") - expected_result = textwrap.dedent("""\ - SF:main_file.py - DA:1,1 - DA:2,0 - DA:3,0 - DA:5,0 - LF:4 - LH:1 - FN:1,5,is_it_x - FNDA:0,is_it_x - FNF:1 - FNH:0 - BRDA:2,0,to line 3,- - BRDA:2,0,to line 5,- - BRF:2 - BRH:0 - end_of_record - """) + expected_result = fn_coverage_missing_in_pypy_38( + textwrap.dedent("""\ + SF:main_file.py + DA:1,1 + DA:2,0 + DA:3,0 + DA:5,0 + LF:4 + LH:1 + """), + textwrap.dedent("""\ + FN:1,5,is_it_x + FNDA:0,is_it_x + FNF:1 + FNH:0 + """), + textwrap.dedent("""\ + BRDA:2,0,to line 3,- + BRDA:2,0,to line 5,- + BRF:2 + BRH:0 + end_of_record + """) + ) actual_result = self.get_lcov_report_content() assert expected_result == actual_result @@ -221,23 +245,33 @@ def test_is_it_x(self): pct = cov.lcov_report() assert math.isclose(pct, 41.666666666666664) self.assert_exists("coverage.lcov") - expected_result = textwrap.dedent("""\ - SF:main_file.py - DA:1,1 - DA:2,0 - DA:3,0 - DA:5,0 - LF:4 - LH:1 - FN:1,5,is_it_x - FNDA:0,is_it_x - FNF:1 - FNH:0 - BRDA:2,0,to line 3,- - BRDA:2,0,to line 5,- - BRF:2 - BRH:0 - end_of_record + # The pypy 3.8 bug that prevents us from generating function coverage + # records only applies to top-level functions, not to member functions. + expected_result = fn_coverage_missing_in_pypy_38( + textwrap.dedent("""\ + SF:main_file.py + DA:1,1 + DA:2,0 + DA:3,0 + DA:5,0 + LF:4 + LH:1 + """), + textwrap.dedent("""\ + FN:1,5,is_it_x + FNDA:0,is_it_x + FNF:1 + FNH:0 + """), + textwrap.dedent("""\ + BRDA:2,0,to line 3,- + BRDA:2,0,to line 5,- + BRF:2 + BRH:0 + end_of_record + """) + ) + expected_result += textwrap.dedent("""\ SF:test_file.py DA:1,1 DA:2,1 @@ -371,27 +405,33 @@ def foo(a): cov = coverage.Coverage(source=".", branch=True) self.start_import_stop(cov, "runme") cov.lcov_report() - expected_result = textwrap.dedent("""\ - SF:runme.py - DA:1,1 - DA:2,1 - DA:3,1 - DA:4,1 - DA:5,1 - DA:6,1 - DA:7,1 - LF:7 - LH:7 - FN:1,3,foo - FNDA:1,foo - FNF:1 - FNH:1 - BRDA:2,0,to line 3,1 - BRDA:2,0,to exit,1 - BRF:2 - BRH:2 - end_of_record - """) + expected_result = fn_coverage_missing_in_pypy_38( + textwrap.dedent("""\ + SF:runme.py + DA:1,1 + DA:2,1 + DA:3,1 + DA:4,1 + DA:5,1 + DA:6,1 + DA:7,1 + LF:7 + LH:7 + """), + textwrap.dedent("""\ + FN:1,3,foo + FNDA:1,foo + FNF:1 + FNH:1 + """), + textwrap.dedent("""\ + BRDA:2,0,to line 3,1 + BRDA:2,0,to exit,1 + BRF:2 + BRH:2 + end_of_record + """), + ) actual_result = self.get_lcov_report_content() assert expected_result == actual_result @@ -408,27 +448,33 @@ def foo(a): cov = coverage.Coverage(source=".", branch=True) self.start_import_stop(cov, "runme") cov.lcov_report() - expected_result = textwrap.dedent("""\ - SF:runme.py - DA:1,1 - DA:2,1 - DA:3,1 - DA:4,1 - DA:5,1 - DA:6,1 - DA:7,1 - LF:7 - LH:7 - FN:1,3,foo - FNDA:1,foo - FNF:1 - FNH:1 - BRDA:2,0,to line 3,1 - BRDA:2,0,to exit 1,1 - BRDA:2,0,to exit 2,1 - BRF:2 - BRH:2 - end_of_record - """) + expected_result = fn_coverage_missing_in_pypy_38( + textwrap.dedent("""\ + SF:runme.py + DA:1,1 + DA:2,1 + DA:3,1 + DA:4,1 + DA:5,1 + DA:6,1 + DA:7,1 + LF:7 + LH:7 + """), + textwrap.dedent("""\ + FN:1,3,foo + FNDA:1,foo + FNF:1 + FNH:1 + """), + textwrap.dedent("""\ + BRDA:2,0,to line 3,1 + BRDA:2,0,to exit 1,1 + BRDA:2,0,to exit 2,1 + BRF:2 + BRH:2 + end_of_record + """), + ) actual_result = self.get_lcov_report_content() assert expected_result == actual_result From 837bc7ee7a7e253560cf47e952e8387fa842e2bb Mon Sep 17 00:00:00 2001 From: Zack Weinberg Date: Mon, 16 Sep 2024 10:47:49 -0400 Subject: [PATCH 08/15] Split up test_multiple_exit_branches into 3 tests exercising #1852. --- tests/test_lcov.py | 77 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/tests/test_lcov.py b/tests/test_lcov.py index c64249b96..48c15ccdc 100644 --- a/tests/test_lcov.py +++ b/tests/test_lcov.py @@ -435,7 +435,7 @@ def foo(a): actual_result = self.get_lcov_report_content() assert expected_result == actual_result - def test_multiple_exit_branches(self) -> None: + def test_genexpr_exit_arcs_pruned_when_executed(self) -> None: self.make_file("runme.py", """\ def foo(a): if any(x > 0 for x in a): @@ -469,8 +469,7 @@ def foo(a): """), textwrap.dedent("""\ BRDA:2,0,to line 3,1 - BRDA:2,0,to exit 1,1 - BRDA:2,0,to exit 2,1 + BRDA:2,0,to exit,1 BRF:2 BRH:2 end_of_record @@ -478,3 +477,75 @@ def foo(a): ) actual_result = self.get_lcov_report_content() assert expected_result == actual_result + + def test_genexpr_exit_arcs_pruned_when_missing(self) -> None: + self.make_file("runme.py", """\ + def foo(a): + if any(x > 0 for x in a): + print(f"{a!r} has positives") + foo([]) + """) + cov = coverage.Coverage(source=".", branch=True) + self.start_import_stop(cov, "runme") + cov.lcov_report() + expected_result = fn_coverage_missing_in_pypy_38( + textwrap.dedent("""\ + SF:runme.py + DA:1,1 + DA:2,1 + DA:3,0 + DA:4,1 + LF:4 + LH:3 + """), + textwrap.dedent("""\ + FN:1,3,foo + FNDA:1,foo + FNF:1 + FNH:1 + """), + textwrap.dedent("""\ + BRDA:2,0,to line 3,0 + BRDA:2,0,to exit,1 + BRF:2 + BRH:1 + end_of_record + """), + ) + actual_result = self.get_lcov_report_content() + assert expected_result == actual_result + + def test_genexpr_exit_arcs_pruned_when_not_reached(self) -> None: + self.make_file("runme.py", """\ + def foo(a): + if any(x > 0 for x in a): + print(f"{a!r} has positives") + """) + cov = coverage.Coverage(source=".", branch=True) + self.start_import_stop(cov, "runme") + cov.lcov_report() + expected_result = fn_coverage_missing_in_pypy_38( + textwrap.dedent("""\ + SF:runme.py + DA:1,1 + DA:2,0 + DA:3,0 + LF:3 + LH:1 + """), + textwrap.dedent("""\ + FN:1,3,foo + FNDA:0,foo + FNF:1 + FNH:0 + """), + textwrap.dedent("""\ + BRDA:2,0,to line 3,- + BRDA:2,0,to exit,- + BRF:2 + BRH:0 + end_of_record + """), + ) + actual_result = self.get_lcov_report_content() + assert expected_result == actual_result From 228854a902fb022f37917adcb94c6be21ccf0497 Mon Sep 17 00:00:00 2001 From: Zack Weinberg Date: Mon, 16 Sep 2024 11:04:00 -0400 Subject: [PATCH 09/15] add a fourth case to the tests for #1852 --- tests/test_lcov.py | 51 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/tests/test_lcov.py b/tests/test_lcov.py index 48c15ccdc..6c03c2cd8 100644 --- a/tests/test_lcov.py +++ b/tests/test_lcov.py @@ -435,7 +435,7 @@ def foo(a): actual_result = self.get_lcov_report_content() assert expected_result == actual_result - def test_genexpr_exit_arcs_pruned_when_executed(self) -> None: + def test_genexpr_exit_arcs_pruned_full_coverage(self) -> None: self.make_file("runme.py", """\ def foo(a): if any(x > 0 for x in a): @@ -478,12 +478,13 @@ def foo(a): actual_result = self.get_lcov_report_content() assert expected_result == actual_result - def test_genexpr_exit_arcs_pruned_when_missing(self) -> None: + def test_genexpr_exit_arcs_pruned_never_true(self) -> None: self.make_file("runme.py", """\ def foo(a): if any(x > 0 for x in a): print(f"{a!r} has positives") foo([]) + foo([0]) """) cov = coverage.Coverage(source=".", branch=True) self.start_import_stop(cov, "runme") @@ -495,8 +496,9 @@ def foo(a): DA:2,1 DA:3,0 DA:4,1 - LF:4 - LH:3 + DA:5,1 + LF:5 + LH:4 """), textwrap.dedent("""\ FN:1,3,foo @@ -515,7 +517,46 @@ def foo(a): actual_result = self.get_lcov_report_content() assert expected_result == actual_result - def test_genexpr_exit_arcs_pruned_when_not_reached(self) -> None: + def test_genexpr_exit_arcs_pruned_always_true(self) -> None: + self.make_file("runme.py", """\ + def foo(a): + if any(x > 0 for x in a): + print(f"{a!r} has positives") + foo([1]) + foo([1,2]) + """) + cov = coverage.Coverage(source=".", branch=True) + self.start_import_stop(cov, "runme") + cov.lcov_report() + expected_result = fn_coverage_missing_in_pypy_38( + textwrap.dedent("""\ + SF:runme.py + DA:1,1 + DA:2,1 + DA:3,1 + DA:4,1 + DA:5,1 + LF:5 + LH:5 + """), + textwrap.dedent("""\ + FN:1,3,foo + FNDA:1,foo + FNF:1 + FNH:1 + """), + textwrap.dedent("""\ + BRDA:2,0,to line 3,1 + BRDA:2,0,to exit,0 + BRF:2 + BRH:1 + end_of_record + """), + ) + actual_result = self.get_lcov_report_content() + assert expected_result == actual_result + + def test_genexpr_exit_arcs_pruned_not_reached(self) -> None: self.make_file("runme.py", """\ def foo(a): if any(x > 0 for x in a): From d767721059ab51aef18cb5d4f25584e80a208ca2 Mon Sep 17 00:00:00 2001 From: Zack Weinberg Date: Tue, 24 Sep 2024 09:33:50 -0400 Subject: [PATCH 10/15] Revise lcovreport.lcov_arcs using the new arc_description API. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doesn’t quite work yet, see discussion in #1850. --- coverage/lcovreport.py | 38 +++++++++++++++++--------------------- tests/test_lcov.py | 36 ++++++++++++++++++------------------ 2 files changed, 35 insertions(+), 39 deletions(-) diff --git a/coverage/lcovreport.py b/coverage/lcovreport.py index 0ae462160..1d31baa5c 100644 --- a/coverage/lcovreport.py +++ b/coverage/lcovreport.py @@ -96,6 +96,7 @@ def lcov_functions( def lcov_arcs( + fr: FileReporter, analysis: Analysis, lines: list[int], outfile: IO[str], @@ -116,39 +117,34 @@ def lcov_arcs( if taken == 0: # When _none_ of the out arcs from 'line' were executed, - # this probably means means 'line' was never executed at all. + # this probably means 'line' was never executed at all. # Cross-check with the line stats. assert len(executed_arcs[line]) == 0 - if line in analysis.missing: - hit = "-" # indeed, never reached - else: - hit = "0" # I am not prepared to swear this is impossible + assert line in analysis.missing destinations = [ - (int(dst < 0), abs(dst), hit) for dst in missing_arcs[line] + (dst, "-") for dst in missing_arcs[line] ] else: # Q: can we get counts of the number of times each arc was executed? # branch_stats has "total" and "taken" counts for each branch, # but it doesn't have "taken" broken down by destination. destinations = [ - (int(dst < 0), abs(dst), "1") for dst in executed_arcs[line] + (dst, "1") for dst in executed_arcs[line] ] destinations.extend( - (int(dst < 0), abs(dst), "0") for dst in missing_arcs[line] + (dst, "0") for dst in missing_arcs[line] ) - destinations.sort() - n_exits = sum( - 1 for is_exit, _, _ in destinations if is_exit - ) - for is_exit, dst, hit in destinations: - if is_exit: - if n_exits == 1: - branch = "to exit" - else: - branch = f"to exit {dst}" - else: - branch = f"to line {dst}" + # Sort exit arcs after normal arcs. Exit arcs typically come from + # an if statement, at the end of a function, with no else clause. + # This structure reads like you're jumping to the end of the function + # when the conditional expression is false, so it should be presented + # as the second alternative for the branch, after the alternative that + # enters the if clause. + destinations.sort(key=lambda d: (d[0] < 0, d)) + + for dst, hit in destinations: + branch = fr.arc_description(line, dst) outfile.write(f"BRDA:{line},0,{branch},{hit}\n") # Summary of the branch coverage. @@ -221,6 +217,6 @@ def lcov_file( lcov_lines(analysis, lines, source_lines, outfile) lcov_functions(fr, analysis, outfile) if analysis.has_arcs: - lcov_arcs(analysis, lines, outfile) + lcov_arcs(fr, analysis, lines, outfile) outfile.write("end_of_record\n") diff --git a/tests/test_lcov.py b/tests/test_lcov.py index 6c03c2cd8..8fc04c2d3 100644 --- a/tests/test_lcov.py +++ b/tests/test_lcov.py @@ -209,8 +209,8 @@ def is_it_x(x): FNH:0 """), textwrap.dedent("""\ - BRDA:2,0,to line 3,- - BRDA:2,0,to line 5,- + BRDA:2,0,jump to line 3,- + BRDA:2,0,jump to line 5,- BRF:2 BRH:0 end_of_record @@ -264,8 +264,8 @@ def test_is_it_x(self): FNH:0 """), textwrap.dedent("""\ - BRDA:2,0,to line 3,- - BRDA:2,0,to line 5,- + BRDA:2,0,jump to line 3,- + BRDA:2,0,jump to line 5,- BRF:2 BRH:0 end_of_record @@ -315,8 +315,8 @@ def test_half_covered_branch(self) -> None: DA:6,0 LF:4 LH:3 - BRDA:3,0,to line 4,1 - BRDA:3,0,to line 6,0 + BRDA:3,0,jump to line 4,1 + BRDA:3,0,jump to line 6,0 BRF:2 BRH:1 end_of_record @@ -383,8 +383,8 @@ def test_excluded_lines(self) -> None: DA:6,1 LF:4 LH:3 - BRDA:3,0,to line 4,0 - BRDA:3,0,to line 6,1 + BRDA:3,0,jump to line 4,0 + BRDA:3,0,jump to line 6,1 BRF:2 BRH:1 end_of_record @@ -425,8 +425,8 @@ def foo(a): FNH:1 """), textwrap.dedent("""\ - BRDA:2,0,to line 3,1 - BRDA:2,0,to exit,1 + BRDA:2,0,jump to line 3,1 + BRDA:2,0,return from function 'foo',1 BRF:2 BRH:2 end_of_record @@ -468,8 +468,8 @@ def foo(a): FNH:1 """), textwrap.dedent("""\ - BRDA:2,0,to line 3,1 - BRDA:2,0,to exit,1 + BRDA:2,0,jump to line 3,1 + BRDA:2,0,return from function 'foo',1 BRF:2 BRH:2 end_of_record @@ -507,8 +507,8 @@ def foo(a): FNH:1 """), textwrap.dedent("""\ - BRDA:2,0,to line 3,0 - BRDA:2,0,to exit,1 + BRDA:2,0,jump to line 3,0 + BRDA:2,0,return from function 'foo',1 BRF:2 BRH:1 end_of_record @@ -546,8 +546,8 @@ def foo(a): FNH:1 """), textwrap.dedent("""\ - BRDA:2,0,to line 3,1 - BRDA:2,0,to exit,0 + BRDA:2,0,jump to line 3,1 + BRDA:2,0,return from function 'foo',0 BRF:2 BRH:1 end_of_record @@ -581,8 +581,8 @@ def foo(a): FNH:0 """), textwrap.dedent("""\ - BRDA:2,0,to line 3,- - BRDA:2,0,to exit,- + BRDA:2,0,jump to line 3,- + BRDA:2,0,return from function 'foo',- BRF:2 BRH:0 end_of_record From 24b21d198a47ec50d2974d723a02f8e11e8de22d Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 25 Sep 2024 16:40:57 -0400 Subject: [PATCH 11/15] fix: forgot to add arc_descriptions to Python filereporter --- coverage/python.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/coverage/python.py b/coverage/python.py index 3ef0e5ff4..ef1174d81 100644 --- a/coverage/python.py +++ b/coverage/python.py @@ -227,6 +227,13 @@ def missing_arc_description( ) -> str: return self.parser.missing_arc_description(start, end) + def arc_description( + self, + start: TLineNo, + end: TLineNo + ) -> str: + return self.parser.arc_description(start, end) + def source(self) -> str: if self._source is None: self._source = get_python_source(self.filename) From a6c26f2af79594742a4a239d42c9c43c8f6ee713 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 25 Sep 2024 16:41:40 -0400 Subject: [PATCH 12/15] refactor: make arc possibilities and arcs executed available --- coverage/results.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/coverage/results.py b/coverage/results.py index d5842d944..0bcc317bd 100644 --- a/coverage/results.py +++ b/coverage/results.py @@ -33,12 +33,12 @@ def analysis_from_file_reporter( executed = file_reporter.translate_lines(data.lines(filename) or []) if has_arcs: - _arc_possibilities_set = file_reporter.arcs() + arc_possibilities_set = file_reporter.arcs() arcs = data.arcs(filename) or [] # Reduce the set of arcs to the ones that could be branches. dests = collections.defaultdict(set) - for fromno, tono in _arc_possibilities_set: + for fromno, tono in arc_possibilities_set: dests[fromno].add(tono) single_dests = { fromno: list(tonos)[0] @@ -53,12 +53,12 @@ def analysis_from_file_reporter( if fromno in single_dests: new_arcs.add((fromno, single_dests[fromno])) - _arcs_executed_set = file_reporter.translate_arcs(new_arcs) + arcs_executed_set = file_reporter.translate_arcs(new_arcs) exit_counts = file_reporter.exit_counts() no_branch = file_reporter.no_branch_lines() else: - _arc_possibilities_set = set() - _arcs_executed_set = set() + arc_possibilities_set = set() + arcs_executed_set = set() exit_counts = {} no_branch = set() @@ -69,8 +69,8 @@ def analysis_from_file_reporter( statements=statements, excluded=excluded, executed=executed, - _arc_possibilities_set=_arc_possibilities_set, - _arcs_executed_set=_arcs_executed_set, + arc_possibilities_set=arc_possibilities_set, + arcs_executed_set=arcs_executed_set, exit_counts=exit_counts, no_branch=no_branch, ) @@ -86,14 +86,14 @@ class Analysis: statements: set[TLineNo] excluded: set[TLineNo] executed: set[TLineNo] - _arc_possibilities_set: set[TArc] - _arcs_executed_set: set[TArc] + arc_possibilities_set: set[TArc] + arcs_executed_set: set[TArc] exit_counts: dict[TLineNo, int] no_branch: set[TLineNo] def __post_init__(self) -> None: - self.arc_possibilities = sorted(self._arc_possibilities_set) - self.arcs_executed = sorted(self._arcs_executed_set) + self.arc_possibilities = sorted(self.arc_possibilities_set) + self.arcs_executed = sorted(self.arcs_executed_set) self.missing = self.statements - self.executed if self.has_arcs: @@ -127,12 +127,12 @@ def narrow(self, lines: Container[TLineNo]) -> Analysis: executed = {lno for lno in self.executed if lno in lines} if self.has_arcs: - _arc_possibilities_set = { - (a, b) for a, b in self._arc_possibilities_set + arc_possibilities_set = { + (a, b) for a, b in self.arc_possibilities_set if a in lines or b in lines } - _arcs_executed_set = { - (a, b) for a, b in self._arcs_executed_set + arcs_executed_set = { + (a, b) for a, b in self.arcs_executed_set if a in lines or b in lines } exit_counts = { @@ -141,8 +141,8 @@ def narrow(self, lines: Container[TLineNo]) -> Analysis: } no_branch = {lno for lno in self.no_branch if lno in lines} else: - _arc_possibilities_set = set() - _arcs_executed_set = set() + arc_possibilities_set = set() + arcs_executed_set = set() exit_counts = {} no_branch = set() @@ -153,8 +153,8 @@ def narrow(self, lines: Container[TLineNo]) -> Analysis: statements=statements, excluded=excluded, executed=executed, - _arc_possibilities_set=_arc_possibilities_set, - _arcs_executed_set=_arcs_executed_set, + arc_possibilities_set=arc_possibilities_set, + arcs_executed_set=arcs_executed_set, exit_counts=exit_counts, no_branch=no_branch, ) From 77ee8f2355fc2613143b0b1dd49325cdd3ad1ca6 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 25 Sep 2024 16:56:06 -0400 Subject: [PATCH 13/15] fix: executed_branch_arcs should limit itself to parsed possible arcs --- coverage/results.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/coverage/results.py b/coverage/results.py index 0bcc317bd..7191dcd1a 100644 --- a/coverage/results.py +++ b/coverage/results.py @@ -211,6 +211,8 @@ def missing_branch_arcs(self) -> dict[TLineNo, list[TLineNo]]: def executed_branch_arcs(self) -> dict[TLineNo, list[TLineNo]]: """Return arcs that were executed from branch lines. + Only include ones that we considered possible. + Returns {l1:[l2a,l2b,...], ...} """ @@ -219,6 +221,8 @@ def executed_branch_arcs(self) -> dict[TLineNo, list[TLineNo]]: for l1, l2 in self.arcs_executed: if l1 == l2: continue + if (l1, l2) not in self.arc_possibilities_set: + continue if l1 in branch_lines: eba[l1].append(l2) return eba From f90ef28bed87719185829bbebd642b0d9279584e Mon Sep 17 00:00:00 2001 From: Zack Weinberg Date: Wed, 2 Oct 2024 11:28:16 -0400 Subject: [PATCH 14/15] Use tests.helpers.xfail_pypy38 for lcov tests that fail with pypy 3.8. This replaces the custom fn_coverage_missing_in_pypy_38 logic that was used in earlier commits. --- tests/test_lcov.py | 338 +++++++++++++++++++-------------------------- 1 file changed, 141 insertions(+), 197 deletions(-) diff --git a/tests/test_lcov.py b/tests/test_lcov.py index 8fc04c2d3..b67d22e0c 100644 --- a/tests/test_lcov.py +++ b/tests/test_lcov.py @@ -8,27 +8,10 @@ import math import textwrap -from tests.coveragetest import CoverageTest - import coverage -from coverage import env - -def fn_coverage_missing_in_pypy_38( - line_records: str, - fn_records: str, - arc_records: str, -) -> str: - """ - Helper for tests that trip a bug in PyPy 3.8 that prevent us from - generating function coverage records for top-level functions when - coverage data was collected in --branch mode. See lcov_functions - in lcovreport.py for more detail. - """ - if env.PYPY and env.PYVERSION[:2] == (3, 8): - return line_records + arc_records - else: - return line_records + fn_records + arc_records +from tests.coveragetest import CoverageTest +from tests.helpers import xfail_pypy38 class LcovTest(CoverageTest): @@ -177,6 +160,7 @@ def test_simple_line_coverage_two_files(self) -> None: actual_result = self.get_lcov_report_content(filename="data.lcov") assert expected_result == actual_result + @xfail_pypy38 def test_branch_coverage_one_file(self) -> None: # Test that the reporter produces valid branch coverage. self.make_file("main_file.py", """\ @@ -192,33 +176,28 @@ def is_it_x(x): pct = cov.lcov_report() assert math.isclose(pct, 16.666666666666668) self.assert_exists("coverage.lcov") - expected_result = fn_coverage_missing_in_pypy_38( - textwrap.dedent("""\ - SF:main_file.py - DA:1,1 - DA:2,0 - DA:3,0 - DA:5,0 - LF:4 - LH:1 - """), - textwrap.dedent("""\ - FN:1,5,is_it_x - FNDA:0,is_it_x - FNF:1 - FNH:0 - """), - textwrap.dedent("""\ - BRDA:2,0,jump to line 3,- - BRDA:2,0,jump to line 5,- - BRF:2 - BRH:0 - end_of_record - """) - ) + expected_result = textwrap.dedent("""\ + SF:main_file.py + DA:1,1 + DA:2,0 + DA:3,0 + DA:5,0 + LF:4 + LH:1 + FN:1,5,is_it_x + FNDA:0,is_it_x + FNF:1 + FNH:0 + BRDA:2,0,jump to line 3,- + BRDA:2,0,jump to line 5,- + BRF:2 + BRH:0 + end_of_record + """) actual_result = self.get_lcov_report_content() assert expected_result == actual_result + @xfail_pypy38 def test_branch_coverage_two_files(self) -> None: # Test that valid branch coverage is generated # in the case of two files. @@ -245,33 +224,23 @@ def test_is_it_x(self): pct = cov.lcov_report() assert math.isclose(pct, 41.666666666666664) self.assert_exists("coverage.lcov") - # The pypy 3.8 bug that prevents us from generating function coverage - # records only applies to top-level functions, not to member functions. - expected_result = fn_coverage_missing_in_pypy_38( - textwrap.dedent("""\ - SF:main_file.py - DA:1,1 - DA:2,0 - DA:3,0 - DA:5,0 - LF:4 - LH:1 - """), - textwrap.dedent("""\ - FN:1,5,is_it_x - FNDA:0,is_it_x - FNF:1 - FNH:0 - """), - textwrap.dedent("""\ - BRDA:2,0,jump to line 3,- - BRDA:2,0,jump to line 5,- - BRF:2 - BRH:0 - end_of_record - """) - ) - expected_result += textwrap.dedent("""\ + expected_result = textwrap.dedent("""\ + SF:main_file.py + DA:1,1 + DA:2,0 + DA:3,0 + DA:5,0 + LF:4 + LH:1 + FN:1,5,is_it_x + FNDA:0,is_it_x + FNF:1 + FNH:0 + BRDA:2,0,jump to line 3,- + BRDA:2,0,jump to line 5,- + BRF:2 + BRH:0 + end_of_record SF:test_file.py DA:1,1 DA:2,1 @@ -392,6 +361,7 @@ def test_excluded_lines(self) -> None: actual_result = self.get_lcov_report_content() assert expected_result == actual_result + @xfail_pypy38 def test_exit_branches(self) -> None: self.make_file("runme.py", """\ def foo(a): @@ -405,36 +375,31 @@ def foo(a): cov = coverage.Coverage(source=".", branch=True) self.start_import_stop(cov, "runme") cov.lcov_report() - expected_result = fn_coverage_missing_in_pypy_38( - textwrap.dedent("""\ - SF:runme.py - DA:1,1 - DA:2,1 - DA:3,1 - DA:4,1 - DA:5,1 - DA:6,1 - DA:7,1 - LF:7 - LH:7 - """), - textwrap.dedent("""\ - FN:1,3,foo - FNDA:1,foo - FNF:1 - FNH:1 - """), - textwrap.dedent("""\ - BRDA:2,0,jump to line 3,1 - BRDA:2,0,return from function 'foo',1 - BRF:2 - BRH:2 - end_of_record - """), - ) + expected_result = textwrap.dedent("""\ + SF:runme.py + DA:1,1 + DA:2,1 + DA:3,1 + DA:4,1 + DA:5,1 + DA:6,1 + DA:7,1 + LF:7 + LH:7 + FN:1,3,foo + FNDA:1,foo + FNF:1 + FNH:1 + BRDA:2,0,jump to line 3,1 + BRDA:2,0,return from function 'foo',1 + BRF:2 + BRH:2 + end_of_record + """) actual_result = self.get_lcov_report_content() assert expected_result == actual_result + @xfail_pypy38 def test_genexpr_exit_arcs_pruned_full_coverage(self) -> None: self.make_file("runme.py", """\ def foo(a): @@ -448,36 +413,31 @@ def foo(a): cov = coverage.Coverage(source=".", branch=True) self.start_import_stop(cov, "runme") cov.lcov_report() - expected_result = fn_coverage_missing_in_pypy_38( - textwrap.dedent("""\ - SF:runme.py - DA:1,1 - DA:2,1 - DA:3,1 - DA:4,1 - DA:5,1 - DA:6,1 - DA:7,1 - LF:7 - LH:7 - """), - textwrap.dedent("""\ - FN:1,3,foo - FNDA:1,foo - FNF:1 - FNH:1 - """), - textwrap.dedent("""\ - BRDA:2,0,jump to line 3,1 - BRDA:2,0,return from function 'foo',1 - BRF:2 - BRH:2 - end_of_record - """), - ) + expected_result = textwrap.dedent("""\ + SF:runme.py + DA:1,1 + DA:2,1 + DA:3,1 + DA:4,1 + DA:5,1 + DA:6,1 + DA:7,1 + LF:7 + LH:7 + FN:1,3,foo + FNDA:1,foo + FNF:1 + FNH:1 + BRDA:2,0,jump to line 3,1 + BRDA:2,0,return from function 'foo',1 + BRF:2 + BRH:2 + end_of_record + """) actual_result = self.get_lcov_report_content() assert expected_result == actual_result + @xfail_pypy38 def test_genexpr_exit_arcs_pruned_never_true(self) -> None: self.make_file("runme.py", """\ def foo(a): @@ -489,34 +449,29 @@ def foo(a): cov = coverage.Coverage(source=".", branch=True) self.start_import_stop(cov, "runme") cov.lcov_report() - expected_result = fn_coverage_missing_in_pypy_38( - textwrap.dedent("""\ - SF:runme.py - DA:1,1 - DA:2,1 - DA:3,0 - DA:4,1 - DA:5,1 - LF:5 - LH:4 - """), - textwrap.dedent("""\ - FN:1,3,foo - FNDA:1,foo - FNF:1 - FNH:1 - """), - textwrap.dedent("""\ - BRDA:2,0,jump to line 3,0 - BRDA:2,0,return from function 'foo',1 - BRF:2 - BRH:1 - end_of_record - """), - ) + expected_result = textwrap.dedent("""\ + SF:runme.py + DA:1,1 + DA:2,1 + DA:3,0 + DA:4,1 + DA:5,1 + LF:5 + LH:4 + FN:1,3,foo + FNDA:1,foo + FNF:1 + FNH:1 + BRDA:2,0,jump to line 3,0 + BRDA:2,0,return from function 'foo',1 + BRF:2 + BRH:1 + end_of_record + """) actual_result = self.get_lcov_report_content() assert expected_result == actual_result + @xfail_pypy38 def test_genexpr_exit_arcs_pruned_always_true(self) -> None: self.make_file("runme.py", """\ def foo(a): @@ -528,34 +483,29 @@ def foo(a): cov = coverage.Coverage(source=".", branch=True) self.start_import_stop(cov, "runme") cov.lcov_report() - expected_result = fn_coverage_missing_in_pypy_38( - textwrap.dedent("""\ - SF:runme.py - DA:1,1 - DA:2,1 - DA:3,1 - DA:4,1 - DA:5,1 - LF:5 - LH:5 - """), - textwrap.dedent("""\ - FN:1,3,foo - FNDA:1,foo - FNF:1 - FNH:1 - """), - textwrap.dedent("""\ - BRDA:2,0,jump to line 3,1 - BRDA:2,0,return from function 'foo',0 - BRF:2 - BRH:1 - end_of_record - """), - ) + expected_result = textwrap.dedent("""\ + SF:runme.py + DA:1,1 + DA:2,1 + DA:3,1 + DA:4,1 + DA:5,1 + LF:5 + LH:5 + FN:1,3,foo + FNDA:1,foo + FNF:1 + FNH:1 + BRDA:2,0,jump to line 3,1 + BRDA:2,0,return from function 'foo',0 + BRF:2 + BRH:1 + end_of_record + """) actual_result = self.get_lcov_report_content() assert expected_result == actual_result + @xfail_pypy38 def test_genexpr_exit_arcs_pruned_not_reached(self) -> None: self.make_file("runme.py", """\ def foo(a): @@ -565,28 +515,22 @@ def foo(a): cov = coverage.Coverage(source=".", branch=True) self.start_import_stop(cov, "runme") cov.lcov_report() - expected_result = fn_coverage_missing_in_pypy_38( - textwrap.dedent("""\ - SF:runme.py - DA:1,1 - DA:2,0 - DA:3,0 - LF:3 - LH:1 - """), - textwrap.dedent("""\ - FN:1,3,foo - FNDA:0,foo - FNF:1 - FNH:0 - """), - textwrap.dedent("""\ - BRDA:2,0,jump to line 3,- - BRDA:2,0,return from function 'foo',- - BRF:2 - BRH:0 - end_of_record - """), - ) + expected_result = textwrap.dedent("""\ + SF:runme.py + DA:1,1 + DA:2,0 + DA:3,0 + LF:3 + LH:1 + FN:1,3,foo + FNDA:0,foo + FNF:1 + FNH:0 + BRDA:2,0,jump to line 3,- + BRDA:2,0,return from function 'foo',- + BRF:2 + BRH:0 + end_of_record + """) actual_result = self.get_lcov_report_content() assert expected_result == actual_result From e6a79ae5f832d809ff6584bde3d9bae3b869ac0a Mon Sep 17 00:00:00 2001 From: Zack Weinberg Date: Wed, 2 Oct 2024 11:35:47 -0400 Subject: [PATCH 15/15] tests: split xfail_pypy38 decorator into _older_ and _all_ variants The lcov output tests that are affected by bugs in PyPy 3.8, fail with the current version of PyPy 3.8 (7.3.11), unlike the other tests annotated with @xfail_pypy38. Split this decorator into two variants, xfail_older_pypy38 (used for all the tests that were labeled xfail_py38 prior to this patchset) and xfail_all_pypy38 (used for the lcov output tests). --- tests/helpers.py | 8 +++++++- tests/test_arcs.py | 4 ++-- tests/test_lcov.py | 16 ++++++++-------- tests/test_parser.py | 6 +++--- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 823bbfd41..6312c7dfa 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -326,7 +326,13 @@ def swallow_warnings( yield -xfail_pypy38 = pytest.mark.xfail( +xfail_all_pypy38 = pytest.mark.xfail( + env.PYPY and env.PYVERSION[:2] == (3, 8), + reason="These tests fail on all versions of PyPy 3.8", +) + + +xfail_older_pypy38 = pytest.mark.xfail( env.PYPY and env.PYVERSION[:2] == (3, 8) and env.PYPYVERSION < (7, 3, 11), reason="These tests fail on older PyPy 3.8", ) diff --git a/tests/test_arcs.py b/tests/test_arcs.py index 516e2acda..10e1e2f01 100644 --- a/tests/test_arcs.py +++ b/tests/test_arcs.py @@ -8,7 +8,7 @@ import pytest from tests.coveragetest import CoverageTest -from tests.helpers import assert_count_equal, xfail_pypy38 +from tests.helpers import assert_count_equal, xfail_older_pypy38 import coverage from coverage import env @@ -1695,7 +1695,7 @@ def my_function( branchz="", branchz_missing="", ) - @xfail_pypy38 + @xfail_older_pypy38 def test_class_decorator(self) -> None: self.check_coverage("""\ def decorator(arg): diff --git a/tests/test_lcov.py b/tests/test_lcov.py index b67d22e0c..964b315a7 100644 --- a/tests/test_lcov.py +++ b/tests/test_lcov.py @@ -11,7 +11,7 @@ import coverage from tests.coveragetest import CoverageTest -from tests.helpers import xfail_pypy38 +from tests.helpers import xfail_all_pypy38 class LcovTest(CoverageTest): @@ -160,7 +160,7 @@ def test_simple_line_coverage_two_files(self) -> None: actual_result = self.get_lcov_report_content(filename="data.lcov") assert expected_result == actual_result - @xfail_pypy38 + @xfail_all_pypy38 def test_branch_coverage_one_file(self) -> None: # Test that the reporter produces valid branch coverage. self.make_file("main_file.py", """\ @@ -197,7 +197,7 @@ def is_it_x(x): actual_result = self.get_lcov_report_content() assert expected_result == actual_result - @xfail_pypy38 + @xfail_all_pypy38 def test_branch_coverage_two_files(self) -> None: # Test that valid branch coverage is generated # in the case of two files. @@ -361,7 +361,7 @@ def test_excluded_lines(self) -> None: actual_result = self.get_lcov_report_content() assert expected_result == actual_result - @xfail_pypy38 + @xfail_all_pypy38 def test_exit_branches(self) -> None: self.make_file("runme.py", """\ def foo(a): @@ -399,7 +399,7 @@ def foo(a): actual_result = self.get_lcov_report_content() assert expected_result == actual_result - @xfail_pypy38 + @xfail_all_pypy38 def test_genexpr_exit_arcs_pruned_full_coverage(self) -> None: self.make_file("runme.py", """\ def foo(a): @@ -437,7 +437,7 @@ def foo(a): actual_result = self.get_lcov_report_content() assert expected_result == actual_result - @xfail_pypy38 + @xfail_all_pypy38 def test_genexpr_exit_arcs_pruned_never_true(self) -> None: self.make_file("runme.py", """\ def foo(a): @@ -471,7 +471,7 @@ def foo(a): actual_result = self.get_lcov_report_content() assert expected_result == actual_result - @xfail_pypy38 + @xfail_all_pypy38 def test_genexpr_exit_arcs_pruned_always_true(self) -> None: self.make_file("runme.py", """\ def foo(a): @@ -505,7 +505,7 @@ def foo(a): actual_result = self.get_lcov_report_content() assert expected_result == actual_result - @xfail_pypy38 + @xfail_all_pypy38 def test_genexpr_exit_arcs_pruned_not_reached(self) -> None: self.make_file("runme.py", """\ def foo(a): diff --git a/tests/test_parser.py b/tests/test_parser.py index 8bde8ed65..154a7d2b4 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -14,7 +14,7 @@ from coverage.parser import PythonParser from tests.coveragetest import CoverageTest -from tests.helpers import arcz_to_arcs, xfail_pypy38 +from tests.helpers import arcz_to_arcs, xfail_older_pypy38 class PythonParserTestBase(CoverageTest): @@ -672,7 +672,7 @@ def test_formfeed(self) -> None: ) assert parser.statements == {1, 6} - @xfail_pypy38 + @xfail_older_pypy38 def test_decorator_pragmas(self) -> None: parser = self.parse_text("""\ # 1 @@ -706,7 +706,7 @@ def func(x=25): assert parser.raw_statements == raw_statements assert parser.statements == {8} - @xfail_pypy38 + @xfail_older_pypy38 def test_decorator_pragmas_with_colons(self) -> None: # A colon in a decorator expression would confuse the parser, # ending the exclusion of the decorated function.