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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 1 addition & 4 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -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
30 changes: 22 additions & 8 deletions codecov/config.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -35,23 +33,28 @@ 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)
GITHUB_PR_NUMBER: int | None = None
# 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
Expand All @@ -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:
Expand Down Expand Up @@ -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():
Expand Down
12 changes: 0 additions & 12 deletions codecov/coverage/__init__.py
Original file line number Diff line number Diff line change
@@ -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',
]
169 changes: 39 additions & 130 deletions codecov/coverage/base.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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())
Expand All @@ -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
Loading
Loading