diff --git a/.github/workflows/lint-sdks.yml b/.github/workflows/lint-sdks.yml index a320642..0e1124b 100644 --- a/.github/workflows/lint-sdks.yml +++ b/.github/workflows/lint-sdks.yml @@ -28,7 +28,7 @@ jobs: working-directory: flipt-python run: | poetry install - poetry run black --check . + make lint lint-typescript: name: Lint TypeScript diff --git a/.gitignore b/.gitignore index 50e86ae..5ff2583 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,8 @@ Cargo.lock .vscode/ .envrc + +/flipt-python/.coverage +/flipt-python/.mypy_cache/ +/flipt-python/.pytest_cache/ +/flipt-python/.ruff_cache/ diff --git a/flipt-python/Makefile b/flipt-python/Makefile new file mode 100644 index 0000000..f1d7ad2 --- /dev/null +++ b/flipt-python/Makefile @@ -0,0 +1,19 @@ +format: + poetry run ruff flipt tests --fix + poetry run black flipt tests + +lint: + poetry run ruff flipt tests + poetry run black flipt tests --check + poetry run mypy flipt + poetry run pytest --dead-fixtures + +test: + poetry run pytest --cov + +testcov: + poetry run pytest --cov --cov-report html + +check: format lint test + +.PHONY: format lint test check testcov \ No newline at end of file diff --git a/flipt-python/README.md b/flipt-python/README.md index 1b2ceb8..826ebaf 100644 --- a/flipt-python/README.md +++ b/flipt-python/README.md @@ -22,9 +22,9 @@ In your Python code you can import this client and use it as so: from flipt import FliptClient from flipt.evaluation import BatchEvaluationRequest, EvaluationRequest -fliptClient = FliptClient() +flipt_client = FliptClient() -v = fliptClient.evaluation.variant( +variant_flag = flipt_client.evaluation.variant( EvaluationRequest( namespace_key="default", flag_key="flagll", @@ -33,7 +33,13 @@ v = fliptClient.evaluation.variant( ) ) -print(v) +print(variant_flag) ``` There is a more detailed example in the [examples](./examples) directory. + + +## For developers + +After adding new code, please don't forget to add unit tests for new features. +To format the code, check it with linters and run tests, use the `make check` command. diff --git a/flipt-python/example/async_client.py b/flipt-python/example/async_client.py new file mode 100644 index 0000000..8a9f846 --- /dev/null +++ b/flipt-python/example/async_client.py @@ -0,0 +1,51 @@ +import asyncio + +from flipt import AsyncFliptClient +from flipt.evaluation import BatchEvaluationRequest, EvaluationRequest + + +async def main(): + flipt_client = AsyncFliptClient() + + variant_flag = await flipt_client.evaluation.variant( + EvaluationRequest( + namespace_key="default", + flag_key="flag1", + entity_id="entity", + context={"fizz": "buzz"}, + ) + ) + boolean_flag = await flipt_client.evaluation.boolean( + EvaluationRequest( + namespace_key="default", + flag_key="flag_boolean", + entity_id="entity", + context={"fizz": "buzz"}, + ) + ) + batch = await flipt_client.evaluation.batch( + BatchEvaluationRequest( + requests=[ + EvaluationRequest( + namespace_key="default", + flag_key="flag1", + entity_id="entity", + context={"fizz": "buzz"}, + ), + EvaluationRequest( + namespace_key="default", + flag_key="flag_boolean", + entity_id="entity", + context={"fizz": "buzz"}, + ), + ] + ) + ) + + print(variant_flag) + print(boolean_flag) + print(batch) + + +loop = asyncio.new_event_loop() +loop.run_until_complete(main()) diff --git a/flipt-python/example/main.py b/flipt-python/example/sync_client.py similarity index 77% rename from flipt-python/example/main.py rename to flipt-python/example/sync_client.py index 4624a85..d98ae8e 100644 --- a/flipt-python/example/main.py +++ b/flipt-python/example/sync_client.py @@ -1,17 +1,17 @@ from flipt import FliptClient from flipt.evaluation import BatchEvaluationRequest, EvaluationRequest -fliptClient = FliptClient() +flipt_client = FliptClient() -v = fliptClient.evaluation.variant( +variant_flag = flipt_client.evaluation.variant( EvaluationRequest( namespace_key="default", - flag_key="flagll", + flag_key="flag1", entity_id="entity", context={"fizz": "buzz"}, ) ) -b = fliptClient.evaluation.boolean( +boolean_flag = flipt_client.evaluation.boolean( EvaluationRequest( namespace_key="default", flag_key="flag_boolean", @@ -19,7 +19,7 @@ context={"fizz": "buzz"}, ) ) -ba = fliptClient.evaluation.batch( +batch = flipt_client.evaluation.batch( BatchEvaluationRequest( requests=[ EvaluationRequest( @@ -38,6 +38,6 @@ ) ) -print(v) -print(b) -print(ba) +print(variant_flag) +print(boolean_flag) +print(batch) diff --git a/flipt-python/flipt/__init__.py b/flipt-python/flipt/__init__.py index 556f93e..38acc88 100644 --- a/flipt-python/flipt/__init__.py +++ b/flipt-python/flipt/__init__.py @@ -1,13 +1,7 @@ -import typing -from .evaluation import Evaluation -from .authentication import AuthenticationStrategy +from .async_client import AsyncFliptClient +from .sync_client import FliptClient - -class FliptClient: - def __init__( - self, - url: str = "http://localhost:8080", - timeout: int = 60, - authentication: typing.Optional[AuthenticationStrategy] = None, - ): - self.evaluation = Evaluation(url, timeout, authentication) +__all__ = [ + 'FliptClient', + 'AsyncFliptClient', +] diff --git a/flipt-python/flipt/async_client.py b/flipt-python/flipt/async_client.py new file mode 100644 index 0000000..284461a --- /dev/null +++ b/flipt-python/flipt/async_client.py @@ -0,0 +1,19 @@ +import httpx + +from .authentication import AuthenticationStrategy +from .evaluation import AsyncEvaluation + + +class AsyncFliptClient: + def __init__( + self, + url: str = "http://localhost:8080", + timeout: int = 60, + authentication: AuthenticationStrategy | None = None, + ): + self.httpx_client = httpx.AsyncClient(timeout=timeout) + + self.evaluation = AsyncEvaluation(url, authentication, self.httpx_client) + + async def close(self) -> None: + await self.httpx_client.aclose() diff --git a/flipt-python/flipt/authentication/__init__.py b/flipt-python/flipt/authentication/__init__.py index 9007c2f..a7fda09 100644 --- a/flipt-python/flipt/authentication/__init__.py +++ b/flipt-python/flipt/authentication/__init__.py @@ -1,19 +1,19 @@ class AuthenticationStrategy: - def authenticate(self, headers: dict): + def authenticate(self, headers: dict[str, str]) -> None: raise NotImplementedError() class ClientTokenAuthentication(AuthenticationStrategy): - def __init__(self, token: str): + def __init__(self, token: str) -> None: self.token = token - def authenticate(self, headers: dict): + def authenticate(self, headers: dict[str, str]) -> None: headers["Authorization"] = f"Bearer {self.token}" class JWTAuthentication(AuthenticationStrategy): - def __init__(self, token: str): + def __init__(self, token: str) -> None: self.token = token - def authenticate(self, headers: dict): + def authenticate(self, headers: dict[str, str]) -> None: headers["Authorization"] = f"JWT {self.token}" diff --git a/flipt-python/flipt/evaluation/__init__.py b/flipt-python/flipt/evaluation/__init__.py index e3e2a24..61e5d85 100644 --- a/flipt-python/flipt/evaluation/__init__.py +++ b/flipt-python/flipt/evaluation/__init__.py @@ -1,85 +1,29 @@ -import httpx -import typing -import json +from .async_evaluation_client import AsyncEvaluation from .models import ( BatchEvaluationRequest, BatchEvaluationResponse, BooleanEvaluationResponse, + ErrorEvaluationReason, + ErrorEvaluationResponse, + EvaluationReason, EvaluationRequest, + EvaluationResponse, + EvaluationResponseType, VariantEvaluationResponse, ) -from ..authentication import AuthenticationStrategy - - -class Evaluation: - def __init__( - self, - url: str, - timeout: int, - authentication: typing.Optional[AuthenticationStrategy] = None, - ): - self.url = url - self.headers = {} - self.timeout = timeout - if authentication: - authentication.authenticate(self.headers) - - def variant(self, request: EvaluationRequest) -> VariantEvaluationResponse: - response = httpx.post( - f"{self.url}/evaluate/v1/variant", - headers=self.headers, - json=request.model_dump(), - timeout=self.timeout, - ) - - if response.status_code != 200: - body = response.json() - message = "internal error" - - if "message" in body: - message = body["message"] - - raise Exception(message) - - variant_response = json.dumps(response.json()).encode("utf-8") - return VariantEvaluationResponse.model_validate_json(variant_response) - - def boolean(self, request: EvaluationRequest) -> BooleanEvaluationResponse: - response = httpx.post( - f"{self.url}/evaluate/v1/boolean", - headers=self.headers, - json=request.model_dump(), - timeout=self.timeout, - ) - - if response.status_code != 200: - body = response.json() - message = "internal error" - - if "message" in body: - message = body["message"] - - raise Exception(message) - - boolean_response = json.dumps(response.json()).encode("utf-8") - return BooleanEvaluationResponse.model_validate_json(boolean_response) - - def batch(self, request: BatchEvaluationRequest) -> BatchEvaluationResponse: - response = httpx.post( - f"{self.url}/evaluate/v1/batch", - headers=self.headers, - json=request.model_dump(), - timeout=self.timeout, - ) - - if response.status_code != 200: - body = response.json() - message = "internal error" - - if "message" in body: - message = body["message"] - - raise Exception(message) - - batch_response = json.dumps(response.json()).encode("utf-8") - return BatchEvaluationResponse.model_validate_json(batch_response) +from .sync_evaluation_client import Evaluation + +__all__ = [ + 'Evaluation', + 'AsyncEvaluation', + 'EvaluationResponseType', + 'EvaluationReason', + 'ErrorEvaluationReason', + 'EvaluationRequest', + 'BatchEvaluationRequest', + 'VariantEvaluationResponse', + 'BooleanEvaluationResponse', + 'ErrorEvaluationResponse', + 'EvaluationResponse', + 'BatchEvaluationResponse', +] diff --git a/flipt-python/flipt/evaluation/async_evaluation_client.py b/flipt-python/flipt/evaluation/async_evaluation_client.py new file mode 100644 index 0000000..2b9b632 --- /dev/null +++ b/flipt-python/flipt/evaluation/async_evaluation_client.py @@ -0,0 +1,74 @@ +from http import HTTPStatus + +import httpx + +from ..authentication import AuthenticationStrategy +from ..exceptions import FliptApiError +from .models import ( + BatchEvaluationRequest, + BatchEvaluationResponse, + BooleanEvaluationResponse, + EvaluationRequest, + VariantEvaluationResponse, +) + + +class AsyncEvaluation: + 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() + + async def variant(self, request: EvaluationRequest) -> VariantEvaluationResponse: + response = await self._client.post( + f"{self.url}/evaluate/v1/variant", + headers=self.headers, + json=request.model_dump(), + ) + + if response.status_code != 200: + body = response.json() + message = body.get("message", HTTPStatus(response.status_code).description) + raise FliptApiError(message, response.status_code) + + return VariantEvaluationResponse.model_validate_json(response.text) + + async def boolean(self, request: EvaluationRequest) -> BooleanEvaluationResponse: + response = await self._client.post( + f"{self.url}/evaluate/v1/boolean", + headers=self.headers, + json=request.model_dump(), + ) + + if response.status_code != 200: + body = response.json() + message = body.get("message", HTTPStatus(response.status_code).description) + raise FliptApiError(message, response.status_code) + + return BooleanEvaluationResponse.model_validate_json(response.text) + + async def batch(self, request: BatchEvaluationRequest) -> BatchEvaluationResponse: + response = await self._client.post( + f"{self.url}/evaluate/v1/batch", + headers=self.headers, + json=request.model_dump(), + ) + + if response.status_code != 200: + body = response.json() + message = body.get("message", HTTPStatus(response.status_code).description) + raise FliptApiError(message, response.status_code) + + return BatchEvaluationResponse.model_validate_json(response.text) diff --git a/flipt-python/flipt/evaluation/models.py b/flipt-python/flipt/evaluation/models.py index 79514ae..536cf16 100644 --- a/flipt-python/flipt/evaluation/models.py +++ b/flipt-python/flipt/evaluation/models.py @@ -1,79 +1,81 @@ -import enum -from pydantic import BaseModel, Field -from typing import List, Optional +from enum import StrEnum +from pydantic import AliasGenerator, BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel -class EvaluationResponseType(str, enum.Enum): + +class EvaluationResponseType(StrEnum): VARIANT_EVALUATION_RESPONSE_TYPE = "VARIANT_EVALUATION_RESPONSE_TYPE" BOOLEAN_EVALUATION_RESPONSE_TYPE = "BOOLEAN_EVALUATION_RESPONSE_TYPE" ERROR_EVALUATION_RESPONSE_TYPE = "ERROR_EVALUATION_RESPONSE_TYPE" -class EvaluationReason(str, enum.Enum): +class EvaluationReason(StrEnum): UNKNOWN_EVALUATION_REASON = "UNKNOWN_EVALUATION_REASON" FLAG_DISABLED_EVALUATION_REASON = "FLAG_DISABLED_EVALUATION_REASON" MATCH_EVALUATION_REASON = "MATCH_EVALUATION_REASON" DEFAULT_EVALUATION_REASON = "DEFAULT_EVALUATION_REASON" -class ErrorEvaluationReason(str, enum.Enum): +class ErrorEvaluationReason(StrEnum): UNKNOWN_ERROR_EVALUATION_REASON = "UNKNOWN_ERROR_EVALUATION_REASON" NOT_FOUND_ERROR_EVALUATION_REASON = "NOT_FOUND_ERROR_EVALUATION_REASON" -class EvaluationRequest(BaseModel): +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 entity_id: str context: dict - reference: Optional[str] = None + reference: str | None = None -class BatchEvaluationRequest(BaseModel): - request_id: Optional[str] = None - requests: List[EvaluationRequest] - reference: Optional[str] = None +class BatchEvaluationRequest(CamelAliasModel): + request_id: str | None = None + requests: list[EvaluationRequest] + reference: str | None = None -class VariantEvaluationResponse(BaseModel): +class VariantEvaluationResponse(CamelAliasModel): match: bool - segment_keys: List[str] = Field(..., alias="segmentKeys") + segment_keys: list[str] reason: EvaluationReason - flag_key: str = Field(..., alias="flagKey") - variant_key: str = Field(..., alias="variantKey") - variant_attachment: str = Field(..., alias="variantAttachment") - request_duration_millis: float = Field(..., alias="requestDurationMillis") + flag_key: str + variant_key: str + variant_attachment: str + request_duration_millis: float timestamp: str -class BooleanEvaluationResponse(BaseModel): +class BooleanEvaluationResponse(CamelAliasModel): enabled: bool - flag_key: str = Field(..., alias="flagKey") + flag_key: str reason: EvaluationReason - request_duration_millis: float = Field(..., alias="requestDurationMillis") + request_duration_millis: float timestamp: str -class ErrorEvaluationResponse(BaseModel): - flag_key: str = Field(..., alias="flagKey") - namespace_key: str = Field(..., alias="namespaceKey") +class ErrorEvaluationResponse(CamelAliasModel): + flag_key: str + namespace_key: str reason: ErrorEvaluationReason -class EvaluationResponse(BaseModel): +class EvaluationResponse(CamelAliasModel): type: EvaluationResponseType - boolean_response: Optional[BooleanEvaluationResponse] = Field( - default=None, alias="booleanResponse" - ) - variant_response: Optional[VariantEvaluationResponse] = Field( - default=None, alias="variantResponse" - ) - error_response: Optional[ErrorEvaluationResponse] = Field( - default=None, alias="errorResponse" - ) + boolean_response: BooleanEvaluationResponse | None = None + variant_response: VariantEvaluationResponse | None = None + error_response: ErrorEvaluationResponse | None = None -class BatchEvaluationResponse(BaseModel): - request_id: str = Field(..., alias="requestId") - responses: List[EvaluationResponse] - request_duration_millis: float = Field(..., alias="requestDurationMillis") +class BatchEvaluationResponse(CamelAliasModel): + request_id: str + responses: list[EvaluationResponse] + request_duration_millis: float diff --git a/flipt-python/flipt/evaluation/sync_evaluation_client.py b/flipt-python/flipt/evaluation/sync_evaluation_client.py new file mode 100644 index 0000000..231c862 --- /dev/null +++ b/flipt-python/flipt/evaluation/sync_evaluation_client.py @@ -0,0 +1,74 @@ +from http import HTTPStatus + +import httpx + +from ..authentication import AuthenticationStrategy +from ..exceptions import FliptApiError +from .models import ( + BatchEvaluationRequest, + BatchEvaluationResponse, + BooleanEvaluationResponse, + EvaluationRequest, + VariantEvaluationResponse, +) + + +class Evaluation: + 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 variant(self, request: EvaluationRequest) -> VariantEvaluationResponse: + response = self._client.post( + f"{self.url}/evaluate/v1/variant", + headers=self.headers, + json=request.model_dump(), + ) + + if response.status_code != 200: + body = response.json() + message = body.get("message", HTTPStatus(response.status_code).description) + raise FliptApiError(message, response.status_code) + + return VariantEvaluationResponse.model_validate_json(response.text) + + def boolean(self, request: EvaluationRequest) -> BooleanEvaluationResponse: + response = self._client.post( + f"{self.url}/evaluate/v1/boolean", + headers=self.headers, + json=request.model_dump(), + ) + + if response.status_code != 200: + body = response.json() + message = body.get("message", HTTPStatus(response.status_code).description) + raise FliptApiError(message, response.status_code) + + return BooleanEvaluationResponse.model_validate_json(response.text) + + def batch(self, request: BatchEvaluationRequest) -> BatchEvaluationResponse: + response = self._client.post( + f"{self.url}/evaluate/v1/batch", + headers=self.headers, + json=request.model_dump(), + ) + + if response.status_code != 200: + body = response.json() + message = body.get("message", HTTPStatus(response.status_code).description) + raise FliptApiError(message, response.status_code) + + return BatchEvaluationResponse.model_validate_json(response.text) diff --git a/flipt-python/flipt/exceptions.py b/flipt-python/flipt/exceptions.py new file mode 100644 index 0000000..2abcc91 --- /dev/null +++ b/flipt-python/flipt/exceptions.py @@ -0,0 +1,14 @@ +class BaseFliptError(Exception): + pass + + +class FliptApiError(BaseFliptError): + message: str + status_code: int + + def __init__(self, message: str, status_code: int): + self.message = message + self.status_code = status_code + + def __repr__(self) -> str: + return f"" diff --git a/flipt-python/flipt/py.typed b/flipt-python/flipt/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/flipt-python/flipt/sync_client.py b/flipt-python/flipt/sync_client.py new file mode 100644 index 0000000..2a1f522 --- /dev/null +++ b/flipt-python/flipt/sync_client.py @@ -0,0 +1,19 @@ +import httpx + +from .authentication import AuthenticationStrategy +from .evaluation import Evaluation + + +class FliptClient: + def __init__( + self, + url: str = "http://localhost:8080", + timeout: int = 60, + authentication: AuthenticationStrategy | None = None, + ): + self.httpx_client = httpx.Client(timeout=timeout) + + self.evaluation = Evaluation(url, authentication, self.httpx_client) + + def close(self) -> None: + self.httpx_client.close() diff --git a/flipt-python/poetry.lock b/flipt-python/poetry.lock index 811eb37..4435def 100644 --- a/flipt-python/poetry.lock +++ b/flipt-python/poetry.lock @@ -111,6 +111,70 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coverage" +version = "7.4.2" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bf54c3e089179d9d23900e3efc86d46e4431188d9a657f345410eecdd0151f50"}, + {file = "coverage-7.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fe6e43c8b510719b48af7db9631b5fbac910ade4bd90e6378c85ac5ac706382c"}, + {file = "coverage-7.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b98c89db1b150d851a7840142d60d01d07677a18f0f46836e691c38134ed18b"}, + {file = "coverage-7.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5f9683be6a5b19cd776ee4e2f2ffb411424819c69afab6b2db3a0a364ec6642"}, + {file = "coverage-7.4.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78cdcbf7b9cb83fe047ee09298e25b1cd1636824067166dc97ad0543b079d22f"}, + {file = "coverage-7.4.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2599972b21911111114100d362aea9e70a88b258400672626efa2b9e2179609c"}, + {file = "coverage-7.4.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ef00d31b7569ed3cb2036f26565f1984b9fc08541731ce01012b02a4c238bf03"}, + {file = "coverage-7.4.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:20a875bfd8c282985c4720c32aa05056f77a68e6d8bbc5fe8632c5860ee0b49b"}, + {file = "coverage-7.4.2-cp310-cp310-win32.whl", hash = "sha256:b3f2b1eb229f23c82898eedfc3296137cf1f16bb145ceab3edfd17cbde273fb7"}, + {file = "coverage-7.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7df95fdd1432a5d2675ce630fef5f239939e2b3610fe2f2b5bf21fa505256fa3"}, + {file = "coverage-7.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a8ddbd158e069dded57738ea69b9744525181e99974c899b39f75b2b29a624e2"}, + {file = "coverage-7.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81a5fb41b0d24447a47543b749adc34d45a2cf77b48ca74e5bf3de60a7bd9edc"}, + {file = "coverage-7.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2412e98e70f16243be41d20836abd5f3f32edef07cbf8f407f1b6e1ceae783ac"}, + {file = "coverage-7.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb79414c15c6f03f56cc68fa06994f047cf20207c31b5dad3f6bab54a0f66ef"}, + {file = "coverage-7.4.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf89ab85027427d351f1de918aff4b43f4eb5f33aff6835ed30322a86ac29c9e"}, + {file = "coverage-7.4.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a178b7b1ac0f1530bb28d2e51f88c0bab3e5949835851a60dda80bff6052510c"}, + {file = "coverage-7.4.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:06fe398145a2e91edaf1ab4eee66149c6776c6b25b136f4a86fcbbb09512fd10"}, + {file = "coverage-7.4.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:18cac867950943fe93d6cd56a67eb7dcd2d4a781a40f4c1e25d6f1ed98721a55"}, + {file = "coverage-7.4.2-cp311-cp311-win32.whl", hash = "sha256:f72cdd2586f9a769570d4b5714a3837b3a59a53b096bb954f1811f6a0afad305"}, + {file = "coverage-7.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:d779a48fac416387dd5673fc5b2d6bd903ed903faaa3247dc1865c65eaa5a93e"}, + {file = "coverage-7.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:adbdfcda2469d188d79771d5696dc54fab98a16d2ef7e0875013b5f56a251047"}, + {file = "coverage-7.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ac4bab32f396b03ebecfcf2971668da9275b3bb5f81b3b6ba96622f4ef3f6e17"}, + {file = "coverage-7.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:006d220ba2e1a45f1de083d5022d4955abb0aedd78904cd5a779b955b019ec73"}, + {file = "coverage-7.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3733545eb294e5ad274abe131d1e7e7de4ba17a144505c12feca48803fea5f64"}, + {file = "coverage-7.4.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42a9e754aa250fe61f0f99986399cec086d7e7a01dd82fd863a20af34cbce962"}, + {file = "coverage-7.4.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2ed37e16cf35c8d6e0b430254574b8edd242a367a1b1531bd1adc99c6a5e00fe"}, + {file = "coverage-7.4.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b953275d4edfab6cc0ed7139fa773dfb89e81fee1569a932f6020ce7c6da0e8f"}, + {file = "coverage-7.4.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32b4ab7e6c924f945cbae5392832e93e4ceb81483fd6dc4aa8fb1a97b9d3e0e1"}, + {file = "coverage-7.4.2-cp312-cp312-win32.whl", hash = "sha256:f5df76c58977bc35a49515b2fbba84a1d952ff0ec784a4070334dfbec28a2def"}, + {file = "coverage-7.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:34423abbaad70fea9d0164add189eabaea679068ebdf693baa5c02d03e7db244"}, + {file = "coverage-7.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5b11f9c6587668e495cc7365f85c93bed34c3a81f9f08b0920b87a89acc13469"}, + {file = "coverage-7.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:51593a1f05c39332f623d64d910445fdec3d2ac2d96b37ce7f331882d5678ddf"}, + {file = "coverage-7.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69f1665165ba2fe7614e2f0c1aed71e14d83510bf67e2ee13df467d1c08bf1e8"}, + {file = "coverage-7.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3c8bbb95a699c80a167478478efe5e09ad31680931ec280bf2087905e3b95ec"}, + {file = "coverage-7.4.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:175f56572f25e1e1201d2b3e07b71ca4d201bf0b9cb8fad3f1dfae6a4188de86"}, + {file = "coverage-7.4.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8562ca91e8c40864942615b1d0b12289d3e745e6b2da901d133f52f2d510a1e3"}, + {file = "coverage-7.4.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d9a1ef0f173e1a19738f154fb3644f90d0ada56fe6c9b422f992b04266c55d5a"}, + {file = "coverage-7.4.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f40ac873045db4fd98a6f40387d242bde2708a3f8167bd967ccd43ad46394ba2"}, + {file = "coverage-7.4.2-cp38-cp38-win32.whl", hash = "sha256:d1b750a8409bec61caa7824bfd64a8074b6d2d420433f64c161a8335796c7c6b"}, + {file = "coverage-7.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b4ae777bebaed89e3a7e80c4a03fac434a98a8abb5251b2a957d38fe3fd30088"}, + {file = "coverage-7.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ff7f92ae5a456101ca8f48387fd3c56eb96353588e686286f50633a611afc95"}, + {file = "coverage-7.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:861d75402269ffda0b33af94694b8e0703563116b04c681b1832903fac8fd647"}, + {file = "coverage-7.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3507427d83fa961cbd73f11140f4a5ce84208d31756f7238d6257b2d3d868405"}, + {file = "coverage-7.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bf711d517e21fb5bc429f5c4308fbc430a8585ff2a43e88540264ae87871e36a"}, + {file = "coverage-7.4.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c00e54f0bd258ab25e7f731ca1d5144b0bf7bec0051abccd2bdcff65fa3262c9"}, + {file = "coverage-7.4.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f8e845d894e39fb53834da826078f6dc1a933b32b1478cf437007367efaf6f6a"}, + {file = "coverage-7.4.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:840456cb1067dc350af9080298c7c2cfdddcedc1cb1e0b30dceecdaf7be1a2d3"}, + {file = "coverage-7.4.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c11ca2df2206a4e3e4c4567f52594637392ed05d7c7fb73b4ea1c658ba560265"}, + {file = "coverage-7.4.2-cp39-cp39-win32.whl", hash = "sha256:3ff5bdb08d8938d336ce4088ca1a1e4b6c8cd3bef8bb3a4c0eb2f37406e49643"}, + {file = "coverage-7.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:ac9e95cefcf044c98d4e2c829cd0669918585755dd9a92e28a1a7012322d0a95"}, + {file = "coverage-7.4.2-pp38.pp39.pp310-none-any.whl", hash = "sha256:f593a4a90118d99014517c2679e04a4ef5aee2d81aa05c26c734d271065efcb6"}, + {file = "coverage-7.4.2.tar.gz", hash = "sha256:1a5ee18e3a8d766075ce9314ed1cb695414bae67df6a4b0805f5137d93d6f1cb"}, +] + +[package.extras] +toml = ["tomli"] + [[package]] name = "h11" version = "0.14.0" @@ -145,13 +209,13 @@ trio = ["trio (>=0.22.0,<0.23.0)"] [[package]] name = "httpx" -version = "0.26.0" +version = "0.27.0" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd"}, - {file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"}, + {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, + {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, ] [package.dependencies] @@ -178,6 +242,63 @@ files = [ {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, ] +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "mypy" +version = "1.8.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, + {file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, + {file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, + {file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, + {file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, + {file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, + {file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, + {file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, + {file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, + {file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, + {file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, + {file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, + {file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, + {file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, + {file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, + {file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, + {file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, + {file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, + {file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, + {file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, + {file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, + {file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, + {file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, + {file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, + {file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, + {file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, + {file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + [[package]] name = "mypy-extensions" version = "1.0.0" @@ -226,6 +347,21 @@ files = [ docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] +[[package]] +name = "pluggy" +version = "1.4.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, + {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + [[package]] name = "pydantic" version = "2.6.1" @@ -336,6 +472,137 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pytest" +version = "8.0.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.0.1-py3-none-any.whl", hash = "sha256:3e4f16fe1c0a9dc9d9389161c127c3edc5d810c38d6793042fb81d9f48a59fca"}, + {file = "pytest-8.0.1.tar.gz", hash = "sha256:267f6563751877d772019b13aacbe4e860d73fe8f651f28112e9ac37de7513ae"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.3.0,<2.0" + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.23.5" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-asyncio-0.23.5.tar.gz", hash = "sha256:3a048872a9c4ba14c3e90cc1aa20cbc2def7d01c7c8db3777ec281ba9c057675"}, + {file = "pytest_asyncio-0.23.5-py3-none-any.whl", hash = "sha256:4e7093259ba018d58ede7d5315131d21923a60f8a6e9ee266ce1589685c89eac"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pytest-deadfixtures" +version = "2.2.1" +description = "A simple plugin to list unused fixtures in pytest" +optional = false +python-versions = "*" +files = [ + {file = "pytest-deadfixtures-2.2.1.tar.gz", hash = "sha256:ca15938a4e8330993ccec9c6c847383d88b3cd574729530647dc6b492daa9c1e"}, + {file = "pytest_deadfixtures-2.2.1-py2.py3-none-any.whl", hash = "sha256:db71533f2d9456227084e00a1231e732973e299ccb7c37ab92e95032ab6c083e"}, +] + +[package.dependencies] +pytest = ">=3.0.0" + +[[package]] +name = "pytest-httpx" +version = "0.30.0" +description = "Send responses to httpx." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pytest-httpx-0.30.0.tar.gz", hash = "sha256:755b8edca87c974dd4f3605c374fda11db84631de3d163b99c0df5807023a19a"}, + {file = "pytest_httpx-0.30.0-py3-none-any.whl", hash = "sha256:6d47849691faf11d2532565d0c8e0e02b9f4ee730da31687feae315581d7520c"}, +] + +[package.dependencies] +httpx = "==0.27.*" +pytest = ">=7,<9" + +[package.extras] +testing = ["pytest-asyncio (==0.23.*)", "pytest-cov (==4.*)"] + +[[package]] +name = "pytest-mock" +version = "3.12.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-mock-3.12.0.tar.gz", hash = "sha256:31a40f038c22cad32287bb43932054451ff5583ff094bca6f675df2f8bc1a6e9"}, + {file = "pytest_mock-3.12.0-py3-none-any.whl", hash = "sha256:0972719a7263072da3a21c7f4773069bcc7486027d7e8e1f81d98a47e701bc4f"}, +] + +[package.dependencies] +pytest = ">=5.0" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + +[[package]] +name = "ruff" +version = "0.2.2" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0a9efb032855ffb3c21f6405751d5e147b0c6b631e3ca3f6b20f917572b97eb6"}, + {file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d450b7fbff85913f866a5384d8912710936e2b96da74541c82c1b458472ddb39"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecd46e3106850a5c26aee114e562c329f9a1fbe9e4821b008c4404f64ff9ce73"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e22676a5b875bd72acd3d11d5fa9075d3a5f53b877fe7b4793e4673499318ba"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1695700d1e25a99d28f7a1636d85bafcc5030bba9d0578c0781ba1790dbcf51c"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b0c232af3d0bd8f521806223723456ffebf8e323bd1e4e82b0befb20ba18388e"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f63d96494eeec2fc70d909393bcd76c69f35334cdbd9e20d089fb3f0640216ca"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a61ea0ff048e06de273b2e45bd72629f470f5da8f71daf09fe481278b175001"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e1439c8f407e4f356470e54cdecdca1bd5439a0673792dbe34a2b0a551a2fe3"}, + {file = "ruff-0.2.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:940de32dc8853eba0f67f7198b3e79bc6ba95c2edbfdfac2144c8235114d6726"}, + {file = "ruff-0.2.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0c126da55c38dd917621552ab430213bdb3273bb10ddb67bc4b761989210eb6e"}, + {file = "ruff-0.2.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3b65494f7e4bed2e74110dac1f0d17dc8e1f42faaa784e7c58a98e335ec83d7e"}, + {file = "ruff-0.2.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ec49be4fe6ddac0503833f3ed8930528e26d1e60ad35c2446da372d16651ce9"}, + {file = "ruff-0.2.2-py3-none-win32.whl", hash = "sha256:d920499b576f6c68295bc04e7b17b6544d9d05f196bb3aac4358792ef6f34325"}, + {file = "ruff-0.2.2-py3-none-win_amd64.whl", hash = "sha256:cc9a91ae137d687f43a44c900e5d95e9617cb37d4c989e462980ba27039d239d"}, + {file = "ruff-0.2.2-py3-none-win_arm64.whl", hash = "sha256:c9d15fc41e6054bfc7200478720570078f0b41c9ae4f010bcc16bd6f4d1aacdd"}, + {file = "ruff-0.2.2.tar.gz", hash = "sha256:e62ed7f36b3068a30ba39193a14274cd706bc486fad521276458022f7bccb31d"}, +] + [[package]] name = "sniffio" version = "1.3.0" @@ -361,4 +628,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "f9c6761428555ffe99a349621fce523028aca08d3eabe61f5f4753365c6aec87" +content-hash = "b9d4943ac5e0e18d2acf6d5409013c7ba8f3299d1f0303ee39b1d74379a8a468" diff --git a/flipt-python/pyproject.toml b/flipt-python/pyproject.toml index 6e08e3b..61c534b 100644 --- a/flipt-python/pyproject.toml +++ b/flipt-python/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "flipt" -version = "1.0.0" +version = "1.1.0" description = "Flipt Server SDK" authors = ["Flipt Devs "] license = "MIT" @@ -9,11 +9,18 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.11" pydantic = "^2.5.3" -httpx = "^0.26.0" - +httpx = "^0.27.0" [tool.poetry.group.dev.dependencies] black = ">=23.12.1,<25.0.0" +mypy = "^1.8.0" +pytest = "^8.0.1" +pytest-asyncio = "^0.23.5" +pytest-cov = "^4.1.0" +pytest-deadfixtures = "^2.2.1" +pytest-httpx = "^0.30.0" +pytest-mock = "^3.12.0" +ruff = "^0.2.2" [build-system] requires = ["poetry-core"] @@ -21,3 +28,61 @@ build-backend = "poetry.core.masonry.api" [tool.poetry.scripts] test = 'scripts:test' + +[tool.mypy] +disallow_untyped_defs = true +disallow_untyped_decorators = true +plugins = ["pydantic.mypy"] + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false +disallow_incomplete_defs = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" + +[tool.black] +line-length = 120 +skip-string-normalization = true + +[tool.coverage.report] +precision = 1 +fail_under = 95 +exclude_also = [ + "raise NotImplementedError", + "pragma: no cover", + "if TYPE_CHECKING:", + "def __repr__", +] +show_missing = true +skip_covered = true + +[tool.coverage.run] +source = ["flipt"] +branch = true + +[tool.ruff.lint] +exclude = [".git", ".venv"] +select = [ + "ARG", + "B", + "C", + "C4", + "E", + "F", + "I", + "PL", + "PT", + "T", + "W", +] +ignore = ["E501", "PLR2004"] + +[tool.ruff.lint.pylint] +max-args = 5 +max-returns = 5 + +[tool.ruff.lint.mccabe] +max-complexity = 10 diff --git a/flipt-python/scripts.py b/flipt-python/scripts.py index 945b2fb..538ed61 100644 --- a/flipt-python/scripts.py +++ b/flipt-python/scripts.py @@ -3,7 +3,7 @@ def test(): """ - Run all unittests. Equivalent to: - `poetry run python -m unittest tests` + Run all tests. Equivalent to: + `poetry run pytest tests` """ - subprocess.run(["python", "-m", "unittest", "tests"]) + subprocess.run(["python", "-m", "pytest", "tests"]) diff --git a/flipt-python/tests/__init__.py b/flipt-python/tests/__init__.py index 79d32bb..e69de29 100644 --- a/flipt-python/tests/__init__.py +++ b/flipt-python/tests/__init__.py @@ -1,99 +0,0 @@ -import os -import unittest -from flipt import FliptClient -from flipt.evaluation import BatchEvaluationRequest, EvaluationRequest -from flipt.authentication import ClientTokenAuthentication - - -class TestFliptEvaluationClient(unittest.TestCase): - def setUp(self) -> None: - flipt_url = os.environ.get("FLIPT_URL") - if flipt_url is None: - raise Exception("FLIPT_URL not set") - - auth_token = os.environ.get("FLIPT_AUTH_TOKEN") - if auth_token is None: - raise Exception("FLIPT_AUTH_TOKEN not set") - - self.flipt_client = FliptClient( - url=flipt_url, authentication=ClientTokenAuthentication(auth_token) - ) - - def test_variant(self): - variant = self.flipt_client.evaluation.variant( - EvaluationRequest( - namespace_key="default", - flag_key="flag1", - entity_id="entity", - context={"fizz": "buzz"}, - ) - ) - self.assertTrue(variant.match) - self.assertEqual("flag1", variant.flag_key) - self.assertEqual("variant1", variant.variant_key) - self.assertEqual("MATCH_EVALUATION_REASON", variant.reason) - self.assertIn("segment1", variant.segment_keys) - - def test_boolean(self): - boolean = self.flipt_client.evaluation.boolean( - EvaluationRequest( - namespace_key="default", - flag_key="flag_boolean", - entity_id="entity", - context={"fizz": "buzz"}, - ) - ) - self.assertTrue(boolean.enabled) - self.assertEqual("flag_boolean", boolean.flag_key) - self.assertEqual("MATCH_EVALUATION_REASON", boolean.reason) - - def test_batch(self): - batch = self.flipt_client.evaluation.batch( - BatchEvaluationRequest( - requests=[ - EvaluationRequest( - namespace_key="default", - flag_key="flag1", - entity_id="entity", - context={"fizz": "buzz"}, - ), - EvaluationRequest( - namespace_key="default", - flag_key="flag_boolean", - entity_id="entity", - context={"fizz": "buzz"}, - ), - EvaluationRequest( - namespace_key="default", - flag_key="notfound", - entity_id="entity", - context={"fizz": "buzz"}, - ), - ] - ) - ) - - self.assertEqual(3, len(batch.responses)) - - # Variant - self.assertEqual("VARIANT_EVALUATION_RESPONSE_TYPE", batch.responses[0].type) - variant = batch.responses[0].variant_response - self.assertTrue(variant.match) - self.assertEqual("flag1", variant.flag_key) - self.assertEqual("variant1", variant.variant_key) - self.assertEqual("MATCH_EVALUATION_REASON", variant.reason) - self.assertIn("segment1", variant.segment_keys) - - # Boolean - self.assertEqual("BOOLEAN_EVALUATION_RESPONSE_TYPE", batch.responses[1].type) - boolean = batch.responses[1].boolean_response - self.assertTrue(boolean.enabled) - self.assertEqual("flag_boolean", boolean.flag_key) - self.assertEqual("MATCH_EVALUATION_REASON", boolean.reason) - - # Error - self.assertEqual("ERROR_EVALUATION_RESPONSE_TYPE", batch.responses[2].type) - error = batch.responses[2].error_response - self.assertEqual("notfound", error.flag_key) - self.assertEqual("default", error.namespace_key) - self.assertEqual("NOT_FOUND_ERROR_EVALUATION_REASON", error.reason) diff --git a/flipt-python/tests/conftest.py b/flipt-python/tests/conftest.py new file mode 100644 index 0000000..ccd793c --- /dev/null +++ b/flipt-python/tests/conftest.py @@ -0,0 +1,33 @@ +import os + +import pytest + +from flipt import AsyncFliptClient, FliptClient +from flipt.authentication import ClientTokenAuthentication + + +@pytest.fixture(scope='session') +def flipt_url() -> str: + flipt_url = os.environ.get("FLIPT_URL") + if flipt_url is None: + raise Exception("FLIPT_URL not set") + return flipt_url + + +@pytest.fixture(scope='session') +def flipt_auth_token() -> str: + auth_token = os.environ.get("FLIPT_AUTH_TOKEN") + if auth_token is None: + raise Exception("FLIPT_AUTH_TOKEN not set") + + return auth_token + + +@pytest.fixture(scope='session') +def sync_flipt_client(flipt_url, flipt_auth_token): + return FliptClient(url=flipt_url, authentication=ClientTokenAuthentication(flipt_auth_token)) + + +@pytest.fixture() +def async_flipt_client(flipt_url, flipt_auth_token): + return AsyncFliptClient(url=flipt_url, authentication=ClientTokenAuthentication(flipt_auth_token)) diff --git a/flipt-python/tests/evaluation/__init__.py b/flipt-python/tests/evaluation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/flipt-python/tests/evaluation/conftest.py b/flipt-python/tests/evaluation/conftest.py new file mode 100644 index 0000000..fef581c --- /dev/null +++ b/flipt-python/tests/evaluation/conftest.py @@ -0,0 +1,33 @@ +from http import HTTPStatus + +import pytest + + +@pytest.fixture(params=[{}, {'message': 'some error'}]) +def _mock_variant_response_error(httpx_mock, flipt_url, request): + httpx_mock.add_response( + method="POST", + url=f'{flipt_url}/evaluate/v1/variant', + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + json=request.param, + ) + + +@pytest.fixture(params=[{}, {'message': 'some error'}]) +def _mock_boolean_response_error(httpx_mock, flipt_url, request): + httpx_mock.add_response( + method="POST", + url=f'{flipt_url}/evaluate/v1/boolean', + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + json=request.param, + ) + + +@pytest.fixture(params=[{}, {'message': 'some error'}]) +def _mock_batch_response_error(httpx_mock, flipt_url, request): + httpx_mock.add_response( + method="POST", + url=f'{flipt_url}/evaluate/v1/batch', + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + json=request.param, + ) diff --git a/flipt-python/tests/evaluation/test_async_client.py b/flipt-python/tests/evaluation/test_async_client.py new file mode 100644 index 0000000..9a75f2f --- /dev/null +++ b/flipt-python/tests/evaluation/test_async_client.py @@ -0,0 +1,146 @@ +import pytest + +from flipt.evaluation import BatchEvaluationRequest, EvaluationRequest +from flipt.exceptions import FliptApiError + + +async def test_variant(async_flipt_client): + variant = await async_flipt_client.evaluation.variant( + EvaluationRequest( + namespace_key="default", + flag_key="flag1", + entity_id="entity", + context={"fizz": "buzz"}, + ) + ) + + assert variant.match + assert variant.flag_key == 'flag1' + assert variant.variant_key == 'variant1' + assert variant.reason == 'MATCH_EVALUATION_REASON' + assert 'segment1' in variant.segment_keys + + +@pytest.mark.usefixtures('_mock_variant_response_error') +async def test_evaluate_variant_error(async_flipt_client): + with pytest.raises(FliptApiError): + await async_flipt_client.evaluation.variant( + EvaluationRequest( + namespace_key="default", + flag_key="flag1", + entity_id="entity", + context={"fizz": "buzz"}, + ) + ) + + +async def test_boolean(async_flipt_client): + boolean = await async_flipt_client.evaluation.boolean( + EvaluationRequest( + namespace_key="default", + flag_key="flag_boolean", + entity_id="entity", + context={"fizz": "buzz"}, + ) + ) + + assert boolean.enabled + assert boolean.flag_key == 'flag_boolean' + assert boolean.reason == 'MATCH_EVALUATION_REASON' + + +@pytest.mark.usefixtures('_mock_boolean_response_error') +async def test_evaluate_boolean_error(async_flipt_client): + with pytest.raises(FliptApiError): + await async_flipt_client.evaluation.boolean( + EvaluationRequest( + namespace_key="default", + flag_key="flag_boolean", + entity_id="entity", + context={"fizz": "buzz"}, + ) + ) + + +async def test_batch(async_flipt_client): + batch = await async_flipt_client.evaluation.batch( + BatchEvaluationRequest( + requests=[ + EvaluationRequest( + namespace_key="default", + flag_key="flag1", + entity_id="entity", + context={"fizz": "buzz"}, + ), + EvaluationRequest( + namespace_key="default", + flag_key="flag_boolean", + entity_id="entity", + context={"fizz": "buzz"}, + ), + EvaluationRequest( + namespace_key="default", + flag_key="notfound", + entity_id="entity", + context={"fizz": "buzz"}, + ), + ] + ) + ) + + assert len(batch.responses) == 3 + + # Variant + assert batch.responses[0].type == "VARIANT_EVALUATION_RESPONSE_TYPE" + + variant = batch.responses[0].variant_response + assert variant.match + assert variant.flag_key == "flag1" + assert variant.variant_key == "variant1" + assert variant.reason == "MATCH_EVALUATION_REASON" + assert 'segment1' in variant.segment_keys + + # Boolean + assert batch.responses[1].type == 'BOOLEAN_EVALUATION_RESPONSE_TYPE' + + boolean = batch.responses[1].boolean_response + assert boolean.enabled + assert boolean.flag_key == "flag_boolean" + assert boolean.reason == "MATCH_EVALUATION_REASON" + + # Error + assert batch.responses[2].type == 'ERROR_EVALUATION_RESPONSE_TYPE' + + error = batch.responses[2].error_response + assert error.flag_key == "notfound" + assert error.namespace_key == "default" + assert error.reason == "NOT_FOUND_ERROR_EVALUATION_REASON" + + +@pytest.mark.usefixtures('_mock_batch_response_error') +async def test_evaluate_batch_error(async_flipt_client): + with pytest.raises(FliptApiError): + await async_flipt_client.evaluation.batch( + BatchEvaluationRequest( + requests=[ + EvaluationRequest( + namespace_key="default", + flag_key="flag1", + entity_id="entity", + context={"fizz": "buzz"}, + ), + EvaluationRequest( + namespace_key="default", + flag_key="flag_boolean", + entity_id="entity", + context={"fizz": "buzz"}, + ), + EvaluationRequest( + namespace_key="default", + flag_key="notfound", + entity_id="entity", + context={"fizz": "buzz"}, + ), + ] + ) + ) diff --git a/flipt-python/tests/evaluation/test_sync_client.py b/flipt-python/tests/evaluation/test_sync_client.py new file mode 100644 index 0000000..b7fff72 --- /dev/null +++ b/flipt-python/tests/evaluation/test_sync_client.py @@ -0,0 +1,146 @@ +import pytest + +from flipt.evaluation import BatchEvaluationRequest, EvaluationRequest +from flipt.exceptions import FliptApiError + + +def test_variant(sync_flipt_client): + variant = sync_flipt_client.evaluation.variant( + EvaluationRequest( + namespace_key="default", + flag_key="flag1", + entity_id="entity", + context={"fizz": "buzz"}, + ) + ) + + assert variant.match + assert variant.flag_key == 'flag1' + assert variant.variant_key == 'variant1' + assert variant.reason == 'MATCH_EVALUATION_REASON' + assert 'segment1' in variant.segment_keys + + +@pytest.mark.usefixtures('_mock_variant_response_error') +def test_evaluate_variant_error(sync_flipt_client): + with pytest.raises(FliptApiError): + sync_flipt_client.evaluation.variant( + EvaluationRequest( + namespace_key="default", + flag_key="flag1", + entity_id="entity", + context={"fizz": "buzz"}, + ) + ) + + +def test_boolean(sync_flipt_client): + boolean = sync_flipt_client.evaluation.boolean( + EvaluationRequest( + namespace_key="default", + flag_key="flag_boolean", + entity_id="entity", + context={"fizz": "buzz"}, + ) + ) + + assert boolean.enabled + assert boolean.flag_key == 'flag_boolean' + assert boolean.reason == 'MATCH_EVALUATION_REASON' + + +@pytest.mark.usefixtures('_mock_boolean_response_error') +def test_evaluate_boolean_error(sync_flipt_client): + with pytest.raises(FliptApiError): + sync_flipt_client.evaluation.boolean( + EvaluationRequest( + namespace_key="default", + flag_key="flag_boolean", + entity_id="entity", + context={"fizz": "buzz"}, + ) + ) + + +def test_batch(sync_flipt_client): + batch = sync_flipt_client.evaluation.batch( + BatchEvaluationRequest( + requests=[ + EvaluationRequest( + namespace_key="default", + flag_key="flag1", + entity_id="entity", + context={"fizz": "buzz"}, + ), + EvaluationRequest( + namespace_key="default", + flag_key="flag_boolean", + entity_id="entity", + context={"fizz": "buzz"}, + ), + EvaluationRequest( + namespace_key="default", + flag_key="notfound", + entity_id="entity", + context={"fizz": "buzz"}, + ), + ] + ) + ) + + assert len(batch.responses) == 3 + + # Variant + assert batch.responses[0].type == "VARIANT_EVALUATION_RESPONSE_TYPE" + + variant = batch.responses[0].variant_response + assert variant.match + assert variant.flag_key == "flag1" + assert variant.variant_key == "variant1" + assert variant.reason == "MATCH_EVALUATION_REASON" + assert 'segment1' in variant.segment_keys + + # Boolean + assert batch.responses[1].type == 'BOOLEAN_EVALUATION_RESPONSE_TYPE' + + boolean = batch.responses[1].boolean_response + assert boolean.enabled + assert boolean.flag_key == "flag_boolean" + assert boolean.reason == "MATCH_EVALUATION_REASON" + + # Error + assert batch.responses[2].type == 'ERROR_EVALUATION_RESPONSE_TYPE' + + error = batch.responses[2].error_response + assert error.flag_key == "notfound" + assert error.namespace_key == "default" + assert error.reason == "NOT_FOUND_ERROR_EVALUATION_REASON" + + +@pytest.mark.usefixtures('_mock_batch_response_error') +def test_evaluate_batch_error(sync_flipt_client): + with pytest.raises(FliptApiError): + sync_flipt_client.evaluation.batch( + BatchEvaluationRequest( + requests=[ + EvaluationRequest( + namespace_key="default", + flag_key="flag1", + entity_id="entity", + context={"fizz": "buzz"}, + ), + EvaluationRequest( + namespace_key="default", + flag_key="flag_boolean", + entity_id="entity", + context={"fizz": "buzz"}, + ), + EvaluationRequest( + namespace_key="default", + flag_key="notfound", + entity_id="entity", + context={"fizz": "buzz"}, + ), + ] + ) + ) diff --git a/test/main.go b/test/main.go index eb63342..b666d8f 100644 --- a/test/main.go +++ b/test/main.go @@ -108,7 +108,7 @@ func pythonTests(ctx context.Context, client *dagger.Client, flipt *dagger.Conta WithServiceBinding("flipt", flipt.WithExec(nil).AsService()). WithEnvVariable("FLIPT_URL", "http://flipt:8080"). WithEnvVariable("FLIPT_AUTH_TOKEN", "secret"). - WithExec([]string{"poetry", "install", "--without=dev"}). + WithExec([]string{"poetry", "install"}). WithExec([]string{"poetry", "run", "test"}). Sync(ctx)