diff --git a/esg_fastapi/api/versions/v1/models.py b/esg_fastapi/api/versions/v1/models.py index 4fa13d2..dd0666a 100644 --- a/esg_fastapi/api/versions/v1/models.py +++ b/esg_fastapi/api/versions/v1/models.py @@ -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 @@ -26,59 +26,7 @@ ) 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))] @@ -86,18 +34,6 @@ def one_or_list(value: list[T] | T) -> T | list[T]: 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. diff --git a/esg_fastapi/settings.py b/esg_fastapi/settings.py index b3e8b08..b52a04d 100644 --- a/esg_fastapi/settings.py +++ b/esg_fastapi/settings.py @@ -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)): diff --git a/esg_fastapi/utils.py b/esg_fastapi/utils.py new file mode 100644 index 0000000..0d41232 --- /dev/null +++ b/esg_fastapi/utils.py @@ -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] diff --git a/tests/Features/esg_search_parity.feature b/features/ESGSearch_Parity/esg_search_parity.feature similarity index 100% rename from tests/Features/esg_search_parity.feature rename to features/ESGSearch_Parity/esg_search_parity.feature diff --git a/features/Observability/observability_endpoints.feature b/features/Observability/observability_endpoints.feature new file mode 100644 index 0000000..b4bf939 --- /dev/null +++ b/features/Observability/observability_endpoints.feature @@ -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 + When its is querried + Then it should return a positive + + Examples: + | probe_type | endpoint | status | + | readiness | /healthz/readiness | ready | + | liveness | /healthz/liveness | live | diff --git a/pyproject.toml b/pyproject.toml index 5b38620..04cfcad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/tests/test_feature_parity.py b/tests/test_esgsearch_parity.py similarity index 98% rename from tests/test_feature_parity.py rename to tests/test_esgsearch_parity.py index c764b4a..66e83e0 100644 --- a/tests/test_feature_parity.py +++ b/tests/test_esgsearch_parity.py @@ -11,7 +11,7 @@ from esg_fastapi import api -scenarios("Features") +scenarios("ESGSearch_Parity") class RequestResponseFixture(TypedDict): diff --git a/tests/test_observability.py b/tests/test_observability.py new file mode 100644 index 0000000..dace3d2 --- /dev/null +++ b/tests/test_observability.py @@ -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 diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..caf2b01 --- /dev/null +++ b/tests/test_settings.py @@ -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)