diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bbd6c822..8f817690 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,3 +64,16 @@ To run the end-to-end tests, you'll need: - Please be aware that the tests will launch `gh auth setup-git` which might be surprising if you use `https` remotes (sadly, setting `GIT_CONFIG_GLOBAL` seems not to be enough to isolate tests.) + +## Coverage labs + +### Computing the coverage rate + +The coverage rate is `covered_lines / total_lines` (as one would expect). +In case "branch coverage" is enabled, the coverage rate is +`(covered_lines + covered_branches) / (total_lines + total_branches)`. +In order to display coverage rates, we need to round the values. Depending on +the situation, we either round to 0 or 2 decimal places. Rounding rules are: +- We always round down (truncate) the value. +- We don't display the trailing zeros in the decimal part (nor the decimal point + if the decimal part is 0). diff --git a/coverage_comment/coverage.py b/coverage_comment/coverage.py index 564649a5..591e9de9 100644 --- a/coverage_comment/coverage.py +++ b/coverage_comment/coverage.py @@ -13,7 +13,7 @@ # The dataclasses in this module are accessible in the template, which is overridable by the user. # As a coutesy, we should do our best to keep the existing fields for backward compatibility, # and if we really can't and can't add properties, at least bump the major version. -@dataclasses.dataclass +@dataclasses.dataclass(kw_only=True) class CoverageMetadata: version: str timestamp: datetime.datetime @@ -21,26 +21,28 @@ class CoverageMetadata: show_contexts: bool -@dataclasses.dataclass +@dataclasses.dataclass(kw_only=True) class CoverageInfo: covered_lines: int num_statements: int percent_covered: decimal.Decimal missing_lines: int excluded_lines: int - num_branches: int | None - num_partial_branches: int | None - covered_branches: int | None - missing_branches: int | None + num_branches: int = 0 + num_partial_branches: int = 0 + covered_branches: int = 0 + missing_branches: int = 0 -@dataclasses.dataclass +@dataclasses.dataclass(kw_only=True) class FileCoverage: path: pathlib.Path executed_lines: list[int] missing_lines: list[int] excluded_lines: list[int] info: CoverageInfo + executed_branches: list[list[int]] | None = None + missing_branches: list[list[int]] | None = None @dataclasses.dataclass @@ -56,7 +58,7 @@ class Coverage: # Maybe in v4, we can change it to a simpler format. -@dataclasses.dataclass +@dataclasses.dataclass(kw_only=True) class FileDiffCoverage: path: pathlib.Path percent_covered: decimal.Decimal @@ -73,7 +75,7 @@ def violation_lines(self) -> list[int]: return self.missing_statements -@dataclasses.dataclass +@dataclasses.dataclass(kw_only=True) class DiffCoverage: total_num_lines: int total_num_violations: int @@ -82,10 +84,18 @@ class DiffCoverage: files: dict[pathlib.Path, FileDiffCoverage] -def compute_coverage(num_covered: int, num_total: int) -> decimal.Decimal: - if num_total == 0: +def compute_coverage( + num_covered: int, + num_total: int, + num_branches_covered: int = 0, + num_branches_total: int = 0, +) -> decimal.Decimal: + """Compute the coverage percentage, with or without branch coverage.""" + numerator = decimal.Decimal(num_covered + num_branches_covered) + denominator = decimal.Decimal(num_total + num_branches_total) + if denominator == 0: return decimal.Decimal("1") - return decimal.Decimal(num_covered) / decimal.Decimal(num_total) + return numerator / denominator def get_coverage_info( @@ -138,6 +148,26 @@ def generate_coverage_markdown(coverage_path: pathlib.Path) -> str: ) +def _make_coverage_info(data: dict) -> CoverageInfo: + """Build a CoverageInfo object from a "summary" or "totals" key.""" + return CoverageInfo( + covered_lines=data["covered_lines"], + num_statements=data["num_statements"], + percent_covered=compute_coverage( + num_covered=data["covered_lines"], + num_total=data["num_statements"], + num_branches_covered=data.get("covered_branches", 0), + num_branches_total=data.get("num_branches", 0), + ), + missing_lines=data["missing_lines"], + excluded_lines=data["excluded_lines"], + num_branches=data.get("num_branches", 0), + num_partial_branches=data.get("num_partial_branches", 0), + covered_branches=data.get("covered_branches", 0), + missing_branches=data.get("missing_branches", 0), + ) + + def extract_info(data: dict, coverage_path: pathlib.Path) -> Coverage: """ { @@ -191,39 +221,13 @@ def extract_info(data: dict, coverage_path: pathlib.Path) -> Coverage: excluded_lines=file_data["excluded_lines"], executed_lines=file_data["executed_lines"], missing_lines=file_data["missing_lines"], - info=CoverageInfo( - covered_lines=file_data["summary"]["covered_lines"], - num_statements=file_data["summary"]["num_statements"], - percent_covered=compute_coverage( - file_data["summary"]["covered_lines"], - file_data["summary"]["num_statements"], - ), - missing_lines=file_data["summary"]["missing_lines"], - excluded_lines=file_data["summary"]["excluded_lines"], - num_branches=file_data["summary"].get("num_branches"), - num_partial_branches=file_data["summary"].get( - "num_partial_branches" - ), - covered_branches=file_data["summary"].get("covered_branches"), - missing_branches=file_data["summary"].get("missing_branches"), - ), + executed_branches=file_data.get("executed_branches"), + missing_branches=file_data.get("missing_branches"), + info=_make_coverage_info(file_data["summary"]), ) for path, file_data in data["files"].items() }, - info=CoverageInfo( - covered_lines=data["totals"]["covered_lines"], - num_statements=data["totals"]["num_statements"], - percent_covered=compute_coverage( - data["totals"]["covered_lines"], - data["totals"]["num_statements"], - ), - missing_lines=data["totals"]["missing_lines"], - excluded_lines=data["totals"]["excluded_lines"], - num_branches=data["totals"].get("num_branches"), - num_partial_branches=data["totals"].get("num_partial_branches"), - covered_branches=data["totals"].get("covered_branches"), - missing_branches=data["totals"].get("missing_branches"), - ), + info=_make_coverage_info(data["totals"]), ) @@ -256,7 +260,8 @@ def get_diff_coverage_info( total_num_violations += count_missing percent_covered = compute_coverage( - num_covered=count_executed, num_total=count_total + num_covered=count_executed, + num_total=count_total, ) files[path] = FileDiffCoverage( diff --git a/tests/conftest.py b/tests/conftest.py index 7aead5fe..84ac63cf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -282,10 +282,10 @@ def _(code: str, has_branches: bool = True) -> coverage_module.Coverage: percent_covered=decimal.Decimal("1.0"), missing_lines=0, excluded_lines=0, - num_branches=0 if has_branches else None, - num_partial_branches=0 if has_branches else None, - covered_branches=0 if has_branches else None, - missing_branches=0 if has_branches else None, + num_branches=0, + num_partial_branches=0, + covered_branches=0, + missing_branches=0, ), files={}, ) @@ -313,10 +313,10 @@ def _(code: str, has_branches: bool = True) -> coverage_module.Coverage: percent_covered=decimal.Decimal("1.0"), missing_lines=0, excluded_lines=0, - num_branches=0 if has_branches else None, - num_partial_branches=0 if has_branches else None, - covered_branches=0 if has_branches else None, - missing_branches=0 if has_branches else None, + num_branches=0, + num_partial_branches=0, + covered_branches=0, + missing_branches=0, ), ) if set(line.split()) & { @@ -340,7 +340,6 @@ def _(code: str, has_branches: bool = True) -> coverage_module.Coverage: coverage_obj.files[current_file].excluded_lines.append(line_number) coverage_obj.files[current_file].info.excluded_lines += 1 coverage_obj.info.excluded_lines += 1 - if has_branches and "branch" in line: coverage_obj.files[current_file].info.num_branches += 1 coverage_obj.info.num_branches += 1 @@ -353,21 +352,22 @@ def _(code: str, has_branches: bool = True) -> coverage_module.Coverage: elif "branch missing" in line: coverage_obj.files[current_file].info.missing_branches += 1 coverage_obj.info.missing_branches += 1 - info = coverage_obj.files[current_file].info coverage_obj.files[ current_file ].info.percent_covered = coverage_module.compute_coverage( num_covered=info.covered_lines, num_total=info.num_statements, + num_branches_covered=info.covered_branches, + num_branches_total=info.num_branches, ) - info = coverage_obj.info coverage_obj.info.percent_covered = coverage_module.compute_coverage( num_covered=info.covered_lines, num_total=info.num_statements, + num_branches_covered=info.covered_branches, + num_branches_total=info.num_branches, ) - return coverage_obj return _ @@ -425,9 +425,19 @@ def coverage_code(): 9 10 branch missing 11 missing - 12 + 12 covered 13 branch covered 14 covered + 15 branch partial + 16 branch covered + 17 branch missing + 18 covered + 19 covered + 20 branch partial + 21 branch missing + 22 branch covered + 23 branch covered + 24 branch covered """ @@ -442,32 +452,48 @@ def coverage_json(): }, "files": { "codebase/code.py": { - "executed_lines": [1, 2, 3, 5, 13, 14], + "executed_lines": [ + 1, + 2, + 3, + 5, + 12, + 13, + 14, + 15, + 16, + 18, + 19, + 20, + 22, + 23, + 24, + ], "summary": { - "covered_lines": 6, - "num_statements": 10, - "percent_covered": 60.0, - "missing_lines": 4, + "covered_lines": 15, + "num_statements": 21, + "percent_covered": 0.625, + "missing_lines": 6, "excluded_lines": 0, - "num_branches": 3, - "num_partial_branches": 1, - "covered_branches": 1, - "missing_branches": 1, + "num_branches": 11, + "num_partial_branches": 3, + "covered_branches": 5, + "missing_branches": 3, }, - "missing_lines": [6, 8, 10, 11], + "missing_lines": [6, 8, 10, 11, 17, 21], "excluded_lines": [], } }, "totals": { - "covered_lines": 6, - "num_statements": 10, - "percent_covered": 60.0, - "missing_lines": 4, + "covered_lines": 15, + "num_statements": 21, + "percent_covered": 0.625, + "missing_lines": 6, "excluded_lines": 0, - "num_branches": 3, - "num_partial_branches": 1, - "covered_branches": 1, - "missing_branches": 1, + "num_branches": 11, + "num_partial_branches": 3, + "covered_branches": 5, + "missing_branches": 3, }, } diff --git a/tests/unit/test_coverage.py b/tests/unit/test_coverage.py index 6ea249a2..621219da 100644 --- a/tests/unit/test_coverage.py +++ b/tests/unit/test_coverage.py @@ -39,6 +39,24 @@ def test_compute_coverage(num_covered, num_total, expected_coverage): ) +@pytest.mark.parametrize( + "num_covered, num_total, branch_covered, branch_total, expected_coverage", + [ + (0, 10, 0, 15, "0"), + (0, 0, 0, 0, "1"), + (5, 0, 5, 0, "1"), + (5, 10, 5, 10, "0.5"), + (1, 50, 1, 50, "0.02"), + ], +) +def test_compute_coverage_with_branches( + num_covered, num_total, branch_covered, branch_total, expected_coverage +): + assert coverage.compute_coverage( + num_covered, num_total, branch_covered, branch_total + ) == decimal.Decimal(expected_coverage) + + def test_get_coverage_info(mocker, coverage_json, coverage_obj): run = mocker.patch( "coverage_comment.subprocess.run", return_value=json.dumps(coverage_json) @@ -132,6 +150,42 @@ def test_generate_coverage_markdown(mocker): assert result == "foo" +def test__make_coverage_info(): + result = coverage._make_coverage_info( + { + "covered_lines": 14, + "num_statements": 20, + "missing_lines": 6, + "excluded_lines": 0, + } + ) + assert isinstance(result, coverage.CoverageInfo) + assert result.percent_covered == decimal.Decimal(14) / decimal.Decimal(20) + assert result.num_branches == 0 + assert result.num_partial_branches == 0 + assert result.covered_branches == 0 + assert result.missing_branches == 0 + + +def test__make_coverage_info__with_branches(): + result = coverage._make_coverage_info( + { + "covered_lines": 4, + "num_statements": 10, + "missing_lines": 1, + "excluded_lines": 0, + "covered_branches": 4, + "num_branches": 6, + "num_partial_branches": 2, + } + ) + assert isinstance(result, coverage.CoverageInfo) + assert result.percent_covered == decimal.Decimal(4 + 4) / decimal.Decimal(10 + 6) + assert result.covered_branches == 4 + assert result.missing_branches == 0 + assert result.excluded_lines == 0 + + @pytest.mark.parametrize( "added_lines, update_obj, expected", [ diff --git a/tests/unit/test_template.py b/tests/unit/test_template.py index 1e60c18c..41d2b54c 100644 --- a/tests/unit/test_template.py +++ b/tests/unit/test_template.py @@ -44,7 +44,7 @@ def test_get_comment_markdown(coverage_obj, diff_coverage_obj): .split(maxsplit=4) ) - expected = ["92%", "60%", "50%", "bar", ""] + expected = ["92%", "62.5%", "60%", "bar", ""] assert result == expected @@ -79,17 +79,17 @@ def test_template(coverage_obj, diff_coverage_obj): expected = """## Coverage report (foo) -
Click to see where and how coverage changed +
Click to see where and how coverage changed
- + - +
FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  codebase
  code.py6-8
6-8
Project Total  
@@ -264,17 +264,17 @@ def test_template__no_previous(coverage_obj_no_branch, diff_coverage_obj): expected = """## Coverage report -
Click to see where and how coverage changed +
Click to see where and how coverage changed
- + - +
FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  codebase
  code.py6-8
6-8
Project Total