diff --git a/coverage/lcovreport.py b/coverage/lcovreport.py index 850bb6f93..1d31baa5c 100644 --- a/coverage/lcovreport.py +++ b/coverage/lcovreport.py @@ -32,6 +32,129 @@ 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_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. + + # "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 + max(region.start, max(region.lines)), #pylint: disable=nested-min-max + region) + for region in fr.code_regions() + if region.kind == "function" and region.lines + ] + 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( + fr: FileReporter, + 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 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 'line' was never executed at all. + # Cross-check with the line stats. + assert len(executed_arcs[line]) == 0 + assert line in analysis.missing + destinations = [ + (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 = [ + (dst, "1") for dst in executed_arcs[line] + ] + destinations.extend( + (dst, "0") for dst in missing_arcs[line] + ) + + # 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. + 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 +208,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 = [] - # 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. + lcov_lines(analysis, lines, source_lines, outfile) + lcov_functions(fr, analysis, outfile) 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(fr, analysis, lines, outfile) outfile.write("end_of_record\n") diff --git a/coverage/parser.py b/coverage/parser.py index 7831c3c44..59b58e099 100644 --- a/coverage/parser.py +++ b/coverage/parser.py @@ -346,32 +346,45 @@ 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: 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 = [] - 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 {actual_start} {emsg}" - if smsg is not None: - msg += f" because {smsg.format(lineno=actual_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.""" @@ -452,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}" @@ -486,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]]]] @@ -550,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 @@ -558,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 @@ -602,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 @@ -716,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`. 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) diff --git a/coverage/results.py b/coverage/results.py index d5842d944..7191dcd1a 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, ) @@ -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 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 65671f3fc..964b315a7 100644 --- a/tests/test_lcov.py +++ b/tests/test_lcov.py @@ -8,10 +8,11 @@ import math import textwrap -from tests.coveragetest import CoverageTest - import coverage +from tests.coveragetest import CoverageTest +from tests.helpers import xfail_all_pypy38 + class LcovTest(CoverageTest): """Tests of the LCOV reports from coverage.py.""" @@ -64,6 +65,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 +103,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 +133,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,11 +151,16 @@ 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") assert expected_result == actual_result + @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", """\ @@ -160,8 +184,12 @@ def is_it_x(x): DA:5,0 LF:4 LH:1 - BRDA:2,0,0,- - BRDA:2,0,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 @@ -169,6 +197,7 @@ def is_it_x(x): actual_result = self.get_lcov_report_content() assert expected_result == actual_result + @xfail_all_pypy38 def test_branch_coverage_two_files(self) -> None: # Test that valid branch coverage is generated # in the case of two files. @@ -203,8 +232,12 @@ def test_is_it_x(self): DA:5,0 LF:4 LH:1 - BRDA:2,0,0,- - BRDA:2,0,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 @@ -217,6 +250,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() @@ -247,8 +284,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,jump to line 4,1 + BRDA:3,0,jump to line 6,0 BRF:2 BRH:1 end_of_record @@ -315,11 +352,185 @@ 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,jump to line 4,0 + BRDA:3,0,jump to line 6,1 + BRF:2 + BRH:1 + end_of_record + """) + actual_result = self.get_lcov_report_content() + assert expected_result == actual_result + + @xfail_all_pypy38 + 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 + 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_all_pypy38 + 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): + 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 + 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_all_pypy38 + 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") + cov.lcov_report() + 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_all_pypy38 + 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 = 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_all_pypy38 + 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): + print(f"{a!r} has positives") + """) + 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,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 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.