From e862964bb6f1606d72180ee9705d99ab3d021a5e Mon Sep 17 00:00:00 2001 From: Liam Jarvis Date: Sat, 9 Mar 2024 03:16:41 +0900 Subject: [PATCH] feat(python): Add `ListFlags` api (#127) --- flipt-python/flipt/async_client.py | 2 + flipt-python/flipt/evaluation/models.py | 12 ++--- flipt-python/flipt/flags/__init__.py | 15 +++++++ flipt-python/flipt/flags/async_flag_client.py | 45 +++++++++++++++++++ flipt-python/flipt/flags/models.py | 26 +++++++++++ flipt-python/flipt/flags/sync_flag_client.py | 45 +++++++++++++++++++ flipt-python/flipt/models.py | 21 +++++++++ flipt-python/flipt/sync_client.py | 2 + flipt-python/tests/conftest.py | 6 +-- flipt-python/tests/flag/__init__.py | 0 flipt-python/tests/flag/conftest.py | 13 ++++++ flipt-python/tests/flag/test_async_client.py | 15 +++++++ flipt-python/tests/flag/test_sync_client.py | 14 ++++++ 13 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 flipt-python/flipt/flags/__init__.py create mode 100644 flipt-python/flipt/flags/async_flag_client.py create mode 100644 flipt-python/flipt/flags/models.py create mode 100644 flipt-python/flipt/flags/sync_flag_client.py create mode 100644 flipt-python/flipt/models.py create mode 100644 flipt-python/tests/flag/__init__.py create mode 100644 flipt-python/tests/flag/conftest.py create mode 100644 flipt-python/tests/flag/test_async_client.py create mode 100644 flipt-python/tests/flag/test_sync_client.py diff --git a/flipt-python/flipt/async_client.py b/flipt-python/flipt/async_client.py index 284461af..24f12aba 100644 --- a/flipt-python/flipt/async_client.py +++ b/flipt-python/flipt/async_client.py @@ -2,6 +2,7 @@ from .authentication import AuthenticationStrategy from .evaluation import AsyncEvaluation +from .flags import AsyncFlag class AsyncFliptClient: @@ -14,6 +15,7 @@ def __init__( self.httpx_client = httpx.AsyncClient(timeout=timeout) self.evaluation = AsyncEvaluation(url, authentication, self.httpx_client) + self.flag = AsyncFlag(url, authentication, self.httpx_client) async def close(self) -> None: await self.httpx_client.aclose() diff --git a/flipt-python/flipt/evaluation/models.py b/flipt-python/flipt/evaluation/models.py index 536cf16d..6cba5f33 100644 --- a/flipt-python/flipt/evaluation/models.py +++ b/flipt-python/flipt/evaluation/models.py @@ -1,7 +1,8 @@ from enum import StrEnum -from pydantic import AliasGenerator, BaseModel, ConfigDict, Field -from pydantic.alias_generators import to_camel +from pydantic import Field + +from flipt.models import CamelAliasModel class EvaluationResponseType(StrEnum): @@ -22,13 +23,6 @@ class ErrorEvaluationReason(StrEnum): NOT_FOUND_ERROR_EVALUATION_REASON = "NOT_FOUND_ERROR_EVALUATION_REASON" -class CamelAliasModel(BaseModel): - model_config = ConfigDict( - alias_generator=AliasGenerator(alias=to_camel), - populate_by_name=True, - ) - - class EvaluationRequest(CamelAliasModel): namespace_key: str = Field(default="default") flag_key: str diff --git a/flipt-python/flipt/flags/__init__.py b/flipt-python/flipt/flags/__init__.py new file mode 100644 index 00000000..00f964f1 --- /dev/null +++ b/flipt-python/flipt/flags/__init__.py @@ -0,0 +1,15 @@ +from .async_flag_client import AsyncFlag +from .models import ( + Flag, + FlagType, + ListFlagsResponse, +) +from .sync_flag_client import SyncFlag + +__all__ = [ + "AsyncFlag", + "SyncFlag", + "ListFlagsResponse", + "Flag", + "FlagType", +] diff --git a/flipt-python/flipt/flags/async_flag_client.py b/flipt-python/flipt/flags/async_flag_client.py new file mode 100644 index 00000000..5b2255dd --- /dev/null +++ b/flipt-python/flipt/flags/async_flag_client.py @@ -0,0 +1,45 @@ +from http import HTTPStatus + +import httpx + +from flipt.models import ListParameters + +from ..authentication import AuthenticationStrategy +from ..exceptions import FliptApiError +from .models import ( + ListFlagsResponse, +) + + +class AsyncFlag: + def __init__( + self, + url: str, + authentication: AuthenticationStrategy | None = None, + httpx_client: httpx.AsyncClient | None = None, + ): + self.url = url + self.headers: dict[str, str] = {} + + self._client = httpx_client or httpx.AsyncClient() + + if authentication: + authentication.authenticate(self.headers) + + async def close(self) -> None: + await self._client.aclose() + + def _raise_on_error(self, response: httpx.Response) -> None: + if response.status_code != 200: + body = response.json() + message = body.get("message", HTTPStatus(response.status_code).description) + raise FliptApiError(message, response.status_code) + + async def list_flags(self, *, namespace_key: str, params: ListParameters | None = None) -> ListFlagsResponse: + response = await self._client.get( + f"{self.url}/api/v1/namespaces/{namespace_key}/flags", + params=params.model_dump_json(exclude_none=True) if params else {}, + headers=self.headers, + ) + self._raise_on_error(response) + return ListFlagsResponse.model_validate_json(response.text) diff --git a/flipt-python/flipt/flags/models.py b/flipt-python/flipt/flags/models.py new file mode 100644 index 00000000..e30d2d71 --- /dev/null +++ b/flipt-python/flipt/flags/models.py @@ -0,0 +1,26 @@ +from datetime import datetime +from enum import Enum +from typing import Any + +from flipt.models import CamelAliasModel, PaginatedResponse + + +class FlagType(str, Enum): + variant = "VARIANT_FLAG_TYPE" + boolean = "BOOLEAN_FLAG_TYPE" + + +class Flag(CamelAliasModel): + created_at: datetime + description: str + enabled: bool + key: str + name: str + namespacekey: str | None = None + type: FlagType + updatedAt: datetime + variants: list[Any] + + +class ListFlagsResponse(CamelAliasModel, PaginatedResponse): + flags: list[Flag] diff --git a/flipt-python/flipt/flags/sync_flag_client.py b/flipt-python/flipt/flags/sync_flag_client.py new file mode 100644 index 00000000..c3cb00c2 --- /dev/null +++ b/flipt-python/flipt/flags/sync_flag_client.py @@ -0,0 +1,45 @@ +from http import HTTPStatus + +import httpx + +from flipt.models import ListParameters + +from ..authentication import AuthenticationStrategy +from ..exceptions import FliptApiError +from .models import ( + ListFlagsResponse, +) + + +class SyncFlag: + def __init__( + self, + url: str, + authentication: AuthenticationStrategy | None = None, + httpx_client: httpx.Client | None = None, + ): + self.url = url + self.headers: dict[str, str] = {} + + self._client = httpx_client or httpx.Client() + + if authentication: + authentication.authenticate(self.headers) + + def close(self) -> None: + self._client.close() + + def _raise_on_error(self, response: httpx.Response) -> None: + if response.status_code != 200: + body = response.json() + message = body.get("message", HTTPStatus(response.status_code).description) + raise FliptApiError(message, response.status_code) + + def list_flags(self, *, namespace_key: str, params: ListParameters | None = None) -> ListFlagsResponse: + response = self._client.get( + f"{self.url}/api/v1/namespaces/{namespace_key}/flags", + params=params.model_dump_json(exclude_none=True) if params else {}, + headers=self.headers, + ) + self._raise_on_error(response) + return ListFlagsResponse.model_validate_json(response.text) diff --git a/flipt-python/flipt/models.py b/flipt-python/flipt/models.py new file mode 100644 index 00000000..adbb1b6c --- /dev/null +++ b/flipt-python/flipt/models.py @@ -0,0 +1,21 @@ +from pydantic import AliasGenerator, BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class CamelAliasModel(BaseModel): + model_config = ConfigDict( + alias_generator=AliasGenerator(alias=to_camel), + populate_by_name=True, + ) + + +class ListParameters(BaseModel): + limit: int | None = None + offset: int | None = None + pageToken: str | None = None + reference: str | None = None + + +class PaginatedResponse(BaseModel): + nextPageToken: str + totalCount: int diff --git a/flipt-python/flipt/sync_client.py b/flipt-python/flipt/sync_client.py index 2a1f5224..fe821aab 100644 --- a/flipt-python/flipt/sync_client.py +++ b/flipt-python/flipt/sync_client.py @@ -2,6 +2,7 @@ from .authentication import AuthenticationStrategy from .evaluation import Evaluation +from .flags import SyncFlag class FliptClient: @@ -14,6 +15,7 @@ def __init__( self.httpx_client = httpx.Client(timeout=timeout) self.evaluation = Evaluation(url, authentication, self.httpx_client) + self.flag = SyncFlag(url, authentication, self.httpx_client) def close(self) -> None: self.httpx_client.close() diff --git a/flipt-python/tests/conftest.py b/flipt-python/tests/conftest.py index ccd793c8..511aee73 100644 --- a/flipt-python/tests/conftest.py +++ b/flipt-python/tests/conftest.py @@ -6,7 +6,7 @@ from flipt.authentication import ClientTokenAuthentication -@pytest.fixture(scope='session') +@pytest.fixture(scope="session") def flipt_url() -> str: flipt_url = os.environ.get("FLIPT_URL") if flipt_url is None: @@ -14,7 +14,7 @@ def flipt_url() -> str: return flipt_url -@pytest.fixture(scope='session') +@pytest.fixture(scope="session") def flipt_auth_token() -> str: auth_token = os.environ.get("FLIPT_AUTH_TOKEN") if auth_token is None: @@ -23,7 +23,7 @@ def flipt_auth_token() -> str: return auth_token -@pytest.fixture(scope='session') +@pytest.fixture(scope="session") def sync_flipt_client(flipt_url, flipt_auth_token): return FliptClient(url=flipt_url, authentication=ClientTokenAuthentication(flipt_auth_token)) diff --git a/flipt-python/tests/flag/__init__.py b/flipt-python/tests/flag/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/flipt-python/tests/flag/conftest.py b/flipt-python/tests/flag/conftest.py new file mode 100644 index 00000000..f7889b62 --- /dev/null +++ b/flipt-python/tests/flag/conftest.py @@ -0,0 +1,13 @@ +from http import HTTPStatus + +import pytest + + +@pytest.fixture(params=[{}, {'message': 'some error'}]) +def _mock_list_flags_response_error(httpx_mock, flipt_url, request): + httpx_mock.add_response( + method="GET", + url=f'{flipt_url}/api/v1/namespaces/default/flags', + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + json=request.param, + ) diff --git a/flipt-python/tests/flag/test_async_client.py b/flipt-python/tests/flag/test_async_client.py new file mode 100644 index 00000000..a6603b7d --- /dev/null +++ b/flipt-python/tests/flag/test_async_client.py @@ -0,0 +1,15 @@ +import pytest + +from flipt.async_client import AsyncFliptClient +from flipt.exceptions import FliptApiError + + +class TestListFlags: + async def test_success(self, async_flipt_client: AsyncFliptClient): + list_response = await async_flipt_client.flag.list_flags(namespace_key="default") + assert len(list_response.flags) == 2 + + @pytest.mark.usefixtures("_mock_list_flags_response_error") + async def test_list_error(self, async_flipt_client): + with pytest.raises(FliptApiError): + await async_flipt_client.flag.list_flags(namespace_key="default") diff --git a/flipt-python/tests/flag/test_sync_client.py b/flipt-python/tests/flag/test_sync_client.py new file mode 100644 index 00000000..308c1adf --- /dev/null +++ b/flipt-python/tests/flag/test_sync_client.py @@ -0,0 +1,14 @@ +import pytest + +from flipt.exceptions import FliptApiError + + +class TestListFlags: + def test_success(self, sync_flipt_client): + list_response = sync_flipt_client.flag.list_flags(namespace_key="default") + assert len(list_response.flags) == 2 + + @pytest.mark.usefixtures("_mock_list_flags_response_error") + def test_list_error(self, sync_flipt_client): + with pytest.raises(FliptApiError): + sync_flipt_client.flag.list_flags(namespace_key="default")