diff --git a/README.md b/README.md
index 0378d435..01bb0441 100644
--- a/README.md
+++ b/README.md
@@ -322,6 +322,15 @@ jobs:
# Same with orange. Below is red.
MINIMUM_ORANGE: 70
+ # Maximum number of files to display in the comment. If there are more
+ # files than this number, they will only appear in the workflow summary.
+ # The selected files are the ones with the most new uncovered lines. The
+ # closer this number gets to 35, the higher the risk that it reaches
+ # GitHub's maximum comment size limit of 65536 characters. If you want
+ # more files, you may need to use a custom comment template (see below).
+ # (Feel free to open an issue.)
+ MAX_FILES_IN_COMMENT: 25
+
# If true, will run `coverage combine` before reading the `.coverage` file.
MERGE_COVERAGE_FILES: false
@@ -583,6 +592,14 @@ to use the svg badge directly, and not the `shields.io` URL.
## Upgrading from v2 to v3
-- When upgrading, we change the location and format where the coverage
- data is kept. Pull request that have not been re-based may be displaying
- slightly wrong information.
+When upgrading, we change the location and format where the coverage
+data is kept. Pull request that have not been re-based may be displaying
+slightly wrong information.
+
+## New comment format starting with 3.19
+
+Starting with 3.19, the format for the Pull Request changed to a table
+with badges. We've been iterating a lot on the new format.
+It's perfectly ok if you preferred the old format. In that case, see
+#335 for instructions on how to emulate the old format using
+`COMMENT_TEMPLATE`.
diff --git a/action.yml b/action.yml
index 42249d1b..362dfebf 100644
--- a/action.yml
+++ b/action.yml
@@ -70,6 +70,17 @@ inputs:
the badge will be orange. Otherwise it will be red.
default: 70
required: false
+ MAX_FILES_IN_COMMENT:
+ description: >
+ Maximum number of files to display in the comment. If there are more
+ files than this number, they will only appear in the workflow summary.
+ The selected files are the ones with the most new uncovered lines. The
+ closer this number gets to 35, the higher the risk that it reaches
+ GitHub's maximum comment size limit of 65536 characters. If you want
+ more files, you may need to use a custom comment template.
+ (Feel free to open an issue.)
+ default: 25
+ required: false
MERGE_COVERAGE_FILES:
description: >
If true, will run `coverage combine` before reading the `.coverage` file.
diff --git a/coverage_comment/badge.py b/coverage_comment/badge.py
index c7090740..9871932d 100644
--- a/coverage_comment/badge.py
+++ b/coverage_comment/badge.py
@@ -24,6 +24,19 @@ def get_badge_color(
return "red"
+def get_evolution_badge_color(
+ delta: decimal.Decimal | int,
+ up_is_good: bool = True,
+ neutral_color: str = "lightgrey",
+) -> str:
+ if delta == 0:
+ return neutral_color
+ elif (delta > 0) is up_is_good:
+ return "brightgreen"
+ else:
+ return "red"
+
+
def compute_badge_endpoint_data(
line_rate: decimal.Decimal,
color: str,
@@ -53,6 +66,15 @@ def compute_badge_image(
).text
+def get_static_badge_url(label: str, message: str, color: str) -> str:
+ if not color or not message:
+ raise ValueError("color and message are required")
+ code = "-".join(
+ e.replace("_", "__").replace("-", "--") for e in (label, message, color) if e
+ )
+ return "https://img.shields.io/badge/" + urllib.parse.quote(f"{code}.svg")
+
+
def get_endpoint_url(endpoint_url: str) -> str:
return f"https://img.shields.io/endpoint?url={endpoint_url}"
diff --git a/coverage_comment/coverage.py b/coverage_comment/coverage.py
index f1976cb7..3e023fdf 100644
--- a/coverage_comment/coverage.py
+++ b/coverage_comment/coverage.py
@@ -10,6 +10,9 @@
from coverage_comment import log, subprocess
+# 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
class CoverageMetadata:
version: str
@@ -57,13 +60,17 @@ class Coverage:
class FileDiffCoverage:
path: pathlib.Path
percent_covered: decimal.Decimal
- missing_lines: list[int]
+ covered_statements: list[int]
+ missing_statements: list[int]
+ added_statements: list[int]
+ # 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]
# for backward compatibility
@property
def violation_lines(self) -> list[int]:
- return self.missing_lines
+ return self.missing_statements
@dataclasses.dataclass
@@ -188,10 +195,8 @@ def extract_info(data: dict, coverage_path: pathlib.Path) -> Coverage:
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"].get("covered_branches", 0),
- file_data["summary"]["num_statements"]
- + file_data["summary"].get("num_branches", 0),
+ file_data["summary"]["covered_lines"],
+ file_data["summary"]["num_statements"],
),
missing_lines=file_data["summary"]["missing_lines"],
excluded_lines=file_data["summary"]["excluded_lines"],
@@ -209,10 +214,8 @@ def extract_info(data: dict, coverage_path: pathlib.Path) -> Coverage:
covered_lines=data["totals"]["covered_lines"],
num_statements=data["totals"]["num_statements"],
percent_covered=compute_coverage(
- data["totals"]["covered_lines"]
- + data["totals"].get("covered_branches", 0),
- data["totals"]["num_statements"]
- + data["totals"].get("num_branches", 0),
+ data["totals"]["covered_lines"],
+ data["totals"]["num_statements"],
),
missing_lines=data["totals"]["missing_lines"],
excluded_lines=data["totals"]["excluded_lines"],
@@ -245,9 +248,9 @@ def get_diff_coverage_info(
missing = set(file.missing_lines) & set(added_lines_for_file)
count_missing = len(missing)
- # Even partially covered lines are considered as covered, no line
- # appears in both counts
- count_total = count_executed + count_missing
+
+ added = executed | missing
+ count_total = len(added)
total_num_lines += count_total
total_num_violations += count_missing
@@ -259,7 +262,9 @@ def get_diff_coverage_info(
files[path] = FileDiffCoverage(
path=path,
percent_covered=percent_covered,
- missing_lines=sorted(missing),
+ covered_statements=sorted(executed),
+ missing_statements=sorted(missing),
+ added_statements=sorted(added),
added_lines=added_lines_for_file,
)
final_percentage = compute_coverage(
diff --git a/coverage_comment/diff_grouper.py b/coverage_comment/diff_grouper.py
new file mode 100644
index 00000000..bc9a54ca
--- /dev/null
+++ b/coverage_comment/diff_grouper.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+from collections.abc import Iterable
+
+from coverage_comment import coverage as coverage_module
+from coverage_comment import groups
+
+MAX_ANNOTATION_GAP = 3
+
+
+def get_diff_missing_groups(
+ coverage: coverage_module.Coverage,
+ diff_coverage: coverage_module.DiffCoverage,
+) -> Iterable[groups.Group]:
+ for path, diff_file in diff_coverage.files.items():
+ coverage_file = coverage.files[path]
+
+ # Lines that are covered or excluded should not be considered for
+ # filling a gap between violation groups.
+ # (so, lines that can appear in a gap are lines that are missing, or
+ # lines that do not contain code: blank lines or lines containing comments)
+ separators = {
+ *coverage_file.executed_lines,
+ *coverage_file.excluded_lines,
+ }
+ # Lines that are added should be considered for filling a gap, unless
+ # they are separators.
+ joiners = set(diff_file.added_lines) - separators
+
+ for start, end in groups.compute_contiguous_groups(
+ values=diff_file.missing_statements,
+ separators=separators,
+ joiners=joiners,
+ max_gap=MAX_ANNOTATION_GAP,
+ ):
+ yield groups.Group(
+ file=path,
+ line_start=start,
+ line_end=end,
+ )
diff --git a/coverage_comment/files.py b/coverage_comment/files.py
index 69ec48ec..fd09b58f 100644
--- a/coverage_comment/files.py
+++ b/coverage_comment/files.py
@@ -108,10 +108,18 @@ def compute_datafile(
)
-def parse_datafile(contents) -> decimal.Decimal:
- return decimal.Decimal(str(json.loads(contents)["coverage"])) / decimal.Decimal(
+def parse_datafile(contents) -> tuple[coverage.Coverage | None, decimal.Decimal]:
+ file_contents = json.loads(contents)
+ coverage_rate = decimal.Decimal(str(file_contents["coverage"])) / decimal.Decimal(
"100"
)
+ try:
+ return coverage.extract_info(
+ data=file_contents["raw_data"],
+ coverage_path=pathlib.Path(file_contents["coverage_path"]),
+ ), coverage_rate
+ except KeyError:
+ return None, coverage_rate
class ImageURLs(TypedDict):
diff --git a/coverage_comment/annotations.py b/coverage_comment/groups.py
similarity index 52%
rename from coverage_comment/annotations.py
rename to coverage_comment/groups.py
index d8f61094..7f44f164 100644
--- a/coverage_comment/annotations.py
+++ b/coverage_comment/groups.py
@@ -4,22 +4,17 @@
import functools
import itertools
import pathlib
-from collections.abc import Iterable
-
-from coverage_comment import coverage as coverage_module
-
-MAX_ANNOTATION_GAP = 3
@dataclasses.dataclass(frozen=True)
-class Annotation:
+class Group:
file: pathlib.Path
line_start: int
line_end: int
def compute_contiguous_groups(
- values: list[int], separators: set[int], joiners: set[int]
+ values: list[int], separators: set[int], joiners: set[int], max_gap: int
) -> list[tuple[int, int]]:
"""
Given a list of (sorted) values, a list of separators and a list of
@@ -28,8 +23,8 @@ def compute_contiguous_groups(
Groups are created by joining contiguous values together, and in some cases
by merging groups, enclosing a gap of values between them. Gaps that may be
- enclosed are small gaps (<= MAX_ANNOTATION_GAP values after removing all
- joiners) where no line is a "separator"
+ enclosed are small gaps (<= max_gap values after removing all joiners)
+ where no line is a "separator"
"""
contiguous_groups: list[tuple[int, int]] = []
for _, contiguous_group in itertools.groupby(
@@ -55,7 +50,7 @@ def reducer(
gap = set(range(last_end + 1, next_start)) - joiners
- gap_is_small = len(gap) <= MAX_ANNOTATION_GAP
+ gap_is_small = len(gap) <= max_gap
gap_contains_separators = gap & separators
if gap_is_small and not gap_contains_separators:
@@ -66,34 +61,3 @@ def reducer(
return acc
return functools.reduce(reducer, contiguous_groups, [])
-
-
-def group_annotations(
- coverage: coverage_module.Coverage,
- diff_coverage: coverage_module.DiffCoverage,
-) -> Iterable[Annotation]:
- for path, diff_file in diff_coverage.files.items():
- coverage_file = coverage.files[path]
-
- # Lines that are covered or excluded should not be considered for
- # filling a gap between violation groups.
- # (so, lines that can appear in a gap are lines that are missing, or
- # lines that do not contain code: blank lines or lines containing comments)
- separators = {
- *coverage_file.executed_lines,
- *coverage_file.excluded_lines,
- }
- # Lines that are added should be considered for filling a gap, unless
- # they are separators.
- joiners = set(diff_file.added_lines) - separators
-
- for start, end in compute_contiguous_groups(
- values=diff_file.missing_lines,
- separators=separators,
- joiners=joiners,
- ):
- yield Annotation(
- file=path,
- line_start=start,
- line_end=end,
- )
diff --git a/coverage_comment/main.py b/coverage_comment/main.py
index 1857c44c..701a1c14 100644
--- a/coverage_comment/main.py
+++ b/coverage_comment/main.py
@@ -8,12 +8,10 @@
import httpx
from coverage_comment import activity as activity_module
-from coverage_comment import (
- annotations as annotations_module,
-)
from coverage_comment import (
comment_file,
communication,
+ diff_grouper,
files,
github,
github_client,
@@ -151,16 +149,33 @@ def process_pr(
branch=config.FINAL_COVERAGE_DATA_BRANCH,
)
- previous_coverage = None
+ previous_coverage, previous_coverage_rate = None, None
if previous_coverage_data_file:
- previous_coverage = files.parse_datafile(contents=previous_coverage_data_file)
+ previous_coverage, previous_coverage_rate = files.parse_datafile(
+ contents=previous_coverage_data_file
+ )
marker = template.get_marker(marker_id=config.SUBPROJECT_ID)
+
+ files_info, count_files = template.select_files(
+ coverage=coverage,
+ diff_coverage=diff_coverage,
+ previous_coverage=previous_coverage,
+ max_files=config.MAX_FILES_IN_COMMENT,
+ )
try:
comment = template.get_comment_markdown(
coverage=coverage,
diff_coverage=diff_coverage,
- previous_coverage_rate=previous_coverage,
+ previous_coverage=previous_coverage,
+ previous_coverage_rate=previous_coverage_rate,
+ files=files_info,
+ count_files=count_files,
+ max_files=config.MAX_FILES_IN_COMMENT,
+ minimum_green=config.MINIMUM_GREEN,
+ minimum_orange=config.MINIMUM_ORANGE,
+ repo_name=config.GITHUB_REPOSITORY,
+ pr_number=config.GITHUB_PR_NUMBER,
base_template=template.read_template_file("comment.md.j2"),
custom_template=config.COMMENT_TEMPLATE,
pr_targets_default_branch=pr_targets_default_branch,
@@ -203,7 +218,7 @@ def process_pr(
pr_number = None
if pr_number is not None and config.ANNOTATE_MISSING_LINES:
- annotations = annotations_module.group_annotations(
+ annotations = diff_grouper.get_diff_missing_groups(
coverage=coverage, diff_coverage=diff_coverage
)
github.create_missing_coverage_annotations(
diff --git a/coverage_comment/settings.py b/coverage_comment/settings.py
index acc09c5e..ed0c27c1 100644
--- a/coverage_comment/settings.py
+++ b/coverage_comment/settings.py
@@ -60,6 +60,7 @@ class Config:
MERGE_COVERAGE_FILES: bool = False
ANNOTATE_MISSING_LINES: bool = False
ANNOTATION_TYPE: str = "warning"
+ MAX_FILES_IN_COMMENT: int = 25
VERBOSE: bool = False
# Only for debugging, not exposed in the action:
FORCE_WORKFLOW_RUN: bool = False
diff --git a/coverage_comment/template.py b/coverage_comment/template.py
index 8c1572f1..7a397878 100644
--- a/coverage_comment/template.py
+++ b/coverage_comment/template.py
@@ -1,12 +1,18 @@
from __future__ import annotations
+import dataclasses
import decimal
+import functools
+import hashlib
+import itertools
+import pathlib
from collections.abc import Callable
from importlib import resources
import jinja2
from jinja2.sandbox import SandboxedEnvironment
+from coverage_comment import badge, diff_grouper
from coverage_comment import coverage as coverage_module
MARKER = (
@@ -19,7 +25,9 @@ def uptodate():
class CommentLoader(jinja2.BaseLoader):
- def __init__(self, base_template: str, custom_template: str | None):
+ def __init__(
+ self, base_template: str, custom_template: str | None, debug: bool = False
+ ):
self.base_template = base_template
self.custom_template = custom_template
@@ -27,7 +35,11 @@ def get_source(
self, environment: jinja2.Environment, template: str
) -> tuple[str, str | None, Callable[..., bool]]:
if template == "base":
- return self.base_template, None, uptodate
+ return (
+ self.base_template,
+ "coverage_comment/template_files/comment.md.j2",
+ uptodate,
+ )
if self.custom_template and template == "custom":
return self.custom_template, None, uptodate
@@ -47,10 +59,77 @@ def get_marker(marker_id: str | None):
return MARKER.format(id_part=f" (id: {marker_id})" if marker_id else "")
+def pluralize(number, singular="", plural="s"):
+ if number == 1:
+ return singular
+ else:
+ return plural
+
+
+def sign(val: int | decimal.Decimal) -> str:
+ return "+" if val > 0 else "" if val < 0 else "±"
+
+
+def delta(val: int) -> str:
+ return f"({sign(val)}{val})"
+
+
+def remove_exponent(val: decimal.Decimal) -> decimal.Decimal:
+ # From https://docs.python.org/3/library/decimal.html#decimal-faq
+ return (
+ val.quantize(decimal.Decimal(1))
+ if val == val.to_integral()
+ else val.normalize()
+ )
+
+
+def percentage_value(val: decimal.Decimal, precision: int = 2) -> decimal.Decimal:
+ return remove_exponent(
+ (decimal.Decimal("100") * val).quantize(
+ decimal.Decimal("1." + ("0" * precision)),
+ rounding=decimal.ROUND_DOWN,
+ )
+ )
+
+
+def pct(val: decimal.Decimal, precision: int = 2) -> str:
+ rounded = percentage_value(val=val, precision=precision)
+ return f"{rounded:f}%"
+
+
+def x100(val: decimal.Decimal):
+ return val * 100
+
+
+@dataclasses.dataclass
+class FileInfo:
+ path: pathlib.Path
+ coverage: coverage_module.FileCoverage
+ diff: coverage_module.FileDiffCoverage | None
+ previous: coverage_module.FileCoverage | None
+
+ @property
+ def new_missing_lines(self) -> list[int]:
+ missing_lines = set(self.coverage.missing_lines)
+ if self.previous:
+ missing_lines -= set(self.previous.missing_lines)
+
+ return sorted(missing_lines)
+
+
def get_comment_markdown(
+ *,
coverage: coverage_module.Coverage,
diff_coverage: coverage_module.DiffCoverage,
previous_coverage_rate: decimal.Decimal | None,
+ previous_coverage: coverage_module.Coverage | None,
+ files: list[FileInfo],
+ max_files: int | None,
+ count_files: int,
+ minimum_green: decimal.Decimal,
+ minimum_orange: decimal.Decimal,
+ repo_name: str,
+ pr_number: int,
base_template: str,
marker: str,
subproject_id: str | None = None,
@@ -60,12 +139,39 @@ def get_comment_markdown(
loader = CommentLoader(base_template=base_template, custom_template=custom_template)
env = SandboxedEnvironment(loader=loader)
env.filters["pct"] = pct
+ env.filters["delta"] = delta
+ env.filters["x100"] = x100
+ env.filters["get_evolution_color"] = badge.get_evolution_badge_color
+ env.filters["generate_badge"] = badge.get_static_badge_url
+ env.filters["pluralize"] = pluralize
+ env.filters["file_url"] = functools.partial(
+ get_file_url, repo_name=repo_name, pr_number=pr_number
+ )
+ env.filters["get_badge_color"] = functools.partial(
+ badge.get_badge_color,
+ minimum_green=minimum_green,
+ minimum_orange=minimum_orange,
+ )
+ missing_diff_lines = {
+ key: list(value)
+ for key, value in itertools.groupby(
+ diff_grouper.get_diff_missing_groups(
+ coverage=coverage, diff_coverage=diff_coverage
+ ),
+ lambda x: x.file,
+ )
+ }
try:
comment = env.get_template("custom" if custom_template else "base").render(
previous_coverage_rate=previous_coverage_rate,
coverage=coverage,
diff_coverage=diff_coverage,
+ previous_coverage=previous_coverage,
+ count_files=count_files,
+ max_files=max_files,
+ files=files,
+ missing_diff_lines=missing_diff_lines,
subproject_id=subproject_id,
marker=marker,
pr_targets_default_branch=pr_targets_default_branch,
@@ -79,6 +185,47 @@ def get_comment_markdown(
return comment
+def select_files(
+ *,
+ coverage: coverage_module.Coverage,
+ diff_coverage: coverage_module.DiffCoverage,
+ previous_coverage: coverage_module.Coverage | None = None,
+ max_files: int | None,
+) -> tuple[list[FileInfo], int]:
+ """
+ Selects the MAX_FILES files with the most new missing lines sorted by path
+
+ """
+ previous_coverage_files = previous_coverage.files if previous_coverage else {}
+
+ files = []
+ for path, coverage_file in coverage.files.items():
+ diff_coverage_file = diff_coverage.files.get(path)
+ previous_coverage_file = previous_coverage_files.get(path)
+
+ file_info = FileInfo(
+ path=path,
+ coverage=coverage_file,
+ diff=diff_coverage_file,
+ previous=previous_coverage_file,
+ )
+ has_diff = bool(diff_coverage_file)
+ has_evolution_from_previous = (
+ previous_coverage_file.info != coverage_file.info
+ if previous_coverage_file
+ else False
+ )
+
+ if has_diff or has_evolution_from_previous:
+ files.append(file_info)
+
+ count_files = len(files)
+ files = sorted(files, key=lambda x: len(x.new_missing_lines), reverse=True)
+ if max_files is not None:
+ files = files[:max_files]
+ return sorted(files, key=lambda x: x.path), count_files
+
+
def get_readme_markdown(
is_public: bool,
readme_url: str,
@@ -131,9 +278,18 @@ def read_template_file(template: str) -> str:
).read_text()
-def pct(val: decimal.Decimal | float) -> str:
- if isinstance(val, decimal.Decimal):
- val *= decimal.Decimal("100")
- return f"{val.quantize(decimal.Decimal('0.01'), rounding=decimal.ROUND_DOWN).normalize():f}%"
- else:
- return f"{val:.0%}"
+def get_file_url(
+ filename: pathlib.Path,
+ lines: tuple[int, int] | None = None,
+ *,
+ repo_name: str,
+ pr_number: int,
+) -> str:
+ # To link to a file in a PR, GitHub uses the link to the file overview combined with a SHA256 hash of the file path
+ s = f"https://github.com/{repo_name}/pull/{pr_number}/files#diff-{hashlib.sha256(str(filename).encode('utf-8')).hexdigest()}"
+
+ if lines is not None:
+ # R stands for Right side of the diff. But since we generate these links for new code we only need the right side.
+ s += f"R{lines[0]}-R{lines[1]}"
+
+ return s
diff --git a/coverage_comment/template_files/comment.md.j2 b/coverage_comment/template_files/comment.md.j2
index a4d6339a..650ade22 100644
--- a/coverage_comment/template_files/comment.md.j2
+++ b/coverage_comment/template_files/comment.md.j2
@@ -1,78 +1,264 @@
-{% block title %}## Coverage report{% if subproject_id %} ({{ subproject_id }}){% endif %}{% endblock title %}
-{% block coverage_evolution -%}
-{% if previous_coverage_rate -%}
-{% block coverage_evolution_wording -%}
-The coverage rate went from `{{ previous_coverage_rate | pct }}` to `{{ coverage.info.percent_covered | pct }}`{{" "}}
-{%- endblock coverage_evolution_wording %}
-{%- block emoji_coverage -%}
-{%- if previous_coverage_rate | float < coverage.info.percent_covered | float -%}
-{%- block emoji_coverage_up -%}:arrow_up:{%- endblock emoji_coverage_up -%}
-{%- elif previous_coverage_rate | float > coverage.info.percent_covered | float -%}
-{%- block emoji_coverage_down -%}:arrow_down:{%- endblock emoji_coverage_down -%}
+{%- block title -%}## Coverage report{%- if subproject_id %} ({{ subproject_id }}){%- endif -%}{%- endblock title%}
+
+{# Coverage evolution badge #}
+{% block coverage_badges -%}
+{%- block coverage_evolution_badge -%}
+{%- if previous_coverage_rate %}
+{%- set text = "Coverage for the whole project went from " ~ (previous_coverage_rate | pct) ~ " to " ~ (coverage.info.percent_covered | pct) -%}
+{%- set color = (coverage.info.percent_covered - previous_coverage_rate) | get_evolution_color(neutral_color='blue') -%}
+
+
+{%- else -%}
+{%- set text = "Coverage for the whole project is " ~ (coverage.info.percent_covered | pct) ~ ". Previous coverage rate is not available, cannot report on evolution." -%}
+{%- set color = coverage.info.percent_covered | x100 | get_badge_color -%}
+
+
+{%- endif -%}
+{%- endblock coverage_evolution_badge -%}
+
+{#- Coverage diff badge -#}
+{#- space #} {# space -#}
+{%- block diff_coverage_badge -%}
+{%- set text = (diff_coverage.total_percent_covered | pct) ~ " of the statement lines added by this PR are covered" -%}
+
+
+{%- endblock diff_coverage_badge -%}
+{%- endblock coverage_badges -%}
+
+
+{%- macro statements_badge(path, statements_count, previous_statements_count) -%}
+{% if previous_statements_count is not none -%}
+{% set statements_diff = statements_count - previous_statements_count %}
+{% if statements_diff > 0 -%}
+{% set text = "This PR adds " ~ statements_diff ~ " to the number of statements in " ~ path ~ ", taking it from " ~ previous_statements_count ~ " to " ~ statements_count ~"." -%}
+{% set color = "007ec6" -%}
+{% elif statements_diff < 0 -%}
+{% set text = "This PR removes " ~ (-statements_diff) ~ " from the number of statements in " ~ path ~ ", taking it from " ~ previous_statements_count ~ " to " ~ statements_count ~"." -%}
+{% set color = "49c2ee" -%}
+{% else -%}
+{% set text = "This PR doesn't change the number of statements in " ~ path ~ ", which is " ~ statements_count ~ "." -%}
+{% set color = "5d89ba" -%}
+{% endif -%}
+{% set message = statements_diff %}
+{% else -%}
+{% set text = "This PR adds " ~ statements_count ~ " statement" ~ (statements_count | pluralize) ~ " to " ~ path ~ ". The file did not seem to exist on the base branch." -%}
+{% set color = "007ec6" -%}
+{% set message = statements_count %}
+{% endif -%}
+
|
+
+{%- endmacro -%}
+
+{%- macro missing_lines_badge(path, missing_lines_count, previous_missing_lines_count) -%}
+{%- if previous_missing_lines_count is not none -%}
+{%- set missing_diff = missing_lines_count - previous_missing_lines_count %}
+{%- if missing_diff > 0 -%}
+{%- set text = "This PR adds " ~ missing_diff ~ " to the number of statements missing coverage in " ~ path ~ ", taking it from " ~ previous_missing_lines_count ~ " to " ~ missing_lines_count ~ "." -%}
+{%- elif missing_diff < 0 -%}
+{%- set text = "This PR removes " ~ (-missing_diff) ~ " from the number of statements missing coverage in " ~ path ~ ", taking it from " ~ previous_missing_lines_count ~ " to " ~ missing_lines_count ~ "." -%}
{%- else -%}
-{%- block emoji_coverage_constant -%}:arrow_right:{%- endblock emoji_coverage_constant -%}
-{%- endif %}
-{%- endblock emoji_coverage -%}
+{%- set text = "This PR doesn't change the number of statements missing coverage in " ~ path ~ ", which is " ~ missing_lines_count ~ "." -%}
+{%- endif -%}
+{%- set message = missing_diff -%}
{%- else -%}
-{% block no_comparison_info -%}
-{%- if pr_targets_default_branch -%}
-{% block no_comparison_info_data_not_found_message -%}
-> [!NOTE]
-> Coverage data for the default branch was not found.
-> This usually happens when the action has not run on the default
-> branch yet, for example right after deploying it into the workflows.
-{%- endblock no_comparison_info_data_not_found_message -%}
-{% else %}
-{% block no_comparison_info_non_default_target -%}
-> [!NOTE]
-> Coverage evolution disabled because this PR targets a different branch
-> than the default branch, for which coverage data is not available.
-{%- endblock no_comparison_info_non_default_target %}
-{%- endif %}
-{%- endblock no_comparison_info %}
-
-{% block coverage_value_wording -%}
-The coverage rate is `{{ coverage.info.percent_covered | pct }}`.
-{%- endblock coverage_value_wording %}
-{%- endif %}
-{%- endblock coverage_evolution %}
-{% block branch_coverage -%}
-{% if coverage.meta.branch_coverage and coverage.info.num_branches -%}
-{% block branch_coverage_wording -%}
-The branch rate is `{{ (coverage.info.covered_branches / coverage.info.num_branches) | pct }}`.
-{% endblock branch_coverage_wording -%}
-{%- endif %}
-{% endblock branch_coverage -%}
-
-{%- if diff_coverage.total_num_lines > 0 -%}
-{% block diff_coverage_wording -%}
-`{{ diff_coverage.total_percent_covered | pct }}` of new lines are covered.
-{%- endblock diff_coverage_wording %}
+{%- set text = "This PR adds " ~ missing_lines_count ~ " statement" ~ (statements_count | pluralize) ~ " missing coverage to " ~ path ~ ". The file did not seem to exist on the base branch." -%}
+{%- set message = missing_lines_count -%}
+{%- endif -%}
+{%- set color = message | get_evolution_color(up_is_good=false) -%}
+ |
+
+{%- endmacro -%}
+
+{%- macro coverage_rate_badge(path, previous_percent_covered, previous_covered_statements_count, previous_statements_count, percent_covered, covered_statements_count, statements_count) -%}
+{%- if previous_percent_covered is not none -%}
+{%- set coverage_diff = percent_covered - previous_percent_covered -%}
+{%- if coverage_diff > 0 -%}
+{%- set text = "This PR adds " ~ ("{:.02f}".format(coverage_diff * 100)) ~ " percentage points to the coverage rate in " ~ path ~ ", taking it from " ~ previous_percent_covered | pct ~ " (" ~ previous_covered_statements_count ~ "/" ~ previous_statements_count ~ ") to " ~ percent_covered | pct ~ " (" ~ covered_statements_count ~ "/" ~ statements_count ~ ")." -%}
+{%- elif coverage_diff < 0 -%}
+{%- set text = "This PR removes " ~ ("{:.02f}".format(-coverage_diff * 100)) ~ " percentage points from the coverage rate in " ~ path ~ ", taking it from " ~ previous_percent_covered | pct ~ " (" ~ previous_covered_statements_count ~ "/" ~ previous_statements_count ~ ") to " ~ percent_covered | pct ~ " (" ~ covered_statements_count ~ "/" ~ statements_count ~ ")." -%}
+{%- else -%}
+{%- set text = "This PR doesn't change the coverage rate in " ~ path ~ ", which is " ~ percent_covered | pct ~ " (" ~ covered_statements_count ~ "/" ~ statements_count ~ ")." -%}
+{%- endif -%}
+{%- set color = coverage_diff | get_evolution_color() -%}
+{%- set message = "(" ~ previous_covered_statements_count ~ "/" ~ previous_statements_count ~ " > " ~ covered_statements_count ~ "/" ~ statements_count ~ ")" -%}
+{%- else -%}
+{%- set text = "The coverage rate of " ~ path ~ " is " ~ percent_covered | pct ~ " (" ~ covered_statements_count ~ "/" ~ statements_count ~ "). The file did not seem to exist on the base branch." -%}
+{%- set message = "(" ~ covered_statements_count ~ "/" ~ statements_count ~ ")" -%}
+{%- set color = percent_covered | x100 | get_badge_color -%}
+{%- endif -%}
+ |
+
+{%- endmacro -%}
+
+{%- macro diff_coverage_rate_badge(path, added_statements_count, covered_statements_count, percent_covered) -%}
+{% if added_statements_count -%}
+{% set text = "In this PR, " ~ (added_statements_count) ~ " new statements are added to " ~ path ~ ", " ~ covered_statements_count ~ " of which are covered (" ~ (percent_covered | pct) ~ ")." -%}
+{% set label = (percent_covered | pct(precision=0)) -%}
+{% set message = "(" ~ covered_statements_count ~ "/" ~ added_statements_count ~ ")" -%}
+{%- set color = (percent_covered | x100 | get_badge_color()) -%}
+{% else -%}
+{% set text = "This PR does not seem to add statements to " ~ path ~ "." -%}
+{% set label = "" -%}
+{%- set color = "grey" -%}
+{% set message = "N/A" -%}
+{% endif -%}
+ |
+
+{%- endmacro -%}
+
+
+{# Individual file report #}
+{%- block coverage_by_file -%}
+{%- if not files -%}
+_This PR does not seem to contain any modification to coverable code._
{%- else -%}
-{% block diff_coverage_empty_wording -%}
-_None of the new lines are part of the tested code. Therefore, there is no coverage data about them._
-{%- endblock diff_coverage_empty_wording %}
-{%- endif %}
-
-{% block coverage_by_file -%}
-{%if diff_coverage.files -%}
-
-{% block coverage_by_file_summary_wording -%}Diff Coverage details (click to unfold){% endblock coverage_by_file_summary_wording -%}
-{% for filename, diff_file_coverage in diff_coverage.files.items() -%}
-{% block coverage_single_file scoped %}
-{% block coverage_single_file_title scoped %}### {{ filename }}{% endblock coverage_single_file_title %}
-{% block diff_coverage_single_file_wording scoped -%}
-`{{ diff_file_coverage.percent_covered | pct }}` of new lines are covered (`{{ coverage.files[filename].info.percent_covered | pct }}` of the complete file).
-{%- endblock diff_coverage_single_file_wording %}
-{%- if diff_file_coverage.violation_lines -%}
-{% block single_file_missing_lines_wording scoped -%}
-{% set separator = joiner(", ") %}
-Missing lines: {% for line in diff_file_coverage.violation_lines %}{{ separator() }}`{{ line }}`{% endfor %}
-{%- endblock single_file_missing_lines_wording %}
-{%- endif %}
-{% endblock coverage_single_file -%}
-{%- endfor %}
+Click to see where and how coverage changed
+ File | Statements | Missing | Coverage | Coverage (new stmts) | Lines missing |
+
+
+
+{%- for parent, files_in_folder in files|groupby(attribute="path.parent") -%}
+
+ {{ parent }} |
+
+{%- for file in files_in_folder -%}
+{%- set path = file.coverage.path -%}
+
+ {{ path.name }} |
+
+{#- Statements cell -#}
+{%- block statements_badge_cell scoped -%}
+{{- statements_badge(
+ path=path,
+ statements_count=file.coverage.info.num_statements,
+ previous_statements_count=(file.previous.info.num_statements if file.previous else none),
+) -}}
+{%- endblock statements_badge_cell-%}
+
+{#- Missing cell -#}
+{%- block missing_lines_badge_cell scoped -%}
+{{- missing_lines_badge(
+ path=path,
+ missing_lines_count=file.coverage.info.missing_lines,
+ previous_missing_lines_count=(file.previous.info.missing_lines if file.previous else none),
+) -}}
+{%- endblock missing_lines_badge_cell -%}
+
+{#- Coverage rate -#}
+{%- block coverage_rate_badge_cell scoped -%}
+{{- coverage_rate_badge(
+ path=path,
+ previous_percent_covered=(file.previous.info.percent_covered if file.previous else none),
+ previous_covered_statements_count=(file.previous.info.covered_lines if file.previous else none),
+ previous_statements_count=(file.previous.info.num_statements if file.previous else none),
+ percent_covered=file.coverage.info.percent_covered,
+ covered_statements_count=file.coverage.info.covered_lines,
+ statements_count=file.coverage.info.num_statements,
+) -}}
+{%- endblock coverage_rate_badge_cell -%}
+
+{#- Coverage of added lines -#}
+{%- block diff_coverage_rate_badge_cell scoped -%}
+{{- diff_coverage_rate_badge(
+ path=path,
+ added_statements_count=((file.diff.added_statements | length) if file.diff else none),
+ covered_statements_count=((file.diff.covered_statements | length) if file.diff else none),
+ percent_covered=(file.diff.percent_covered if file.diff else none)
+) -}}
+{%- endblock diff_coverage_rate_badge_cell -%}
+
+{#- Link to missing lines -#}
+{%- block link_to_missing_diff_lines_cell scoped -%}
+
+
+{%- set comma = joiner() -%}
+{%- for group in missing_diff_lines.get(path, []) -%}
+{{- comma() -}}
+
+
+{{- group.line_start -}}
+{%- if group.line_start != group.line_end -%}
+-
+{{- group.line_end -}}
+{%- endif -%}
+
+
+{%- endfor -%}
+ |
+
+{%- endblock link_to_missing_diff_lines_cell -%}
+{%- endfor -%}
+{%- endfor -%}
+
+
+
+Project Total |
+
+
+{#- Statements cell -#}
+{%- block statements_badge_total_cell scoped -%}
+{{- statements_badge(
+ path="the whole project",
+ statements_count=coverage.info.num_statements,
+ previous_statements_count=(previous_coverage.info.num_statements if previous_coverage else none),
+) -}}
+{%- endblock statements_badge_total_cell -%}
+
+{#- Missing cell -#}
+{%- block missing_lines_badge_total_cell scoped -%}
+{{- missing_lines_badge(
+ path="the whole project",
+ missing_lines_count=coverage.info.missing_lines,
+ previous_missing_lines_count=(previous_coverage.info.missing_lines if previous_coverage else none),
+) -}}
+{%- endblock missing_lines_badge_total_cell -%}
+
+{#- Coverage rate -#}
+{%- block coverage_rate_badge_total_cell scoped -%}
+{{- coverage_rate_badge(
+ path="the whole project",
+ previous_percent_covered=(previous_coverage.info.percent_covered if previous_coverage else none),
+ previous_covered_statements_count=(previous_coverage.info.covered_lines if previous_coverage else none),
+ previous_statements_count=(previous_coverage.info.num_statements if previous_coverage else none),
+ percent_covered=coverage.info.percent_covered,
+ covered_statements_count=coverage.info.covered_lines,
+ statements_count=coverage.info.num_statements,
+) -}}
+{%- endblock coverage_rate_badge_total_cell -%}
+
+{# Coverage of added lines #}
+{%- block diff_coverage_rate_badge_total_cell scoped -%}
+{{- diff_coverage_rate_badge(
+ path="the whole project",
+ added_statements_count=diff_coverage.total_num_lines,
+ covered_statements_count=(diff_coverage.total_num_lines-diff_coverage.total_num_violations),
+ percent_covered=diff_coverage.total_percent_covered,
+) -}}
+{%- endblock diff_coverage_rate_badge_total_cell -%}
+
+ |
+
+
+
+
+{%- if max_files and count_files > max_files %}
+
+> [!NOTE]
+> The report is truncated to {{ max_files }} files out of {{ count_files }}. To see the full report, please visit the workflow summary page.
+
+{% endif -%}
+
+{%- endif -%}
+{%- endblock coverage_by_file -%}
+
+{%- block footer -%}
+
+
+This report was generated by [python-coverage-comment-action](https://github.com/py-cov-action/python-coverage-comment-action)
+
+
+
+{%- endblock footer -%}
-{%- endif %}
-{%- endblock coverage_by_file %}
-{{ marker }}
+
+{{ marker -}}
diff --git a/coverage_comment/template_files/log.txt.j2 b/coverage_comment/template_files/log.txt.j2
index b8d252c2..affa2fae 100644
--- a/coverage_comment/template_files/log.txt.j2
+++ b/coverage_comment/template_files/log.txt.j2
@@ -1,4 +1,4 @@
-{% if subproject_id %} Coverage info for {{ subproject_id }}:
+{% if subproject_id %}Coverage info for {{ subproject_id }}:
{% endif -%}
{% if is_public -%}
diff --git a/pyproject.toml b/pyproject.toml
index fa13cd9d..57cfedcb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,7 +34,7 @@ build-backend = "poetry.core.masonry.api"
[tool.pytest.ini_options]
addopts = """
--cov-report term-missing --cov-branch --cov-report html --cov-report term
- --cov=coverage_comment -vv --strict-markers -rfE
+ --cov=coverage_comment --cov-context=test -vv --strict-markers -rfE
--ignore=tests/end_to_end/repo
"""
testpaths = ["tests/unit", "tests/integration", "tests/end_to_end"]
@@ -53,6 +53,9 @@ relative_files = true
[tool.coverage.report]
exclude_also = ["\\.\\.\\."]
+[tool.coverage.html]
+show_contexts = true
+
[tool.mypy]
no_implicit_optional = true
@@ -64,6 +67,7 @@ extend-select = [
"W", # pycodestyle warnings
"RUF", # ruff
]
+fixable = ["ALL"]
extend-ignore = [
"E501", # line too long
]
diff --git a/tests/conftest.py b/tests/conftest.py
index 9399d5bc..c7f44601 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -74,208 +74,6 @@ def _(**kwargs):
return _
-@pytest.fixture
-def coverage_json():
- return {
- "meta": {
- "version": "1.2.3",
- "timestamp": "2000-01-01T00:00:00",
- "branch_coverage": True,
- "show_contexts": False,
- },
- "files": {
- "codebase/code.py": {
- "executed_lines": [1, 2, 5, 6, 9],
- "summary": {
- "covered_lines": 5,
- "num_statements": 6,
- "percent_covered": 75.0,
- "missing_lines": 1,
- "excluded_lines": 0,
- "num_branches": 2,
- "num_partial_branches": 1,
- "covered_branches": 1,
- "missing_branches": 1,
- },
- "missing_lines": [7, 9],
- "excluded_lines": [],
- }
- },
- "totals": {
- "covered_lines": 5,
- "num_statements": 6,
- "percent_covered": 75.0,
- "missing_lines": 1,
- "excluded_lines": 0,
- "num_branches": 2,
- "num_partial_branches": 1,
- "covered_branches": 1,
- "missing_branches": 1,
- },
- }
-
-
-@pytest.fixture
-def coverage_obj():
- return coverage_module.Coverage(
- meta=coverage_module.CoverageMetadata(
- version="1.2.3",
- timestamp=datetime.datetime(2000, 1, 1),
- branch_coverage=True,
- show_contexts=False,
- ),
- info=coverage_module.CoverageInfo(
- covered_lines=5,
- num_statements=6,
- percent_covered=decimal.Decimal("0.75"),
- missing_lines=1,
- excluded_lines=0,
- num_branches=2,
- num_partial_branches=1,
- covered_branches=1,
- missing_branches=1,
- ),
- files={
- pathlib.Path("codebase/code.py"): coverage_module.FileCoverage(
- path=pathlib.Path("codebase/code.py"),
- executed_lines=[1, 2, 5, 6, 9],
- missing_lines=[7, 9],
- excluded_lines=[],
- info=coverage_module.CoverageInfo(
- covered_lines=5,
- num_statements=6,
- percent_covered=decimal.Decimal("0.75"),
- missing_lines=1,
- excluded_lines=0,
- num_branches=2,
- num_partial_branches=1,
- covered_branches=1,
- missing_branches=1,
- ),
- )
- },
- )
-
-
-@pytest.fixture
-def coverage_obj_no_branch():
- return coverage_module.Coverage(
- meta=coverage_module.CoverageMetadata(
- version="1.2.3",
- timestamp=datetime.datetime(2000, 1, 1),
- branch_coverage=False,
- show_contexts=False,
- ),
- info=coverage_module.CoverageInfo(
- covered_lines=5,
- num_statements=6,
- percent_covered=decimal.Decimal("0.75"),
- missing_lines=1,
- excluded_lines=0,
- num_branches=None,
- num_partial_branches=None,
- covered_branches=None,
- missing_branches=None,
- ),
- files={
- pathlib.Path("codebase/code.py"): coverage_module.FileCoverage(
- path=pathlib.Path("codebase/code.py"),
- executed_lines=[1, 2, 5, 6, 9],
- missing_lines=[7],
- excluded_lines=[],
- info=coverage_module.CoverageInfo(
- covered_lines=5,
- num_statements=6,
- percent_covered=decimal.Decimal("0.8333"),
- missing_lines=1,
- excluded_lines=0,
- num_branches=None,
- num_partial_branches=None,
- covered_branches=None,
- missing_branches=None,
- ),
- )
- },
- )
-
-
-@pytest.fixture
-def coverage_obj_more_files(coverage_obj_no_branch):
- coverage_obj_no_branch.files[
- pathlib.Path("codebase/other.py")
- ] = coverage_module.FileCoverage(
- path=pathlib.Path("codebase/other.py"),
- executed_lines=[10, 11, 12],
- missing_lines=[13],
- excluded_lines=[],
- info=coverage_module.CoverageInfo(
- covered_lines=3,
- num_statements=4,
- percent_covered=decimal.Decimal("0.75"),
- missing_lines=1,
- excluded_lines=0,
- num_branches=None,
- num_partial_branches=None,
- covered_branches=None,
- missing_branches=None,
- ),
- )
- return coverage_obj_no_branch
-
-
-@pytest.fixture
-def make_coverage_obj(coverage_obj_more_files):
- def f(**kwargs):
- obj = coverage_obj_more_files
- for key, value in kwargs.items():
- vars(obj.files[pathlib.Path(key)]).update(value)
- return obj
-
- return f
-
-
-@pytest.fixture
-def diff_coverage_obj():
- return coverage_module.DiffCoverage(
- total_num_lines=5,
- total_num_violations=1,
- total_percent_covered=decimal.Decimal("0.8"),
- num_changed_lines=39,
- files={
- pathlib.Path("codebase/code.py"): coverage_module.FileDiffCoverage(
- path=pathlib.Path("codebase/code.py"),
- percent_covered=decimal.Decimal("0.8"),
- missing_lines=[7, 9],
- added_lines=[7, 8, 9],
- )
- },
- )
-
-
-@pytest.fixture
-def diff_coverage_obj_many_missing_lines():
- return coverage_module.DiffCoverage(
- total_num_lines=5,
- total_num_violations=1,
- total_percent_covered=decimal.Decimal("0.8"),
- num_changed_lines=39,
- files={
- pathlib.Path("codebase/code.py"): coverage_module.FileDiffCoverage(
- path=pathlib.Path("codebase/code.py"),
- percent_covered=decimal.Decimal("0.8"),
- missing_lines=[7, 9],
- added_lines=[7, 8, 9],
- ),
- pathlib.Path("codebase/main.py"): coverage_module.FileDiffCoverage(
- path=pathlib.Path("codebase/code.py"),
- percent_covered=decimal.Decimal("0.8"),
- missing_lines=[1, 2, 8, 17],
- added_lines=[1, 2, 3, 4, 5, 6, 7, 8, 17],
- ),
- },
- )
-
-
@pytest.fixture
def session(is_failed):
"""
@@ -465,3 +263,296 @@ def f():
yield f
_is_failed.clear()
+
+
+@pytest.fixture
+def make_coverage():
+ def _(code: str, has_branches: bool = True) -> coverage_module.Coverage:
+ current_file = None
+ coverage_obj = coverage_module.Coverage(
+ meta=coverage_module.CoverageMetadata(
+ version="1.2.3",
+ timestamp=datetime.datetime(2000, 1, 1),
+ branch_coverage=True,
+ show_contexts=False,
+ ),
+ info=coverage_module.CoverageInfo(
+ covered_lines=0,
+ num_statements=0,
+ 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,
+ ),
+ files={},
+ )
+ line_number = 0
+ # (we start at 0 because the first line will be empty for readabilty)
+ for line in code.splitlines()[1:]:
+ line = line.strip()
+ if line.startswith("# file: "):
+ current_file = pathlib.Path(line.split("# file: ")[1])
+ continue
+ assert current_file, (line, current_file, code)
+ line_number += 1
+ if coverage_obj.files.get(current_file) is None:
+ coverage_obj.files[current_file] = coverage_module.FileCoverage(
+ path=current_file,
+ executed_lines=[],
+ missing_lines=[],
+ excluded_lines=[],
+ info=coverage_module.CoverageInfo(
+ covered_lines=0,
+ num_statements=0,
+ 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,
+ ),
+ )
+ if set(line.split()) & {
+ "covered",
+ "missing",
+ "excluded",
+ "partial",
+ "branch",
+ }:
+ coverage_obj.files[current_file].info.num_statements += 1
+ coverage_obj.info.num_statements += 1
+ if "covered" in line or "partial" in line:
+ coverage_obj.files[current_file].executed_lines.append(line_number)
+ coverage_obj.files[current_file].info.covered_lines += 1
+ coverage_obj.info.covered_lines += 1
+ elif "missing" in line:
+ coverage_obj.files[current_file].missing_lines.append(line_number)
+ coverage_obj.files[current_file].info.missing_lines += 1
+ coverage_obj.info.missing_lines += 1
+ elif "excluded" in line:
+ 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
+ if "branch partial" in line:
+ coverage_obj.files[current_file].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
+ coverage_obj.info.covered_branches += 1
+ 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,
+ )
+
+ info = coverage_obj.info
+ coverage_obj.info.percent_covered = coverage_module.compute_coverage(
+ num_covered=info.covered_lines,
+ num_total=info.num_statements,
+ )
+
+ return coverage_obj
+
+ return _
+
+
+@pytest.fixture
+def make_diff_coverage():
+ return coverage_module.get_diff_coverage_info
+
+
+@pytest.fixture
+def make_coverage_and_diff(make_coverage, make_diff_coverage):
+ def _(code: str) -> tuple[coverage_module.Coverage, coverage_module.DiffCoverage]:
+ added_lines: dict[pathlib.Path, list[int]] = {}
+ new_code = ""
+ current_file = None
+ # (we start at 0 because the first line will be empty for readabilty)
+ line_number = 0
+ for line in code.splitlines()[1:]:
+ line = line.strip()
+ if line.startswith("# file: "):
+ new_code += line + "\n"
+ current_file = pathlib.Path(line.split("# file: ")[1])
+ line_number = 0
+ continue
+ assert current_file
+ line_number += 1
+
+ if line.startswith("+ "):
+ added_lines.setdefault(current_file, []).append(line_number)
+ new_code += line[2:] + "\n"
+ else:
+ new_code += line + "\n"
+
+ coverage = make_coverage("\n" + new_code)
+ return coverage, make_diff_coverage(added_lines=added_lines, coverage=coverage)
+
+ return _
+
+
+@pytest.fixture
+def coverage_code():
+ return """
+ # file: codebase/code.py
+ 1 covered
+ 2 covered
+ 3 covered
+ 4
+ 5 branch partial
+ 6 missing
+ 7
+ 8 missing
+ 9
+ 10 branch missing
+ 11 missing
+ 12
+ 13 branch covered
+ 14 covered
+ """
+
+
+@pytest.fixture
+def coverage_json():
+ return {
+ "meta": {
+ "version": "1.2.3",
+ "timestamp": "2000-01-01T00:00:00",
+ "branch_coverage": True,
+ "show_contexts": False,
+ },
+ "files": {
+ "codebase/code.py": {
+ "executed_lines": [1, 2, 3, 5, 13, 14],
+ "summary": {
+ "covered_lines": 6,
+ "num_statements": 10,
+ "percent_covered": 60.0,
+ "missing_lines": 4,
+ "excluded_lines": 0,
+ "num_branches": 3,
+ "num_partial_branches": 1,
+ "covered_branches": 1,
+ "missing_branches": 1,
+ },
+ "missing_lines": [6, 8, 10, 11],
+ "excluded_lines": [],
+ }
+ },
+ "totals": {
+ "covered_lines": 6,
+ "num_statements": 10,
+ "percent_covered": 60.0,
+ "missing_lines": 4,
+ "excluded_lines": 0,
+ "num_branches": 3,
+ "num_partial_branches": 1,
+ "covered_branches": 1,
+ "missing_branches": 1,
+ },
+ }
+
+
+@pytest.fixture
+def coverage_obj(make_coverage, coverage_code):
+ return make_coverage(coverage_code)
+
+
+@pytest.fixture
+def coverage_obj_no_branch_code():
+ return """
+ # file: codebase/code.py
+ covered
+ covered
+ missing
+
+ covered
+ missing
+
+ missing
+ missing
+ covered
+ """
+
+
+@pytest.fixture
+def coverage_obj_no_branch(make_coverage, coverage_obj_no_branch_code):
+ return make_coverage(coverage_obj_no_branch_code, has_branches=False)
+
+
+@pytest.fixture
+def coverage_obj_more_files(make_coverage):
+ return make_coverage(
+ """
+ # file: codebase/code.py
+ covered
+ covered
+ covered
+
+ branch partial
+ missing
+
+ missing
+
+ branch missing
+ missing
+
+ branch covered
+ covered
+ # file: codebase/other.py
+
+
+ missing
+ covered
+ missing
+ missing
+
+ missing
+ covered
+ covered
+ """
+ )
+
+
+@pytest.fixture
+def make_coverage_obj(coverage_obj_more_files):
+ def f(**kwargs):
+ obj = coverage_obj_more_files
+ for key, value in kwargs.items():
+ vars(obj.files[pathlib.Path(key)]).update(value)
+ return obj
+
+ return f
+
+
+@pytest.fixture
+def diff_coverage_obj(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,
+ )
+
+
+@pytest.fixture
+def diff_coverage_obj_more_files(coverage_obj_more_files, make_diff_coverage):
+ return make_diff_coverage(
+ added_lines={
+ pathlib.Path("codebase/code.py"): [3, 4, 5, 6, 7, 8, 9, 12],
+ pathlib.Path("codebase/other.py"): [1, 2, 3, 4, 5, 6, 7, 8, 17],
+ },
+ coverage=coverage_obj_more_files,
+ )
diff --git a/tests/end_to_end/test_all.py b/tests/end_to_end/test_all.py
index 30137198..5ca63c2f 100644
--- a/tests/end_to_end/test_all.py
+++ b/tests/end_to_end/test_all.py
@@ -125,7 +125,6 @@ def test_public_repo(
"--jq=.comments[0].body",
fail_value="\n",
)
- assert ":arrow_up:" in comment
assert "## Coverage report (my-great-project)" in comment
assert (
"This comment was produced by python-coverage-comment-action (id: my-great-project)"
@@ -184,7 +183,7 @@ def test_public_repo(
fail_value="\n",
)
- assert ":arrow_up:" in ext_comment
+ assert "-brightgreen.svg" in ext_comment
@pytest.mark.repo_suffix("private")
@@ -275,7 +274,7 @@ def test_private_repo(
"--jq=.comments[0].body",
fail_value="\n",
)
- assert ":arrow_up:" in comment
+ assert "-brightgreen.svg" in comment
# Let's merge the PR and see if everything works fine
gh_me("pr", "merge", "1", "--merge")
diff --git a/tests/integration/test_github.py b/tests/integration/test_github.py
index 066a9656..ca2d5bad 100644
--- a/tests/integration/test_github.py
+++ b/tests/integration/test_github.py
@@ -346,13 +346,13 @@ def test_annotations(capsys):
annotation_type="warning",
annotations=[
(pathlib.Path("codebase/code.py"), 1, 3),
- (pathlib.Path("codebase/main.py"), 5, 5),
+ (pathlib.Path("codebase/other.py"), 5, 5),
],
)
expected = """::group::Annotations of lines with missing coverage
::warning file=codebase/code.py,line=1,endLine=3,title=Missing coverage::Missing coverage on lines 1-3
-::warning file=codebase/main.py,line=5,endLine=5,title=Missing coverage::Missing coverage on line 5
+::warning file=codebase/other.py,line=5,endLine=5,title=Missing coverage::Missing coverage on line 5
::endgroup::"""
output = capsys.readouterr()
assert output.err.strip() == expected
diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py
index 17995c01..a2419e90 100644
--- a/tests/integration/test_main.py
+++ b/tests/integration/test_main.py
@@ -192,11 +192,16 @@ def checker(payload):
comment_file = pathlib.Path("python-coverage-comment-action.txt").read_text()
assert comment == comment_file
assert comment == summary_file.read_text()
- assert "Coverage data for the default branch was not found." in comment
- assert "The coverage rate is `77.77%`" in comment
- assert "`75%` of new lines are covered." in comment
assert (
- "### foo.py\n`75%` of new lines are covered (`77.77%` of the complete file)"
+ "Coverage for the whole project is 77.77%. Previous coverage rate is not available"
+ in comment
+ )
+ assert (
+ "In this PR, 4 new statements are added to the whole project, 3 of which are covered (75%)."
+ in comment
+ )
+ assert (
+ "https://github.com/py-cov-action/foobar/pull/2/files#diff-b08fd7a517303ab07cfa211f74d03c1a4c2e64b3b0656d84ff32ecb449b785d2"
in comment
)
assert (
@@ -270,7 +275,10 @@ def checker(payload):
comment_file = pathlib.Path("python-coverage-comment-action.txt").read_text()
assert comment == comment_file
assert comment == summary_file.read_text()
- assert "Coverage evolution disabled because this PR targets" in comment
+ assert (
+ "Previous coverage rate is not available, cannot report on evolution."
+ in comment
+ )
def test_action__pull_request__post_comment(
@@ -324,7 +332,8 @@ def checker(payload):
assert result == 0
assert not pathlib.Path("python-coverage-comment-action.txt").exists()
- assert "The coverage rate went from `30%` to `77.77%` :arrow_up:" in comment
+ assert "Coverage for the whole project went from 30% to 77.77%" in comment
+ assert comment.count(" 99%",
+ "",
+ ),
+ ],
+)
+def test_get_static_badge_url__error(label, message, color):
+ with pytest.raises(ValueError):
+ badge.get_static_badge_url(label=label, message=message, color=color)
+
+
def test_get_endpoint_url():
url = badge.get_endpoint_url(endpoint_url="https://foo")
expected = "https://img.shields.io/endpoint?url=https://foo"
diff --git a/tests/unit/test_coverage.py b/tests/unit/test_coverage.py
index ca45b07d..6ea249a2 100644
--- a/tests/unit/test_coverage.py
+++ b/tests/unit/test_coverage.py
@@ -9,6 +9,20 @@
from coverage_comment import coverage, subprocess
+def test_diff_violations(make_coverage_and_diff):
+ _, diff = make_coverage_and_diff(
+ """
+ # file: a.py
+ + 1 missing
+ 2 missing
+ + 3 missing
+ 4 covered
+ + 5 covered
+ """
+ )
+ assert diff.files[pathlib.Path("a.py")].violation_lines == [1, 3]
+
+
@pytest.mark.parametrize(
"num_covered, num_total, expected_coverage",
[
@@ -137,7 +151,9 @@ def test_generate_coverage_markdown(mocker):
pathlib.Path("codebase/code.py"): coverage.FileDiffCoverage(
path=pathlib.Path("codebase/code.py"),
percent_covered=decimal.Decimal("0.5"),
- missing_lines=[3],
+ added_statements=[1, 3],
+ covered_statements=[1],
+ missing_statements=[3],
added_lines=[1, 3],
)
},
@@ -174,7 +190,9 @@ def test_generate_coverage_markdown(mocker):
pathlib.Path("codebase/code.py"): coverage.FileDiffCoverage(
path=pathlib.Path("codebase/code.py"),
percent_covered=decimal.Decimal("1"),
- missing_lines=[],
+ added_statements=[],
+ covered_statements=[],
+ missing_statements=[],
added_lines=[4, 5, 6],
)
},
@@ -207,13 +225,17 @@ def test_generate_coverage_markdown(mocker):
pathlib.Path("codebase/code.py"): coverage.FileDiffCoverage(
path=pathlib.Path("codebase/code.py"),
percent_covered=decimal.Decimal("1"),
- missing_lines=[],
+ added_statements=[5, 6],
+ covered_statements=[5, 6],
+ missing_statements=[],
added_lines=[4, 5, 6],
),
pathlib.Path("codebase/other.py"): coverage.FileDiffCoverage(
path=pathlib.Path("codebase/other.py"),
percent_covered=decimal.Decimal("0.5"),
- missing_lines=[13],
+ added_statements=[10, 13],
+ covered_statements=[10],
+ missing_statements=[13],
added_lines=[10, 13],
),
},
diff --git a/tests/unit/test_diff_grouper.py b/tests/unit/test_diff_grouper.py
new file mode 100644
index 00000000..404d56c7
--- /dev/null
+++ b/tests/unit/test_diff_grouper.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+import pathlib
+
+from coverage_comment import diff_grouper, groups
+
+
+def test_group_annotations(coverage_obj, diff_coverage_obj):
+ result = diff_grouper.get_diff_missing_groups(
+ coverage=coverage_obj, diff_coverage=diff_coverage_obj
+ )
+
+ assert list(result) == [
+ groups.Group(file=pathlib.Path("codebase/code.py"), line_start=6, line_end=8),
+ ]
+
+
+def test_group_annotations_more_files(
+ coverage_obj_more_files, diff_coverage_obj_more_files
+):
+ result = diff_grouper.get_diff_missing_groups(
+ coverage=coverage_obj_more_files,
+ diff_coverage=diff_coverage_obj_more_files,
+ )
+
+ assert list(result) == [
+ groups.Group(file=pathlib.Path("codebase/code.py"), line_start=6, line_end=8),
+ groups.Group(
+ file=pathlib.Path("codebase/other.py"), line_start=17, line_end=17
+ ),
+ ]
diff --git a/tests/unit/test_files.py b/tests/unit/test_files.py
index e0bb191d..0fe36d6d 100644
--- a/tests/unit/test_files.py
+++ b/tests/unit/test_files.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import decimal
+import json
import pathlib
from coverage_comment import files
@@ -65,11 +66,26 @@ def test_compute_datafile():
def test_parse_datafile():
- assert files.parse_datafile(contents="""{"coverage": 12.34}""") == decimal.Decimal(
- "0.1234"
+ assert files.parse_datafile(contents="""{"coverage": 12.34}""") == (
+ None,
+ decimal.Decimal("0.1234"),
)
+def test_parse_datafile__previous(coverage_json, coverage_obj):
+ result = files.parse_datafile(
+ contents=json.dumps(
+ {
+ "coverage": 12.34,
+ "raw_data": coverage_json,
+ "coverage_path": ".",
+ }
+ )
+ )
+
+ assert result == (coverage_obj, decimal.Decimal("0.1234"))
+
+
def test_get_urls():
def getter(path):
return f"https://{path}"
diff --git a/tests/unit/test_annotations.py b/tests/unit/test_groups.py
similarity index 79%
rename from tests/unit/test_annotations.py
rename to tests/unit/test_groups.py
index 4b154d43..7edcd254 100644
--- a/tests/unit/test_annotations.py
+++ b/tests/unit/test_groups.py
@@ -1,10 +1,8 @@
from __future__ import annotations
-import pathlib
-
import pytest
-from coverage_comment import annotations
+from coverage_comment import groups
@pytest.mark.parametrize(
@@ -49,19 +47,7 @@
],
)
def test_compute_contiguous_groups(values, separators, joiners, expected):
- result = annotations.compute_contiguous_groups(
- values=values, separators=separators, joiners=joiners
+ result = groups.compute_contiguous_groups(
+ values=values, separators=separators, joiners=joiners, max_gap=3
)
assert result == expected
-
-
-def test_group_annotations(coverage_obj, diff_coverage_obj):
- result = annotations.group_annotations(
- coverage=coverage_obj, diff_coverage=diff_coverage_obj
- )
-
- assert list(result) == [
- annotations.Annotation(
- file=pathlib.Path("codebase/code.py"), line_start=7, line_end=9
- )
- ]
diff --git a/tests/unit/test_template.py b/tests/unit/test_template.py
index b62cdafe..c9ebd89d 100644
--- a/tests/unit/test_template.py
+++ b/tests/unit/test_template.py
@@ -1,6 +1,5 @@
from __future__ import annotations
-import datetime
import decimal
import pathlib
@@ -10,12 +9,26 @@
def test_get_comment_markdown(coverage_obj, diff_coverage_obj):
+ files, total = template.select_files(
+ coverage=coverage_obj,
+ diff_coverage=diff_coverage_obj,
+ previous_coverage=coverage_obj,
+ max_files=25,
+ )
result = (
template.get_comment_markdown(
coverage=coverage_obj,
+ previous_coverage=coverage_obj,
diff_coverage=diff_coverage_obj,
+ files=files,
+ count_files=total,
+ max_files=25,
previous_coverage_rate=decimal.Decimal("0.92"),
+ minimum_green=decimal.Decimal("100"),
+ minimum_orange=decimal.Decimal("70"),
marker="",
+ repo_name="org/repo",
+ pr_number=1,
base_template="""
{{ previous_coverage_rate | pct }}
{{ coverage.info.percent_covered | pct }}
@@ -31,22 +44,30 @@ def test_get_comment_markdown(coverage_obj, diff_coverage_obj):
.split(maxsplit=4)
)
- expected = [
- "92%",
- "75%",
- "80%",
- "bar",
- "",
- ]
+ expected = ["92%", "60%", "50%", "bar", ""]
assert result == expected
def test_template(coverage_obj, diff_coverage_obj):
+ files, total = template.select_files(
+ coverage=coverage_obj,
+ diff_coverage=diff_coverage_obj,
+ previous_coverage=None,
+ max_files=25,
+ )
result = template.get_comment_markdown(
coverage=coverage_obj,
diff_coverage=diff_coverage_obj,
+ previous_coverage=None,
previous_coverage_rate=decimal.Decimal("0.92"),
+ minimum_green=decimal.Decimal("79"),
+ minimum_orange=decimal.Decimal("40"),
+ files=files,
+ count_files=total,
+ max_files=25,
+ repo_name="org/repo",
+ pr_number=5,
base_template=template.read_template_file("comment.md.j2"),
marker="",
subproject_id="foo",
@@ -54,190 +75,313 @@ def test_template(coverage_obj, diff_coverage_obj):
{% block emoji_coverage_down %}:sob:{% endblock emoji_coverage_down %}
""",
)
+ print(result)
expected = """## Coverage report (foo)
-The coverage rate went from `92%` to `75%` :sob:
-The branch rate is `50%`.
-`80%` of new lines are covered.
-
-Diff Coverage details (click to unfold)
+ Click to see where and how coverage changed
+ File | Statements | Missing | Coverage | Coverage (new stmts) | Lines missing |
+
+
+ codebase |
+ code.py |
+ | | | | 6-8 |
+
+
+Project Total |
+ | | | | |
+
+
+
-### codebase/code.py
-`80%` of new lines are covered (`75%` of the complete file).
-Missing lines: `7`, `9`
+This report was generated by [python-coverage-comment-action](https://github.com/py-cov-action/python-coverage-comment-action)
+
+
-
"""
assert result == expected
-def test_template_full():
- cov = coverage.Coverage(
- meta=coverage.CoverageMetadata(
- version="1.2.3",
- timestamp=datetime.datetime(2000, 1, 1),
- branch_coverage=True,
- show_contexts=False,
- ),
- info=coverage.CoverageInfo(
- covered_lines=6,
- num_statements=6,
- percent_covered=decimal.Decimal("1"),
- missing_lines=0,
- excluded_lines=0,
- num_branches=2,
- num_partial_branches=0,
- covered_branches=2,
- missing_branches=0,
- ),
- files={
- pathlib.Path("codebase/code.py"): coverage.FileCoverage(
- path=pathlib.Path("codebase/code.py"),
- executed_lines=[1, 2, 5, 6, 9],
- missing_lines=[],
- excluded_lines=[],
- info=coverage.CoverageInfo(
- covered_lines=5,
- num_statements=6,
- percent_covered=decimal.Decimal("5") / decimal.Decimal("6"),
- missing_lines=1,
- excluded_lines=0,
- num_branches=2,
- num_partial_branches=0,
- covered_branches=2,
- missing_branches=0,
- ),
- ),
- pathlib.Path("codebase/other.py"): coverage.FileCoverage(
- path=pathlib.Path("codebase/other.py"),
- executed_lines=[1, 2, 3],
- missing_lines=[],
- excluded_lines=[],
- info=coverage.CoverageInfo(
- covered_lines=6,
- num_statements=6,
- percent_covered=decimal.Decimal("1"),
- missing_lines=0,
- excluded_lines=0,
- num_branches=2,
- num_partial_branches=0,
- covered_branches=2,
- missing_branches=0,
- ),
- ),
- },
+def test_template_full(make_coverage, make_coverage_and_diff):
+ previous_cov = make_coverage(
+ """
+ # file: codebase/code.py
+ 1 covered
+ 2 covered
+ 3
+ 4 missing
+ 5 covered
+ 6 covered
+ 7
+ 8
+ 9 covered
+ # file: codebase/other.py
+ 1 covered
+ 2 covered
+ 3 covered
+ 4 covered
+ 5 covered
+ 6 covered
+ # file: codebase/third.py
+ 1 covered
+ 2 covered
+ 3 covered
+ 4 covered
+ 5 covered
+ 6 missing
+ 7 missing
+ """
+ )
+ cov, diff_cov = make_coverage_and_diff(
+ """
+ # file: codebase/code.py
+ 1 covered
+ 2 covered
+ 3
+ 4
+ 5 covered
+ 6 covered
+ 7
+ 8
+ 9 covered
+ 10
+ 11
+ + 12 missing
+ + 13 missing
+ + 14 missing
+ + 15 covered
+ + 16 covered
+ + 17
+ + 18
+ + 19
+ + 20
+ + 21
+ + 22 missing
+ # file: codebase/other.py
+ 1 covered
+ 2 covered
+ 3 covered
+ # file: codebase/third.py
+ 1 covered
+ 2 covered
+ 3 covered
+ 4 covered
+ 5 covered
+ 6 covered
+ 7 covered
+ """
)
- diff_cov = coverage.DiffCoverage(
- total_num_lines=6,
- total_num_violations=0,
- total_percent_covered=decimal.Decimal("1"),
- num_changed_lines=39,
- files={
- pathlib.Path("codebase/code.py"): coverage.FileDiffCoverage(
- path=pathlib.Path("codebase/code.py"),
- percent_covered=decimal.Decimal("0.5"),
- missing_lines=[5],
- added_lines=[5],
- ),
- pathlib.Path("codebase/other.py"): coverage.FileDiffCoverage(
- path=pathlib.Path("codebase/other.py"),
- percent_covered=decimal.Decimal("1"),
- missing_lines=[],
- added_lines=[4, 5, 6],
- ),
- },
+ files, total = template.select_files(
+ coverage=cov,
+ diff_coverage=diff_cov,
+ previous_coverage=previous_cov,
+ max_files=25,
)
result = template.get_comment_markdown(
coverage=cov,
diff_coverage=diff_cov,
- previous_coverage_rate=decimal.Decimal("1.0"),
+ previous_coverage=previous_cov,
+ files=files,
+ count_files=total,
+ max_files=25,
+ previous_coverage_rate=decimal.Decimal("11") / decimal.Decimal("12"),
+ minimum_green=decimal.Decimal("100"),
+ minimum_orange=decimal.Decimal("70"),
marker="",
+ repo_name="org/repo",
+ pr_number=12,
base_template=template.read_template_file("comment.md.j2"),
)
expected = """## Coverage report
-The coverage rate went from `100%` to `100%` :arrow_right:
-The branch rate is `100%`.
-`100%` of new lines are covered.
-
-Diff Coverage details (click to unfold)
+ Click to see where and how coverage changed
+ File | Statements | Missing | Coverage | Coverage (new stmts) | Lines missing |
+
+
+ codebase |
+ code.py |
+
+ | | | | 12-14, 22 |
+ other.py |
+
+ | | | | |
+ third.py |
-### codebase/code.py
-`50%` of new lines are covered (`83.33%` of the complete file).
-Missing lines: `5`
+ | | | | |
+
+
+Project Total |
-### codebase/other.py
-`100%` of new lines are covered (`100%` of the complete file).
+ | | | | |
+
+
+
+
+This report was generated by [python-coverage-comment-action](https://github.com/py-cov-action/python-coverage-comment-action)
+
+
-
"""
+ print(result)
assert result == expected
-def test_template__no_new_lines_with_coverage(coverage_obj):
- diff_cov = coverage.DiffCoverage(
- total_num_lines=0,
- total_num_violations=0,
- total_percent_covered=decimal.Decimal("1"),
- num_changed_lines=39,
- files={},
+def test_template__no_previous(coverage_obj_no_branch, diff_coverage_obj):
+ files, total = template.select_files(
+ coverage=coverage_obj_no_branch,
+ diff_coverage=diff_coverage_obj,
+ previous_coverage=None,
+ max_files=25,
)
-
result = template.get_comment_markdown(
- coverage=coverage_obj,
- diff_coverage=diff_cov,
- previous_coverage_rate=decimal.Decimal("1.0"),
+ coverage=coverage_obj_no_branch,
+ diff_coverage=diff_coverage_obj,
+ previous_coverage_rate=None,
+ previous_coverage=None,
+ files=files,
+ count_files=total,
+ max_files=25,
+ minimum_green=decimal.Decimal("100"),
+ minimum_orange=decimal.Decimal("70"),
marker="",
+ repo_name="org/repo",
+ pr_number=3,
base_template=template.read_template_file("comment.md.j2"),
)
expected = """## Coverage report
-The coverage rate went from `100%` to `75%` :arrow_down:
-The branch rate is `50%`.
-_None of the new lines are part of the tested code. Therefore, there is no coverage data about them._
+ Click to see where and how coverage changed
+ File | Statements | Missing | Coverage | Coverage (new stmts) | Lines missing |
+
+
+ codebase |
+ code.py |
+ | | | | 6-8 |
+
+
+Project Total |
+ | | | | |
+
+
+
+
+This report was generated by [python-coverage-comment-action](https://github.com/py-cov-action/python-coverage-comment-action)
+
+
"""
+ print(result)
assert result == expected
-def test_template__no_branch_no_previous(coverage_obj_no_branch, diff_coverage_obj):
+def test_template__max_files(coverage_obj_more_files, diff_coverage_obj_more_files):
+ files, total = template.select_files(
+ coverage=coverage_obj_more_files,
+ diff_coverage=diff_coverage_obj_more_files,
+ previous_coverage=None,
+ max_files=25,
+ )
result = template.get_comment_markdown(
- coverage=coverage_obj_no_branch,
- diff_coverage=diff_coverage_obj,
- previous_coverage_rate=None,
- marker="",
+ coverage=coverage_obj_more_files,
+ diff_coverage=diff_coverage_obj_more_files,
+ previous_coverage=None,
+ files=files,
+ count_files=total,
+ previous_coverage_rate=decimal.Decimal("0.92"),
+ minimum_green=decimal.Decimal("79"),
+ minimum_orange=decimal.Decimal("40"),
+ repo_name="org/repo",
+ pr_number=5,
+ max_files=1,
base_template=template.read_template_file("comment.md.j2"),
+ marker="",
+ subproject_id="foo",
+ custom_template="""{% extends "base" %}
+ {% block emoji_coverage_down %}:sob:{% endblock emoji_coverage_down %}
+ """,
)
- expected = """## Coverage report
-> [!NOTE]
-> Coverage data for the default branch was not found.
-> This usually happens when the action has not run on the default
-> branch yet, for example right after deploying it into the workflows.
+ print(result)
-The coverage rate is `75%`.
+ assert "The report is truncated to 1 files out of 2." in result
-`80%` of new lines are covered.
-
-Diff Coverage details (click to unfold)
+def test_template__no_max_files(coverage_obj_more_files, diff_coverage_obj_more_files):
+ files, total = template.select_files(
+ coverage=coverage_obj_more_files,
+ diff_coverage=diff_coverage_obj_more_files,
+ previous_coverage=None,
+ max_files=25,
+ )
+ result = template.get_comment_markdown(
+ coverage=coverage_obj_more_files,
+ diff_coverage=diff_coverage_obj_more_files,
+ previous_coverage=None,
+ files=files,
+ count_files=total,
+ previous_coverage_rate=decimal.Decimal("0.92"),
+ minimum_green=decimal.Decimal("79"),
+ minimum_orange=decimal.Decimal("40"),
+ repo_name="org/repo",
+ pr_number=5,
+ max_files=None,
+ base_template=template.read_template_file("comment.md.j2"),
+ marker="",
+ subproject_id="foo",
+ custom_template="""{% extends "base" %}
+ {% block emoji_coverage_down %}:sob:{% endblock emoji_coverage_down %}
+ """,
+ )
+ print(result)
-### codebase/code.py
-`80%` of new lines are covered (`83.33%` of the complete file).
-Missing lines: `7`, `9`
+ assert "The report is truncated" not in result
+ assert "code.py" in result
+ assert "other.py" in result
-
-"""
- assert result == expected
+
+def test_template__no_files(coverage_obj, diff_coverage_obj_more_files):
+ diff_coverage = coverage.DiffCoverage(
+ total_num_lines=0,
+ total_num_violations=0,
+ total_percent_covered=decimal.Decimal("1"),
+ num_changed_lines=0,
+ files={},
+ )
+ result = template.get_comment_markdown(
+ coverage=coverage_obj,
+ diff_coverage=diff_coverage,
+ previous_coverage=coverage_obj,
+ files=[],
+ count_files=0,
+ previous_coverage_rate=decimal.Decimal("0.92"),
+ minimum_green=decimal.Decimal("79"),
+ minimum_orange=decimal.Decimal("40"),
+ repo_name="org/repo",
+ pr_number=5,
+ max_files=25,
+ base_template=template.read_template_file("comment.md.j2"),
+ marker="",
+ subproject_id="foo",
+ custom_template="""{% extends "base" %}
+ {% block emoji_coverage_down %}:sob:{% endblock emoji_coverage_down %}
+ """,
+ )
+ print(result)
+
+ assert (
+ "_This PR does not seem to contain any modification to coverable code."
+ in result
+ )
+ assert "code.py" not in result
+ assert "other.py" not in result
def test_read_template_file():
assert template.read_template_file("comment.md.j2").startswith(
- "{% block title %}## Coverage report{% if subproject_id %}"
+ "{%- block title -%}## Coverage report{%- if subproject_id %}"
)
@@ -245,8 +389,16 @@ def test_template__no_marker(coverage_obj, diff_coverage_obj):
with pytest.raises(template.MissingMarker):
template.get_comment_markdown(
coverage=coverage_obj,
+ previous_coverage=None,
+ files=[],
+ count_files=0,
+ max_files=25,
diff_coverage=diff_coverage_obj,
previous_coverage_rate=decimal.Decimal("0.92"),
+ minimum_green=decimal.Decimal("100"),
+ minimum_orange=decimal.Decimal("70"),
+ repo_name="org/repo",
+ pr_number=1,
base_template=template.read_template_file("comment.md.j2"),
marker="",
custom_template="""foo bar""",
@@ -257,8 +409,16 @@ def test_template__broken_template(coverage_obj, diff_coverage_obj):
with pytest.raises(template.TemplateError):
template.get_comment_markdown(
coverage=coverage_obj,
+ previous_coverage=None,
diff_coverage=diff_coverage_obj,
+ files=[],
+ count_files=0,
+ max_files=25,
previous_coverage_rate=decimal.Decimal("0.92"),
+ minimum_green=decimal.Decimal("100"),
+ minimum_orange=decimal.Decimal("70"),
+ repo_name="org/repo",
+ pr_number=1,
base_template=template.read_template_file("comment.md.j2"),
marker="",
custom_template="""{% extends "foo" %}""",
@@ -268,16 +428,63 @@ def test_template__broken_template(coverage_obj, diff_coverage_obj):
@pytest.mark.parametrize(
"value, displayed_coverage",
[
- ("0.83", "83%"),
- ("0.99999", "99.99%"),
- ("0.00001", "0%"),
- ("0.0501", "5.01%"),
- ("1", "100%"),
- ("0.8392", "83.92%"),
+ (decimal.Decimal("0.83"), "83%"),
+ (decimal.Decimal("0.99999"), "99.99%"),
+ (decimal.Decimal("0.00001"), "0%"),
+ (decimal.Decimal("0.0501"), "5.01%"),
+ (decimal.Decimal("1"), "100%"),
+ (decimal.Decimal("0.2"), "20%"),
+ (decimal.Decimal("0.8392"), "83.92%"),
],
)
def test_pct(value, displayed_coverage):
- assert template.pct(decimal.Decimal(value)) == displayed_coverage
+ assert template.pct(value) == displayed_coverage
+
+
+@pytest.mark.parametrize(
+ "number, singular, plural, expected",
+ [
+ (1, "", "s", ""),
+ (2, "", "s", "s"),
+ (0, "", "s", "s"),
+ (1, "y", "ies", "y"),
+ (2, "y", "ies", "ies"),
+ ],
+)
+def test_pluralize(number, singular, plural, expected):
+ assert (
+ template.pluralize(number=number, singular=singular, plural=plural) == expected
+ )
+
+
+@pytest.mark.parametrize(
+ "filepath, lines, expected",
+ [
+ (
+ pathlib.Path("tests/conftest.py"),
+ None,
+ "https://github.com/py-cov-action/python-coverage-comment-action/pull/33/files#diff-e52e4ddd58b7ef887ab03c04116e676f6280b824ab7469d5d3080e5cba4f2128",
+ ),
+ (
+ pathlib.Path("main.py"),
+ (12, 15),
+ "https://github.com/py-cov-action/python-coverage-comment-action/pull/33/files#diff-b10564ab7d2c520cdd0243874879fb0a782862c3c902ab535faabe57d5a505e1R12-R15",
+ ),
+ (
+ pathlib.Path("codebase/other.py"),
+ (22, 22),
+ "https://github.com/py-cov-action/python-coverage-comment-action/pull/33/files#diff-30cad827f61772ec66bb9ef8887058e6d8443a2afedb331d800feaa60228a26eR22-R22",
+ ),
+ ],
+)
+def test_get_file_url(filepath, lines, expected):
+ result = template.get_file_url(
+ filename=filepath,
+ lines=lines,
+ repo_name="py-cov-action/python-coverage-comment-action",
+ pr_number=33,
+ )
+ assert result == expected
def test_uptodate():
@@ -296,3 +503,213 @@ def test_uptodate():
)
def test_get_marker(marker_id, result):
assert template.get_marker(marker_id=marker_id) == result
+
+
+@pytest.mark.parametrize(
+ "previous_code, current_code_and_diff, max_files, expected_files, expected_total",
+ [
+ pytest.param(
+ """
+ # file: a.py
+ 1 covered
+ """,
+ """
+ # file: a.py
+ 1 covered
+ """,
+ 2,
+ [],
+ 0,
+ id="unmodified",
+ ),
+ pytest.param(
+ """
+ # file: a.py
+ 1 covered
+ """,
+ """
+ # file: a.py
+ 1
+ 2 covered
+ """,
+ 2,
+ [],
+ 0,
+ id="info_did_not_change",
+ ),
+ pytest.param(
+ """
+ # file: a.py
+ 1 covered
+ """,
+ """
+ # file: a.py
+ 1 missing
+ """,
+ 2,
+ ["a.py"],
+ 1,
+ id="info_did_change",
+ ),
+ pytest.param(
+ """
+ # file: a.py
+ 1 covered
+ """,
+ """
+ # file: a.py
+ + 1 covered
+ """,
+ 2,
+ ["a.py"],
+ 1,
+ id="with_diff",
+ ),
+ pytest.param(
+ """
+ # file: b.py
+ 1 covered
+ # file: a.py
+ 1 covered
+ """,
+ """
+ # file: b.py
+ + 1 covered
+ # file: a.py
+ + 1 covered
+ """,
+ 2,
+ ["a.py", "b.py"],
+ 2,
+ id="ordered",
+ ),
+ pytest.param(
+ """
+ # file: a.py
+ 1 covered
+ # file: b.py
+ 1 covered
+ """,
+ """
+ # file: a.py
+ 1 covered
+ 2 covered
+ # file: b.py
+ 1 missing
+ """,
+ 1,
+ ["b.py"],
+ 2,
+ id="truncated",
+ ),
+ pytest.param(
+ """
+ # file: a.py
+ 1 covered
+ # file: c.py
+ 1 covered
+ # file: b.py
+ 1 covered
+ """,
+ """
+ # file: a.py
+ + 1 covered
+ # file: c.py
+ 1 missing
+ # file: b.py
+ 1 missing
+ """,
+ 2,
+ ["b.py", "c.py"],
+ 3,
+ id="truncated_and_ordered",
+ ),
+ pytest.param(
+ """
+ # file: a.py
+ 1 covered
+ # file: b.py
+ 1 covered
+ """,
+ """
+ # file: a.py
+ 1 covered
+ 2 covered
+ # file: b.py
+ 1 missing
+ """,
+ None,
+ ["a.py", "b.py"],
+ 2,
+ id="max_none",
+ ),
+ ],
+)
+def test_select_files(
+ make_coverage,
+ make_coverage_and_diff,
+ previous_code,
+ current_code_and_diff,
+ max_files,
+ expected_files,
+ expected_total,
+):
+ previous_cov = make_coverage(previous_code)
+ cov, diff_cov = make_coverage_and_diff(current_code_and_diff)
+
+ files, total = template.select_files(
+ coverage=cov,
+ diff_coverage=diff_cov,
+ previous_coverage=previous_cov,
+ max_files=max_files,
+ )
+ assert [str(e.path) for e in files] == expected_files
+ assert total == expected_total
+
+
+def test_select_files__no_previous(
+ make_coverage_and_diff,
+):
+ cov, diff_cov = make_coverage_and_diff(
+ """
+ # file: a.py
+ 1 covered
+ + 1 missing
+ """
+ )
+
+ files, total = template.select_files(
+ coverage=cov,
+ diff_coverage=diff_cov,
+ previous_coverage=None,
+ max_files=1,
+ )
+ assert [str(e.path) for e in files] == ["a.py"]
+ assert total == 1
+
+
+def test_get_readme_markdown():
+ result = template.get_readme_markdown(
+ is_public=True,
+ readme_url="https://example.com",
+ markdown_report="...markdown report...",
+ direct_image_url="https://example.com/direct.png",
+ html_report_url="https://example.com/report.html",
+ dynamic_image_url="https://example.com/dynamic.png",
+ endpoint_image_url="https://example.com/endpoint.png",
+ subproject_id="foo",
+ )
+ assert result.startswith("# Repository Coverage (foo)")
+
+
+def test_get_log_message():
+ result = template.get_log_message(
+ is_public=True,
+ readme_url="https://example.com",
+ direct_image_url="https://example.com/direct.png",
+ html_report_url="https://example.com/report.html",
+ dynamic_image_url="https://example.com/dynamic.png",
+ endpoint_image_url="https://example.com/endpoint.png",
+ subproject_id="foo",
+ )
+ assert result.startswith("Coverage info for foo:")