diff --git a/README.md b/README.md index b542581..71e6f45 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,17 @@ Note: Either `GITHUB_PR_NUMBER` or `GITHUB_REF` is required. `GITHUB_PR_NUMBER` - `COVERAGE_REPORT_URL`: URL of the full coverage report to mention in the comment. - `DEBUG`: Whether to enable debug mode. Default is False. +## Notes + +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. + ## Setting up Local Environment using Pipenv To get started, follow these steps: diff --git a/codecov/coverage/base.py b/codecov/coverage/base.py index 3353538..b48ac3c 100644 --- a/codecov/coverage/base.py +++ b/codecov/coverage/base.py @@ -73,10 +73,24 @@ class DiffCoverage: class BaseCoverage(ABC): - def compute_coverage(self, num_covered: int, num_total: int) -> decimal.Decimal: - if num_total == 0: + def convert_to_decimal(self, value: float, precision: int = 2) -> decimal.Decimal: + return decimal.Decimal(str(float(value) / 100)).quantize( + exp=decimal.Decimal(10) ** -precision, + rounding=decimal.ROUND_DOWN, + ) + + def compute_coverage( + self, + num_covered: int, + num_total: int, + num_branches_covered: int = 0, + num_branches_total: int = 0, + ) -> decimal.Decimal: + numerator = decimal.Decimal(num_covered + num_branches_covered) + denominator = decimal.Decimal(num_total + num_branches_total) + if denominator == 0: return decimal.Decimal('1') - return decimal.Decimal(num_covered) / decimal.Decimal(num_total) + return numerator / denominator def get_coverage_info(self, coverage_path: pathlib.Path) -> Coverage: try: @@ -99,10 +113,13 @@ def get_diff_coverage_info( # pylint: disable=too-many-locals self, added_lines: dict[pathlib.Path, list[int]], coverage: Coverage, + branch_coverage: bool = False, ) -> DiffCoverage: files = {} total_num_lines = 0 total_num_violations = 0 + total_num_branches_covered = 0 + total_num_branches = 0 num_changed_lines = 0 for path, added_lines_for_file in added_lines.items(): @@ -126,7 +143,17 @@ def get_diff_coverage_info( # pylint: disable=too-many-locals total_num_lines += count_total total_num_violations += count_missing - percent_covered = self.compute_coverage(num_covered=count_executed, num_total=count_total) + if branch_coverage: + total_num_branches_covered += file.info.covered_branches or 0 + total_num_branches += file.info.num_branches or 0 + 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, + ) + else: + percent_covered = self.compute_coverage(num_covered=count_executed, num_total=count_total) files[path] = FileDiffCoverage( path=path, @@ -136,10 +163,18 @@ def get_diff_coverage_info( # pylint: disable=too-many-locals added_statements=sorted(added), added_lines=added_lines_for_file, ) - final_percentage = self.compute_coverage( - num_covered=total_num_lines - total_num_violations, - num_total=total_num_lines, - ) + if branch_coverage: + final_percentage = self.compute_coverage( + num_covered=total_num_lines - total_num_violations, + num_total=total_num_lines, + num_branches_covered=total_num_branches_covered, + num_branches_total=total_num_branches, + ) + else: + final_percentage = self.compute_coverage( + num_covered=total_num_lines - total_num_violations, + num_total=total_num_lines, + ) return DiffCoverage( total_num_lines=total_num_lines, diff --git a/codecov/coverage/pytest.py b/codecov/coverage/pytest.py index 6b2638e..dbd18f1 100644 --- a/codecov/coverage/pytest.py +++ b/codecov/coverage/pytest.py @@ -1,16 +1,12 @@ from __future__ import annotations import datetime -import decimal import pathlib from codecov.coverage.base import BaseCoverage, Coverage, CoverageInfo, CoverageMetadata, FileCoverage class PytestCoverage(BaseCoverage): - def _convert_to_decimal(self, value: float, precision: int = 2) -> decimal.Decimal: - return decimal.Decimal(str(float(value) / 100)).quantize(decimal.Decimal(10) ** -precision) - def extract_info(self, data: dict) -> Coverage: """ { @@ -73,7 +69,7 @@ def extract_info(self, data: dict) -> Coverage: info=CoverageInfo( covered_lines=file_data['summary']['covered_lines'], num_statements=file_data['summary']['num_statements'], - percent_covered=self._convert_to_decimal(file_data['summary']['percent_covered']), + percent_covered=self.convert_to_decimal(file_data['summary']['percent_covered']), percent_covered_display=file_data['summary']['percent_covered_display'], missing_lines=file_data['summary']['missing_lines'], excluded_lines=file_data['summary']['excluded_lines'], @@ -88,7 +84,7 @@ def extract_info(self, data: dict) -> Coverage: info=CoverageInfo( covered_lines=data['totals']['covered_lines'], num_statements=data['totals']['num_statements'], - percent_covered=self._convert_to_decimal(data['totals']['percent_covered']), + percent_covered=self.convert_to_decimal(data['totals']['percent_covered']), percent_covered_display=data['totals']['percent_covered_display'], missing_lines=data['totals']['missing_lines'], excluded_lines=data['totals']['excluded_lines'], diff --git a/codecov/main.py b/codecov/main.py index 903c914..e150b66 100644 --- a/codecov/main.py +++ b/codecov/main.py @@ -62,7 +62,11 @@ def _process_coverage(self): if self.config.BRANCH_COVERAGE: coverage = diff_grouper.fill_branch_missing_groups(coverage=coverage) added_lines = GithubDiffParser(diff=self.github.pr_diff).parse() - diff_coverage = self.coverage_module.get_diff_coverage_info(added_lines=added_lines, coverage=coverage) + diff_coverage = self.coverage_module.get_diff_coverage_info( + added_lines=added_lines, + coverage=coverage, + branch_coverage=self.config.BRANCH_COVERAGE, + ) self.coverage = coverage self.diff_coverage = diff_coverage @@ -86,24 +90,24 @@ def _process_pr(self): ) try: comment = template.get_comment_markdown( - coverage=self.coverage, - diff_coverage=self.diff_coverage, - files=files_info, - count_files=count_files, - coverage_files=coverage_files_info, - count_coverage_files=count_coverage_files, - max_files=self.config.MAX_FILES_IN_COMMENT, - minimum_green=self.config.MINIMUM_GREEN, - minimum_orange=self.config.MINIMUM_ORANGE, - repo_name=self.config.GITHUB_REPOSITORY, - pr_number=self.github.pr_number, - base_ref=self.github.base_ref, - base_template=template.read_template_file('comment.md.j2'), - marker=marker, + template.read_template_file('comment.md.j2'), + self.coverage, + self.diff_coverage, + self.config.MINIMUM_GREEN, + self.config.MINIMUM_ORANGE, + self.config.GITHUB_REPOSITORY, + self.github.pr_number, + self.github.base_ref, + marker, subproject_id=self.config.SUBPROJECT_ID, branch_coverage=self.config.BRANCH_COVERAGE, complete_project_report=self.config.COMPLETE_PROJECT_REPORT, coverage_report_url=self.config.COVERAGE_REPORT_URL, + max_files=self.config.MAX_FILES_IN_COMMENT, + files=files_info, + count_files=count_files, + coverage_files=coverage_files_info, + count_coverage_files=count_coverage_files, ) except MissingMarker as e: log.error( diff --git a/codecov/template.py b/codecov/template.py index 77611cb..663cd5a 100644 --- a/codecov/template.py +++ b/codecov/template.py @@ -7,6 +7,7 @@ import itertools import pathlib from importlib import resources +from typing import Any import jinja2 from jinja2.sandbox import SandboxedEnvironment @@ -61,24 +62,17 @@ class FileInfo: def get_comment_markdown( # pylint: disable=too-many-arguments,too-many-locals,too-many-positional-arguments + base_template: str, coverage: Coverage, diff_coverage: DiffCoverage, - files: list[FileInfo], - count_files: int, - coverage_files: list[FileInfo], - count_coverage_files: int, - max_files: int | None, minimum_green: decimal.Decimal, minimum_orange: decimal.Decimal, repo_name: str, pr_number: int, base_ref: str, - base_template: str, marker: str, - subproject_id: str | None = None, - branch_coverage: bool = False, - complete_project_report: bool = False, - coverage_report_url: str | None = None, + /, + **kwargs: Any, ): env = SandboxedEnvironment(loader=jinja2.FileSystemLoader('codecov/template_files/')) env.filters['pct'] = pct @@ -116,18 +110,10 @@ def get_comment_markdown( # pylint: disable=too-many-arguments,too-many-locals, comment = env.from_string(base_template).render( coverage=coverage, diff_coverage=diff_coverage, - max_files=max_files, - files=files, - count_files=count_files, - coverage_files=coverage_files, - count_coverage_files=count_coverage_files, missing_diff_lines=missing_diff_lines, missing_lines_for_whole_project=missing_lines_for_whole_project, - subproject_id=subproject_id, marker=marker, - branch_coverage=branch_coverage, - complete_project_report=complete_project_report, - coverage_report_url=coverage_report_url, + **kwargs, ) except jinja2.exceptions.TemplateError as exc: log.error('Template rendering error: %s', str(exc)) diff --git a/tests/coverage/test_base.py b/tests/coverage/test_base.py index 70bed40..ae2a55e 100644 --- a/tests/coverage/test_base.py +++ b/tests/coverage/test_base.py @@ -164,3 +164,92 @@ def test_get_diff_coverage_info(self, make_coverage_obj, added_lines, update_obj coverage=make_coverage_obj(**update_obj), ) assert result == expected + + @pytest.mark.parametrize( + 'added_lines, update_obj, expected', + [ + # A similar example to the previous one, but with branch coverage enabled. + # The statements are covered, but the branches are not. + ( + { + pathlib.Path('codebase/code.py'): [4, 5, 6], + pathlib.Path('codebase/other.py'): [10, 13], + }, + {}, + DiffCoverage( + total_num_lines=1, + total_num_violations=1, + total_percent_covered=decimal.Decimal('0.25'), + num_changed_lines=5, + # Percent is due to the fact that the branches are not covered. + files={ + pathlib.Path('codebase/code.py'): FileDiffCoverage( + path=pathlib.Path('codebase/code.py'), + percent_covered=decimal.Decimal('0.25'), + added_statements=[6], + covered_statements=[], + missing_statements=[6], + added_lines=[4, 5, 6], + ), + pathlib.Path('codebase/other.py'): FileDiffCoverage( + path=pathlib.Path('codebase/other.py'), + percent_covered=decimal.Decimal('0.25'), + added_statements=[], + covered_statements=[], + missing_statements=[], + added_lines=[10, 13], + ), + }, + ), + ), + # Files with missing coverage and branches + ( + { + pathlib.Path('codebase/code.py'): [2, 3, 4, 5, 6], + pathlib.Path('codebase/other.py'): [10, 11, 12, 13], + }, + { + 'codebase/code.py': { + 'executed_lines': [1, 2, 3, 5, 6], + 'missing_lines': [4, 5], + }, + 'codebase/other.py': { + 'executed_lines': [10, 11, 12, 13], + 'missing_lines': [10, 13], + }, + }, + DiffCoverage( + total_num_lines=9, + total_num_violations=4, + total_percent_covered=decimal.Decimal('0.4375'), + num_changed_lines=9, + # Percent is due to the fact that the branches are not covered. + files={ + pathlib.Path('codebase/code.py'): FileDiffCoverage( + path=pathlib.Path('codebase/code.py'), + percent_covered=decimal.Decimal('0.625'), + added_statements=[2, 3, 4, 5, 6], + covered_statements=[2, 3, 5, 6], + missing_statements=[4, 5], + added_lines=[2, 3, 4, 5, 6], + ), + pathlib.Path('codebase/other.py'): FileDiffCoverage( + path=pathlib.Path('codebase/other.py'), + percent_covered=decimal.Decimal('0.625'), + added_statements=[10, 11, 12, 13], + covered_statements=[10, 11, 12, 13], + missing_statements=[10, 13], + added_lines=[10, 11, 12, 13], + ), + }, + ), + ), + ], + ) + def test_get_diff_coverage_info_branch_coverage(self, make_coverage_obj, added_lines, update_obj, expected): + result = BaseCoverageDemo().get_diff_coverage_info( + added_lines=added_lines, + coverage=make_coverage_obj(**update_obj), + branch_coverage=True, + ) + assert result == expected diff --git a/tests/coverage/test_pytest.py b/tests/coverage/test_pytest.py index c9b8c11..456d7c5 100644 --- a/tests/coverage/test_pytest.py +++ b/tests/coverage/test_pytest.py @@ -24,7 +24,7 @@ def test_extract_info(self, coverage_json): info=CoverageInfo( covered_lines=6, num_statements=10, - percent_covered=PytestCoverage()._convert_to_decimal(60.0), + percent_covered=PytestCoverage().convert_to_decimal(60.0), percent_covered_display='60%', missing_lines=4, excluded_lines=0, @@ -40,7 +40,7 @@ def test_extract_info(self, coverage_json): info=CoverageInfo( covered_lines=6, num_statements=10, - percent_covered=PytestCoverage()._convert_to_decimal(60.0), + percent_covered=PytestCoverage().convert_to_decimal(60.0), percent_covered_display='60%', missing_lines=4, excluded_lines=0, diff --git a/tests/test_template.py b/tests/test_template.py index 9da3a07..9fe2ec2 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -73,40 +73,40 @@ def test_template_no_marker(coverage_obj, diff_coverage_obj): with pytest.raises(MissingMarker): marker = '' template.get_comment_markdown( - coverage=coverage_obj, - diff_coverage=diff_coverage_obj, - files=[], + template.read_template_file('comment.md.j2')[: -len(marker)], + coverage_obj, + diff_coverage_obj, + decimal.Decimal('100'), + decimal.Decimal('70'), + 'org/repo', + 1, + 'main', + marker, count_files=0, coverage_files=[], count_coverage_files=0, - base_ref='main', max_files=25, - 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')[: -len(marker)], - marker=marker, + files=[], ) def test_template_error(coverage_obj, diff_coverage_obj): with pytest.raises(TemplateException): template.get_comment_markdown( - coverage=coverage_obj, - diff_coverage=diff_coverage_obj, + '{% for i in range(5) %}{{ i }{% endfor %}', + coverage_obj, + diff_coverage_obj, + decimal.Decimal('100'), + decimal.Decimal('70'), + 'org/repo', + 1, + 'main', + '', files=[], count_files=0, coverage_files=[], count_coverage_files=0, - base_ref='main', max_files=25, - minimum_green=decimal.Decimal('100'), - minimum_orange=decimal.Decimal('70'), - repo_name='org/repo', - pr_number=1, - base_template='{% for i in range(5) %}{{ i }{% endfor %}', - marker='', ) @@ -119,25 +119,25 @@ def test_get_comment_markdown(coverage_obj, diff_coverage_obj): ) result = ( template.get_comment_markdown( - coverage=coverage_obj, - diff_coverage=diff_coverage_obj, - coverage_files=chaned_files, - count_coverage_files=total, - files=chaned_files, - count_files=total, - max_files=25, - minimum_green=decimal.Decimal('100'), - minimum_orange=decimal.Decimal('70'), - base_ref='main', - marker='', - repo_name='org/repo', - pr_number=1, - base_template=""" + """ {{ coverage.info.percent_covered | pct }} {{ diff_coverage.total_percent_covered | pct }} {% block foo %}foo{% endblock foo %} {{ marker }} """, + coverage_obj, + diff_coverage_obj, + decimal.Decimal('100'), + decimal.Decimal('70'), + 'org/repo', + 1, + 'main', + '', + coverage_files=chaned_files, + count_coverage_files=total, + files=chaned_files, + count_files=total, + max_files=25, ) .strip() .split(maxsplit=3) @@ -161,20 +161,20 @@ def test_comment_template(coverage_obj, diff_coverage_obj): skip_covered_files_in_report=True, ) result = template.get_comment_markdown( - coverage=coverage_obj, - diff_coverage=diff_coverage_obj, + template.read_template_file('comment.md.j2'), + coverage_obj, + diff_coverage_obj, + decimal.Decimal('100'), + decimal.Decimal('70'), + 'org/repo', + 1, + 'main', + '', coverage_files=chaned_files, count_coverage_files=total, files=chaned_files, count_files=total, max_files=25, - minimum_green=decimal.Decimal('100'), - minimum_orange=decimal.Decimal('70'), - base_ref='main', - marker='', - repo_name='org/repo', - pr_number=1, - base_template=template.read_template_file('comment.md.j2'), ) assert result.startswith('## Coverage report') assert '' in result @@ -188,20 +188,20 @@ def test_comment_template_branch_coverage(coverage_obj, diff_coverage_obj): skip_covered_files_in_report=True, ) result = template.get_comment_markdown( - coverage=coverage_obj, - diff_coverage=diff_coverage_obj, + template.read_template_file('comment.md.j2'), + coverage_obj, + diff_coverage_obj, + decimal.Decimal('100'), + decimal.Decimal('70'), + 'org/repo', + 1, + 'main', + '', coverage_files=chaned_files, count_coverage_files=total, files=chaned_files, count_files=total, max_files=25, - minimum_green=decimal.Decimal('100'), - minimum_orange=decimal.Decimal('70'), - base_ref='main', - marker='', - repo_name='org/repo', - pr_number=1, - base_template=template.read_template_file('comment.md.j2'), branch_coverage=True, ) assert result.startswith('## Coverage report') @@ -220,20 +220,20 @@ def test_template_no_files(coverage_obj): files={}, ) result = template.get_comment_markdown( - coverage=coverage_obj, - diff_coverage=diff_coverage, + template.read_template_file('comment.md.j2'), + coverage_obj, + diff_coverage, + decimal.Decimal('79'), + decimal.Decimal('40'), + 'org/repo', + 5, + 'main', + '', files=[], count_files=0, coverage_files=[], count_coverage_files=0, - minimum_green=decimal.Decimal('79'), - minimum_orange=decimal.Decimal('40'), - repo_name='org/repo', - base_ref='main', - pr_number=5, max_files=25, - base_template=template.read_template_file('comment.md.j2'), - marker='', subproject_id='foo', ) assert '_This PR does not include changes to coverable code or code with missing coverage.' in result