diff --git a/Makefile b/Makefile index e06961e..42c7d22 100644 --- a/Makefile +++ b/Makefile @@ -20,10 +20,10 @@ lint: pipenv run pre-commit run --all-files test: - pipenv run pytest tests/* --cov + pipenv run pytest tests/* --cov=codecov --cov-report=term-missing report: - pipenv run pytest tests --cov-branch --cov=codecov --cov-report=json:/tmp/report.json + pipenv run pytest tests --cov-branch --cov=codecov --cov-report=term-missing --cov-report=json:/tmp/report.json build: python3 -m build diff --git a/README.md b/README.md index 8c35e96..aa736ee 100644 --- a/README.md +++ b/README.md @@ -133,4 +133,5 @@ For example, if your project has a run.py file, you can run it using the followi That's it! You have successfully set up your local environment using Pipenv. -This project is inspired by the concepts of [py-cov-action/python-coverage-comment-action](https://github.com/py-cov-action/python-coverage-comment-action.git). +This project is almost copy of [py-cov-action/python-coverage-comment-action] +() with few modifications. diff --git a/codecov/github.py b/codecov/github.py index 0d91c7f..4da120c 100644 --- a/codecov/github.py +++ b/codecov/github.py @@ -6,7 +6,7 @@ from codecov import github_client, groups, log, settings -GITHUB_ACTIONS_LOGIN = 'CI-codecov[bot]' +GITHUB_CODECOV_LOGIN = 'CI-codecov[bot]' class CannotDeterminePR(Exception): @@ -83,7 +83,7 @@ def get_my_login(github: github_client.GitHub) -> str: # The GitHub actions user cannot access its own details # and I'm not sure there's a way to see that we're using # the GitHub actions user except noting that it fails - return GITHUB_ACTIONS_LOGIN + return GITHUB_CODECOV_LOGIN return response.login diff --git a/codecov/github_client.py b/codecov/github_client.py index e52dea9..d6ce5ae 100644 --- a/codecov/github_client.py +++ b/codecov/github_client.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- from __future__ import annotations @@ -9,7 +8,7 @@ class _Executable: - def __init__(self, _gh, _method, _path): + def __init__(self, _gh: GitHub, _method: str, _path: str): self._gh = _gh self._method = _method self._path = _path @@ -37,7 +36,6 @@ def __getattr__(self, attr): class GitHub: - """ GitHub client. """ @@ -48,7 +46,7 @@ def __init__(self, session: httpx.Client): def __getattr__(self, attr): return _Callable(self, f'/{attr}') - def _http(self, method, path, *, use_bytes=False, use_text=False, **kw): + def _http(self, method: str, path: str, *, use_bytes: bool = False, use_text: bool = False, **kw): _method = method.lower() requests_kwargs = {} headers = kw.pop('headers', {}) diff --git a/codecov/settings.py b/codecov/settings.py index b7c1146..17f029b 100644 --- a/codecov/settings.py +++ b/codecov/settings.py @@ -18,16 +18,13 @@ class InvalidAnnotationType(Exception): def path_below(path_str: str | pathlib.Path) -> pathlib.Path: - try: - path = pathlib.Path(path_str).resolve() - if not (path.exists() and path.is_file()): - raise ValueError('Path does not exist') - - if path.suffix != '.json': - raise ValueError('The file is not a JSON file.') - return path - except ValueError as exc: - raise ValueError('Path can not be resolved') from exc + path = pathlib.Path(path_str).resolve() + if not (path.exists() and path.is_file()): + raise ValueError('Path does not exist') + + if path.suffix != '.json': + raise ValueError('The file is not a JSON file.') + return path def str_to_bool(value: str) -> bool: diff --git a/codecov/template.py b/codecov/template.py index 8948303..6a635e4 100644 --- a/codecov/template.py +++ b/codecov/template.py @@ -7,7 +7,6 @@ import hashlib import itertools import pathlib -from collections.abc import Callable from importlib import resources import jinja2 @@ -15,28 +14,7 @@ from codecov import badge, coverage as coverage_module, diff_grouper -MARKER = """""" - - -def uptodate(): - return True - - -class CommentLoader(jinja2.BaseLoader): - def __init__(self, base_template: str): - self.base_template = base_template - - # fmt: off - def get_source(self, environment: jinja2.Environment, template: str) -> tuple[str, str | None, Callable[..., bool]]: # pylint: disable=unused-argument - # fmt: on - if template == 'base': - return ( - self.base_template, - 'codecov/template_files/comment.md.j2', - uptodate, - ) - - raise jinja2.TemplateNotFound(template) +MARKER = """""" class MissingMarker(Exception): @@ -58,10 +36,6 @@ def pluralize(number, singular='', plural='s'): return plural -def sign(val: int | decimal.Decimal) -> str: - return '+' if val > 0 else '' if val < 0 else '±' - - def remove_exponent(val: decimal.Decimal) -> decimal.Decimal: # From https://docs.python.org/3/library/decimal.html#decimal-faq return val.quantize(decimal.Decimal(1)) if val == val.to_integral() else val.normalize() @@ -71,7 +45,7 @@ def percentage_value(val: decimal.Decimal, precision: int = 2) -> decimal.Decima return remove_exponent( (decimal.Decimal('100') * val).quantize( decimal.Decimal('1.' + ('0' * precision)), - rounding=decimal.ROUND_CEILING, + rounding=decimal.ROUND_DOWN, ) ) @@ -112,8 +86,7 @@ def get_comment_markdown( # pylint: disable=too-many-arguments,too-many-locals complete_project_report: bool = False, coverage_report_url: str | None = None, ): - loader = CommentLoader(base_template=base_template) - env = SandboxedEnvironment(loader=loader) + env = SandboxedEnvironment() env.filters['pct'] = pct env.filters['x100'] = x100 env.filters['generate_badge'] = badge.get_static_badge_url @@ -143,7 +116,7 @@ def get_comment_markdown( # pylint: disable=too-many-arguments,too-many-locals ) } try: - comment = env.get_template('base').render( + comment = env.from_string(base_template).render( coverage=coverage, diff_coverage=diff_coverage, max_files=max_files, diff --git a/tests/conftest.py b/tests/conftest.py index e69de29..a581423 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -0,0 +1,383 @@ +# -*- coding: utf-8 -*- +# pylint: disable=redefined-outer-name +# mypy: disable-error-code="operator" +from __future__ import annotations + +import datetime +import decimal +import functools +import pathlib +import secrets +from typing import Callable + +import httpx +import pytest + +from codecov import coverage as coverage_module, github_client, settings + + +@pytest.fixture +def base_config(): + def _(**kwargs): + defaults = { + 'GITHUB_TOKEN': secrets.token_hex(16), + 'GITHUB_PR_NUMBER': 123, + 'GITHUB_REPOSITORY': 'codecov/foobar', + 'COVERAGE_PATH': pathlib.Path('coverage.json'), + } + return settings.Config(**(defaults | kwargs)) + + return _ + + +@pytest.fixture +def make_coverage() -> Callable[[str, bool], coverage_module.Coverage]: + def _(code: str, has_branches: bool = True) -> coverage_module.Coverage: + current_file = None + coverage_obj = coverage_module.Coverage( + meta=coverage_module.CoverageMetadata( + version='1.2.3', + timestamp=datetime.datetime(2000, 1, 1), + branch_coverage=True, + show_contexts=False, + ), + info=coverage_module.CoverageInfo( + covered_lines=0, + num_statements=0, + percent_covered=decimal.Decimal('1.0'), + percent_covered_display='100', + missing_lines=0, + excluded_lines=0, + num_branches=0 if has_branches else None, + num_partial_branches=0 if has_branches else None, + covered_branches=0 if has_branches else None, + missing_branches=0 if has_branches else None, + ), + files={}, + ) + line_number = 0 + # (we start at 0 because the first line will be empty for readabilty) + for line in code.splitlines(): + line = line.strip() + if not line: + continue + if line.startswith('# file: '): + current_file = pathlib.Path(line.split('# file: ')[1]) + line_number = 0 + continue + assert current_file, (line, current_file, code) + line_number += 1 + if coverage_obj.files.get(current_file) is None: + coverage_obj.files[current_file] = coverage_module.FileCoverage( + path=current_file, + executed_lines=[], + missing_lines=[], + excluded_lines=[], + info=coverage_module.CoverageInfo( + covered_lines=0, + num_statements=0, + percent_covered=decimal.Decimal('1.0'), + percent_covered_display='100', + missing_lines=0, + excluded_lines=0, + num_branches=0 if has_branches else None, + num_partial_branches=0 if has_branches else None, + covered_branches=0 if has_branches else None, + missing_branches=0 if has_branches else None, + ), + ) + if set(line.split()) & { + 'covered', + 'missing', + 'excluded', + 'partial', + 'branch', + }: + coverage_obj.files[current_file].info.num_statements += 1 + coverage_obj.info.num_statements += 1 + if 'covered' in line or 'partial' in line: + coverage_obj.files[current_file].executed_lines.append(line_number) + coverage_obj.files[current_file].info.covered_lines += 1 + coverage_obj.info.covered_lines += 1 + elif 'missing' in line: + coverage_obj.files[current_file].missing_lines.append(line_number) + coverage_obj.files[current_file].info.missing_lines += 1 + coverage_obj.info.missing_lines += 1 + elif 'excluded' in line: + coverage_obj.files[current_file].excluded_lines.append(line_number) + coverage_obj.files[current_file].info.excluded_lines += 1 + coverage_obj.info.excluded_lines += 1 + + if has_branches and 'branch' in line: + coverage_obj.files[current_file].info.num_branches += 1 + coverage_obj.info.num_branches += 1 + if 'branch partial' in line: + coverage_obj.files[current_file].info.num_partial_branches += 1 + coverage_obj.info.num_partial_branches += 1 + elif 'branch covered' in line: + coverage_obj.files[current_file].info.covered_branches += 1 + coverage_obj.info.covered_branches += 1 + elif 'branch missing' in line: + coverage_obj.files[current_file].info.missing_branches += 1 + coverage_obj.info.missing_branches += 1 + + info = coverage_obj.files[current_file].info + coverage_obj.files[current_file].info.percent_covered = coverage_module.compute_coverage( + num_covered=info.covered_lines, + num_total=info.num_statements, + ) + coverage_obj.files[ + current_file + ].info.percent_covered_display = f'{coverage_obj.files[current_file].info.percent_covered:.0%}' + + info = coverage_obj.info + coverage_obj.info.percent_covered = coverage_module.compute_coverage( + num_covered=info.covered_lines, + num_total=info.num_statements, + ) + coverage_obj.info.percent_covered_display = f'{coverage_obj.info.percent_covered:.0%}' + return coverage_obj + + return _ + + +@pytest.fixture +def make_diff_coverage(): + return coverage_module.get_diff_coverage_info + + +@pytest.fixture +def make_coverage_and_diff( + make_coverage, make_diff_coverage +) -> Callable[[str], tuple[coverage_module.Coverage, coverage_module.DiffCoverage]]: + def _(code: str) -> tuple[coverage_module.Coverage, coverage_module.DiffCoverage]: + added_lines: dict[pathlib.Path, list[int]] = {} + new_code = '' + current_file = None + # (we start at 0 because the first line will be empty for readabilty) + line_number = 0 + for line in code.splitlines(): + line = line.strip() + if not line: + continue + if line.startswith('# file: '): + new_code += line + '\n' + current_file = pathlib.Path(line.split('# file: ')[1]) + line_number = 0 + continue + assert current_file + line_number += 1 + + if line.startswith('+ '): + added_lines.setdefault(current_file, []).append(line_number) + new_code += line[2:] + '\n' + else: + new_code += line + '\n' + + coverage = make_coverage('\n' + new_code) + return coverage, make_diff_coverage(added_lines=added_lines, coverage=coverage) + + return _ + + +@pytest.fixture +def coverage_json(): + return { + 'meta': { + 'version': '1.2.3', + 'timestamp': '2000-01-01T00:00:00', + 'branch_coverage': True, + 'show_contexts': False, + }, + 'files': { + 'codebase/code.py': { + 'executed_lines': [1, 2, 3, 5, 13, 14], + 'summary': { + 'covered_lines': 6, + 'num_statements': 10, + 'percent_covered': 60.0, + 'percent_covered_display': '60%', + 'missing_lines': 4, + 'excluded_lines': 0, + 'num_branches': 3, + 'num_partial_branches': 1, + 'covered_branches': 1, + 'missing_branches': 1, + }, + 'missing_lines': [6, 8, 10, 11], + 'excluded_lines': [], + } + }, + 'totals': { + 'covered_lines': 6, + 'num_statements': 10, + 'percent_covered': 60.0, + 'percent_covered_display': '60%', + 'missing_lines': 4, + 'excluded_lines': 0, + 'num_branches': 3, + 'num_partial_branches': 1, + 'covered_branches': 1, + 'missing_branches': 1, + }, + } + + +@pytest.fixture +def coverage_obj_more_files(make_coverage): + return make_coverage( + """ + # file: codebase/code.py + covered + covered + covered + + branch partial + missing + + missing + + branch missing + missing + + branch covered + covered + # file: codebase/other.py + + + missing + covered + missing + missing + + missing + covered + covered + """ + ) + + +@pytest.fixture +def make_coverage_obj(coverage_obj_more_files): + def f(**kwargs): + obj = coverage_obj_more_files + for key, value in kwargs.items(): + vars(obj.files[pathlib.Path(key)]).update(value) + return obj + + return f + + +@pytest.fixture +def coverage_code(): + return """ + # file: codebase/code.py + 1 covered + 2 covered + 3 covered + 4 + 5 branch partial + 6 missing + 7 + 8 missing + 9 + 10 branch missing + 11 missing + 12 + 13 branch covered + 14 covered + """ + + +@pytest.fixture +def coverage_obj(make_coverage, coverage_code): + return make_coverage(coverage_code) + + +@pytest.fixture +def diff_coverage_obj(coverage_obj, make_diff_coverage): + return make_diff_coverage( + added_lines={pathlib.Path('codebase/code.py'): [3, 4, 5, 6, 7, 8, 9, 12]}, + coverage=coverage_obj, + ) + + +@pytest.fixture +def diff_coverage_obj_more_files(coverage_obj_more_files, make_diff_coverage): + return make_diff_coverage( + added_lines={ + pathlib.Path('codebase/code.py'): [3, 4, 5, 6, 7, 8, 9, 12], + pathlib.Path('codebase/other.py'): [1, 2, 3, 4, 5, 6, 7, 8, 17], + }, + coverage=coverage_obj_more_files, + ) + + +@pytest.fixture +def session(): + """ + You get a session object. Register responses on it: + session.register(method="GET", path="/a/b")(status_code=200) + or + session.register(method="GET", path="/a/b", json=checker)(status_code=200) + (where checker is a function receiving the json value, and returning True if it + matches) + + if session.request(method="GET", path="/a/b") is called, it will return a response + with status_code 200. Also, if not called by the end of the test, it will raise. + """ + + class Session: + def __init__(self): + self.responses = [] # List[Tuples[request kwargs, response kwargs]] + + def request(self, method, path, **kwargs): + request_kwargs = {'method': method, 'path': path} | kwargs + + for i, (match_kwargs, response_kwargs) in enumerate(self.responses): + match = True + for key, match_value in match_kwargs.items(): + if key not in request_kwargs: + match = False + break + request_value = request_kwargs[key] + + if hasattr(match_value, '__call__'): + try: + assert match_value(request_value) + except Exception: # pylint: disable=broad-except + match = False + break + else: + if not match_value == request_value: + match = False + break + if match: + self.responses.pop(i) + return httpx.Response( + **response_kwargs, + request=httpx.Request(method=method, url=path), + ) + assert False, f'No response found for kwargs {request_kwargs}\nExpected answers are {self.responses}' + + def __getattr__(self, value): + if value in ['get', 'post', 'patch', 'delete', 'put']: + return functools.partial(self.request, value.upper()) + raise AttributeError(value) + + def register(self, method, path, **request_kwargs): + request_kwargs = {'method': method, 'path': path} | request_kwargs + + def _(**response_kwargs): + response_kwargs.setdefault('status_code', 200) + self.responses.append((request_kwargs, response_kwargs)) + + return _ + + session = Session() + yield session + + +@pytest.fixture +def gh(session): + return github_client.GitHub(session=session) diff --git a/tests/test_coverage.py b/tests/test_coverage.py new file mode 100644 index 0000000..583d84d --- /dev/null +++ b/tests/test_coverage.py @@ -0,0 +1,344 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import datetime +import decimal +import json +import pathlib +from unittest.mock import patch + +import pytest + +from codecov import coverage + + +def test_diff_violations(make_coverage_and_diff): + _, diff = make_coverage_and_diff( + """ + # file: a.py + + 1 missing + 2 missing + + 3 missing + 4 covered + + 5 covered + """ + ) + assert diff.files[pathlib.Path('a.py')].violation_lines == [1, 3] + + +@pytest.mark.parametrize( + 'num_covered, num_total, expected_coverage', + [ + (0, 10, '0'), + (0, 0, '1'), + (5, 0, '1'), + (5, 10, '0.5'), + (1, 100, '0.01'), + ], +) +def test_compute_coverage(num_covered, num_total, expected_coverage): + assert coverage.compute_coverage(num_covered, num_total) == decimal.Decimal(expected_coverage) + + +@pytest.mark.parametrize( + 'added_lines, update_obj, expected', + [ + # A first simple example. We added lines 1 and 3 to a file. Coverage + # info says that lines 1 and 2 were executed and line 3 was not. + # Diff coverage should report that the violation is line 3 and + # that the total coverage is 50%. + ( + {pathlib.Path('codebase/code.py'): [1, 3]}, + {'codebase/code.py': {'executed_lines': [1, 2], 'missing_lines': [3]}}, + coverage.DiffCoverage( + total_num_lines=2, + total_num_violations=1, + total_percent_covered=decimal.Decimal('0.5'), + num_changed_lines=2, + files={ + pathlib.Path('codebase/code.py'): coverage.FileDiffCoverage( + path=pathlib.Path('codebase/code.py'), + percent_covered=decimal.Decimal('0.5'), + added_statements=[1, 3], + covered_statements=[1], + missing_statements=[3], + added_lines=[1, 3], + ) + }, + ), + ), + # A second simple example. This time, the only modified file (code2.py) + # is not the same as the files that received coverage info (code.py). + # Consequently, no line should be reported as a violation (we could + # imagine that the file code2.py only contains comments and is not + # covered, nor imported.) + ( + {pathlib.Path('codebase/code2.py'): [1, 3]}, + {'codebase/code.py': {'executed_lines': [1, 2], 'missing_lines': [3]}}, + coverage.DiffCoverage( + total_num_lines=0, + total_num_violations=0, + total_percent_covered=decimal.Decimal('1'), + num_changed_lines=2, + files={}, + ), + ), + # A third simple example. This time, there's no intersection between + # the modified files and the files that received coverage info. We + # 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]}}, + coverage.DiffCoverage( + total_num_lines=0, + total_num_violations=0, + total_percent_covered=decimal.Decimal('1'), + num_changed_lines=3, + files={ + pathlib.Path('codebase/code.py'): coverage.FileDiffCoverage( + path=pathlib.Path('codebase/code.py'), + percent_covered=decimal.Decimal('1'), + added_statements=[], + covered_statements=[], + missing_statements=[], + added_lines=[4, 5, 6], + ) + }, + ), + ), + # A more complex example with 2 distinct files. We want to check both + # that they are individually handled correctly and that the general + # stats are correct. + ( + { + pathlib.Path('codebase/code.py'): [4, 5, 6], + pathlib.Path('codebase/other.py'): [10, 13], + }, + { + 'codebase/code.py': { + 'executed_lines': [1, 2, 3, 5, 6], + 'missing_lines': [7], + }, + 'codebase/other.py': { + 'executed_lines': [10, 11, 12], + 'missing_lines': [13], + }, + }, + coverage.DiffCoverage( + total_num_lines=4, # 2 lines in code.py + 2 lines in other.py + total_num_violations=1, # 1 line in other.py + total_percent_covered=decimal.Decimal('0.75'), # 3/4 lines covered + num_changed_lines=5, # 3 lines in code.py + 2 lines in other.py + files={ + pathlib.Path('codebase/code.py'): coverage.FileDiffCoverage( + path=pathlib.Path('codebase/code.py'), + percent_covered=decimal.Decimal('1'), + added_statements=[5, 6], + covered_statements=[5, 6], + missing_statements=[], + added_lines=[4, 5, 6], + ), + pathlib.Path('codebase/other.py'): coverage.FileDiffCoverage( + path=pathlib.Path('codebase/other.py'), + percent_covered=decimal.Decimal('0.5'), + added_statements=[10, 13], + covered_statements=[10], + missing_statements=[13], + added_lines=[10, 13], + ), + }, + ), + ), + ], +) +def test_get_diff_coverage_info(make_coverage_obj, added_lines, update_obj, expected): + result = coverage.get_diff_coverage_info(added_lines=added_lines, coverage=make_coverage_obj(**update_obj)) + assert result == expected + + +@pytest.mark.parametrize( + 'line_number_diff_line, expected', + [ + ( + 'diff --git a/example.txt b/example.txt\n' + 'index abcdef1..2345678 100644\n' + '--- a/example.txt\n' + '+++ b/example.txt\n' + '@@ -1,2 +1,5 @@\n' + '-old_line_1\n' + '+new_line_1\n' + '+new_line_2\n' + '+new_line_3\n' + '@@ -10,3 +10,4 @@\n' + '+added_line\n' + '+added_line\n' + '+added_line\n' + '+added_line\n', + { + pathlib.Path('example.txt'): [1, 2, 3, 10, 11, 12, 13], + }, + ), + ( + 'diff --git a/sample.py b/sample.py\n' + 'index 1234567..abcdef1 100644\n' + '--- a/sample.py\n' + '+++ b/sample.py\n' + '@@ -5,5 +5,6 @@\n' + '+added_line_1\n' + '+added_line_2\n' + '@@ -20,6 +20,7 @@\n' + '+new_function_call()\n' + '+new_function_call()\n' + '+new_function_call()\n' + '+new_function_call()\n' + '+new_function_call()\n' + '+new_function_call()\n', + { + pathlib.Path('sample.py'): [5, 6, 20, 21, 22, 23, 24, 25], + }, + ), + ( + 'diff --git a/test.txt b/test.txt\n' + 'index 1111111..2222222 100644\n' + '--- a/test.txt\n' + '+++ b/test.txt\n' + '@@ -1 +1 @@\n' + '-old_content\n' + '+new_content\n', + { + pathlib.Path('test.txt'): [1], + }, + ), + ( + 'diff --git a/example.py b/example.py\n' + 'index abcdef1..2345678 100644\n' + '--- a/example.py\n' + '+++ b/example.py\n' + '@@ -7,6 +7,8 @@ def process_data(data):\n' + ' if item > 0:\n' + ' result.append(item * 2)\n' + "- logger.debug('Item processed: {}'.format(item))\n" + "+ logger.info('Item processed: {}'.format(item))\n" + ' return result\n', + { + pathlib.Path('example.py'): [9], + }, + ), + ( + 'diff --git a/sample.py b/sample.py\n' + 'index 1234567..abcdef1 100644\n' + '--- a/sample.py\n' + '+++ b/sample.py\n' + '@@ -15,6 +15,8 @@ def main():\n' + " print('Processing item:', item)\n" + ' result = process_item(item)\n' + '- if result:\n' + "- print('Result:', result)\n" + "+ logger.debug('Item processed successfully')\n" + '+ else:\n' + "+ print('Item processing failed')\n", + { + pathlib.Path('sample.py'): [17, 18, 19], + }, + ), + ( + 'diff --git a/test.py b/test.py\n' + 'index 1111111..2222222 100644\n' + '--- a/test.py\n' + '+++ b/test.py\n' + '@@ -5,5 +5,7 @@ def calculate_sum(a, b):\n' + ' return a + b\n' + ' def test_calculate_sum():\n' + '+ assert calculate_sum(2, 3) == 5\n' + '- assert calculate_sum(0, 0) == 0\n' + ' assert calculate_sum(-1, 1) == 0\n', + { + pathlib.Path('test.py'): [7], + }, + ), + ], +) +def test_parse_line_number_diff_line(line_number_diff_line, expected): + result = coverage.parse_diff_output(line_number_diff_line) + assert result == expected + + +def test_parse_line_number_raise_value_error(): + lines = ( + 'diff --git a/test.py b/test.py\n' + 'index 1111111..2222222 100644\n' + '--- a/test.py\n' + '@@ -5,5 +5,7 @@ def calculate_sum(a, b):\n' + ' return a + b\n' + ' def test_calculate_sum():\n' + '+ assert calculate_sum(2, 3) == 5\n' + '- assert calculate_sum(0, 0) == 0\n' + ' assert calculate_sum(-1, 1) == 0\n' + ) + with pytest.raises(ValueError): + coverage.parse_diff_output(lines) + + +def test_extract_info(coverage_json): + expected_coverage = coverage.Coverage( + meta=coverage.CoverageMetadata( + 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'): coverage.FileCoverage( + path=pathlib.Path('codebase/code.py'), + excluded_lines=[], + executed_lines=[1, 2, 3, 5, 13, 14], + missing_lines=[6, 8, 10, 11], + info=coverage.CoverageInfo( + covered_lines=6, + num_statements=10, + percent_covered=60.0, + percent_covered_display='60%', + missing_lines=4, + excluded_lines=0, + num_branches=3, + num_partial_branches=1, + covered_branches=1, + missing_branches=1, + ), + ) + }, + info=coverage.CoverageInfo( + covered_lines=6, + num_statements=10, + percent_covered=60.0, + percent_covered_display='60%', + missing_lines=4, + excluded_lines=0, + num_branches=3, + num_partial_branches=1, + covered_branches=1, + missing_branches=1, + ), + ) + + assert coverage.extract_info(coverage_json) == expected_coverage + + +def test_get_coverage_info(coverage_json): + with patch('pathlib.Path.open') as mock_open: + mock_open.return_value.__enter__.return_value.read.return_value = json.dumps(coverage_json) + _, result = coverage.get_coverage_info(pathlib.Path('path/to/file.json')) + + assert result == coverage.extract_info(coverage_json) + + +def test_get_coverage_info_json_decode_error(): + with patch('pathlib.Path.open') as mock_open: + mock_open.return_value.__enter__.return_value.read.return_value = b'invalid json' + with pytest.raises(json.JSONDecodeError): + coverage.get_coverage_info(pathlib.Path('path/to/file.json')) + + +def test_get_coverage_info_file_not_found(): + with pytest.raises(FileNotFoundError): + coverage.get_coverage_info(pathlib.Path('path/to/file.json')) diff --git a/tests/test_diff_grouper.py b/tests/test_diff_grouper.py new file mode 100644 index 0000000..5286250 --- /dev/null +++ b/tests/test_diff_grouper.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import pathlib + +from codecov import diff_grouper, groups + + +def test_group_annotations(coverage_obj, diff_coverage_obj): + result = diff_grouper.get_diff_missing_groups(coverage=coverage_obj, diff_coverage=diff_coverage_obj) + + assert list(result) == [ + groups.Group(file=pathlib.Path('codebase/code.py'), line_start=6, line_end=8), + ] + + +def test_group_annotations_more_files(coverage_obj_more_files, diff_coverage_obj_more_files): + result = diff_grouper.get_diff_missing_groups( + coverage=coverage_obj_more_files, + diff_coverage=diff_coverage_obj_more_files, + ) + + assert list(result) == [ + groups.Group(file=pathlib.Path('codebase/code.py'), line_start=5, line_end=8), + groups.Group(file=pathlib.Path('codebase/other.py'), line_start=1, line_end=1), + groups.Group(file=pathlib.Path('codebase/other.py'), line_start=3, line_end=5), + ] + + +def test_coverage_group_annotations(coverage_obj): + result = diff_grouper.get_missing_groups(coverage=coverage_obj) + + assert list(result) == [ + groups.Group(file=pathlib.Path('codebase/code.py'), line_start=6, line_end=11), + ] + + +def test_coverage_group_annotations_more_files(coverage_obj_more_files): + result = diff_grouper.get_missing_groups(coverage=coverage_obj_more_files) + + assert list(result) == [ + groups.Group(file=pathlib.Path('codebase/code.py'), line_start=5, line_end=8), + groups.Group(file=pathlib.Path('codebase/other.py'), line_start=1, line_end=1), + groups.Group(file=pathlib.Path('codebase/other.py'), line_start=3, line_end=5), + ] diff --git a/tests/test_github.py b/tests/test_github.py new file mode 100644 index 0000000..c29eb81 --- /dev/null +++ b/tests/test_github.py @@ -0,0 +1,395 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import json +import pathlib + +import pytest + +from codecov import github, groups + + +@pytest.mark.parametrize( + 'ref, expected', + [ + ('refs/heads/main', True), + ('refs/heads/other', False), + ], +) +def test_is_default_branch(ref, expected): + info = github.RepositoryInfo(default_branch='main', visibility='public') + result = info.is_default_branch(ref=ref) + + assert result is expected + + +@pytest.mark.parametrize( + 'visibility, expected', + [ + ('private', False), + ('internal', False), + ('public', True), + ], +) +def test_is_public(visibility, expected): + info = github.RepositoryInfo(default_branch='main', visibility=visibility) + result = info.is_public() + + assert result is expected + + +def test_get_repository_info(gh, session): + session.register('GET', '/repos/foo/bar')(json={'default_branch': 'baz', 'visibility': 'public'}) + + info = github.get_repository_info(github=gh, repository='foo/bar') + + assert info == github.RepositoryInfo(default_branch='baz', visibility='public') + + +def test_get_pr(gh, session, base_config): + config = base_config() + session.register('GET', f'/repos/{config.GITHUB_REPOSITORY}/pulls/{config.GITHUB_PR_NUMBER}')( + json={'number': config.GITHUB_PR_NUMBER, 'state': 'open'} + ) + + result = github.get_pr_number(github=gh, config=config) + assert result == config.GITHUB_PR_NUMBER + + +def test_get_pr_no_open_pr(gh, session, base_config): + config = base_config() + session.register('GET', f'/repos/{config.GITHUB_REPOSITORY}/pulls/{config.GITHUB_PR_NUMBER}')( + json={'number': config.GITHUB_PR_NUMBER, 'state': 'closed'} + ) + + with pytest.raises(github.CannotGetPullRequest): + github.get_pr_number(github=gh, config=config) + + +def test_get_pr_forbidden(gh, session, base_config): + config = base_config() + session.register('GET', f'/repos/{config.GITHUB_REPOSITORY}/pulls/{config.GITHUB_PR_NUMBER}')(status_code=403) + + with pytest.raises(github.CannotGetPullRequest): + github.get_pr_number(github=gh, config=config) + + +def test_get_pr_for_branch(gh, session, base_config): + config = base_config(GITHUB_PR_NUMBER=None, GITHUB_REF='featuer/branch') + session.register('GET', f'/repos/{config.GITHUB_REPOSITORY}/pulls')( + json=[{'number': config.GITHUB_PR_NUMBER, 'state': 'open'}] + ) + + result = github.get_pr_number(github=gh, config=config) + assert result == config.GITHUB_PR_NUMBER + + +def test_get_pr_for_branch_no_open_pr(gh, session, base_config): + config = base_config(GITHUB_PR_NUMBER=None, GITHUB_REF='featuer/branch') + session.register('GET', f'/repos/{config.GITHUB_REPOSITORY}/pulls')(json=[]) + + with pytest.raises(github.CannotGetPullRequest): + github.get_pr_number(github=gh, config=config) + + +def test_get_pr_for_branch_forbidden(gh, session, base_config): + config = base_config(GITHUB_PR_NUMBER=None, GITHUB_REF='featuer/branch') + session.register('GET', f'/repos/{config.GITHUB_REPOSITORY}/pulls')(status_code=403) + + with pytest.raises(github.CannotGetPullRequest): + github.get_pr_number(github=gh, config=config) + + +def test_get_pr_diff(gh, session, base_config): + config = base_config() + diff_data = 'diff --git a/file.py b/file.py\nindex 1234567..abcdefg 100644\n--- a/file.py\n+++ b/file.py\n@@ -1,2 +1,2 @@\n-foo\n+bar\n-baz\n+qux\n' + session.register('GET', f'/repos/{config.GITHUB_REPOSITORY}/pulls/{config.GITHUB_PR_NUMBER}')(text=diff_data) + + result = github.get_pr_diff(github=gh, repository=config.GITHUB_REPOSITORY, pr_number=config.GITHUB_PR_NUMBER) + assert result == diff_data + + +def test_get_pr_diff_forbidden(gh, session, base_config): + config = base_config() + session.register('GET', f'/repos/{config.GITHUB_REPOSITORY}/pulls/{config.GITHUB_PR_NUMBER}')(status_code=403) + + with pytest.raises(github.CannotGetPullRequest): + github.get_pr_diff(github=gh, repository=config.GITHUB_REPOSITORY, pr_number=config.GITHUB_PR_NUMBER) + + +def test_get_pr_diff_not_found(gh, session, base_config): + config = base_config() + session.register('GET', f'/repos/{config.GITHUB_REPOSITORY}/pulls/{config.GITHUB_PR_NUMBER}')(status_code=404) + + with pytest.raises(github.CannotGetPullRequest): + github.get_pr_diff(github=gh, repository=config.GITHUB_REPOSITORY, pr_number=config.GITHUB_PR_NUMBER) + + +def test_get_my_login(gh, session): + session.register('GET', '/user')(json={'login': 'foo'}) + result = github.get_my_login(github=gh) + assert result == 'foo' + + +def test_get_my_login_github_bot(gh, session): + session.register('GET', '/user')(status_code=403) + result = github.get_my_login(github=gh) + assert result == github.GITHUB_CODECOV_LOGIN + + +@pytest.mark.parametrize( + 'existing_comments', + [ + [], + [{'user': {'login': 'foo'}, 'body': 'Hey! hi! how are you?', 'id': 456}], + [{'user': {'login': 'bar'}, 'body': 'Hey marker!', 'id': 456}], + ], +) +def test_post_comment_create(gh, session, existing_comments): + session.register('GET', '/repos/foo/bar/issues/123/comments')(json=existing_comments) + session.register('POST', '/repos/foo/bar/issues/123/comments', json={'body': 'hi!'})() + + github.post_comment( + github=gh, + me='foo', + repository='foo/bar', + pr_number=123, + contents='hi!', + marker='marker', + ) + + +def test_post_comment_content_too_long_error(gh, session): + session.register('GET', '/repos/foo/bar/issues/123/comments')(json=[]) + session.register('POST', '/repos/foo/bar/issues/123/comments', json={'body': 'hi!'})(status_code=403) + + with pytest.raises(github.CannotPostComment): + github.post_comment( + github=gh, + me='foo', + repository='foo/bar', + pr_number=123, + contents='a' * 65537, + marker='marker', + ) + + +def test_post_comment_create_error(gh, session): + session.register('GET', '/repos/foo/bar/issues/123/comments')(json=[]) + session.register('POST', '/repos/foo/bar/issues/123/comments', json={'body': 'hi!'})(status_code=403) + + with pytest.raises(github.CannotPostComment): + github.post_comment( + github=gh, + me='foo', + repository='foo/bar', + pr_number=123, + contents='hi!', + marker='marker', + ) + + +def test_post_comment_update(gh, session): + comment = { + 'user': {'login': 'foo'}, + 'body': 'Hey! Hi! How are you? marker', + 'id': 456, + } + session.register('GET', '/repos/foo/bar/issues/123/comments')(json=[comment]) + session.register('PATCH', '/repos/foo/bar/issues/comments/456', json={'body': 'hi!'})() + + github.post_comment( + github=gh, + me='foo', + repository='foo/bar', + pr_number=123, + contents='hi!', + marker='marker', + ) + + +def test_post_comment_update_error(gh, session): + comment = { + 'user': {'login': 'foo'}, + 'body': 'Hey! Hi! How are you? marker', + 'id': 456, + } + session.register('GET', '/repos/foo/bar/issues/123/comments')(json=[comment]) + session.register('PATCH', '/repos/foo/bar/issues/comments/456', json={'body': 'hi!'})(status_code=403) + + with pytest.raises(github.CannotPostComment): + github.post_comment( + github=gh, + me='foo', + repository='foo/bar', + pr_number=123, + contents='hi!', + marker='marker', + ) + + +def test_post_comment_server_error(gh, session): + comment = { + 'user': {'login': 'foo'}, + 'body': 'Hey! Hi! How are you? marker', + 'id': 456, + } + session.register('GET', '/repos/foo/bar/issues/123/comments')(json=[comment]) + session.register('PATCH', '/repos/foo/bar/issues/comments/456', json={'body': 'hi!'})(status_code=500) + + with pytest.raises(github.CannotPostComment): + github.post_comment( + github=gh, + me='foo', + repository='foo/bar', + pr_number=123, + contents='hi!', + marker='marker', + ) + + +def test_annotation_str(): + file = pathlib.Path('/path/to/file.py') + annotation = github.Annotation( + file=file, line_start=10, line_end=15, title='Error', message_type='ERROR', message='Something went wrong' + ) + expected_str = 'ERROR Something went wrong in /path/to/file.py:10-15' + assert str(annotation) == expected_str + + +def test_annotation_repr(): + file = pathlib.Path('/path/to/file.py') + annotation = github.Annotation( + file=file, line_start=10, line_end=15, title='Error', message_type='ERROR', message='Something went wrong' + ) + expected_repr = 'ERROR Something went wrong in /path/to/file.py:10-15' + assert repr(annotation) == expected_repr + + +def test_annotation_to_dict(): + file = pathlib.Path('/path/to/file.py') + annotation = github.Annotation( + file=file, line_start=10, line_end=15, title='Error', message_type='ERROR', message='Something went wrong' + ) + expected_dict = { + 'file': '/path/to/file.py', + 'line_start': 10, + 'line_end': 15, + 'title': 'Error', + 'message_type': 'ERROR', + 'message': 'Something went wrong', + } + assert annotation.to_dict() == expected_dict + + +def test_annotation_encoder_annotation(): + encoder = github.AnnotationEncoder() + annotation = github.Annotation( + file='/path/to/file.py', + line_start=10, + line_end=15, + title='Error', + message_type='ERROR', + message='Something went wrong', + ) + expected_dict = { + 'file': '/path/to/file.py', + 'line_start': 10, + 'line_end': 15, + 'title': 'Error', + 'message_type': 'ERROR', + 'message': 'Something went wrong', + } + result = encoder.default(annotation) + assert result == expected_dict + + +def test_annotation_encoder_json(): + annotation = github.Annotation( + file=pathlib.Path('/path/to/file.py'), + line_start=10, + line_end=15, + title='Error', + message_type='ERROR', + message='Something went wrong', + ) + expected_json = '{"file": "/path/to/file.py", "line_start": 10, "line_end": 15, "title": "Error", "message_type": "ERROR", "message": "Something went wrong"}' + result = json.dumps(annotation, cls=github.AnnotationEncoder) + assert result == expected_json + + +def test_non_annotation_encoder(): + sample = { + 'file': 'test_file', + 'line_start': 1, + 'line_end': 2, + 'title': 'Test Annotation', + 'message_type': 'warning', + 'message': 'This is a test annotation.', + } + + with pytest.raises(TypeError): + github.AnnotationEncoder().default(sample) + + +@pytest.mark.parametrize( + 'annotation_type, annotations, expected_annotations', + [ + ('error', [], []), + ( + 'error', + [groups.Group(file=pathlib.Path('file.py'), line_start=10, line_end=10)], + [ + github.Annotation( + file=pathlib.Path('file.py'), + line_start=10, + line_end=10, + title='Missing coverage', + message_type='error', + message='Missing coverage on line 10', + ) + ], + ), + ( + 'warning', + [groups.Group(file=pathlib.Path('file.py'), line_start=5, line_end=10)], + [ + github.Annotation( + file=pathlib.Path('file.py'), + line_start=5, + line_end=10, + title='Missing coverage', + message_type='warning', + message='Missing coverage on lines 5-10', + ) + ], + ), + ( + 'notice', + [ + groups.Group(file=pathlib.Path('file1.py'), line_start=5, line_end=5), + groups.Group(file=pathlib.Path('file2.py'), line_start=10, line_end=15), + ], + [ + github.Annotation( + file=pathlib.Path('file1.py'), + line_start=5, + line_end=5, + title='Missing coverage', + message_type='notice', + message='Missing coverage on line 5', + ), + github.Annotation( + file=pathlib.Path('file2.py'), + line_start=10, + line_end=15, + title='Missing coverage', + message_type='notice', + message='Missing coverage on lines 10-15', + ), + ], + ), + ], +) +def test_create_missing_coverage_annotations(annotation_type, annotations, expected_annotations): + assert github.create_missing_coverage_annotations(annotation_type, annotations) == expected_annotations diff --git a/tests/test_github_client.py b/tests/test_github_client.py new file mode 100644 index 0000000..6c91bc6 --- /dev/null +++ b/tests/test_github_client.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import pytest + +from codecov import github_client + + +def test_github_client_get(session, gh): + session.register('GET', '/repos/a/b/issues', timeout=60, params={'a': 1})(json={'foo': 'bar'}) + + assert gh.repos('a/b').issues().get(a=1) == {'foo': 'bar'} + + +def test_github_client_get_text(session, gh): + session.register('GET', '/repos/a/b/issues', timeout=60, params={'a': 1})( + text='foobar', headers={'content-type': 'application/vnd.github.raw+json'} + ) + + assert gh.repos('a/b').issues().get(a=1, use_text=True) == 'foobar' + + +def test_github_client_get_bytes(session, gh): + session.register('GET', '/repos/a/b/issues', timeout=60, params={'a': 1})( + text='foobar', headers={'content-type': 'application/vnd.github.raw+json'} + ) + + assert gh.repos('a/b').issues().get(a=1, use_bytes=True) == b'foobar' + + +def test_github_client_get_headers(session, gh): + session.register('GET', '/repos/a/b/issues', timeout=60, params={'a': 1})( + json={'foo': 'bar'}, + headers={'X-foo': 'yay'}, + ) + + assert gh.repos('a/b').issues().get(a=1, headers={'X-foo': 'yay'}) == {'foo': 'bar'} + + +def test_github_client_post_non_json(session, gh): + session.register('POST', '/repos/a/b/issues', timeout=60, json={'a': 1})() + + gh.repos('a/b').issues().post(a=1) + + +def test_json_object(): + obj = github_client.JsonObject({'a': 1}) + + assert obj.a == 1 + + +def test_json_object_error(): + obj = github_client.JsonObject({'a': 1}) + + with pytest.raises(AttributeError): + _ = obj.b + + +def test_github_client_get_error(session, gh): + session.register('GET', '/repos')( + json={'foo': 'bar'}, + status_code=404, + ) + + with pytest.raises(github_client.ApiError) as exc_info: + gh.repos.get() + + assert str(exc_info.value) == "{'foo': 'bar'}" + + +def test_github_client_get_error_non_json(session, gh): + session.register('GET', '/repos')( + text='{foobar', + headers={'content-type': 'text/plain'}, + status_code=404, + ) + + with pytest.raises(github_client.ApiError) as exc_info: + gh.repos.get() + + assert str(exc_info.value) == "b'{foobar'" diff --git a/tests/test_log.py b/tests/test_log.py new file mode 100644 index 0000000..8e5fd52 --- /dev/null +++ b/tests/test_log.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from unittest.mock import patch + +from codecov import log + + +def test_setup_debug(): + with patch('logging.basicConfig') as mock_basic_config: + log.setup(debug=True) + mock_basic_config.assert_called_once_with( + level='DEBUG', + format='%(asctime)s.%(msecs)03d %(levelname)s %(name)s %(module)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + ) + + +def test_setup_not_debug(): + with patch('logging.basicConfig') as mock_basic_config: + log.setup(debug=False) + mock_basic_config.assert_called_once_with( + level='INFO', + format='%(asctime)s.%(msecs)03d %(levelname)s %(name)s %(module)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + ) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..fd977c5 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +import json +import pathlib +from unittest import mock + +import pytest + +from codecov import github, main + + +@mock.patch('codecov.main.settings.Config.from_environ') +@mock.patch('codecov.main.log.setup') +@mock.patch('codecov.main.sys.exit') +@mock.patch('codecov.main.httpx.Client') +@mock.patch('codecov.main.action') +def test_main_success(mock_action, mock_httpx_client, mock_sys_exit, mock_log_setup, mock_config_from_environ): + mock_config = mock_config_from_environ.return_value + mock_github_session = mock_httpx_client.return_value + mock_action.return_value = 0 + + main.main() + + mock_config_from_environ.assert_called_once_with(environ=mock.ANY) + mock_log_setup.assert_called_once_with(debug=mock_config.DEBUG) + mock_action.assert_called_once_with(config=mock_config, github_session=mock_github_session) + mock_sys_exit.assert_called_once_with(0) + + +@mock.patch('codecov.main.settings.Config.from_environ') +def test_main_skip_coverage(mock_config_from_environ, base_config): + mock_config_from_environ.return_value = base_config(SKIP_COVERAGE=True) + with pytest.raises(SystemExit): + main.main() + + +@mock.patch('codecov.main.settings.Config.from_environ') +@mock.patch('codecov.main.sys.exit') +@mock.patch('codecov.main.httpx.Client') +@mock.patch('codecov.main.action') +def test_main_exception(mock_action, mock_httpx_client, mock_sys_exit, mock_config_from_environ): + mock_config = mock_config_from_environ.return_value + mock_github_session = mock_httpx_client.return_value + mock_action.side_effect = Exception() + + main.main() + + mock_config_from_environ.assert_called_once_with(environ=mock.ANY) + mock_action.assert_called_once_with(config=mock_config, github_session=mock_github_session) + mock_sys_exit.assert_called_once_with(1) + + +def test_action_pull_request_success(session, base_config): + config = base_config() + main.process_pr = mock.Mock(return_value=0) + session.register('GET', f'/repos/{config.GITHUB_REPOSITORY}/pulls/{config.GITHUB_PR_NUMBER}')( + json={'number': config.GITHUB_PR_NUMBER, 'state': 'open'} + ) + session.register('GET', f'/repos/{config.GITHUB_REPOSITORY}')( + json={'default_branch': 'baz', 'visibility': 'public'} + ) + + result = main.action(config=config, github_session=session) + + assert result == 0 + + +def test_action_pull_request_failed(session, base_config): + config = base_config() + session.register('GET', f'/repos/{config.GITHUB_REPOSITORY}/pulls/{config.GITHUB_PR_NUMBER}')(status_code=404) + result = main.action(config=config, github_session=session) + assert result == 1 + + +@mock.patch('pathlib.Path.open') +@mock.patch('codecov.main.template.read_template_file') +@mock.patch('codecov.main.github.post_comment') +def test_process_pr_with_annotations( + mock_post_comment: mock.Mock, + mock_read_template_file: mock.Mock, + mock_open: mock.Mock, + base_config, + gh, + coverage_json, + session, +): + config = base_config( + ANNOTATE_MISSING_LINES=True, + ANNOTATIONS_OUTPUT_PATH=pathlib.Path('output.json'), + SUBPROJECT_ID='sub_project', + ) + mock_read_template_file.return_value = """ + {% block foo %}foo{% endblock foo %} + {{ marker }} + """ + mock_post_comment.return_value = None + mock_open.return_value.__enter__.return_value.read.return_value = json.dumps(coverage_json) + diff_data = 'diff --git a/file.py b/file.py\nindex 1234567..abcdefg 100644\n--- a/file.py\n+++ b/file.py\n@@ -1,2 +1,2 @@\n-foo\n+bar\n-baz\n+qux\n' + session.register('GET', f'/repos/{config.GITHUB_REPOSITORY}/pulls/{config.GITHUB_PR_NUMBER}')(text=diff_data) + session.register('GET', '/user')(json={'login': 'foo'}) + + repo_info = github.RepositoryInfo(default_branch='main', visibility='public') + result = main.process_pr(config, gh, repo_info, config.GITHUB_PR_NUMBER) + + assert result == 0 + + +@mock.patch('pathlib.Path.open') +def test_process_pr_with_annotations_skip_coverage( + mock_open: mock.Mock, + base_config, + gh, + coverage_json, + session, +): + config = base_config( + ANNOTATE_MISSING_LINES=True, + ANNOTATIONS_OUTPUT_PATH=pathlib.Path('output.json'), + SKIP_COVERAGE=True, + ) + mock_open.return_value.__enter__.return_value.read.return_value = json.dumps(coverage_json) + diff_data = 'diff --git a/file.py b/file.py\nindex 1234567..abcdefg 100644\n--- a/file.py\n+++ b/file.py\n@@ -1,2 +1,2 @@\n-foo\n+bar\n-baz\n+qux\n' + session.register('GET', f'/repos/{config.GITHUB_REPOSITORY}/pulls/{config.GITHUB_PR_NUMBER}')(text=diff_data) + + repo_info = github.RepositoryInfo(default_branch='main', visibility='public') + result = main.process_pr(config, gh, repo_info, config.GITHUB_PR_NUMBER) + + assert result == 0 diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..ac454ea --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import decimal +import pathlib +import tempfile + +import pytest + +from codecov import settings + + +def test_path_below_existing_file(): + with tempfile.NamedTemporaryFile(suffix='.json') as temp_file: + path = pathlib.Path(temp_file.name) + assert settings.path_below(path) == path.resolve() + + +def test_path_below_nonexistent_file(): + path = pathlib.Path('/path/to/nonexistent_file.json') + with pytest.raises(ValueError): + settings.path_below(path) + + +def test_path_below_directory(): + path = pathlib.Path('/path/to/directory') + with pytest.raises(ValueError): + settings.path_below(path) + + +def test_path_below_non_json_file(): + with tempfile.NamedTemporaryFile(suffix='.txt') as temp_file: + path = pathlib.Path(temp_file.name) + with pytest.raises(ValueError): + settings.path_below(path) + + +def test_config_from_environ_missing(): + with pytest.raises(settings.MissingEnvironmentVariable): + settings.Config.from_environ({}) + + +def test_config__from_environ__sample(): + with tempfile.NamedTemporaryFile(suffix='.json') as temp_file: + assert settings.Config.from_environ( + { + 'GITHUB_BASE_REF': 'main', + 'GITHUB_TOKEN': 'your_token', + 'GITHUB_REPOSITORY': 'your_repository', + 'COVERAGE_PATH': temp_file.name, + 'GITHUB_REF': 'main', + 'GITHUB_PR_NUMBER': '123', + 'SUBPROJECT_ID': 'your_subproject_id', + 'MINIMUM_GREEN': '90', + 'MINIMUM_ORANGE': '70', + 'SKIP_COVERAGE': 'False', + 'ANNOTATE_MISSING_LINES': 'True', + 'ANNOTATION_TYPE': 'warning', + 'ANNOTATIONS_OUTPUT_PATH': '/path/to/annotations', + 'MAX_FILES_IN_COMMENT': 25, + 'COMPLETE_PROJECT_REPORT': 'True', + 'COVERAGE_REPORT_URL': 'https://your_coverage_report_url', + 'DEBUG': 'False', + } + ) == settings.Config( + GITHUB_REPOSITORY='your_repository', + COVERAGE_PATH=pathlib.Path(temp_file.name).resolve(), + GITHUB_TOKEN='your_token', # noqa: S106 + GITHUB_PR_NUMBER=123, + GITHUB_REF='main', + GITHUB_BASE_REF='main', + SUBPROJECT_ID='your_subproject_id', + MINIMUM_GREEN=decimal.Decimal('90'), + MINIMUM_ORANGE=decimal.Decimal('70'), + SKIP_COVERAGE=False, + ANNOTATE_MISSING_LINES=True, + ANNOTATION_TYPE='warning', + ANNOTATIONS_OUTPUT_PATH=pathlib.Path('/path/to/annotations'), + MAX_FILES_IN_COMMENT=25, + COMPLETE_PROJECT_REPORT=True, + COVERAGE_REPORT_URL='https://your_coverage_report_url', + DEBUG=False, + ) + + +def test_config_required_pr_or_ref(): + with tempfile.NamedTemporaryFile(suffix='.json') as temp_file: + with pytest.raises(ValueError): + settings.Config.from_environ( + { + 'GITHUB_TOKEN': 'your_token', + 'GITHUB_REPOSITORY': 'your_repository', + 'COVERAGE_PATH': temp_file.name, + } + ) + + +def test_config_invalid_annotation_type(): + with pytest.raises(settings.InvalidAnnotationType): + settings.Config.from_environ({'ANNOTATION_TYPE': 'foo'}) + + +@pytest.mark.parametrize( + 'input_data, output_data', + [ + ('true', True), + ('True', True), + ('1', True), + ('yes', True), + ('false', False), + ('False', False), + ('0', False), + ('no', False), + ('foo', False), + ], +) +def test_str_to_bool(input_data, output_data): + assert settings.str_to_bool(input_data) is output_data + + +def test_config_clean_minimum_green(): + value = settings.Config.clean_minimum_green('90') + assert value == decimal.Decimal('90') + + +def test_config_clean_minimum_orange(): + value = settings.Config.clean_minimum_orange('70') + assert value == decimal.Decimal('70') + + +def test_config_clean_annotate_missing_lines(): + value = settings.Config.clean_annotate_missing_lines('True') + assert value is True + + +def test_config_clean_skip_coverage(): + value = settings.Config.clean_skip_coverage('False') + assert value is False + + +def test_config_clean_complete_project_report(): + value = settings.Config.clean_complete_project_report('True') + assert value is True + + +def test_config_clean_debug(): + value = settings.Config.clean_debug('False') + assert value is False + + +def test_config_clean_annotation_type(): + value = settings.Config.clean_annotation_type('warning') + assert value == 'warning' + + +def test_config_clean_annotation_type_invalid(): + with pytest.raises(settings.InvalidAnnotationType): + settings.Config.clean_annotation_type('foo') + + +def test_config_clean_github_pr_number(): + value = settings.Config.clean_github_pr_number('123') + assert value == 123 + + +def test_config_clean_coverage_path(): + with tempfile.NamedTemporaryFile(suffix='.json') as temp_file: + value = settings.Config.clean_coverage_path(temp_file.name) + assert value == pathlib.Path(temp_file.name).resolve() + + +def test_config_clean_annotations_output_path(): + value = settings.Config.clean_annotations_output_path('/path/to/annotations') + assert value == pathlib.Path('/path/to/annotations') + + +def test_str_to_bool_invalid(): + assert settings.str_to_bool('invalid') is False + + +def test_config_required_clean_env_var_error(): + with tempfile.NamedTemporaryFile(suffix='.json') as temp_file: + with pytest.raises(ValueError): + settings.Config.from_environ( + { + 'GITHUB_TOKEN': 'your_token', + 'GITHUB_REPOSITORY': 'your_repository', + 'COVERAGE_PATH': temp_file.name, + 'GITHUB_PR_NUMBER': 'invalid', + } + ) diff --git a/tests/test_template.py b/tests/test_template.py new file mode 100644 index 0000000..8712643 --- /dev/null +++ b/tests/test_template.py @@ -0,0 +1,595 @@ +# -*- coding: utf-8 -*- +import decimal +import hashlib +import pathlib + +import pytest + +from codecov import coverage, template + + +@pytest.mark.parametrize( + 'value, displayed_coverage', + [ + (decimal.Decimal('0.83'), '83%'), + (decimal.Decimal('0.99999'), '99.99%'), + (decimal.Decimal('0.00001'), '0%'), + (decimal.Decimal('0.0501'), '5.01%'), + (decimal.Decimal('1'), '100%'), + (decimal.Decimal('0.2'), '20%'), + (decimal.Decimal('0.8392'), '83.92%'), + ], +) +def test_pct(value, displayed_coverage): + assert template.pct(value) == displayed_coverage + + +@pytest.mark.parametrize( + 'value, displayed_percentage', + [ + (decimal.Decimal('0.83'), decimal.Decimal('83.00')), + (decimal.Decimal('0.99'), decimal.Decimal('99.00')), + (decimal.Decimal('0.00'), decimal.Decimal('0.00')), + (decimal.Decimal('0.0501'), decimal.Decimal('5.01')), + (decimal.Decimal('1'), decimal.Decimal('100.00')), + (decimal.Decimal('0.2'), decimal.Decimal('20.00')), + (decimal.Decimal('0.8392'), decimal.Decimal('83.92')), + ], +) +def test_x100(value, displayed_percentage): + assert template.x100(value) == displayed_percentage + + +@pytest.mark.parametrize( + 'number, singular, plural, expected', + [ + (1, '', 's', ''), + (2, '', 's', 's'), + (0, '', 's', 's'), + (1, 'y', 'ies', 'y'), + (2, 'y', 'ies', 'ies'), + ], +) +def test_pluralize(number, singular, plural, expected): + assert template.pluralize(number=number, singular=singular, plural=plural) == expected + + +@pytest.mark.parametrize( + 'marker_id, result', + [ + (None, ''), + ( + 'foo', + '', + ), + ], +) +def test_get_marker(marker_id, result): + assert template.get_marker(marker_id=marker_id) == result + + +def test_template_no_marker(coverage_obj, diff_coverage_obj): + with pytest.raises(template.MissingMarker): + marker = '' + template.get_comment_markdown( + coverage=coverage_obj, + diff_coverage=diff_coverage_obj, + 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=template.read_template_file('comment.md.j2')[: -len(marker)], + marker=marker, + ) + + +def test_template_error(coverage_obj, diff_coverage_obj): + with pytest.raises(template.TemplateError): + template.get_comment_markdown( + coverage=coverage_obj, + diff_coverage=diff_coverage_obj, + 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='', + ) + + +def test_get_comment_markdown(coverage_obj, diff_coverage_obj): + chaned_files, total, files = template.select_changed_files( + coverage=coverage_obj, + diff_coverage=diff_coverage_obj, + max_files=25, + ) + result = ( + template.get_comment_markdown( + coverage=coverage_obj, + diff_coverage=diff_coverage_obj, + coverage_files=chaned_files, + count_coverage_files=total, + files=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 }} + """, + ) + .strip() + .split(maxsplit=3) + ) + + expected = ['60%', '50%', 'foo', ''] + + assert result == expected + + +def test_comment_template(coverage_obj, diff_coverage_obj): + chaned_files, total, files = template.select_changed_files( + coverage=coverage_obj, + diff_coverage=diff_coverage_obj, + max_files=25, + ) + result = template.get_comment_markdown( + coverage=coverage_obj, + diff_coverage=diff_coverage_obj, + coverage_files=chaned_files, + count_coverage_files=total, + files=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'), + ) + expected = '## Coverage report\n\n\n
Click to see coverage of changed files\n \n\n\n\n\n\n\n\n\n\n
FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  codebase
  code.py6-8
Project Total 
\n\n\n\nThis report was generated by [CI-codecov]\n\n\n
\n\n\n\n\n\n\n' + assert result == expected + + +def test_template_no_files(coverage_obj): + diff_coverage = coverage.DiffCoverage( + total_num_lines=0, + total_num_violations=0, + total_percent_covered=decimal.Decimal('1'), + num_changed_lines=0, + files={}, + ) + result = template.get_comment_markdown( + coverage=coverage_obj, + diff_coverage=diff_coverage, + 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 seem to contain any modification to coverable code.' in result + assert 'code.py' not in result + assert 'other.py' not in result + + +@pytest.mark.parametrize( + 'current_code_and_diff, max_files, expected_files, expected_total, expected_total_files', + [ + pytest.param( + """ + # file: a.py + 1 covered + """, + 2, + [], + 0, + [], + id='unmodified', + ), + pytest.param( + """ + # file: a.py + 1 + 2 covered + """, + 2, + [], + 0, + [], + id='info_did_not_change', + ), + pytest.param( + """ + # file: a.py + 1 missing + """, + 2, + [], + 0, + [], + id='info_did_change', + ), + pytest.param( + """ + # file: a.py + + 1 covered + """, + 2, + ['a.py'], + 1, + ['a.py'], + id='with_diff', + ), + pytest.param( + """ + # file: b.py + + 1 covered + # file: a.py + + 1 covered + """, + 2, + ['a.py', 'b.py'], + 2, + ['a.py', 'b.py'], + id='ordered', + ), + pytest.param( + """ + # file: a.py + + 1 covered + + 2 covered + # file: b.py + + 1 missing + """, + 1, + ['b.py'], + 2, + ['a.py', 'b.py'], + id='info_did_change', + ), + pytest.param( + """ + # file: a.py + + 1 covered + # file: c.py + + 1 missing + # file: b.py + + 1 missing + """, + 2, + ['b.py', 'c.py'], + 3, + ['a.py', 'b.py', 'c.py'], + id='truncated_and_ordered', + ), + pytest.param( + """ + # file: a.py + 1 + 2 covered + # file: c.py + + 1 covered + # file: b.py + + 1 covered + + 1 covered + """, + 2, + ['b.py', 'c.py'], + 2, + ['b.py', 'c.py'], + id='truncated_and_ordered_sort_order_advanced', + ), + pytest.param( + """ + # file: a.py + + 1 covered + + 2 covered + # file: b.py + + 1 missing + """, + None, + ['a.py', 'b.py'], + 2, + ['a.py', 'b.py'], + id='max_none', + ), + ], +) +def test_select_changed_files( + make_coverage_and_diff, + current_code_and_diff, + max_files, + expected_files, + expected_total, + expected_total_files, +): + cov, diff_cov = make_coverage_and_diff(current_code_and_diff) + files, total, total_files = template.select_changed_files( + coverage=cov, + diff_coverage=diff_cov, + max_files=max_files, + ) + assert [str(e.path) for e in files] == expected_files + assert total == expected_total + assert all(str(e.path) in expected_total_files for e in total_files) + + +@pytest.mark.parametrize( + 'current_code_and_diff, max_files, expected_files, expected_total', + [ + pytest.param( + """ + # file: a.py + 1 covered + """, + 2, + ['a.py'], + 1, + id='unmodified', + ), + pytest.param( + """ + # file: a.py + 1 + 2 covered + """, + 2, + ['a.py'], + 1, + id='info_did_not_change', + ), + pytest.param( + """ + # file: a.py + 1 missing + """, + 2, + ['a.py'], + 1, + id='info_did_change', + ), + pytest.param( + """ + # file: a.py + + 1 covered + """, + 2, + [], + 0, + id='with_diff', + ), + pytest.param( + """ + # file: b.py + + 1 covered + # file: a.py + + 1 covered + """, + 2, + [], + 0, + id='ordered', + ), + pytest.param( + """ + # file: a.py + + 1 covered + # file: c.py + 1 missing + # file: b.py + 1 missing + """, + 2, + ['b.py', 'c.py'], + 2, + id='truncated_and_ordered', + ), + pytest.param( + """ + # file: a.py + 1 + 2 covered + # file: c.py + + 1 covered + # file: b.py + 1 covered + 1 covered + """, + 2, + ['a.py', 'b.py'], + 2, + id='truncated_and_ordered_sort_order_advanced', + ), + pytest.param( + """ + # file: a.py + 1 covered + 2 covered + # file: b.py + 1 missing + """, + None, + ['a.py', 'b.py'], + 2, + id='max_none', + ), + ], +) +def test_select_files( + make_coverage_and_diff, + current_code_and_diff, + max_files, + expected_files, + expected_total, +): + cov, diff_cov = make_coverage_and_diff(current_code_and_diff) + _, _, changed_files_info = template.select_changed_files(coverage=cov, diff_coverage=diff_cov, max_files=max_files) + files, total = template.select_files( + coverage=cov, + changed_files_info=changed_files_info, + max_files=max_files, + ) + assert [str(e.path) for e in files] == expected_files + assert total == expected_total + + +def test_select_files_no_statements(make_coverage_and_diff): + code = """ + # file: a.py + 1 covered + """ + cov, diff_cov = make_coverage_and_diff(code) + _, _, changed_files_info = template.select_changed_files(coverage=cov, diff_coverage=diff_cov, max_files=2) + cov.files[pathlib.Path('a.py')].info.num_statements = 0 + files, total = template.select_files( + coverage=cov, + changed_files_info=changed_files_info, + max_files=2, + ) + assert [str(e.path) for e in files] == [] + assert total == 0 + + +@pytest.mark.parametrize( + 'current_code_and_diff, expected_new_missing, expected_added, expected_new_covered', + [ + pytest.param( + """ + # file: a.py + + 1 + 2 covered + + 3 missing + + 4 missing + + 5 covered + """, + 2, + 3, + 2, + id='added_code', + ), + pytest.param( + """ + # file: a.py + + 1 missing + """, + 1, + 1, + 0, + id='removed_code', + ), + ], +) +def test_sort_order( + make_coverage_and_diff, + current_code_and_diff, + expected_new_missing, + expected_added, + expected_new_covered, +): + cov, diff_cov = make_coverage_and_diff(current_code_and_diff) + path = pathlib.Path('a.py') + file_info = template.FileInfo( + path=path, + coverage=cov.files[path], + diff=diff_cov.files[path], + ) + new_missing, added, new_covered = template.sort_order(file_info=file_info) + assert new_missing == expected_new_missing + assert added == expected_added + assert new_covered == expected_new_covered + + +def test_sort_order_none(make_coverage): + cov = make_coverage( + """ + # file: a.py + 1 covered + """ + ) + file_info = template.FileInfo( + path=pathlib.Path('a.py'), + coverage=cov.files[pathlib.Path('a.py')], + diff=None, + ) + new_missing, added, new_covered = template.sort_order(file_info=file_info) + assert new_missing == 0 + assert added == 0 + assert new_covered == 1 + + +def test_read_template_file(): + result = template.read_template_file('comment.md.j2') + assert result.startswith('{%- block title -%}## Coverage report') + + +def test_get_file_url_base(): + filename = pathlib.Path('test_file.py') + lines = (1, 10) + repo_name = 'my_repo' + pr_number = 123 + base_ref = 'main' + + expected_url = f'https://github.com/{repo_name}/blob/{base_ref}/{str(filename)}#L1-L10' + assert ( + template.get_file_url(filename, lines, base=True, repo_name=repo_name, pr_number=pr_number, base_ref=base_ref) + == expected_url + ) + + +def test_get_file_url_pr(): + filename = pathlib.Path('test_file.py') + lines = (1, 10) + repo_name = 'my_repo' + pr_number = 123 + base_ref = 'main' + + expected_hash = hashlib.sha256(str(filename).encode('utf-8')).hexdigest() + expected_url = f'https://github.com/{repo_name}/pull/{pr_number}/files#diff-{expected_hash}R1-R10' + assert ( + template.get_file_url(filename, lines, repo_name=repo_name, pr_number=pr_number, base_ref=base_ref) + == expected_url + ) + + +def test_get_file_url_no_lines(): + filename = pathlib.Path('test_file.py') + lines = None + repo_name = 'my_repo' + pr_number = 123 + base_ref = 'main' + + expected_url = f"https://github.com/{repo_name}/pull/{pr_number}/files#diff-{hashlib.sha256(str(filename).encode('utf-8')).hexdigest()}" + assert ( + template.get_file_url(filename, lines, repo_name=repo_name, pr_number=pr_number, base_ref=base_ref) + == expected_url + )