Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cursor/rules/create-pr.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ docs: update environment variable docs

- Allowed types: `feature`, `fix`, `docs`, `refactor` (optional `!` for breaking changes).
- Only commit when the user asks. Never amend pushed commits unless explicitly requested.
- If there are additional changes added after the agent changes, **do not rollback** to previous changes. Continue with the changes and commit.

## Pre-PR checks

Expand Down
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,21 @@ Note: Either `GITHUB_PR_NUMBER` or `GITHUB_REF` is required. `GITHUB_PR_NUMBER`

1. The coverage report displays only files that have missing coverage. If all files are fully covered, the
report will be empty.
2. When branch coverage is enabled, the coverage percentage is calculated based on the uncovered branches in
the affected files.
3. If the complete project report option is enabled, the report is included as-is in the comment, without any
modifications or recalculations. If you notice discrepancies between the PR coverage and the complete
project coverage, this may be expected. For consistent results, it is recommended to enable branch
coverage when your report includes it.
2. When branch coverage is enabled, the pull request coverage percentage counts branch arcs whose source
line is among the added lines in the diff (alongside the added statements). The `Missing branches`
column lists those arcs as `source -> destination`. An arc that leaves its enclosing scope is shown as
`source -> exit`.
3. In the pull request table, the `Branches` / `Missing` badges are whole-file totals from the coverage
report, while the `Missing branches` links list only arcs on added lines. A Missing count can therefore
be larger than the number of links shown for that file. The same pattern already applies to `Statements` /
`Missing stmts`.
4. Pull request coverage is recalculated from the statement and branch arc lists. The report's overall
coverage percentage comes from coverage.py's summary counts, which can still credit
`# pragma: no branch` arcs. So, the two percentages can differ slightly even when the PR adds an entire
file. Enable `BRANCH_COVERAGE` when your report includes branch data so both sides include branches.
5. If the complete project report option is enabled, file totals and the project coverage percentage are
taken from the report as-is. Differences versus pull request coverage are expected when the scopes or
formulas differ as above.

## Dev Setup

Expand Down
4 changes: 4 additions & 0 deletions codecov/coverage/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ class FileDiffCoverage:
# Added lines tracks all the lines that were added in the diff, not just
# the statements (so it includes comments, blank lines, etc.)
added_lines: list[int]
# Branch arcs ([source line, destination line]) that start on an added line.
# Both stay empty unless the report contains branch coverage and it is enabled.
covered_branches: list[list[int]] = dataclasses.field(default_factory=list)
missing_branches: list[list[int]] = dataclasses.field(default_factory=list)


@dataclasses.dataclass
Expand Down
39 changes: 26 additions & 13 deletions codecov/coverage/pytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import decimal
import pathlib

from codecov import diff_grouper
from codecov.config import Config, TestFramework
from codecov.coverage.base import BaseCoverage, BaseCoverageHandler, DiffCoverage, FileDiffCoverage

Expand Down Expand Up @@ -150,6 +149,18 @@ def extract_info(self, data: dict) -> PytestCoverage:
info=self.extract_coverage_info(data['totals']),
)

@staticmethod
def select_diff_branches(
branches: list[list[int]] | None,
added_lines: set[int],
) -> list[list[int]]:
"""
Branches are ``[source line, destination line]`` arcs, where a negative destination
means the branch leaves the enclosing scope. Only the source line says where the
branch is written, so that is what decides whether the arc belongs to the diff.
"""
return [branch for branch in branches or [] if branch[0] in added_lines]

def get_diff_coverage( # pylint: disable=too-many-locals
self,
added_lines: dict[pathlib.Path, list[int]],
Expand Down Expand Up @@ -184,14 +195,22 @@ def get_diff_coverage( # pylint: disable=too-many-locals
total_num_lines += count_total
total_num_violations += count_missing

covered_branches: list[list[int]] = []
missing_branches: list[list[int]] = []
if config.BRANCH_COVERAGE:
total_num_branches_covered += file.info.covered_branches or 0
total_num_branches += file.info.num_branches or 0
added_lines_set = set(added_lines_for_file)
covered_branches = self.select_diff_branches(file.executed_branches, added_lines_set)
missing_branches = self.select_diff_branches(file.missing_branches, added_lines_set)
count_branches_covered = len(covered_branches)
count_branches = count_branches_covered + len(missing_branches)

total_num_branches_covered += count_branches_covered
total_num_branches += count_branches
percent_covered = self.compute_coverage(
num_covered=count_executed,
num_total=count_total,
num_branches_covered=file.info.covered_branches or 0,
num_branches_total=file.info.num_branches or 0,
num_branches_covered=count_branches_covered,
num_branches_total=count_branches,
)
else:
percent_covered = self.compute_coverage(num_covered=count_executed, num_total=count_total)
Expand All @@ -203,6 +222,8 @@ def get_diff_coverage( # pylint: disable=too-many-locals
missing_statements=sorted(missing),
added_statements=sorted(added),
added_lines=added_lines_for_file,
covered_branches=covered_branches,
missing_branches=missing_branches,
)
if config.BRANCH_COVERAGE:
final_percentage = self.compute_coverage(
Expand All @@ -224,11 +245,3 @@ def get_diff_coverage( # pylint: disable=too-many-locals
num_changed_lines=num_changed_lines,
files=files,
)

def get_coverage(self, config: Config) -> PytestCoverage:
coverage = super().get_coverage(config=config)

if config.BRANCH_COVERAGE:
coverage = diff_grouper.fill_branch_missing_groups(coverage=coverage)

return coverage
34 changes: 0 additions & 34 deletions codecov/diff_grouper.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,6 @@
MAX_GROUP_GAP = 3


def _flatten_branches(branches: list[list[int]] | None) -> list[int]:
flattened_branches: list[int] = []
if not branches:
return flattened_branches

for branch in branches:
start, end = abs(branch[0]), abs(branch[1])
if start == end:
flattened_branches.append(start)
else:
flattened_branches.extend(range(min(start, end), max(start, end) + 1))
return flattened_branches


def get_missing_groups(
coverage: 'PytestCoverage | JestCoverage',
) -> Iterable[groups.Group]:
Expand Down Expand Up @@ -77,23 +63,3 @@ def get_diff_missing_groups(
line_start=start,
line_end=end,
)


def fill_branch_missing_groups(coverage: 'PytestCoverage') -> 'PytestCoverage':
for file_coverage in coverage.files.values():
separators = {
*_flatten_branches(file_coverage.executed_branches),
*file_coverage.excluded_lines,
}
joiners = set(range(1, file_coverage.info.num_statements)) - separators

file_coverage.missing_branches = [
[start, end]
for start, end in groups.compute_contiguous_groups(
values=_flatten_branches(branches=file_coverage.missing_branches),
separators=separators,
joiners=joiners,
max_gap=MAX_GROUP_GAP,
)
]
return coverage
6 changes: 4 additions & 2 deletions codecov/template_files/comment.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@
{%- block coverage_evolution_badge -%}
{%- if coverage %}
{%- set color = coverage.info.percent_covered | x100 | get_badge_color -%}
<img src="{{ 'Coverage' | generate_badge(message=coverage.info.percent_covered_display ~ '%', color=color) }}">
{%- set precise = coverage.info.percent_covered | pct(precision=2) -%}
<img src="{{ 'Coverage' | generate_badge(message=coverage.info.percent_covered_display ~ '%', color=color) }}" title="{{ precise }}">
{%- endif -%}
{%- endblock coverage_evolution_badge -%}
&nbsp;&nbsp;
{#- PR coverage badge -#}
{%- block diff_coverage_badge -%}
{%- set color = diff_coverage.total_percent_covered | x100 | get_badge_color -%}
<img src="{{ 'PR Coverage' | generate_badge(message=diff_coverage.total_percent_covered | pct(precision=0), color=color) }}">
{%- set precise = diff_coverage.total_percent_covered | pct(precision=2) -%}
<img src="{{ 'PR Coverage' | generate_badge(message=diff_coverage.total_percent_covered | pct(precision=0), color=color) }}" title="{{ precise }}">
{%- endblock diff_coverage_badge -%}
{%- endblock coverage_badges -%}

Expand Down
30 changes: 24 additions & 6 deletions codecov/template_files/macros.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
<td align="center">: in a GitHub Flavoured Markdown table the alignment
belongs to the delimiter row of the table.
-#}
{%- macro badge(path, label, message, color, base=false) -%}
[![]({{ label | generate_badge(message=message, color=color) }})]({{ path | file_url(base=base) }})
{%- macro badge(path, label, message, color, base=false, title=none) -%}
[![]({{ label | generate_badge(message=message, color=color) }})]({{ path | file_url(base=base) }}{% if title %} "{{ title }}"{% endif %})
{%- endmacro -%}

{%- macro statements_badge(path, statements_count, base=false) -%}
Expand All @@ -27,14 +27,16 @@
{%- macro coverage_rate_badge(path, percent_covered, percent_covered_display, covered_statements_count, statements_count, base=false) -%}
{%- set label = percent_covered_display ~ "%" -%}
{%- set message = "(" ~ covered_statements_count ~ "/" ~ statements_count ~ ")" -%}
{{- badge(path, label, message, percent_covered | x100 | get_badge_color, base) -}}
{%- set title = percent_covered | pct(precision=2) -%}
{{- badge(path, label, message, percent_covered | x100 | get_badge_color, base, title) -}}
{%- endmacro -%}

{%- macro diff_coverage_rate_badge(path, added_statements_count, covered_statements_count, percent_covered) -%}
{%- if added_statements_count -%}
{%- set label = (percent_covered | pct(precision=0)) -%}
{%- set message = "(" ~ covered_statements_count ~ "/" ~ added_statements_count ~ ")" -%}
{{- badge(path, label, message, percent_covered | x100 | get_badge_color()) -}}
{%- set title = percent_covered | pct(precision=2) -%}
{{- badge(path, label, message, percent_covered | x100 | get_badge_color(), title=title) -}}
{%- else -%}
{{- badge(path, "", "N/A", "grey") -}}
{%- endif -%}
Expand All @@ -48,11 +50,24 @@
{%- endfor -%}
{%- endmacro -%}

{#-
A branch is a `[source line, destination line]` arc. A negative destination means the
branch leaves the enclosing scope, which coverage.py renders as `exit`, and there is no
destination line to link to in that case. The arc of a loop points backwards, so the
link range is normalised to keep the lower line first.
-#}
{%- macro missing_branches_links(path, branches, base=false) -%}
{%- set comma = joiner() -%}
{%- for branch in branches -%}
{%- set source = branch[0] -%}
{%- set destination = branch[1] -%}
{{- comma() -}}
[{{ branch[0] | abs }} -> {{ branch[1] | abs }}]({{ path | file_url(lines=(branch[0] | abs, branch[1] | abs), base=base) }})
{%- if destination < 0 -%}
[{{ source }} -> exit]({{ path | file_url(lines=(source, source), base=base) }})
{%- else -%}
{%- set lines = (source, destination) if destination > source else (destination, source) -%}
[{{ source }} -> {{ destination }}]({{ path | file_url(lines=lines, base=base) }})
{%- endif -%}
{%- endfor -%}
{%- endmacro -%}

Expand All @@ -71,10 +86,13 @@
| :-- | :-: | :-: |{% if branch_coverage %} :-: | :-: |{% endif %} :-: |{% if with_diff %} :-: |{% endif %} :-- |{% if branch_coverage %} :-- |{% endif %}
{%- endmacro -%}

{#- The pull request table lists the branches missing coverage among the added lines only,
the whole project table lists every branch missing coverage in the file. -#}
{%- macro file_row(file, missing_map, branch_coverage, base, with_diff) -%}
{%- set path = file.coverage.path -%}
{%- set info = file.coverage.info -%}
| &nbsp;&nbsp;[{{ path.name }}]({{ path | file_url(base=base) }}) | {{ statements_badge(path, info.num_statements, base) }} | {{ missing_lines_badge(path, info.missing_lines, base) }} |{% if branch_coverage %} {{ branches_badge(path, info.num_branches, base) }} | {{ missing_branches_badge(path, info.missing_branches, base) }} |{% endif %} {{ coverage_rate_badge(path, info.percent_covered, info.percent_covered_display, info.covered_lines, info.num_statements, base) }} |{% if with_diff %} {{ diff_coverage_rate_badge(path, (file.diff.added_statements | length) if file.diff else none, (file.diff.covered_statements | length) if file.diff else none, file.diff.percent_covered if file.diff else none) }} |{% endif %} {{ missing_lines_links(path, missing_map.get(path, []), base) }} |{% if branch_coverage %} {{ missing_branches_links(path, file.coverage.missing_branches or [], base) }} |{% endif %}
{%- set missing_branches = (file.diff.missing_branches if with_diff and file.diff else file.coverage.missing_branches) or [] -%}
| &nbsp;&nbsp;[{{ path.name }}]({{ path | file_url(base=base) }}) | {{ statements_badge(path, info.num_statements, base) }} | {{ missing_lines_badge(path, info.missing_lines, base) }} |{% if branch_coverage %} {{ branches_badge(path, info.num_branches, base) }} | {{ missing_branches_badge(path, info.missing_branches, base) }} |{% endif %} {{ coverage_rate_badge(path, info.percent_covered, info.percent_covered_display, info.covered_lines, info.num_statements, base) }} |{% if with_diff %} {{ diff_coverage_rate_badge(path, (file.diff.added_statements | length) if file.diff else none, (file.diff.covered_statements | length) if file.diff else none, file.diff.percent_covered if file.diff else none) }} |{% endif %} {{ missing_lines_links(path, missing_map.get(path, []), base) }} |{% if branch_coverage %} {{ missing_branches_links(path, missing_branches, base) }} |{% endif %}
{%- endmacro -%}

{%- macro total_row(totals, diff_totals, branch_coverage, with_diff) -%}
Expand Down
57 changes: 40 additions & 17 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,20 +123,32 @@ def _(code: str, has_branches: bool = True) -> PytestCoverage:
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
coverage_obj.files[current_file].executed_branches.append([line_number, line_number + 1])
file_coverage = coverage_obj.files[current_file]
# A branch is a `[source line, destination line]` arc, and an arc is either
# taken or missing, never both. A partial branch is a line with one arc of
# each, the missing one leaving the enclosing scope (a negative destination).
if 'branch partial' in line:
# Even if it's partially covered, it's still considered as a missing branch
coverage_obj.files[current_file].missing_branches.append([line_number, line_number + 1])
coverage_obj.files[current_file].info.num_partial_branches += 1
file_coverage.executed_branches.append([line_number, line_number + 1])
file_coverage.missing_branches.append([line_number, -line_number])
file_coverage.info.num_branches += 2
coverage_obj.info.num_branches += 2
file_coverage.info.covered_branches += 1
coverage_obj.info.covered_branches += 1
file_coverage.info.missing_branches += 1
coverage_obj.info.missing_branches += 1
file_coverage.info.num_partial_branches += 1
coverage_obj.info.num_partial_branches += 1
elif 'branch covered' in line:
coverage_obj.files[current_file].info.covered_branches += 1
file_coverage.executed_branches.append([line_number, line_number + 1])
file_coverage.info.num_branches += 1
coverage_obj.info.num_branches += 1
file_coverage.info.covered_branches += 1
coverage_obj.info.covered_branches += 1
elif 'branch missing' in line:
coverage_obj.files[current_file].missing_branches.append([line_number, line_number + 1])
coverage_obj.files[current_file].info.missing_branches += 1
file_coverage.missing_branches.append([line_number, line_number + 1])
file_coverage.info.num_branches += 1
coverage_obj.info.num_branches += 1
file_coverage.info.missing_branches += 1
coverage_obj.info.missing_branches += 1

info = coverage_obj.files[current_file].info
Expand Down Expand Up @@ -221,15 +233,17 @@ def coverage_json():
'percent_covered_display': '60%',
'missing_lines': 4,
'excluded_lines': 0,
'num_branches': 3,
'num_branches': 7,
# Line 5 is partial: one of its two arcs was taken.
'num_partial_branches': 1,
'covered_branches': 1,
'missing_branches': 1,
'covered_branches': 4,
'missing_branches': 3,
},
'missing_lines': [6, 8, 10, 11],
'excluded_lines': [],
'executed_branches': [[1, 0], [2, 1], [3, 0], [5, 1], [13, 0], [14, 0]],
'missing_branches': [[6, 0], [8, 1], [10, 0], [11, 0]],
'executed_branches': [[2, 3], [3, 5], [5, 6], [13, 14]],
# A negative destination is a branch leaving the enclosing scope.
'missing_branches': [[5, -1], [10, 11], [11, -1]],
}
},
'totals': {
Expand All @@ -239,10 +253,10 @@ def coverage_json():
'percent_covered_display': '60%',
'missing_lines': 4,
'excluded_lines': 0,
'num_branches': 3,
'num_branches': 7,
'num_partial_branches': 1,
'covered_branches': 1,
'missing_branches': 1,
'covered_branches': 4,
'missing_branches': 3,
},
}

Expand Down Expand Up @@ -329,6 +343,15 @@ def diff_coverage_obj(coverage_obj, make_diff_coverage):
)


@pytest.fixture
def diff_coverage_obj_branch(coverage_obj, make_diff_coverage):
return make_diff_coverage(
added_lines={pathlib.Path('codebase/code.py'): [3, 4, 5, 6, 7, 8, 9, 12]},
coverage=coverage_obj,
branch_coverage=True,
)


@pytest.fixture
def diff_coverage_obj_more_files(coverage_obj_more_files, make_diff_coverage):
return make_diff_coverage(
Expand Down
Loading
Loading