Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
suejung-sentry committed Sep 22, 2024
1 parent 761a934 commit 025938e
Show file tree
Hide file tree
Showing 9 changed files with 194 additions and 45 deletions.
19 changes: 19 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,25 @@ lint.check:
echo "Formatting..."
ruff format --check

# lint.branch lints only those files changed between this branch and main
# this command can be removed once we implement a pre-commit runner
# run it from a virtual env on your local machine - `cd <path/to/codecov-api>; python -m venv ./venv; source venv/bin/activate; pip-compile requirements.in; make lint.branch`
lint.branch:
make lint.install
echo "Linting files touched in this branch..."
changed_files=$$(git diff --name-only origin/main -- '*.py'); \
if [ -n "$$changed_files" ]; then \
ruff check $$changed_files --fix; \
else \
echo "No Python files to lint."; \
fi
echo "Formatting files touched in this branch..."
if [ -n "$$changed_files" ]; then \
ruff format $$changed_files --fix; \
else \
echo "No Python files to format."; \
fi

build.requirements:
# if docker pull succeeds, we have already build this version of
# requirements.txt. Otherwise, build and push a version tagged
Expand Down
23 changes: 23 additions & 0 deletions graphql_api/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from typing import Generic, TypeVar
from graphql.type.definition import GraphQLResolveInfo

T = TypeVar("T")


class TypedResolverInfo(GraphQLResolveInfo, Generic[T]):
"""
TypedResolverInfo adds type safety to the `context` field of Ariadne's
GraphQLResolveInfo by letting us declare the expected structure.
Example usage:
class CoverageAnalyticsContext(Protocol):
repository: Optional[Repository]
def resolve_percent_covered(
coverage_analytics: CoverageAnalytics, info: TypedResolverInfo[CoverageAnalyticsContext]
) -> Optional[float]:
repository = info.context.repository # we are able to use IDE autocomplete here
return repository.recent_coverage if repository else None
"""
context: T
78 changes: 78 additions & 0 deletions graphql_api/tests/test_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import unittest
from typing import Optional, Protocol, Any
from graphql import GraphQLSchema, GraphQLObjectType, GraphQLField, GraphQLString, GraphQLResolveInfo

from graphql_api.context import TypedResolverInfo


class MockContextProtocol(Protocol):
repository: Optional[str]


class MockContext(MockContextProtocol):
def __init__(self, repository: Optional[str] = None):
self.repository = repository


def create_mock_graphql_resolve_info(context: MockContext) -> TypedResolverInfo[MockContext]:
"""
Helper function to create a mock TypedResolverInfo object for testing.
"""
mock_field_name = "mockField"
mock_field_nodes = [] # Normally, this would be a list of AST nodes, empty for simplicity
mock_return_type = GraphQLString
mock_parent_type = GraphQLObjectType(
name="MockParent",
fields={"mockField": GraphQLField(GraphQLString)}
)
mock_path = [] # Path should be a valid list, not None
mock_schema = GraphQLSchema(query=mock_parent_type)
mock_fragments = {}
mock_root_value = None
mock_operation = None
mock_variable_values = {}
mock_is_awaitable = False

# Create a mock instance of TypedResolverInfo
info = TypedResolverInfo(
field_name=mock_field_name,
field_nodes=mock_field_nodes,
return_type=mock_return_type,
parent_type=mock_parent_type,
path=mock_path,
schema=mock_schema,
fragments=mock_fragments,
root_value=mock_root_value,
operation=mock_operation,
variable_values=mock_variable_values,
context=context, # The mock context
is_awaitable=mock_is_awaitable,
)

return info


class TestTypedResolverInfoGenerics(unittest.TestCase):
def test_typed_resolver_info_stores_context(self):
# Create a concrete context instance with repository data
mock_context = MockContext(repository="Test Repository")

# Create a TypedResolverInfo with the mock context
typed_info = create_mock_graphql_resolve_info(mock_context)

# Assert that context is stored and accessible with correct typing
self.assertEqual(typed_info.context.repository, "Test Repository")

def test_typed_resolver_info_handles_optional_none(self):
# Create a concrete context instance with repository as None
mock_context = MockContext(repository=None)

# Create a TypedResolverInfo with the mock context
typed_info = create_mock_graphql_resolve_info(mock_context)

# Assert that context repository is None
self.assertIsNone(typed_info.context.repository)


if __name__ == '__main__':
unittest.main()
18 changes: 12 additions & 6 deletions graphql_api/tests/test_coverage_analytics.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import datetime
from unittest.mock import patch

from django.test import TransactionTestCase
from django.utils import timezone
from freezegun import freeze_time

from codecov_auth.tests.factories import OwnerFactory
from core.models import Repository # Import the actual model, not the factory
from core.tests.factories import (
CommitFactory,
RepositoryFactory,
RepositoryTokenFactory,
)

from .helper import GraphQLTestHelper

# Queries for reuse
Expand Down Expand Up @@ -48,7 +49,9 @@


class TestFetchCoverageAnalytics(GraphQLTestHelper, TransactionTestCase):
def fetch_coverage_analytics(self, repo_name, interval="INTERVAL_1_DAY", fields=None):
def fetch_coverage_analytics(
self, repo_name, interval="INTERVAL_1_DAY", fields=None
):
query = query_coverage_analytics % (fields or default_coverage_analytics_fields)
variables = {"owner": "codecov-user", "repo": repo_name, "interval": interval}
return self.gql_request(query=query, owner=self.owner, variables=variables)
Expand All @@ -68,7 +71,9 @@ def setUp(self):

# Ensure the repository is saved correctly
self.repo.save()
self.assertTrue(self.repo.pk, "Repository should be saved and have a primary key.")
self.assertTrue(
self.repo.pk, "Repository should be saved and have a primary key."
)

@freeze_time("2021-01-01")
def test_full_coverage_analytics(self):
Expand All @@ -87,7 +92,9 @@ def test_full_coverage_analytics(self):
self.repo.save()

# Query the database using the actual model (Repository)
repo_from_db = Repository.objects.get(pk=self.repo.pk) # Use the Repository model, not the factory
repo_from_db = Repository.objects.get(
pk=self.repo.pk
) # Use the Repository model, not the factory
self.assertIsNotNone(repo_from_db.updatestamp)

# Fetch the coverage analytics data
Expand All @@ -109,7 +116,6 @@ def test_full_coverage_analytics(self):
}
assert coverage_data["owner"]["repository"] == expected_response


#
# @freeze_time("2021-01-01")
# def test_partial_coverage_analytics(self):
Expand Down
1 change: 1 addition & 0 deletions graphql_api/tests/test_coverage_analytics_measurements.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ def test_measurements_timeseries_enabled(
end_date=datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
branch=None,
)

#
# @override_settings(TIMESERIES_ENABLED=False)
# def test_measurements_timeseries_not_enabled(
Expand Down
5 changes: 4 additions & 1 deletion graphql_api/types/coverage_analytics/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from graphql_api.helpers.ariadne import ariadne_load_local_graphql

from .coverage_analytics import coverage_analytics_bindable, coverage_analytics_result_bindable
from .coverage_analytics import (
coverage_analytics_bindable,
coverage_analytics_result_bindable,
)

coverage_analytics = ariadne_load_local_graphql(__file__, "coverage_analytics.graphql")

Expand Down
62 changes: 40 additions & 22 deletions graphql_api/types/coverage_analytics/coverage_analytics.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Iterable, Optional, List, Union, Any
from typing import Any, Iterable, List, Optional, Union, Protocol

from ariadne import ObjectType, UnionType
from graphql.type.definition import GraphQLResolveInfo

import timeseries.helpers as timeseries_helpers
from codecov.db import sync_to_async
from graphql_api.context import TypedResolverInfo
from graphql_api.types.errors.errors import NotFoundError
from graphql_api.types.repository.repository import Repository
from timeseries.helpers import fill_sparse_measurements
from timeseries.models import Interval, MeasurementSummary

Expand All @@ -22,16 +23,23 @@
# CoverageAnalytics is the GraphQL type related to a repo's coverage analytics
@dataclass
class CoverageAnalytics:
percent_covered: float
commit_sha: str
hits: int
misses: int
lines: int
commit_sha: str
percent_covered: float
measurements: List[MeasurementSummary]


# CoverageAnalyticsContext is the expected Ariadne GraphQL context at this level
class CoverageAnalyticsContext(Protocol):
repository: Optional[Repository]


@coverage_analytics_result_bindable.type_resolver
def resolve_coverage_analytics_result_type(obj: Union[CoverageAnalytics, NotFoundError], *_: Any) -> Optional[str]:
def resolve_coverage_analytics_result_type(
obj: Union[CoverageAnalytics, NotFoundError], *_: Any
) -> Optional[str]:
if isinstance(obj, CoverageAnalytics):
return "CoverageAnalytics"
elif isinstance(obj, NotFoundError):
Expand All @@ -40,45 +48,55 @@ def resolve_coverage_analytics_result_type(obj: Union[CoverageAnalytics, NotFoun


@coverage_analytics_bindable.field("percentCovered")
def resolve_percent_covered(coverage_analytics: CoverageAnalytics, info: GraphQLResolveInfo) -> Optional[float]:
repository = info.context.get("repository", None)
def resolve_percent_covered(
coverage_analytics: CoverageAnalytics, info: TypedResolverInfo[CoverageAnalyticsContext]
) -> Optional[float]:
repository = info.context.repository
return repository.recent_coverage if repository else None


@coverage_analytics_bindable.field("commitSha")
def resolve_commit_sha(coverage_analytics: CoverageAnalytics, info: GraphQLResolveInfo) -> Optional[str]:
repository = info.context.get("repository", None)
def resolve_commit_sha(
coverage_analytics: CoverageAnalytics, info: TypedResolverInfo[CoverageAnalyticsContext]
) -> Optional[str]:
repository = info.context.repository
return repository.coverage_sha if repository else None


@coverage_analytics_bindable.field("hits")
def resolve_hits(coverage_analytics: CoverageAnalytics, info: GraphQLResolveInfo) -> Optional[int]:
repository = info.context.get("repository", None)
def resolve_hits(
coverage_analytics: CoverageAnalytics, info: TypedResolverInfo[CoverageAnalyticsContext]
) -> Optional[int]:
repository = info.context.repository
return repository.hits if repository else None


@coverage_analytics_bindable.field("misses")
def resolve_misses(coverage_analytics: CoverageAnalytics, info: GraphQLResolveInfo) -> Optional[int]:
repository = info.context.get("repository", None)
def resolve_misses(
coverage_analytics: CoverageAnalytics, info: TypedResolverInfo[CoverageAnalyticsContext]
) -> Optional[int]:
repository = info.context.repository
return repository.misses if repository else None


@coverage_analytics_bindable.field("lines")
def resolve_lines(coverage_analytics: CoverageAnalytics, info: GraphQLResolveInfo) -> Optional[int]:
repository = info.context.get("repository", None)
def resolve_lines(
coverage_analytics: CoverageAnalytics, info: TypedResolverInfo[CoverageAnalyticsContext]
) -> Optional[int]:
repository = info.context.repository
return repository.lines if repository else None


@coverage_analytics_bindable.field("measurements")
async def resolve_measurements(
coverage_analytics: CoverageAnalytics,
info: GraphQLResolveInfo,
interval: Interval,
before: Optional[datetime] = None,
after: Optional[datetime] = None,
branch: Optional[str] = None,
coverage_analytics: CoverageAnalytics,
info: TypedResolverInfo[CoverageAnalyticsContext],
interval: Interval,
before: Optional[datetime] = None,
after: Optional[datetime] = None,
branch: Optional[str] = None,
) -> Iterable[MeasurementSummary]:
repository = info.context.get("repository", None)
repository = info.context.repository

coverage_data = await sync_to_async(timeseries_helpers.repository_coverage_measurements_with_fallback)(
repository,
Expand Down
23 changes: 12 additions & 11 deletions graphql_api/types/repository/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@
from datetime import datetime
from typing import Iterable, List, Mapping, Optional

import shared.rate_limits as rate_limits
import yaml
from ariadne import ObjectType, UnionType, convert_kwargs_to_snake_case
from django.conf import settings
from django.forms.utils import from_current_timezone
from graphql.type.definition import GraphQLResolveInfo
from shared.yaml import UserYaml

import shared.rate_limits as rate_limits
import timeseries.helpers as timeseries_helpers
from codecov.db import sync_to_async
from codecov_auth.models import SERVICE_GITHUB, SERVICE_GITHUB_ENTERPRISE
Expand All @@ -33,6 +32,7 @@
from services.components import ComponentMeasurements
from services.profiling import CriticalFile, ProfilingSummary
from services.redis_configuration import get_redis_connection
from shared.yaml import UserYaml
from timeseries.helpers import fill_sparse_measurements
from timeseries.models import Dataset, Interval, MeasurementName, MeasurementSummary
from utils.test_results import aggregate_test_results
Expand Down Expand Up @@ -606,14 +606,15 @@ def resolve_coverage_analytics(
before: Optional[datetime] = None,
after: Optional[datetime] = None,
branch: Optional[str] = None,
):
info.context["repository"] = repository
# these will get resolved by CoverageAnalytics resolvers
) -> CoverageAnalytics:
"""
Returns an empty CoverageAnalytics object, to be populated by field resolvers.
"""
return CoverageAnalytics(
percent_covered=None,
commit_sha=None,
hits=None,
misses=None,
lines=None,
measurements=[],
percent_covered=0.0,
commit_sha="",
hits=0,
misses=0,
lines=0,
measurements=[]
)
Loading

0 comments on commit 025938e

Please sign in to comment.