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 62820423..521b5dc8 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
File | Statements | Missing | Coverage | Coverage (new stmts) | Lines missing |
codebase |
code.py |
- | | | | 6-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
File | Statements | Missing | Coverage | Coverage (new stmts) | Lines missing |
codebase |
code.py |
- | | | | 6-8 |
+ | | | | 6-8 |
Project Total |
- | | | | |
+ | | | | |