diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e20abf2..d75ee7e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -77,7 +77,7 @@ repos: - id: commit-msg name: Check commit message language: pygrep - entry: '^(chore|test|setup|feature|fix|build|docs|refactor|release)!?: [a-zA-Z0-9-_ ]+[a-zA-Z0-9-_ ]+.*' + entry: '^(feature|fix|docs|refactor)!?: [a-zA-Z0-9-_ ]+[a-zA-Z0-9-_ ]+.*' args: - --negate # fails if the entry is NOT matched stages: diff --git a/MANIFEST.in b/MANIFEST.in index f082564..46c851b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1 @@ -include codecov/template_files/comment.md.j2 -include codecov/template_files/pr.md.j2 -include codecov/template_files/project.md.j2 -include codecov/template_files/macros.md.j2 +include codecov/template_files/*.md.j2 diff --git a/codecov/config.py b/codecov/config.py index 95819c8..20b47cd 100644 --- a/codecov/config.py +++ b/codecov/config.py @@ -1,12 +1,10 @@ -from __future__ import annotations - import dataclasses import decimal import inspect import pathlib from collections.abc import MutableMapping from enum import Enum -from typing import Any, Callable +from typing import Any, Callable, Self from codecov.exceptions import MissingEnvironmentVariable @@ -35,11 +33,14 @@ class AnnotationType(Enum): ERROR = 'error' +class TestFramework(Enum): + PYTEST = 'pytest' + JEST = 'jest' + + # pylint: disable=invalid-name, too-many-instance-attributes @dataclasses.dataclass(kw_only=True) class Config: - """This object defines the environment variables""" - GITHUB_REPOSITORY: str COVERAGE_PATH: pathlib.Path GITHUB_TOKEN: str = dataclasses.field(repr=False) @@ -47,11 +48,13 @@ class Config: # Branch to create the comment on (alternate to get PR number if not provided) # Example Organisation:branch-name (Company:sample-branch) or User:branch-name (user:sample-branch) GITHUB_REF: str | None = None - SUBPROJECT_ID: str | None = None + SUBPROJECT_ID: str | None = None # Deprecated MINIMUM_GREEN: decimal.Decimal = decimal.Decimal('100') MINIMUM_ORANGE: decimal.Decimal = decimal.Decimal('70') + TEST_FRAMEWORK: TestFramework = TestFramework.PYTEST + # TODO: Remove branch coverage and just use the report BRANCH_COVERAGE: bool = False - SKIP_COVERAGE: bool = False + SKIP_COVERAGE: bool = False # Deprecated ANNOTATE_MISSING_LINES: bool = False ANNOTATION_TYPE: AnnotationType = AnnotationType.WARNING ANNOTATIONS_OUTPUT_PATH: pathlib.Path | None = None @@ -60,12 +63,19 @@ class Config: SKIP_COVERED_FILES_IN_REPORT: bool = True COMPLETE_PROJECT_REPORT: bool = False COVERAGE_REPORT_URL: str | None = None + # TODO: Change this to LOG_LEVEL. INFO; DEBUG; NONE; DEBUG: bool = False def __post_init__(self) -> None: if self.GITHUB_PR_NUMBER is None and self.GITHUB_REF is None: raise ValueError('Either GITHUB_PR_NUMBER or GITHUB_REF must be provided') + if self.SKIP_COVERAGE and not self.ANNOTATE_MISSING_LINES: + raise ValueError( + 'No action taken as both SKIP_COVERAGE and ANNOTATE_MISSING_LINES are set to False. \ + Neither comments nor annotations will be generated.' + ) + # Clean methods @classmethod def clean_minimum_green(cls, value: str) -> decimal.Decimal: @@ -122,10 +132,14 @@ def clean_annotations_output_path(cls, value: str) -> pathlib.Path: return path raise ValueError + @classmethod + def clean_test_framework(cls, value: str) -> TestFramework: + return TestFramework(value) + # We need to type environ as a MutableMapping because that's what # os.environ is, and `dict[str, str]` is not enough @classmethod - def from_environ(cls, environ: MutableMapping[str, str]) -> Config: + def from_environ(cls, environ: MutableMapping[str, str]) -> Self: possible_variables = list(inspect.signature(cls).parameters) config_dict: dict[str, Any] = {k: v for k, v in environ.items() if k in possible_variables} for key, value in config_dict.items(): diff --git a/codecov/coverage/__init__.py b/codecov/coverage/__init__.py index bbcf007..e69de29 100644 --- a/codecov/coverage/__init__.py +++ b/codecov/coverage/__init__.py @@ -1,12 +0,0 @@ -from .base import Coverage, CoverageInfo, CoverageMetadata, DiffCoverage, FileCoverage, FileDiffCoverage -from .pytest import PytestCoverage - -__all__ = [ - 'Coverage', - 'DiffCoverage', - 'FileCoverage', - 'FileDiffCoverage', - 'CoverageMetadata', - 'CoverageInfo', - 'PytestCoverage', -] diff --git a/codecov/coverage/base.py b/codecov/coverage/base.py index 1e2f4cc..75dccf2 100644 --- a/codecov/coverage/base.py +++ b/codecov/coverage/base.py @@ -1,52 +1,15 @@ import dataclasses -import datetime import decimal import json import pathlib from abc import ABC, abstractmethod +from typing import Any +from codecov.config import Config, TestFramework from codecov.exceptions import ConfigurationException from codecov.log import log - -@dataclasses.dataclass -class CoverageMetadata: - version: str - timestamp: datetime.datetime - branch_coverage: bool - show_contexts: bool - - -@dataclasses.dataclass -class CoverageInfo: # pylint: disable=too-many-instance-attributes - covered_lines: int - num_statements: int - percent_covered: decimal.Decimal - percent_covered_display: str - missing_lines: int - excluded_lines: int - num_branches: int | None - num_partial_branches: int | None - covered_branches: int | None - missing_branches: int | None - - -@dataclasses.dataclass -class FileCoverage: - path: pathlib.Path - executed_lines: list[int] - missing_lines: list[int] - excluded_lines: list[int] - executed_branches: list[list[int]] | None - missing_branches: list[list[int]] | None - info: CoverageInfo - - -@dataclasses.dataclass -class Coverage: - meta: CoverageMetadata - info: CoverageInfo - files: dict[pathlib.Path, FileCoverage] +COVERAGE_HANDLER_REGISTRY: dict[TestFramework, type['BaseCoverageHandler']] = {} @dataclasses.dataclass @@ -70,27 +33,23 @@ class DiffCoverage: files: dict[pathlib.Path, FileDiffCoverage] -class BaseCoverage(ABC): - def convert_to_decimal(self, value: float, precision: int = 2) -> decimal.Decimal: - return decimal.Decimal(str(float(value) / 100)).quantize( +class BaseCoverageHandler(ABC): + TEST_FRAMEWORK: TestFramework + + def __init_subclass__(cls) -> None: + COVERAGE_HANDLER_REGISTRY[cls.TEST_FRAMEWORK] = cls + super().__init_subclass__() + + def convert_to_decimal(self, value: float | decimal.Decimal, precision: int = 2) -> decimal.Decimal: + if not isinstance(value, decimal.Decimal): + value = decimal.Decimal(str(float(value) / 100)) + return value.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 numerator / denominator - - def get_coverage_info(self, coverage_path: pathlib.Path) -> Coverage: + # TODO: Fix the typing and rename this to get_coverage_json + def get_coverage_info(self, coverage_path: pathlib.Path) -> Any: try: with coverage_path.open() as coverage_data: json_coverage = json.loads(coverage_data.read()) @@ -101,83 +60,33 @@ def get_coverage_info(self, coverage_path: pathlib.Path) -> Coverage: log.error('Invalid JSON format in coverage report file: %s', coverage_path) raise ConfigurationException from exc - return self.extract_info(data=json_coverage) + # TODO: Move the below code to a separate function + try: + return self.extract_info(data=json_coverage) + except KeyError as exc: + log.error('Unable to extract coverage info from coverage report file: %s', coverage_path) + raise ConfigurationException from exc @abstractmethod - def extract_info(self, data: dict) -> Coverage: + def extract_info(self, data: dict) -> Any: raise NotImplementedError # pragma: no cover - def get_diff_coverage_info( # pylint: disable=too-many-locals + def get_coverage(self, config: Config) -> Any: + return self.get_coverage_info(coverage_path=config.COVERAGE_PATH) + + @abstractmethod + def get_diff_coverage( self, added_lines: dict[pathlib.Path, list[int]], - coverage: Coverage, - branch_coverage: bool = False, + coverage: Any, + config: Config, ) -> 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(): - num_changed_lines += len(added_lines_for_file) - - try: - file = coverage.files[path] - except KeyError: - continue - - executed = set(file.executed_lines) & set(added_lines_for_file) - count_executed = len(executed) - - missing = set(file.missing_lines) & set(added_lines_for_file) - count_missing = len(missing) - - # Added lines includes comments, blank lines, etc in the diff, So we take the actual statements in the file - added = executed | missing - count_total = len(added) - - total_num_lines += count_total - total_num_violations += count_missing - - 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, - percent_covered=percent_covered, - covered_statements=sorted(executed), - missing_statements=sorted(missing), - added_statements=sorted(added), - added_lines=added_lines_for_file, - ) - 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, - total_num_violations=total_num_violations, - total_percent_covered=final_percentage, - num_changed_lines=num_changed_lines, - files=files, - ) + raise NotImplementedError # pragma: no cover + + @classmethod + def get_coverage_handler(cls, test_framework: TestFramework) -> type['BaseCoverageHandler']: + try: + return COVERAGE_HANDLER_REGISTRY[test_framework] + except KeyError as exc: + log.error('No coverage handler found for test framework: %s', test_framework.value) + raise ConfigurationException from exc diff --git a/codecov/coverage/jest.py b/codecov/coverage/jest.py new file mode 100644 index 0000000..6252757 --- /dev/null +++ b/codecov/coverage/jest.py @@ -0,0 +1,223 @@ +import dataclasses +import decimal +import pathlib + +from codecov.config import Config, TestFramework +from codecov.coverage.base import BaseCoverageHandler, DiffCoverage, FileDiffCoverage + + +@dataclasses.dataclass +class JestCoverageInfo: # pylint: disable=too-many-instance-attributes + num_statements: int + covered_lines: int + missing_lines: int + excluded_lines: int + num_functions: int + covered_functions: int + missing_functions: int + percent_covered: decimal.Decimal + percent_covered_display: str + + +@dataclasses.dataclass +class JestFileCoverage: + path: pathlib.Path + covered_lines: list[int] + missing_lines: list[int] + excluded_lines: list[int] + info: JestCoverageInfo + + +@dataclasses.dataclass +class JestCoverage: + info: JestCoverageInfo + files: dict[pathlib.Path, JestFileCoverage] + + +class JestCoverageHandler(BaseCoverageHandler): + TEST_FRAMEWORK: TestFramework = TestFramework.JEST + + """ + { + "/app/sample/index.ts": { + "path": "/app/sample/index.ts", + "statementMap": {}, + "fnMap": {}, + "branchMap": {}, + "s": {}, + "f": {}, + "b": {}, + "_coverageSchema": "1a1c01bbd47fc00a2c39e90264f33305004495a9", + "hash": "8d4713275a40f0982d031526eae55ee00e551b73" + }, + } + """ + + def compute_coverage( # pylint: disable=too-many-arguments,too-many-positional-arguments + self, + num_covered: int, + num_total: int, + num_functions_covered: int = 0, + num_functions_total: int = 0, + num_branches_covered: int = 0, + num_branches_total: int = 0, + ) -> decimal.Decimal: + numerator = decimal.Decimal(num_covered + num_branches_covered + num_functions_covered) + denominator = decimal.Decimal(num_total + num_branches_total + num_functions_total) + if denominator == 0: + return decimal.Decimal('1') + return numerator / denominator + + # TODO: use pct helper + def _get_percentage_display(self, value: decimal.Decimal) -> str: + percentage_display = str(self.convert_to_decimal(value=value) * 100) + return percentage_display.split('.', maxsplit=1)[0] + + def _get_function_map_coverage(self, file_data: dict) -> dict[str, set[int]]: + covered_lines: set[int] = set() + missing_lines: set[int] = set() + for line, count in file_data['f'].items(): + if count > 0: + start = file_data['fnMap'][line]['loc']['start']['line'] + end = file_data['fnMap'][line]['loc']['end']['line'] + covered_lines.update(range(start, end + 1)) + else: + start = file_data['fnMap'][line]['loc']['start']['line'] + end = file_data['fnMap'][line]['loc']['end']['line'] + missing_lines.update(range(start, end + 1)) + return { + 'covered_lines': covered_lines, + 'missing_lines': missing_lines, + } + + def _get_statement_map_coverage(self, file_data: dict) -> dict[str, set[int]]: + covered_lines: set[int] = set() + missing_lines: set[int] = set() + for line, count in file_data['s'].items(): + if count > 0: + start = file_data['statementMap'][line]['start']['line'] + end = file_data['statementMap'][line]['end']['line'] + covered_lines.update(range(start, end + 1)) + else: + start = file_data['statementMap'][line]['start']['line'] + end = file_data['statementMap'][line]['end']['line'] + missing_lines.update(range(start, end + 1)) + + return { + 'covered_lines': covered_lines, + 'missing_lines': missing_lines, + } + + def extract_file_coverage(self, file_data: dict) -> JestFileCoverage: + statement_map_coverage = self._get_statement_map_coverage(file_data) + function_map_coverage = self._get_function_map_coverage(file_data) + total_covered_lines = statement_map_coverage['covered_lines'] | function_map_coverage['covered_lines'] + total_missing_lines = statement_map_coverage['missing_lines'] | function_map_coverage['missing_lines'] + + # Partially covered lines can be in both covered and missing lines + # Remove missing lines from covered since they are not fully covered + total_covered_lines -= total_missing_lines + + num_covered_lines = len(total_covered_lines) + num_missing_lines = len(total_missing_lines) + num_statements = len(total_covered_lines) + len(total_missing_lines) + + percent_covered = self.compute_coverage(num_covered=num_covered_lines, num_total=num_statements) + return JestFileCoverage( + path=pathlib.Path(file_data['path']), + excluded_lines=[], # TODO: Add excluded lines + missing_lines=list(total_missing_lines), + covered_lines=list(total_covered_lines), + info=JestCoverageInfo( + covered_lines=num_covered_lines, + num_statements=num_statements, + missing_lines=num_missing_lines, + excluded_lines=0, # TODO: Add excluded lines + num_functions=0, + covered_functions=0, + missing_functions=0, + percent_covered=percent_covered, + percent_covered_display=self._get_percentage_display(value=percent_covered), + ), + ) + + def extract_info(self, data: dict) -> JestCoverage: + files: dict[pathlib.Path, JestFileCoverage] = {} + total_covered_lines: int = 0 + total_num_statements: int = 0 + total_missing_lines: int = 0 + total_excluded_lines: int = 0 + for file_data in data.values(): + file_coverage = self.extract_file_coverage(file_data) + files[pathlib.Path(file_data['path'])] = file_coverage + total_covered_lines += file_coverage.info.covered_lines + total_num_statements += file_coverage.info.num_statements + total_missing_lines += file_coverage.info.missing_lines + total_excluded_lines += file_coverage.info.excluded_lines + + percent_covered = self.compute_coverage(num_covered=total_covered_lines, num_total=total_num_statements) + return JestCoverage( + files=files, + info=JestCoverageInfo( + covered_lines=total_covered_lines, + num_statements=total_num_statements, + missing_lines=total_missing_lines, + excluded_lines=total_excluded_lines, + num_functions=0, + covered_functions=0, + missing_functions=0, + percent_covered=percent_covered, + percent_covered_display=self._get_percentage_display(value=percent_covered), + ), + ) + + def get_file_diff_coverage(self, file: JestFileCoverage, added_lines: list[int]) -> FileDiffCoverage: + covered = set(file.covered_lines) & set(added_lines) + missing = set(file.missing_lines) & set(added_lines) + # Added lines includes comments, blank lines, etc in the diff, So we take the actual statements in the file + added = covered | missing + return FileDiffCoverage( + path=file.path, + percent_covered=self.compute_coverage(num_covered=len(covered), num_total=len(added)), + covered_statements=sorted(covered), + missing_statements=sorted(missing), + added_statements=sorted(added), + added_lines=added_lines, + ) + + def get_diff_coverage( # pylint: disable=duplicate-code + self, + added_lines: dict[pathlib.Path, list[int]], + coverage: JestCoverage, + config: Config, + ) -> DiffCoverage: + files = {} + total_num_lines = 0 + total_num_violations = 0 + num_changed_lines = 0 + + for path, added_lines_for_file in added_lines.items(): + num_changed_lines += len(added_lines_for_file) + + try: + file = coverage.files[path] + except KeyError: + continue + + file_diff_coverage = self.get_file_diff_coverage(file=file, added_lines=added_lines_for_file) + files[path] = file_diff_coverage + total_num_lines += len(file_diff_coverage.added_statements) + total_num_violations += len(file_diff_coverage.missing_statements) + + 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, + total_num_violations=total_num_violations, + total_percent_covered=final_percentage, + num_changed_lines=num_changed_lines, + files=files, + ) diff --git a/codecov/coverage/pytest.py b/codecov/coverage/pytest.py index f134b84..fa2cfe3 100644 --- a/codecov/coverage/pytest.py +++ b/codecov/coverage/pytest.py @@ -1,11 +1,104 @@ +import dataclasses import datetime +import decimal import pathlib -from codecov.coverage.base import BaseCoverage, Coverage, CoverageInfo, CoverageMetadata, FileCoverage +from codecov import diff_grouper +from codecov.config import Config, TestFramework +from codecov.coverage.base import BaseCoverageHandler, DiffCoverage, FileDiffCoverage -class PytestCoverage(BaseCoverage): - def extract_info(self, data: dict) -> Coverage: +@dataclasses.dataclass +class PytestCoverageInfo: # pylint: disable=too-many-instance-attributes + covered_lines: int + num_statements: int + missing_lines: int + excluded_lines: int + num_branches: int | None + num_partial_branches: int | None # TODO: Removed this + covered_branches: int | None + missing_branches: int | None + percent_covered: decimal.Decimal + percent_covered_display: str + + +@dataclasses.dataclass +class PytestFileCoverage: + path: pathlib.Path + covered_lines: list[int] + missing_lines: list[int] + excluded_lines: list[int] + info: PytestCoverageInfo + executed_branches: list[list[int]] | None + missing_branches: list[list[int]] | None + + +@dataclasses.dataclass +class PytestCoverageMetadata: + version: str + timestamp: datetime.datetime + branch_coverage: bool + show_contexts: bool + + +@dataclasses.dataclass +class PytestCoverage: + info: PytestCoverageInfo + files: dict[pathlib.Path, PytestFileCoverage] + meta: PytestCoverageMetadata + + +class PytestCoverageHandler(BaseCoverageHandler): + TEST_FRAMEWORK: TestFramework = TestFramework.PYTEST + + 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 numerator / denominator + + def extract_meta(self, data: dict) -> PytestCoverageMetadata: + return PytestCoverageMetadata( + version=data['meta']['version'], + timestamp=datetime.datetime.fromisoformat(data['meta']['timestamp']), + branch_coverage=data['meta']['branch_coverage'], + show_contexts=data['meta']['show_contexts'], + ) + + def extract_coverage_info(self, data: dict) -> PytestCoverageInfo: + return PytestCoverageInfo( + covered_lines=data['covered_lines'], + num_statements=data['num_statements'], + percent_covered=self.convert_to_decimal(data['percent_covered']), + percent_covered_display=data['percent_covered_display'], + missing_lines=data['missing_lines'], + excluded_lines=data['excluded_lines'], + num_branches=data.get('num_branches'), + num_partial_branches=data.get('num_partial_branches'), + covered_branches=data.get('covered_branches'), + missing_branches=data.get('missing_branches'), + ) + + def extract_file_coverage(self, path: str, file_data: dict) -> PytestFileCoverage: + return PytestFileCoverage( + path=pathlib.Path(path), + excluded_lines=file_data['excluded_lines'], + missing_lines=file_data['missing_lines'], + covered_lines=file_data['executed_lines'], + executed_branches=file_data.get('executed_branches'), + missing_branches=file_data.get('missing_branches'), + info=self.extract_coverage_info(file_data['summary']), + ) + + # TODO: Fix the typing of data + def extract_info(self, data: dict) -> PytestCoverage: """ { "meta": { @@ -49,46 +142,94 @@ def extract_info(self, data: dict) -> Coverage: }, } """ - return Coverage( - meta=CoverageMetadata( - version=data['meta']['version'], - timestamp=datetime.datetime.fromisoformat(data['meta']['timestamp']), - branch_coverage=data['meta']['branch_coverage'], - show_contexts=data['meta']['show_contexts'], - ), + return PytestCoverage( + meta=self.extract_meta(data), files={ - pathlib.Path(path): FileCoverage( - path=pathlib.Path(path), - excluded_lines=file_data['excluded_lines'], - missing_lines=file_data['missing_lines'], - executed_lines=file_data['executed_lines'], - executed_branches=file_data.get('executed_branches'), - missing_branches=file_data.get('missing_branches'), - 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_display=file_data['summary']['percent_covered_display'], - missing_lines=file_data['summary']['missing_lines'], - excluded_lines=file_data['summary']['excluded_lines'], - num_branches=file_data['summary'].get('num_branches'), - num_partial_branches=file_data['summary'].get('num_partial_branches'), - covered_branches=file_data['summary'].get('covered_branches'), - missing_branches=file_data['summary'].get('missing_branches'), - ), - ) + pathlib.Path(path): self.extract_file_coverage(path, file_data) for path, file_data in data['files'].items() }, - 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_display=data['totals']['percent_covered_display'], - missing_lines=data['totals']['missing_lines'], - excluded_lines=data['totals']['excluded_lines'], - num_branches=data['totals'].get('num_branches'), - num_partial_branches=data['totals'].get('num_partial_branches'), - covered_branches=data['totals'].get('covered_branches'), - missing_branches=data['totals'].get('missing_branches'), - ), + info=self.extract_coverage_info(data['totals']), + ) + + def get_diff_coverage( # pylint: disable=too-many-locals + self, + added_lines: dict[pathlib.Path, list[int]], + coverage: PytestCoverage, + config: Config, + ) -> 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(): + num_changed_lines += len(added_lines_for_file) + + try: + file = coverage.files[path] + except KeyError: + continue + + executed = set(file.covered_lines) & set(added_lines_for_file) + count_executed = len(executed) + + missing = set(file.missing_lines) & set(added_lines_for_file) + count_missing = len(missing) + + # Added lines includes comments, blank lines, etc in the diff, So we take the actual statements in the file + added = executed | missing + count_total = len(added) + + total_num_lines += count_total + total_num_violations += count_missing + + if config.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, + percent_covered=percent_covered, + covered_statements=sorted(executed), + missing_statements=sorted(missing), + added_statements=sorted(added), + added_lines=added_lines_for_file, + ) + if config.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, + total_num_violations=total_num_violations, + total_percent_covered=final_percentage, + num_changed_lines=num_changed_lines, + files=files, ) + + def get_coverage(self, config: Config) -> PytestCoverage: + coverage = super().get_coverage(config=config) + + if config.BRANCH_COVERAGE: + coverage = diff_grouper.fill_branch_missing_groups(coverage=coverage) + + return coverage diff --git a/codecov/diff_grouper.py b/codecov/diff_grouper.py index f886da8..2c7e53f 100644 --- a/codecov/diff_grouper.py +++ b/codecov/diff_grouper.py @@ -1,7 +1,12 @@ from collections.abc import Iterable +from typing import TYPE_CHECKING from codecov import groups -from codecov.coverage import Coverage, DiffCoverage + +if TYPE_CHECKING: + from codecov.coverage.base import DiffCoverage + from codecov.coverage.jest import JestCoverage + from codecov.coverage.pytest import PytestCoverage MAX_ANNOTATION_GAP = 3 @@ -21,7 +26,7 @@ def _flatten_branches(branches: list[list[int]] | None) -> list[int]: def get_missing_groups( - coverage: Coverage, + coverage: 'PytestCoverage | JestCoverage', ) -> Iterable[groups.Group]: for path, coverage_file in coverage.files.items(): # Lines that are covered or excluded should not be considered for @@ -29,7 +34,7 @@ def get_missing_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.covered_lines, *coverage_file.excluded_lines, } # Lines that should be considered for filling a gap, unless @@ -50,13 +55,13 @@ def get_missing_groups( def get_diff_missing_groups( - coverage: Coverage, - diff_coverage: DiffCoverage, + coverage: 'PytestCoverage | JestCoverage', + diff_coverage: 'DiffCoverage', ) -> Iterable[groups.Group]: for path, diff_file in diff_coverage.files.items(): coverage_file = coverage.files[path] separators = { - *coverage_file.executed_lines, + *coverage_file.covered_lines, *coverage_file.excluded_lines, } joiners = set(diff_file.added_lines) - separators @@ -74,7 +79,7 @@ def get_diff_missing_groups( ) -def fill_branch_missing_groups(coverage: Coverage) -> Coverage: +def fill_branch_missing_groups(coverage: 'PytestCoverage') -> 'PytestCoverage': for file_coverage in coverage.files.values(): separators = { *_flatten_branches(file_coverage.executed_branches), @@ -95,8 +100,8 @@ def fill_branch_missing_groups(coverage: Coverage) -> Coverage: def get_diff_branch_missing_groups( - coverage: Coverage, - diff_coverage: DiffCoverage, + coverage: 'PytestCoverage', + diff_coverage: 'DiffCoverage', ) -> Iterable[groups.Group]: for path, _ in diff_coverage.files.items(): coverage_file = coverage.files[path] diff --git a/codecov/github.py b/codecov/github.py index 707c7f5..4454f04 100644 --- a/codecov/github.py +++ b/codecov/github.py @@ -283,7 +283,7 @@ def __init__(self, diff: str): def _get_hunk_start_and_length(self, diff_line: str) -> tuple[int, int]: # The diff_line looks like: "@@ -60,0 +61,9 @@ ...", and we want to extract the starting line number of the added lines. - # diff_line.split()[2] gives "+61,9" (the added lines part). + # diff_line.split()[2] gives "+61,9" (the added lines part, the right part of the pr diff). # 61 is the starting line number of the added lines and 9 is the number of lines in the hunk (context lines + added lines). # [1:] removes the '+' sign, so we get "61,9". # Adding ',1' ensures that if there's no comma (e.g., "+61"), we still get a tuple ("61", "1"). diff --git a/codecov/github_client.py b/codecov/github_client.py index 7719beb..1dafcc9 100644 --- a/codecov/github_client.py +++ b/codecov/github_client.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any import httpx @@ -19,8 +17,21 @@ BASE_URL = 'https://api.github.com' +class JsonObject(dict): + """ + general json object that can bind any fields but also act as a dict. + """ + + def __getattr__(self, key): + try: + return self[key] + except KeyError as e: + log.error("'Dict' object has no attribute '%s'", key) + raise AttributeError from e + + class _Executable: - def __init__(self, _gh: GitHubClient, _method: str, _path: str): + def __init__(self, _gh: 'GitHubClient', _method: str, _path: str): self._gh = _gh self._method = _method self._path = _path @@ -53,19 +64,6 @@ def _response_contents(response: httpx.Response) -> JsonObject | bytes: return response.content -class JsonObject(dict): - """ - general json object that can bind any fields but also act as a dict. - """ - - def __getattr__(self, key): - try: - return self[key] - except KeyError as e: - log.error("'Dict' object has no attribute '%s'", key) - raise AttributeError from e - - class GitHubClient: def __init__(self, token: str, url: str = BASE_URL, follow_redirects: bool = True): self.token = token diff --git a/codecov/main.py b/codecov/main.py index e150b66..d31c8df 100644 --- a/codecov/main.py +++ b/codecov/main.py @@ -1,10 +1,12 @@ import json import os +from typing import cast from codecov import diff_grouper, groups, template from codecov.config import Config -from codecov.coverage import PytestCoverage -from codecov.coverage.base import Coverage, DiffCoverage +from codecov.coverage.base import BaseCoverageHandler, DiffCoverage +from codecov.coverage.jest import JestCoverage +from codecov.coverage.pytest import PytestCoverage from codecov.exceptions import ConfigurationException, CoreProcessingException, MissingMarker, TemplateException from codecov.github import Github, GithubDiffParser from codecov.github_client import GitHubClient @@ -15,27 +17,20 @@ class Main: def __init__(self): self.config = self._init_config() self._init_log() - self._init_required() self.github = self._init_github() - self.coverage: Coverage + self.coverage_module = self._init_coverage_module() + self.marker: str = self._init_marker() + self.comment: str = '' + self.coverage: PytestCoverage | JestCoverage self.diff_coverage: DiffCoverage - # Default coverage module - self.coverage_module = PytestCoverage() - def _init_config(self): + def _init_config(self) -> Config: return Config.from_environ(environ=os.environ) - def _init_log(self): + def _init_log(self) -> None: log_setup(debug=self.config.DEBUG) - def _init_required(self): - if self.config.SKIP_COVERAGE and not self.config.ANNOTATE_MISSING_LINES: - log.error( - 'No action taken as both SKIP_COVERAGE and ANNOTATE_MISSING_LINES are set to False. No comments or annotations will be generated.' - ) - raise CoreProcessingException - - def _init_github(self): + def _init_github(self) -> Github: gh_client = GitHubClient(token=self.config.GITHUB_TOKEN) github = Github( client=gh_client, @@ -46,46 +41,57 @@ def _init_github(self): ) return github + def _init_coverage_module(self) -> BaseCoverageHandler: + try: + return BaseCoverageHandler.get_coverage_handler(test_framework=self.config.TEST_FRAMEWORK)() + except ConfigurationException as e: + log.error('Error initializing coverage module. Please check the test framework and try again.') + raise CoreProcessingException from e + + def _init_marker(self) -> str: + return template.get_marker(marker_id=self.config.SUBPROJECT_ID) + def run(self): self._process_coverage() - self._process_pr() + self._render_comment_markdown() + self._create_comment() self._generate_annotations() def _process_coverage(self): log.info('Processing coverage data') - try: - coverage = self.coverage_module.get_coverage_info(coverage_path=self.config.COVERAGE_PATH) - except ConfigurationException as e: - log.error('Error parsing the coverage file. Please check the file and try again.') - raise CoreProcessingException from e - - if self.config.BRANCH_COVERAGE: - coverage = diff_grouper.fill_branch_missing_groups(coverage=coverage) + coverage = self._get_coverage() added_lines = GithubDiffParser(diff=self.github.pr_diff).parse() - diff_coverage = self.coverage_module.get_diff_coverage_info( + diff_coverage = self.coverage_module.get_diff_coverage( added_lines=added_lines, coverage=coverage, - branch_coverage=self.config.BRANCH_COVERAGE, + config=self.config, ) self.coverage = coverage self.diff_coverage = diff_coverage - def _process_pr(self): + def _get_coverage(self) -> PytestCoverage | JestCoverage: + try: + return self.coverage_module.get_coverage(config=self.config) + except ConfigurationException as e: + log.error('Error parsing the coverage file. Please check the file and try again.') + raise CoreProcessingException from e + + def _render_comment_markdown(self) -> None: if self.config.SKIP_COVERAGE: log.info('Skipping coverage report generation.') return log.info('Generating comment for PR #%s', self.github.pr_number) - marker = template.get_marker(marker_id=self.config.SUBPROJECT_ID) - files_info, count_files = template.select_changed_files( + diff_files_info, diff_count_files = template.select_changed_files( coverage=self.coverage, diff_coverage=self.diff_coverage, max_files=self.config.MAX_FILES_IN_COMMENT, skip_covered_files_in_report=self.config.SKIP_COVERED_FILES_IN_REPORT, ) + remaining_files = self.config.MAX_FILES_IN_COMMENT - diff_count_files coverage_files_info, count_coverage_files = template.select_files( coverage=self.coverage, - max_files=self.config.MAX_FILES_IN_COMMENT - count_files, # Truncate the report to MAX_FILES_IN_COMMENT + max_files=remaining_files, # Truncate the report to MAX_FILES_IN_COMMENT skip_covered_files_in_report=self.config.SKIP_COVERED_FILES_IN_REPORT, ) try: @@ -98,21 +104,21 @@ def _process_pr(self): self.config.GITHUB_REPOSITORY, self.github.pr_number, self.github.base_ref, - marker, + self.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, + files=diff_files_info, + count_files=diff_count_files, coverage_files=coverage_files_info, count_coverage_files=count_coverage_files, ) except MissingMarker as e: log.error( 'Marker "%s" not found. This marker is required to identify the comment and prevent creating or overwriting comments.', - marker, + self.marker, ) raise CoreProcessingException from e except TemplateException as e: @@ -122,7 +128,14 @@ def _process_pr(self): ) raise CoreProcessingException from e - self.github.post_comment(contents=comment, marker=marker) + self.comment = comment + + def _create_comment(self): + if not self.comment: + log.error('Failed to generate comment, rendered template is empty.') + raise CoreProcessingException + + self.github.post_comment(contents=self.comment, marker=self.marker) log.info('Comment created on PR.') def _generate_annotations(self): @@ -139,7 +152,7 @@ def _generate_annotations(self): if self.config.BRANCH_COVERAGE: branch_annotations = diff_grouper.get_diff_branch_missing_groups( - coverage=self.coverage, + coverage=cast(PytestCoverage, self.coverage), diff_coverage=self.diff_coverage, ) formatted_annotations.extend( diff --git a/codecov/template.py b/codecov/template.py index 010a055..df89203 100644 --- a/codecov/template.py +++ b/codecov/template.py @@ -11,11 +11,14 @@ from jinja2.sandbox import SandboxedEnvironment from codecov import badge, diff_grouper -from codecov.coverage import Coverage, DiffCoverage, FileCoverage, FileDiffCoverage +from codecov.coverage.base import DiffCoverage, FileDiffCoverage +from codecov.coverage.jest import JestCoverage, JestFileCoverage +from codecov.coverage.pytest import PytestCoverage, PytestFileCoverage from codecov.exceptions import MissingMarker, TemplateException from codecov.log import log -MARKER = """""" +# TODO: Move all the helpers to utils.py +MARKER = """""" def get_marker(marker_id: str | None): @@ -55,13 +58,13 @@ def x100(val: decimal.Decimal): @dataclasses.dataclass class FileInfo: path: pathlib.Path - coverage: FileCoverage + coverage: PytestFileCoverage | JestFileCoverage diff: FileDiffCoverage | None def get_comment_markdown( # pylint: disable=too-many-arguments,too-many-locals,too-many-positional-arguments base_template: str, - coverage: Coverage, + coverage: PytestCoverage | JestCoverage, diff_coverage: DiffCoverage, minimum_green: decimal.Decimal, minimum_orange: decimal.Decimal, @@ -97,6 +100,7 @@ def get_comment_markdown( # pylint: disable=too-many-arguments,too-many-locals, ) } + # TODO: Check this code and see the groups are correctly generated missing_lines_for_whole_project = { key: list(value) for key, value in itertools.groupby( @@ -125,7 +129,7 @@ def get_comment_markdown( # pylint: disable=too-many-arguments,too-many-locals, def select_changed_files( - coverage: Coverage, + coverage: PytestCoverage | JestCoverage, diff_coverage: DiffCoverage, max_files: int | None, skip_covered_files_in_report: bool, @@ -156,7 +160,7 @@ def select_changed_files( def select_files( - coverage: Coverage, + coverage: PytestCoverage | JestCoverage, max_files: int | None, skip_covered_files_in_report: bool, ) -> tuple[list[FileInfo], int]: @@ -197,13 +201,13 @@ def sort_order(file_info: FileInfo) -> tuple[int, int, int]: """ new_missing_lines = len(file_info.coverage.missing_lines) added_statements = len(file_info.diff.added_statements) if file_info.diff else 0 - new_covered_lines = len(file_info.coverage.executed_lines) + new_covered_lines = len(file_info.coverage.covered_lines) return abs(new_missing_lines), added_statements, abs(new_covered_lines) def read_template_file(template: str) -> str: - return (resources.files('codecov') / 'template_files' / template).read_text() + return (resources.files(__package__) / 'template_files' / template).read_text() def get_file_url( # pylint: disable=too-many-arguments diff --git a/codecov/template_files/comment.md.j2 b/codecov/template_files/comment.md.j2 index b98d362..180e022 100644 --- a/codecov/template_files/comment.md.j2 +++ b/codecov/template_files/comment.md.j2 @@ -1,21 +1,22 @@ {%- block title -%}## Coverage report{%- endblock title%} -{# Coverage evolution badge #} +{# Coverage badges #} {% block coverage_badges -%} -{%- block coverage_evolution_badge -%} -{%- if coverage %} -{%- set text = "Coverage of the whole project for this PR is " ~ coverage.info.percent_covered_display ~ "%." -%} -{%- set color = coverage.info.percent_covered | x100 | get_badge_color -%} - -{%- endif -%} -{%- endblock coverage_evolution_badge -%} + {#- Project coverage badge -#} + {%- block coverage_evolution_badge -%} + {%- if coverage %} + {%- set text = "The project coverage is at " ~ coverage.info.percent_covered_display ~ "%." -%} + {%- 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 -%} + {#- PR coverage badge -#} + {%- block diff_coverage_badge -%} + {%- set text = (diff_coverage.total_percent_covered | pct) ~ " of the statements added in this PR are covered." -%} + {%- set color = diff_coverage.total_percent_covered | x100 | get_badge_color -%} + + {%- endblock diff_coverage_badge -%} {%- endblock coverage_badges -%} {# macros #} @@ -23,23 +24,23 @@ {# Individual file report #} {%- block coverage_by_file -%} -{% include "pr.md.j2" %} + {% include "pr.md.j2" %} {%- endblock coverage_by_file %} {# Whole project coverage file report #} {%- block project_coverage_by_file -%} -{% include "project.md.j2" %} + {% include "project.md.j2" %} {%- endblock project_coverage_by_file %} -{%- block full_coverage_report -%} -{%- if coverage_report_url %} -

See the full coverage report of the project here.

-{%- endif -%} -{%- endblock full_coverage_report %} +{%- block full_coverage_report_link -%} + {%- if coverage_report_url %} +

See the full coverage report of the project here.

+ {%- endif -%} +{%- endblock full_coverage_report_link %} {%- block footer %} -This report was generated by [CI-codecov] + This report was generated by [codecov] {% endblock footer -%} {{ marker -}} diff --git a/codecov/template_files/macros.md.j2 b/codecov/template_files/macros.md.j2 index cd31927..20122b1 100644 --- a/codecov/template_files/macros.md.j2 +++ b/codecov/template_files/macros.md.j2 @@ -1,54 +1,54 @@ {%- macro statements_badge(path, statements_count, base=false) -%} -{% set text = "The " ~ path ~ " contains " ~ statements_count ~ " statement" ~ (statements_count | pluralize) ~"." -%} -{% set color = "007ec6" -%} - + {% set text = "The " ~ path ~ " contains " ~ statements_count ~ " statement" ~ (statements_count | pluralize) ~"." -%} + {% set color = "007ec6" -%} + {%- endmacro -%} {%- macro branches_badge(path, branches_count, base=false) -%} -{% set text = "The " ~ path ~ " contains " ~ branches_count ~ " branch" ~ (branches_count | pluralize(plural='es')) ~"." -%} -{% set color = "008080" -%} - + {% set text = "The " ~ path ~ " contains " ~ branches_count ~ " branch" ~ (branches_count | pluralize(plural='es')) ~"." -%} + {% set color = "008080" -%} + {%- endmacro -%} {%- macro missing_branches_badge(path, missing_branches_count, base=false) -%} -{%- set text = missing_branches_count ~ " branch" ~ (missing_branches_count | pluralize(plural='es')) ~ " missing the coverage in " ~ path ~ "." -%} -{% if missing_branches_count == 0 -%} -{%- set color = "brightgreen" -%} -{% else -%} -{%- set color = "red" -%} -{% endif -%} - + {%- set text = missing_branches_count ~ " branch" ~ (missing_branches_count | pluralize(plural='es')) ~ " missing the coverage in " ~ path ~ "." -%} + {% if missing_branches_count == 0 -%} + {%- set color = "brightgreen" -%} + {% else -%} + {%- set color = "red" -%} + {% endif -%} + {%- endmacro -%} {%- macro missing_lines_badge(path, missing_lines_count, base=false) -%} -{%- set text = missing_lines_count ~ " statement" ~ (statements_count | pluralize) ~ " missing the coverage in " ~ path ~ "." -%} -{% if missing_lines_count == 0 -%} -{%- set color = "brightgreen" -%} -{% else -%} -{%- set color = "red" -%} -{% endif -%} - + {%- set text = missing_lines_count ~ " statement" ~ (statements_count | pluralize) ~ " missing the coverage in " ~ path ~ "." -%} + {% if missing_lines_count == 0 -%} + {%- set color = "brightgreen" -%} + {% else -%} + {%- set color = "red" -%} + {% endif -%} + {%- endmacro -%} {%- macro coverage_rate_badge(path, percent_covered, percent_covered_display, covered_statements_count, statements_count, base=false) -%} -{%- set text = "The coverage rate of " ~ path ~ " is " ~ percent_covered_display ~ "% (" ~ covered_statements_count ~ "/" ~ statements_count ~ ")." -%} -{%- set label = percent_covered_display ~ "%" -%} -{%- set message = "(" ~ covered_statements_count ~ "/" ~ statements_count ~ ")" -%} -{%- set color = percent_covered | x100 | get_badge_color -%} - + {%- set text = "The coverage rate of " ~ path ~ " is " ~ percent_covered_display ~ "% (" ~ covered_statements_count ~ "/" ~ statements_count ~ ")." -%} + {%- set label = percent_covered_display ~ "%" -%} + {%- set message = "(" ~ covered_statements_count ~ "/" ~ statements_count ~ ")" -%} + {%- set color = percent_covered | x100 | get_badge_color -%} + {%- 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 statement" ~ (added_statements_count | pluralize) ~ " " ~ (added_statements_count | pluralize(singular='is', plural='are')) ~ " added to " ~ path ~ ", and " ~ covered_statements_count ~ " statement" ~ (covered_statements_count | pluralize) ~ " "~ (covered_statements_count | pluralize(singular='is', plural='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 -%} - + {% if added_statements_count -%} + {% set text = "In this PR, " ~ (added_statements_count) ~ " new statement" ~ (added_statements_count | pluralize) ~ " " ~ (added_statements_count | pluralize(singular='is', plural='are')) ~ " added to " ~ path ~ ", and " ~ covered_statements_count ~ " statement" ~ (covered_statements_count | pluralize) ~ " "~ (covered_statements_count | pluralize(singular='is', plural='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 -%} diff --git a/codecov/template_files/pr.md.j2 b/codecov/template_files/pr.md.j2 index eff76ef..b049c0c 100644 --- a/codecov/template_files/pr.md.j2 +++ b/codecov/template_files/pr.md.j2 @@ -1,192 +1,192 @@ {%- if not files %} -_This PR does not include changes to coverable code or code with missing coverage._ + _This PR does not include changes to coverable code or code with missing coverage._ {%- else -%} -
Click to see coverage of changed files -
- - - {% if branch_coverage %}{% endif %} - - - {% if branch_coverage %}{% endif %} - - - -{%- for parent, files_in_folder in files|groupby(attribute="path.parent") -%} - - - - -{%- for file in files_in_folder -%} -{%- set path = file.coverage.path -%} - - - -{#- Statements cell -#} -{%- block statements_badge_cell scoped -%} -{{- macros.statements_badge( - path=path, - statements_count=file.coverage.info.num_statements, -) -}} -{%- endblock statements_badge_cell-%} - -{#- Missing cell -#} -{%- block missing_lines_badge_cell scoped -%} -{{- macros.missing_lines_badge( - path=path, - missing_lines_count=file.coverage.info.missing_lines, -) -}} -{%- endblock missing_lines_badge_cell -%} - -{% if branch_coverage %} -{#- Branches cell -#} -{%- block branches_badge_cell scoped -%} -{{- macros.branches_badge( - path=path, - branches_count=file.coverage.info.num_branches, -) -}} -{%- endblock branches_badge_cell -%} - -{#- Missing cell -#} -{%- block missing_branches_badge_cell scoped -%} -{{- macros.missing_branches_badge( - path=path, - missing_branches_count=file.coverage.info.missing_branches, -) -}} -{%- endblock missing_branches_badge_cell -%} -{% endif %} - -{#- Coverage rate -#} -{%- block coverage_rate_badge_cell scoped -%} -{{- macros.coverage_rate_badge( - path=path, - percent_covered=file.coverage.info.percent_covered, - percent_covered_display=file.coverage.info.percent_covered_display, - covered_statements_count=file.coverage.info.covered_lines, - statements_count=file.coverage.info.num_statements, -) -}} -{%- endblock coverage_rate_badge_cell -%} - -{#- Coverage of added lines (new stmts) -#} -{%- block diff_coverage_rate_badge_cell scoped -%} -{{- macros.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 lines missing -#} -{%- block link_to_missing_diff_lines_cell scoped -%} - - -{%- endblock link_to_missing_diff_lines_cell -%} - -{#- Link to branch missing lines -#} -{%- if branch_coverage -%} -{%- block link_to_branches_missing_lines_cell scoped -%} - -{%- endblock link_to_branches_missing_lines_cell -%} -{%- endif -%} - - -{%- endfor -%} -{%- endfor -%} - - - - - - -{#- Statements cell -#} -{%- block statements_badge_total_cell scoped -%} -{{- macros.statements_badge( - path="whole project", - statements_count=coverage.info.num_statements, -) -}} -{%- endblock statements_badge_total_cell -%} - -{#- Missing cell -#} -{%- block missing_lines_badge_total_cell scoped -%} -{{- macros.missing_lines_badge( - path="the whole project", - missing_lines_count=coverage.info.missing_lines, -) -}} -{%- endblock missing_lines_badge_total_cell -%} - -{% if branch_coverage %} -{#- Branches cell -#} -{%- block branches_badge_total_cell scoped -%} -{{- macros.branches_badge( - path="the whole project", - branches_count=coverage.info.num_branches, -) -}} -{%- endblock branches_badge_total_cell -%} - -{#- Missing cell -#} -{%- block missing_branches_badge_total_cell scoped -%} -{{- macros.missing_branches_badge( - path="the whole project", - missing_branches_count=coverage.info.missing_branches, -) -}} -{%- endblock missing_branches_badge_total_cell -%} -{% endif %} - -{#- Coverage rate -#} -{%- block coverage_rate_badge_total_cell scoped -%} -{{- macros.coverage_rate_badge( - path="the whole project", - percent_covered=coverage.info.percent_covered, - percent_covered_display=coverage.info.percent_covered_display, - 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 -%} -{{- macros.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 branch_coverage %} - -{% endif %} - - -
FileStatementsMissingBranchesMissing
Coverage         
Coverage         
(new stmts)

Missing stmts              

Missing branches              
  {{ parent }}
  {{ path.name }} - -{%- 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 -%} - -{%- set comma = joiner() -%} -{%- for branch in file.coverage.missing_branches -%} -{{- comma() -}} -{{- branch[0] | abs -}} -> {{- branch[1] | abs -}} -{%- endfor -%} -
Project Total  
- -{%- if max_files and count_files > max_files %} -_The report is truncated to {{ max_files }} files out of {{ count_files }}. -{% endif %} +
Click to see the coverage of changed files +
+ + + {% if branch_coverage %}{% endif %} + + + {% if branch_coverage %}{% endif %} + + + + {%- for parent, files_in_folder in files|groupby(attribute="path.parent") -%} + + + + + {%- for file in files_in_folder -%} + {%- set path = file.coverage.path -%} + + + + {#- Statements cell -#} + {%- block statements_badge_cell scoped -%} + {{- macros.statements_badge( + path=path, + statements_count=file.coverage.info.num_statements, + ) -}} + {%- endblock statements_badge_cell-%} + + {#- Missing cell -#} + {%- block missing_lines_badge_cell scoped -%} + {{- macros.missing_lines_badge( + path=path, + missing_lines_count=file.coverage.info.missing_lines, + ) -}} + {%- endblock missing_lines_badge_cell -%} + + {% if branch_coverage %} + {#- Branches cell -#} + {%- block branches_badge_cell scoped -%} + {{- macros.branches_badge( + path=path, + branches_count=file.coverage.info.num_branches, + ) -}} + {%- endblock branches_badge_cell -%} + + {#- Missing cell -#} + {%- block missing_branches_badge_cell scoped -%} + {{- macros.missing_branches_badge( + path=path, + missing_branches_count=file.coverage.info.missing_branches, + ) -}} + {%- endblock missing_branches_badge_cell -%} + {% endif %} + + {#- Coverage rate -#} + {%- block coverage_rate_badge_cell scoped -%} + {{- macros.coverage_rate_badge( + path=path, + percent_covered=file.coverage.info.percent_covered, + percent_covered_display=file.coverage.info.percent_covered_display, + covered_statements_count=file.coverage.info.covered_lines, + statements_count=file.coverage.info.num_statements, + ) -}} + {%- endblock coverage_rate_badge_cell -%} + + {#- Coverage of added lines (new stmts) -#} + {%- block diff_coverage_rate_badge_cell scoped -%} + {{- macros.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 lines missing -#} + {%- block link_to_missing_diff_lines_cell scoped -%} + + + {%- endblock link_to_missing_diff_lines_cell -%} + + {#- Link to branch missing lines -#} + {%- if branch_coverage -%} + {%- block link_to_branches_missing_lines_cell scoped -%} + + {%- endblock link_to_branches_missing_lines_cell -%} + {%- endif -%} + + + {%- endfor -%} + {%- endfor -%} + + + + + + + {#- Statements cell -#} + {%- block statements_badge_total_cell scoped -%} + {{- macros.statements_badge( + path="whole project", + statements_count=coverage.info.num_statements, + ) -}} + {%- endblock statements_badge_total_cell -%} + + {#- Missing cell -#} + {%- block missing_lines_badge_total_cell scoped -%} + {{- macros.missing_lines_badge( + path="the whole project", + missing_lines_count=coverage.info.missing_lines, + ) -}} + {%- endblock missing_lines_badge_total_cell -%} + + {% if branch_coverage %} + {#- Branches cell -#} + {%- block branches_badge_total_cell scoped -%} + {{- macros.branches_badge( + path="the whole project", + branches_count=coverage.info.num_branches, + ) -}} + {%- endblock branches_badge_total_cell -%} + + {#- Missing cell -#} + {%- block missing_branches_badge_total_cell scoped -%} + {{- macros.missing_branches_badge( + path="the whole project", + missing_branches_count=coverage.info.missing_branches, + ) -}} + {%- endblock missing_branches_badge_total_cell -%} + {% endif %} + + {#- Coverage rate -#} + {%- block coverage_rate_badge_total_cell scoped -%} + {{- macros.coverage_rate_badge( + path="the whole project", + percent_covered=coverage.info.percent_covered, + percent_covered_display=coverage.info.percent_covered_display, + 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 -%} + {{- macros.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 branch_coverage %} + + {% endif %} + + +
FileStatementsMissingBranchesMissing
Coverage         
Coverage         
(new stmts)

Missing stmts              

Missing branches              
  {{ parent }}
  {{ path.name }} + + {%- 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 -%} + + {%- set comma = joiner() -%} + {%- for branch in file.coverage.missing_branches -%} + {{- comma() -}} + {{- branch[0] | abs -}} -> {{- branch[1] | abs -}} + {%- endfor -%} +
Project Total  
+ + {%- if max_files and count_files > max_files %} + _The report is truncated to {{ max_files }} files out of {{ count_files }}. + {% endif %}
{%- endif -%} diff --git a/tests/conftest.py b/tests/conftest.py index aab876c..79b8bc2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ # mypy: disable-error-code="operator, union-attr" +import dataclasses import datetime import decimal import functools @@ -14,7 +15,14 @@ import pytest from codecov.config import Config -from codecov.coverage import Coverage, CoverageInfo, CoverageMetadata, DiffCoverage, FileCoverage, PytestCoverage +from codecov.coverage.pytest import ( + DiffCoverage, + PytestCoverage, + PytestCoverageHandler, + PytestCoverageInfo, + PytestCoverageMetadata, + PytestFileCoverage, +) from codecov.github_client import GitHubClient @@ -33,17 +41,17 @@ def _(**kwargs): @pytest.fixture -def make_coverage() -> Callable[[str, bool], Coverage]: - def _(code: str, has_branches: bool = True) -> Coverage: +def make_coverage() -> Callable[[str, bool], PytestCoverage]: + def _(code: str, has_branches: bool = True) -> PytestCoverage: current_file = None - coverage_obj = Coverage( - meta=CoverageMetadata( + coverage_obj = PytestCoverage( + meta=PytestCoverageMetadata( version='1.2.3', timestamp=datetime.datetime(2000, 1, 1), branch_coverage=True, show_contexts=False, ), - info=CoverageInfo( + info=PytestCoverageInfo( covered_lines=0, num_statements=0, percent_covered=decimal.Decimal('1.0'), @@ -70,12 +78,12 @@ def _(code: str, has_branches: bool = True) -> Coverage: assert current_file, (line, current_file, code) line_number += 1 if coverage_obj.files.get(current_file) is None: - coverage_obj.files[current_file] = FileCoverage( + coverage_obj.files[current_file] = PytestFileCoverage( path=current_file, - executed_lines=[], + covered_lines=[], missing_lines=[], excluded_lines=[], - info=CoverageInfo( + info=PytestCoverageInfo( covered_lines=0, num_statements=0, percent_covered=decimal.Decimal('1.0'), @@ -104,7 +112,7 @@ def _(code: str, has_branches: bool = True) -> Coverage: coverage_obj.files[current_file].info.num_statements += 1 coverage_obj.info.num_statements += 1 if 'line covered' in line: - coverage_obj.files[current_file].executed_lines.append(line_number) + coverage_obj.files[current_file].covered_lines.append(line_number) coverage_obj.files[current_file].info.covered_lines += 1 coverage_obj.info.covered_lines += 1 elif 'line missing' in line: @@ -134,7 +142,7 @@ def _(code: str, has_branches: bool = True) -> Coverage: coverage_obj.info.missing_branches += 1 info = coverage_obj.files[current_file].info - coverage_obj.files[current_file].info.percent_covered = PytestCoverage().compute_coverage( + coverage_obj.files[current_file].info.percent_covered = PytestCoverageHandler().compute_coverage( num_covered=info.covered_lines, num_total=info.num_statements, ) @@ -143,7 +151,7 @@ def _(code: str, has_branches: bool = True) -> Coverage: ].info.percent_covered_display = f'{coverage_obj.files[current_file].info.percent_covered:.0%}' info = coverage_obj.info - coverage_obj.info.percent_covered = PytestCoverage().compute_coverage( + coverage_obj.info.percent_covered = PytestCoverageHandler().compute_coverage( num_covered=info.covered_lines, num_total=info.num_statements, ) @@ -154,13 +162,19 @@ def _(code: str, has_branches: bool = True) -> Coverage: @pytest.fixture -def make_diff_coverage(): - return PytestCoverage().get_diff_coverage_info +def make_diff_coverage(test_config): + handler = PytestCoverageHandler() + + def _(added_lines, coverage, branch_coverage=False): + config = dataclasses.replace(test_config, BRANCH_COVERAGE=branch_coverage) + return handler.get_diff_coverage(added_lines=added_lines, coverage=coverage, config=config) + + return _ @pytest.fixture -def make_coverage_and_diff(make_coverage, make_diff_coverage) -> Callable[[str], tuple[Coverage, DiffCoverage]]: - def _(code: str) -> tuple[Coverage, DiffCoverage]: +def make_coverage_and_diff(make_coverage, make_diff_coverage) -> Callable[[str], tuple[PytestCoverage, DiffCoverage]]: + def _(code: str) -> tuple[PytestCoverage, DiffCoverage]: added_lines: dict[pathlib.Path, list[int]] = {} new_code = '' current_file = None diff --git a/tests/coverage/test_base.py b/tests/coverage/test_base.py index 5f949b7..a0e173a 100644 --- a/tests/coverage/test_base.py +++ b/tests/coverage/test_base.py @@ -1,3 +1,4 @@ +import dataclasses import decimal import json import pathlib @@ -6,17 +7,11 @@ import pytest -from codecov.coverage import Coverage, DiffCoverage, FileDiffCoverage -from codecov.coverage.base import BaseCoverage +from codecov.coverage.base import DiffCoverage, FileDiffCoverage +from codecov.coverage.pytest import PytestCoverageHandler from codecov.exceptions import ConfigurationException -class BaseCoverageDemo(BaseCoverage): - def extract_info(self, data): - del data - return Coverage(meta=None, info=None, files={}) - - class TestBase: @pytest.mark.parametrize( 'num_covered, num_total, expected_coverage', @@ -29,21 +24,22 @@ class TestBase: ], ) def test_compute_coverage(self, num_covered, num_total, expected_coverage): - assert BaseCoverageDemo().compute_coverage(num_covered, num_total) == decimal.Decimal(expected_coverage) + assert PytestCoverageHandler().compute_coverage(num_covered, num_total) == decimal.Decimal(expected_coverage) def test_get_coverage_info(self, coverage_json): + handler = PytestCoverageHandler() with patch('pathlib.Path.open') as mock_open: mock_open.return_value.__enter__.return_value.read.return_value = json.dumps(coverage_json) - result = BaseCoverageDemo().get_coverage_info(pathlib.Path(tempfile.mkstemp(suffix='.json')[1])) - assert result == BaseCoverageDemo().extract_info(coverage_json) + result = handler.get_coverage_info(pathlib.Path(tempfile.mkstemp(suffix='.json')[1])) + assert result == handler.extract_info(coverage_json) with patch('pathlib.Path.open') as mock_open: mock_open.return_value.__enter__.return_value.read.return_value = b'invalid json' with pytest.raises(ConfigurationException): - BaseCoverageDemo().get_coverage_info(pathlib.Path(tempfile.mkstemp(suffix='.json')[1])) + handler.get_coverage_info(pathlib.Path(tempfile.mkstemp(suffix='.json')[1])) with pytest.raises(ConfigurationException): - BaseCoverageDemo().get_coverage_info(pathlib.Path('path/to/file.json')) + handler.get_coverage_info(pathlib.Path('path/to/file.json')) @pytest.mark.parametrize( 'added_lines, update_obj, expected', @@ -54,7 +50,7 @@ def test_get_coverage_info(self, coverage_json): # that the total coverage is 50%. ( {pathlib.Path('codebase/code.py'): [1, 3]}, - {'codebase/code.py': {'executed_lines': [1, 2], 'missing_lines': [3]}}, + {'codebase/code.py': {'covered_lines': [1, 2], 'missing_lines': [3]}}, DiffCoverage( total_num_lines=2, total_num_violations=1, @@ -79,7 +75,7 @@ def test_get_coverage_info(self, coverage_json): # covered, nor imported.) ( {pathlib.Path('codebase/code2.py'): [1, 3]}, - {'codebase/code.py': {'executed_lines': [1, 2], 'missing_lines': [3]}}, + {'codebase/code.py': {'covered_lines': [1, 2], 'missing_lines': [3]}}, DiffCoverage( total_num_lines=0, total_num_violations=0, @@ -93,7 +89,7 @@ def test_get_coverage_info(self, coverage_json): # should not report any violation (and 100% coverage) ( {pathlib.Path('codebase/code.py'): [4, 5, 6]}, - {'codebase/code.py': {'executed_lines': [1, 2, 3], 'missing_lines': [7]}}, + {'codebase/code.py': {'covered_lines': [1, 2, 3], 'missing_lines': [7]}}, DiffCoverage( total_num_lines=0, total_num_violations=0, @@ -121,11 +117,11 @@ def test_get_coverage_info(self, coverage_json): }, { 'codebase/code.py': { - 'executed_lines': [1, 2, 3, 5, 6], + 'covered_lines': [1, 2, 3, 5, 6], 'missing_lines': [7], }, 'codebase/other.py': { - 'executed_lines': [10, 11, 12], + 'covered_lines': [10, 11, 12], 'missing_lines': [13], }, }, @@ -156,10 +152,11 @@ def test_get_coverage_info(self, coverage_json): ), ], ) - def test_get_diff_coverage_info(self, make_coverage_obj, added_lines, update_obj, expected): - result = BaseCoverageDemo().get_diff_coverage_info( + def test_get_diff_coverage_info(self, test_config, make_coverage_obj, added_lines, update_obj, expected): + result = PytestCoverageHandler().get_diff_coverage( added_lines=added_lines, coverage=make_coverage_obj(**update_obj), + config=test_config, ) assert result == expected @@ -208,11 +205,11 @@ def test_get_diff_coverage_info(self, make_coverage_obj, added_lines, update_obj }, { 'codebase/code.py': { - 'executed_lines': [1, 2, 3, 5, 6], + 'covered_lines': [1, 2, 3, 5, 6], 'missing_lines': [4, 5], }, 'codebase/other.py': { - 'executed_lines': [10, 11, 12, 13], + 'covered_lines': [10, 11, 12, 13], 'missing_lines': [10, 13], }, }, @@ -244,10 +241,18 @@ def test_get_diff_coverage_info(self, make_coverage_obj, added_lines, update_obj ), ], ) - def test_get_diff_coverage_info_branch_coverage(self, make_coverage_obj, added_lines, update_obj, expected): - result = BaseCoverageDemo().get_diff_coverage_info( + def test_get_diff_coverage_info_branch_coverage( + self, + test_config, + make_coverage_obj, + added_lines, + update_obj, + expected, + ): + config = dataclasses.replace(test_config, BRANCH_COVERAGE=True) + result = PytestCoverageHandler().get_diff_coverage( added_lines=added_lines, coverage=make_coverage_obj(**update_obj), - branch_coverage=True, + config=config, ) assert result == expected diff --git a/tests/coverage/test_pytest.py b/tests/coverage/test_pytest.py index 01834e7..96bec92 100644 --- a/tests/coverage/test_pytest.py +++ b/tests/coverage/test_pytest.py @@ -1,28 +1,37 @@ +import dataclasses import datetime +import json import pathlib +from unittest.mock import patch -from codecov.coverage import Coverage, CoverageInfo, CoverageMetadata, FileCoverage, PytestCoverage +from codecov.coverage.pytest import ( + PytestCoverage, + PytestCoverageHandler, + PytestCoverageInfo, + PytestCoverageMetadata, + PytestFileCoverage, +) class TestPytestCoverage: def test_extract_info(self, coverage_json): - expected_coverage = Coverage( - meta=CoverageMetadata( + expected_coverage = PytestCoverage( + meta=PytestCoverageMetadata( version='1.2.3', timestamp=datetime.datetime.fromisoformat('2000-01-01T00:00:00'), branch_coverage=True, show_contexts=False, ), files={ - pathlib.Path('codebase/code.py'): FileCoverage( + pathlib.Path('codebase/code.py'): PytestFileCoverage( path=pathlib.Path('codebase/code.py'), excluded_lines=[], - executed_lines=[1, 2, 3, 5, 13, 14], + covered_lines=[1, 2, 3, 5, 13, 14], missing_lines=[6, 8, 10, 11], - info=CoverageInfo( + info=PytestCoverageInfo( covered_lines=6, num_statements=10, - percent_covered=PytestCoverage().convert_to_decimal(60.0), + percent_covered=PytestCoverageHandler().convert_to_decimal(60.0), percent_covered_display='60%', missing_lines=4, excluded_lines=0, @@ -35,10 +44,10 @@ def test_extract_info(self, coverage_json): missing_branches=[[6, 0], [8, 1], [10, 0], [11, 0]], ) }, - info=CoverageInfo( + info=PytestCoverageInfo( covered_lines=6, num_statements=10, - percent_covered=PytestCoverage().convert_to_decimal(60.0), + percent_covered=PytestCoverageHandler().convert_to_decimal(60.0), percent_covered_display='60%', missing_lines=4, excluded_lines=0, @@ -49,4 +58,14 @@ def test_extract_info(self, coverage_json): ), ) - assert PytestCoverage().extract_info(coverage_json) == expected_coverage + assert PytestCoverageHandler().extract_info(coverage_json) == expected_coverage + + def test_get_coverage_with_branch_coverage(self, test_config, coverage_json): + config = dataclasses.replace(test_config, BRANCH_COVERAGE=True) + handler = PytestCoverageHandler() + with patch('pathlib.Path.open') as mock_open: + mock_open.return_value.__enter__.return_value.read.return_value = json.dumps(coverage_json) + coverage = handler.get_coverage(config=config) + + assert coverage.meta.branch_coverage is True + assert coverage.files[pathlib.Path('codebase/code.py')].missing_branches == [[0, 11]] diff --git a/tests/test_main.py b/tests/test_main.py index 8459e73..cbb4657 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -5,16 +5,21 @@ import pytest +from codecov.coverage.pytest import PytestCoverageHandler from codecov.exceptions import ConfigurationException, CoreProcessingException, MissingMarker, TemplateException from codecov.main import Main class TestMain: - def test_init_with_exception(self, test_config): - test_config.SKIP_COVERAGE = True + def test_init_with_exception(self, test_config, gh): with patch.object(Main, '_init_config', return_value=test_config): - with pytest.raises(CoreProcessingException): - Main() + with patch.object(Main, '_init_github', return_value=gh): + with patch( + 'codecov.main.BaseCoverageHandler.get_coverage_handler', + side_effect=ConfigurationException, + ): + with pytest.raises(CoreProcessingException): + Main() def test_init(self, test_config, gh): with patch('codecov.main.Config.from_environ', return_value=test_config): @@ -25,13 +30,14 @@ def test_init(self, test_config, gh): assert main.github.repository == test_config.GITHUB_REPOSITORY assert main.github.pr_number == test_config.GITHUB_PR_NUMBER assert main.github.annotations_data_branch == test_config.ANNOTATIONS_DATA_BRANCH + assert isinstance(main.coverage_module, PytestCoverageHandler) def test_process_coverage(self, test_config, gh, coverage_obj, diff_coverage_obj): with patch.object(Main, '_init_config', return_value=test_config): with patch.object(Main, '_init_github', return_value=gh): main = Main() main.coverage_module = MagicMock() - main.coverage_module.get_coverage_info = MagicMock(side_effect=ConfigurationException) + main.coverage_module.get_coverage = MagicMock(side_effect=ConfigurationException) with pytest.raises(CoreProcessingException): main._process_coverage() @@ -39,13 +45,19 @@ def test_process_coverage(self, test_config, gh, coverage_obj, diff_coverage_obj with patch.object(Main, '_init_github', return_value=gh): main = Main() main.coverage_module = MagicMock() - main.coverage_module.get_coverage_info = MagicMock(return_value=coverage_obj) - main.coverage_module.get_diff_coverage_info = MagicMock(return_value=diff_coverage_obj) + main.coverage_module.get_coverage = MagicMock(return_value=coverage_obj) + main.coverage_module.get_diff_coverage = MagicMock(return_value=diff_coverage_obj) main._process_coverage() assert main.coverage == coverage_obj assert main.diff_coverage == diff_coverage_obj + main.coverage_module.get_coverage.assert_called_once_with(config=test_config) + main.coverage_module.get_diff_coverage.assert_called_once_with( + added_lines={pathlib.Path('codebase/code.py'): [1]}, + coverage=coverage_obj, + config=test_config, + ) def test_process_coverage_branch_coverage(self, test_config, gh, coverage_obj, diff_coverage_obj): with patch.object(Main, '_init_config', return_value=test_config): @@ -53,15 +65,15 @@ def test_process_coverage_branch_coverage(self, test_config, gh, coverage_obj, d with patch.object(Main, '_init_github', return_value=gh): main = Main() main.coverage_module = MagicMock() - main.coverage_module.get_coverage_info = MagicMock(return_value=coverage_obj) - main.coverage_module.get_diff_coverage_info = MagicMock(return_value=diff_coverage_obj) + main.coverage_module.get_coverage = MagicMock(return_value=coverage_obj) + main.coverage_module.get_diff_coverage = MagicMock(return_value=diff_coverage_obj) assert main._process_coverage() is None assert main.coverage == coverage_obj assert main.diff_coverage == diff_coverage_obj - def test_process_pr_skip_coverage(self, test_config, gh, coverage_obj, diff_coverage_obj): + def test_render_comment_markdown_skip_coverage(self, test_config, gh, coverage_obj, diff_coverage_obj): with patch.object(Main, '_init_config', return_value=test_config): test_config.SKIP_COVERAGE = True test_config.ANNOTATE_MISSING_LINES = True @@ -69,10 +81,18 @@ def test_process_pr_skip_coverage(self, test_config, gh, coverage_obj, diff_cove main = Main() main.coverage = coverage_obj main.diff_coverage = diff_coverage_obj - assert main._process_pr() is None + main._render_comment_markdown() + assert main.comment == '' @patch('codecov.main.template.get_comment_markdown') - def test_process_pr(self, get_comment_markdown_mock: MagicMock, test_config, gh, coverage_obj, diff_coverage_obj): + def test_render_comment_markdown( + self, + get_comment_markdown_mock: MagicMock, + test_config, + gh, + coverage_obj, + diff_coverage_obj, + ): get_comment_markdown_mock.side_effect = MissingMarker with patch.object(Main, '_init_config', return_value=test_config): with patch.object(Main, '_init_github', return_value=gh): @@ -80,7 +100,7 @@ def test_process_pr(self, get_comment_markdown_mock: MagicMock, test_config, gh, main = Main() main.coverage = coverage_obj main.diff_coverage = diff_coverage_obj - main._process_pr() + main._render_comment_markdown() get_comment_markdown_mock.side_effect = TemplateException with patch.object(Main, '_init_config', return_value=test_config): @@ -89,7 +109,7 @@ def test_process_pr(self, get_comment_markdown_mock: MagicMock, test_config, gh, main = Main() main.coverage = coverage_obj main.diff_coverage = diff_coverage_obj - main._process_pr() + main._render_comment_markdown() get_comment_markdown_mock.reset_mock(side_effect=True) get_comment_markdown_mock.return_value = 'sample comment' @@ -98,7 +118,20 @@ def test_process_pr(self, get_comment_markdown_mock: MagicMock, test_config, gh, main = Main() main.coverage = coverage_obj main.diff_coverage = diff_coverage_obj - assert main._process_pr() is None + main._render_comment_markdown() + assert main.comment == 'sample comment' + + def test_create_comment(self, test_config, gh): + with patch.object(Main, '_init_config', return_value=test_config): + with patch.object(Main, '_init_github', return_value=gh): + main = Main() + main.comment = '' + with pytest.raises(CoreProcessingException): + main._create_comment() + + main.comment = 'sample comment' + assert main._create_comment() is None + gh.post_comment.assert_called_once_with(contents='sample comment', marker=main.marker) @patch('codecov.main.groups.create_missing_coverage_annotations') def test_generate_annotations_empty( @@ -212,11 +245,13 @@ def test_run(self, test_config, gh): with patch.object(Main, '_init_github', return_value=gh): main = Main() main._process_coverage = MagicMock() - main._process_pr = MagicMock() + main._render_comment_markdown = MagicMock() + main._create_comment = MagicMock() main._generate_annotations = MagicMock() assert main.run() is None main._process_coverage.assert_called_once() - main._process_pr.assert_called_once() + main._render_comment_markdown.assert_called_once() + main._create_comment.assert_called_once() main._generate_annotations.assert_called_once() diff --git a/tests/test_template.py b/tests/test_template.py index 9fe2ec2..c3e0efa 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -5,7 +5,7 @@ import pytest from codecov import template -from codecov.coverage import DiffCoverage +from codecov.coverage.base import DiffCoverage from codecov.exceptions import MissingMarker, TemplateException @@ -58,10 +58,10 @@ def test_pluralize(number, singular, plural, expected): @pytest.mark.parametrize( 'marker_id, result', [ - (None, ''), + (None, ''), ( 'foo', - '', + '', ), ], )