Skip to content

Commit

Permalink
Increase test coverage
Browse files Browse the repository at this point in the history
  • Loading branch information
Zach Price committed Apr 3, 2024
1 parent dade89d commit cb9308a
Show file tree
Hide file tree
Showing 9 changed files with 195 additions and 85 deletions.
68 changes: 2 additions & 66 deletions esg_fastapi/api/versions/v1/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from datetime import datetime
from decimal import Decimal
from typing import Annotated, Any, Literal, TypeGuard, cast
from typing import Annotated, Any, Literal, cast

from annotated_types import T
from fastapi import Query
Expand All @@ -26,78 +26,14 @@
)
from pydantic_core import Url


def ensure_list(value: T) -> T | list[T]:
"""If value is a list, return as is. Otherwise, wrap it in a list.
Args:
value (T): The value to be ensured as a list.
Returns:
list: Either the original list passed in, or the passed value wrapped in a list.
Raises:
TypeError: If the passed value is not a list and cannot be converted to one.
Examples:
>>> ensure_list(123)
[123]
>>> ensure_list([123, 456])
[123, 456]
"""
if isinstance(value, list):
return value
else:
return [value]


def one_or_list(value: list[T] | T) -> T | list[T]:
"""Unwrap length 1 lists.
This function takes a value that can be either a single item or a list of items. If the passed value is a list of length 1, the function returns the single item in the list. Otherwise, it returns the original list.
Args:
value (list[T] | T): The value to be unwrapped.
Returns:
T | list[T]: If the passed list is length 1, the function returns the single item in the list. Otherwise, it returns the original list.
Raises:
TypeError: If the passed value is neither a single item nor a list of items.
Example:
>>> one_or_list([1, 2, 3])
[1, 2, 3]
>>> one_or_list(4)
4
>>> one_or_list("hello")
'hello'
>>> one_or_list([1])
1
"""
if is_list(value) and len(value) == 1:
return value[0]
return value

from esg_fastapi.utils import ensure_list, one_or_list

MultiValued = Annotated[list[T], BeforeValidator(ensure_list), Query()]
Stringified = Annotated[T, AfterValidator(lambda x: str(x))]
LowerCased = Annotated[T, AfterValidator(lambda x: x.lower())]
SolrFQ = Annotated[T, BeforeValidator(one_or_list)]


def is_list(value: T) -> TypeGuard[list]:
"""TypeGuard based on whether the value is a list.
Parameters:
value (T): The value to be checked.
Returns:
TypeGuard[list]: Returns True if the value is a list, otherwise False.
"""
return isinstance(value, list)


class ESGSearchQuery(BaseModel):
"""Represents the query parameters accepted by the ESG Search API.
Expand Down
19 changes: 1 addition & 18 deletions esg_fastapi/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,12 @@

import sys
from types import ModuleType
from typing import TYPE_CHECKING
from uuid import UUID

from annotated_types import T
from pydantic import UUID4, Field
from pydantic_settings import BaseSettings, SettingsConfigDict


def type_of(baseclass: T) -> T:
"""Inherit from `baseclass` only for type checking purposes.
This allows informing type checkers that the inheriting class ducktypes
as the given `baseclass` without actually inheriting from it.
Notes:
- `typing.Protocol` is the right answer to this problem, but `sys.modules.__setitem__`
currently checks for the ModuleType directly rather than a Protocol.
- `pydantic_settings.BaseSettings` can't inherit from `ModuleType` due to conflicts
in its use of `__slots__`
"""
if TYPE_CHECKING:
return baseclass
return object
from esg_fastapi.utils import type_of


class ClassModule(type_of(ModuleType)):
Expand Down
87 changes: 87 additions & 0 deletions esg_fastapi/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Utilities that don't fit well in other modules."""

from typing import TYPE_CHECKING, TypeGuard

from annotated_types import T


def type_of(baseclass: T) -> T:
"""Inherit from `baseclass` only for type checking purposes.
This allows informing type checkers that the inheriting class ducktypes
as the given `baseclass` without actually inheriting from it.
Notes:
- `typing.Protocol` is the right answer to this problem, but `sys.modules.__setitem__`
currently checks for the ModuleType directly rather than a Protocol.
- `pydantic_settings.BaseSettings` can't inherit from `ModuleType` due to conflicts
in its use of `__slots__`
"""
if TYPE_CHECKING:
return baseclass
return object


def is_list(value: T) -> TypeGuard[list]:
"""TypeGuard based on whether the value is a list.
Parameters:
value (T): The value to be checked.
Returns:
TypeGuard[list]: Returns True if the value is a list, otherwise False.
"""
return isinstance(value, list)


def one_or_list(value: list[T] | T) -> T | list[T]:
"""Unwrap length 1 lists.
This function takes a value that can be either a single item or a list of items. If the passed value is a list of length 1, the function returns the single item in the list. Otherwise, it returns the original list.
Args:
value (list[T] | T): The value to be unwrapped.
Returns:
T | list[T]: If the passed list is length 1, the function returns the single item in the list. Otherwise, it returns the original list.
Raises:
TypeError: If the passed value is neither a single item nor a list of items.
Example:
>>> one_or_list([1, 2, 3])
[1, 2, 3]
>>> one_or_list(4)
4
>>> one_or_list("hello")
'hello'
>>> one_or_list([1])
1
"""
if is_list(value) and len(value) == 1:
return value[0]
return value


def ensure_list(value: T) -> T | list[T]:
"""If value is a list, return as is. Otherwise, wrap it in a list.
Args:
value (T): The value to be ensured as a list.
Returns:
list: Either the original list passed in, or the passed value wrapped in a list.
Raises:
TypeError: If the passed value is not a list and cannot be converted to one.
Examples:
>>> ensure_list(123)
[123]
>>> ensure_list([123, 456])
[123, 456]
"""
if isinstance(value, list):
return value
else:
return [value]
File renamed without changes.
15 changes: 15 additions & 0 deletions features/Observability/observability_endpoints.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Feature: The API provides observability endpoints for use by Kubernetes

Kubernetes expects pods to provide a method to query their liveness (running or not)
and readiness (whether its ready to serve traffic) states.

Scenario Outline: Happy path (live and ready)

Given a <probe_type>
When its <endpoint> is querried
Then it should return a positive <status>

Examples:
| probe_type | endpoint | status |
| readiness | /healthz/readiness | ready |
| liveness | /healthz/liveness | live |
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ convention = "google"
[tool.ruff.lint.per-file-ignores]
# Ignore `S101` (Use of `assert` detected) in tests.
"tests/*" = ["S101"]
"test_*.py" = ["S101"]

[tool.pytest.ini_options]
bdd_features_base_dir = "features/"

[build-system]
requires = ["poetry-core"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from esg_fastapi import api

scenarios("Features")
scenarios("ESGSearch_Parity")


class RequestResponseFixture(TypedDict):
Expand Down
56 changes: 56 additions & 0 deletions tests/test_observability.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Step definitions for Behave/Cucumber style tests."""

import json
from pathlib import Path
from typing import Literal, Type, TypedDict

from fastapi.testclient import TestClient
from pytest_bdd import given, scenarios, then, when
from pytest_bdd.parsers import parse
from pytest_mock import MockerFixture

from esg_fastapi import api
from esg_fastapi.observability.models import ProbeResponse

scenarios("Observability")


class RequestResponseFixture(TypedDict):
"""Type hint for example request/response fixtures loaded from JSON files."""

request: dict
globus_response: dict
esgsearch_response: dict


class ComparisonFixture(RequestResponseFixture):
"""RequestResponseFixture with fastapi_response populated."""

fastapi_response: dict


@given(parse("a {probe_type}"))
def load_example(probe_type: Literal["ready", "live"]) -> None:
"""Handle the type of probe to be tested (Currently unused)."""


@when(parse("its {endpoint} is querried"), target_fixture="probe_response")
def send_request(endpoint: str) -> ProbeResponse:
"""Send request to ESG FastAPI and return its response as a fixture.
Notes:
- We use the TestClient from FastAPI to send the request to the ESG FastAPI service
which currently raises a Warning until the next release of FastAPI.
ref: https://github.com/encode/starlette/issues/2524
"""
client = TestClient(api)

return ProbeResponse.model_validate(client.get(endpoint).json())


@then(parse("it should return a positive {status}"))
def compare_responses(
status: Literal["ready", "live"], probe_response: ProbeResponse
) -> None:
"""Ensure the expected response is returned for the probe."""
assert status == probe_response.status
29 changes: 29 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Ensure that the settings module works as intended."""

from types import ModuleType

import pytest
from pytest_mock import MockerFixture
from typing_extensions import assert_type

from esg_fastapi.utils import type_of


def test_settings_is_usable() -> None:
"""Ensure that the settings module can be imported and provides at least one setting."""
from esg_fastapi import settings

assert settings.globus_search_index is not None


@pytest.mark.parametrize("enabled", [True, False])
def test_type_of_indicates_correctly(mocker: MockerFixture, enabled: bool) -> None:
"""Ensure that a class inheriting from type_of(T) type checks as a T but otherwise doesn't change its inheritance."""
mocker.patch("typing.TYPE_CHECKING", enabled)

class TestObj(type_of(ModuleType)): ...

if enabled:
assert_type(TestObj, ModuleType)
else:
assert_type(TestObj, object)

0 comments on commit cb9308a

Please sign in to comment.