diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ea879d03..7023c114 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,15 +21,15 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: submodules: recursive - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5 with: python-version: ${{ matrix.python-version }} cache: "pip" @@ -44,9 +44,9 @@ jobs: - name: Run E2E tests with behave run: hatch run e2e - - if: matrix.python-version == '3.11' + - if: matrix.python-version == '3.13' name: Upload coverage to Codecov - uses: codecov/codecov-action@v5.3.1 + uses: codecov/codecov-action@13ce06bfc6bbe3ecf90edbbf1bc32fe5978ca1d3 # v5.3.1 with: flags: unittests # optional name: coverage # optional @@ -58,14 +58,14 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5 with: - python-version: "3.11" + python-version: "3.13" cache: "pip" - name: Run pre-commit - uses: pre-commit/action@v3.0.1 + uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 sast: runs-on: ubuntu-latest @@ -74,16 +74,16 @@ jobs: contents: read security-events: write steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5 with: - python-version: "3.11" + python-version: "3.13" - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3 with: languages: python config-file: ./.github/codeql-config.yml - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3 diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml index d34200a2..2dcb843a 100644 --- a/.github/workflows/lint-pr.yml +++ b/.github/workflows/lint-pr.yml @@ -20,6 +20,6 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v5 + - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29045d87..1d6ac2cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: googleapis/release-please-action@v4 + - uses: googleapis/release-please-action@db8f2c60ee802b3748b512940dde88eabd7b7e01 # v3 id: release with: command: manifest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c8748350..197d17b5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ -default_stages: [commit] +default_stages: [pre-commit] repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.4 + rev: v0.9.6 hooks: - id: ruff args: [--fix] @@ -16,7 +16,7 @@ repos: - id: check-merge-conflict - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.14.1 + rev: v1.15.0 hooks: - id: mypy files: openfeature diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ca445a1c..02e64b96 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1 @@ -{".":"0.7.5"} \ No newline at end of file +{".":"0.8.0"} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f0160f63..a43a48ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## [0.8.0](https://github.com/open-feature/python-sdk/compare/v0.7.5...v0.8.0) (2025-02-11) + + +### โš  BREAKING CHANGES + +* drop Python 3.8 support ([#441](https://github.com/open-feature/python-sdk/issues/441)) + +### โœจ New Features + +* Add async functionality to providers ([#413](https://github.com/open-feature/python-sdk/issues/413)) ([86e7c07](https://github.com/open-feature/python-sdk/commit/86e7c07112cfa9fa6bec15cb7a47f8a675034b8b)) + + +### ๐Ÿงน Chore + +* **deps:** pin dependencies ([#435](https://github.com/open-feature/python-sdk/issues/435)) ([38d13fa](https://github.com/open-feature/python-sdk/commit/38d13fa454e8b7d5a55a8e4e12dcbe4c37f70706)) +* **deps:** update github/codeql-action digest to 9e8d078 ([#440](https://github.com/open-feature/python-sdk/issues/440)) ([ba0213e](https://github.com/open-feature/python-sdk/commit/ba0213e701958a9962676646bec267a5c530184c)) +* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.9.5 ([#439](https://github.com/open-feature/python-sdk/issues/439)) ([75b41dd](https://github.com/open-feature/python-sdk/commit/75b41dd0202e9651801d2144ceec2c16ebe4989f)) +* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.9.6 ([#443](https://github.com/open-feature/python-sdk/issues/443)) ([37296dc](https://github.com/open-feature/python-sdk/commit/37296dc0b5b7450815b3b63d7877968fe07f06be)) +* **deps:** update pre-commit hook pre-commit/mirrors-mypy to v1.15.0 ([#430](https://github.com/open-feature/python-sdk/issues/430)) ([fe0fea1](https://github.com/open-feature/python-sdk/commit/fe0fea1f7328bb97c6985628be2f5c11bae13f22)) +* drop Python 3.8 support ([#441](https://github.com/open-feature/python-sdk/issues/441)) ([bcd1a38](https://github.com/open-feature/python-sdk/commit/bcd1a3807e635dcd80a7894859ae14d54a3dc485)) +* fix renovate syntax issue ([e705af4](https://github.com/open-feature/python-sdk/commit/e705af47b1b44705f0f0cca1846ccb97e820f042)) +* use centralized renovate config, downgrade release please ([#433](https://github.com/open-feature/python-sdk/issues/433)) ([ff52163](https://github.com/open-feature/python-sdk/commit/ff521630a1962f73a1d3f8e3fc65c8cdc691f54b)) + + +### ๐Ÿ“š Documentation + +* fix eval context link ([#438](https://github.com/open-feature/python-sdk/issues/438)) ([154d834](https://github.com/open-feature/python-sdk/commit/154d8345e7a65f3409c168a87d157df583fc8aa8)) +* fix links to the openfeature ecosystem page ([#432](https://github.com/open-feature/python-sdk/issues/432)) ([49edce2](https://github.com/open-feature/python-sdk/commit/49edce226996d7d27a6dd64a1ae45e0def9e9b29)) + ## [0.7.5](https://github.com/open-feature/python-sdk/compare/v0.7.4...v0.7.5) (2025-01-31) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c5c99761..2660e01f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,11 +4,11 @@ ### System Requirements -Python 3.8 and above are required. +Python 3.9 and above are required. ### Target version(s) -Python 3.8 and above are supported by the SDK. +Python 3.9 and above are supported by the SDK. ### Installation and Dependencies diff --git a/README.md b/README.md index 5cc194d1..c018e180 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ - - Latest version + + Latest version @@ -34,7 +34,7 @@ - Min python version + Min python version @@ -51,7 +51,7 @@ ### Requirements -- Python 3.8+ +- Python 3.9+ ### Install @@ -60,13 +60,13 @@ #### Pip install ```bash -pip install openfeature-sdk==0.7.5 +pip install openfeature-sdk==0.8.0 ``` #### requirements.txt ```bash -openfeature-sdk==0.7.5 +openfeature-sdk==0.8.0 ``` ```python @@ -108,7 +108,7 @@ print("Value: " + str(flag_value)) | โœ… | [Domains](#domains) | Logically bind clients with providers. | | โœ… | [Eventing](#eventing) | React to state changes in the provider or flag management system. | | โœ… | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. | -| โœ… | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread) | +| โœ… | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread) | | โœ… | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. | Implemented: โœ… | In-progress: โš ๏ธ | Not implemented yet: โŒ @@ -116,7 +116,7 @@ print("Value: " + str(flag_value)) ### Providers [Providers](https://openfeature.dev/docs/reference/concepts/provider) are an abstraction between a flag management system and the OpenFeature SDK. -Look [here](https://openfeature.dev/ecosystem?instant_search%5BrefinementList%5D%5Btype%5D%5B0%5D=Provider&instant_search%5BrefinementList%5D%5Btechnology%5D%5B0%5D=python) for a complete list of available providers. +Look [here](https://openfeature.dev/ecosystem/?instant_search%5BrefinementList%5D%5Btype%5D%5B0%5D=Provider&instant_search%5BrefinementList%5D%5Btechnology%5D%5B0%5D=Python) for a complete list of available providers. If the provider you're looking for hasn't been created yet, see the [develop a provider](#develop-a-provider) section to learn how to build it yourself. Once you've added a provider as a dependency, it can be registered with OpenFeature like this: @@ -165,7 +165,7 @@ client.get_string_value("email", "fallback", request_context) ### Hooks [Hooks](https://openfeature.dev/docs/reference/concepts/hooks) allow for custom logic to be added at well-defined points of the flag evaluation life-cycle. -Look [here](https://openfeature.dev/ecosystem/?instant_search%5BrefinementList%5D%5Btype%5D%5B0%5D=Hook&instant_search%5BrefinementList%5D%5Btechnology%5D%5B0%5D=python) for a complete list of available hooks. +Look [here](https://openfeature.dev/ecosystem/?instant_search%5BrefinementList%5D%5Btype%5D%5B0%5D=Hook&instant_search%5BrefinementList%5D%5Btechnology%5D%5B0%5D=Python) for a complete list of available hooks. If the hook you're looking for hasn't been created yet, see the [develop a hook](#develop-a-hook) section to learn how to build it yourself. Once you've added a hook as a dependency, it can be registered at the global, client, or flag invocation level. @@ -316,6 +316,25 @@ async def some_endpoint(): return create_response() ``` +### Asynchronous Feature Retrieval + +The OpenFeature API supports asynchronous calls, enabling non-blocking feature evaluations for improved performance, especially useful in concurrent or latency-sensitive scenarios. If a provider *hasn't* implemented asynchronous calls, the client can still be used asynchronously, but calls will be blocking (synchronous). + +```python +import asyncio +from openfeature import api +from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider + +my_flags = { "v2_enabled": InMemoryFlag("on", {"on": True, "off": False}) } +api.set_provider(InMemoryProvider(my_flags)) +client = api.get_client() +flag_value = await client.get_boolean_value_async("v2_enabled", False) # API calls are suffixed by _async + +print("Value: " + str(flag_value)) +``` + +See the [develop a provider](#develop-a-provider) for how to support asynchronous functionality in providers. + ### Shutdown The OpenFeature API provides a shutdown function to perform a cleanup of all registered providers. This should only be called when your application is in the process of shutting down. @@ -390,6 +409,56 @@ class MyProvider(AbstractProvider): ... ``` +Providers can also be extended to support async functionality. +To support add asynchronous calls to a provider: +* Implement the `AbstractProvider` as shown above. +* Define asynchronous calls for each data type. + +```python +class MyProvider(AbstractProvider): + ... + async def resolve_boolean_details_async( + self, + flag_key: str, + default_value: bool, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: + ... + + async def resolve_string_details_async( + self, + flag_key: str, + default_value: str, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[str]: + ... + + async def resolve_integer_details_async( + self, + flag_key: str, + default_value: int, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[int]: + ... + + async def resolve_float_details_async( + self, + flag_key: str, + default_value: float, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[float]: + ... + + async def resolve_object_details_async( + self, + flag_key: str, + default_value: Union[dict, list], + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[Union[dict, list]]: + ... + +``` + > Built a new provider? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=provider&projects=&template=document-provider.yaml&title=%5BProvider%5D%3A+) so we can add it to the docs! ### Develop a hook diff --git a/openfeature/_event_support.py b/openfeature/_event_support.py index 42e6250b..58c72d4e 100644 --- a/openfeature/_event_support.py +++ b/openfeature/_event_support.py @@ -2,7 +2,7 @@ import threading from collections import defaultdict -from typing import TYPE_CHECKING, Dict, List +from typing import TYPE_CHECKING from openfeature.event import ( EventDetails, @@ -17,10 +17,10 @@ _global_lock = threading.RLock() -_global_handlers: Dict[ProviderEvent, List[EventHandler]] = defaultdict(list) +_global_handlers: dict[ProviderEvent, list[EventHandler]] = defaultdict(list) _client_lock = threading.RLock() -_client_handlers: Dict[OpenFeatureClient, Dict[ProviderEvent, List[EventHandler]]] = ( +_client_handlers: dict[OpenFeatureClient, dict[ProviderEvent, list[EventHandler]]] = ( defaultdict(lambda: defaultdict(list)) ) diff --git a/openfeature/api.py b/openfeature/api.py index 4460b695..36432d0c 100644 --- a/openfeature/api.py +++ b/openfeature/api.py @@ -40,7 +40,7 @@ NoOpTransactionContextPropagator() ) -_hooks: typing.List[Hook] = [] +_hooks: list[Hook] = [] def get_client( @@ -96,7 +96,7 @@ def set_transaction_context(evaluation_context: EvaluationContext) -> None: ) -def add_hooks(hooks: typing.List[Hook]) -> None: +def add_hooks(hooks: list[Hook]) -> None: global _hooks _hooks = _hooks + hooks @@ -106,7 +106,7 @@ def clear_hooks() -> None: _hooks = [] -def get_hooks() -> typing.List[Hook]: +def get_hooks() -> list[Hook]: return _hooks diff --git a/openfeature/client.py b/openfeature/client.py index cd82694b..7d1f26df 100644 --- a/openfeature/client.py +++ b/openfeature/client.py @@ -20,7 +20,7 @@ FlagType, Reason, ) -from openfeature.hook import Hook, HookContext +from openfeature.hook import Hook, HookContext, HookHints from openfeature.hook._hook_support import ( after_all_hooks, after_hooks, @@ -55,14 +55,36 @@ FlagResolutionDetails[typing.Union[dict, list]], ], ] -TypeMap = typing.Dict[ +GetDetailCallableAsync = typing.Union[ + typing.Callable[ + [str, bool, typing.Optional[EvaluationContext]], + typing.Awaitable[FlagResolutionDetails[bool]], + ], + typing.Callable[ + [str, int, typing.Optional[EvaluationContext]], + typing.Awaitable[FlagResolutionDetails[int]], + ], + typing.Callable[ + [str, float, typing.Optional[EvaluationContext]], + typing.Awaitable[FlagResolutionDetails[float]], + ], + typing.Callable[ + [str, str, typing.Optional[EvaluationContext]], + typing.Awaitable[FlagResolutionDetails[str]], + ], + typing.Callable[ + [str, typing.Union[dict, list], typing.Optional[EvaluationContext]], + typing.Awaitable[FlagResolutionDetails[typing.Union[dict, list]]], + ], +] +TypeMap = dict[ FlagType, typing.Union[ - typing.Type[bool], - typing.Type[int], - typing.Type[float], - typing.Type[str], - typing.Tuple[typing.Type[dict], typing.Type[list]], + type[bool], + type[int], + type[float], + type[str], + tuple[type[dict], type[list]], ], ] @@ -79,7 +101,7 @@ def __init__( domain: typing.Optional[str], version: typing.Optional[str], context: typing.Optional[EvaluationContext] = None, - hooks: typing.Optional[typing.List[Hook]] = None, + hooks: typing.Optional[list[Hook]] = None, ) -> None: self.domain = domain self.version = version @@ -96,7 +118,7 @@ def get_provider_status(self) -> ProviderStatus: def get_metadata(self) -> ClientMetadata: return ClientMetadata(domain=self.domain) - def add_hooks(self, hooks: typing.List[Hook]) -> None: + def add_hooks(self, hooks: list[Hook]) -> None: self.hooks = self.hooks + hooks def get_boolean_value( @@ -113,6 +135,21 @@ def get_boolean_value( flag_evaluation_options, ).value + async def get_boolean_value_async( + self, + flag_key: str, + default_value: bool, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, + ) -> bool: + details = await self.get_boolean_details_async( + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ) + return details.value + def get_boolean_details( self, flag_key: str, @@ -128,6 +165,21 @@ def get_boolean_details( flag_evaluation_options, ) + async def get_boolean_details_async( + self, + flag_key: str, + default_value: bool, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails[bool]: + return await self.evaluate_flag_details_async( + FlagType.BOOLEAN, + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ) + def get_string_value( self, flag_key: str, @@ -142,6 +194,21 @@ def get_string_value( flag_evaluation_options, ).value + async def get_string_value_async( + self, + flag_key: str, + default_value: str, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, + ) -> str: + details = await self.get_string_details_async( + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ) + return details.value + def get_string_details( self, flag_key: str, @@ -157,6 +224,21 @@ def get_string_details( flag_evaluation_options, ) + async def get_string_details_async( + self, + flag_key: str, + default_value: str, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails[str]: + return await self.evaluate_flag_details_async( + FlagType.STRING, + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ) + def get_integer_value( self, flag_key: str, @@ -171,6 +253,21 @@ def get_integer_value( flag_evaluation_options, ).value + async def get_integer_value_async( + self, + flag_key: str, + default_value: int, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, + ) -> int: + details = await self.get_integer_details_async( + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ) + return details.value + def get_integer_details( self, flag_key: str, @@ -186,6 +283,21 @@ def get_integer_details( flag_evaluation_options, ) + async def get_integer_details_async( + self, + flag_key: str, + default_value: int, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails[int]: + return await self.evaluate_flag_details_async( + FlagType.INTEGER, + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ) + def get_float_value( self, flag_key: str, @@ -200,6 +312,21 @@ def get_float_value( flag_evaluation_options, ).value + async def get_float_value_async( + self, + flag_key: str, + default_value: float, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, + ) -> float: + details = await self.get_float_details_async( + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ) + return details.value + def get_float_details( self, flag_key: str, @@ -215,6 +342,21 @@ def get_float_details( flag_evaluation_options, ) + async def get_float_details_async( + self, + flag_key: str, + default_value: float, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails[float]: + return await self.evaluate_flag_details_async( + FlagType.FLOAT, + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ) + def get_object_value( self, flag_key: str, @@ -229,6 +371,21 @@ def get_object_value( flag_evaluation_options, ).value + async def get_object_value_async( + self, + flag_key: str, + default_value: typing.Union[dict, list], + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, + ) -> typing.Union[dict, list]: + details = await self.get_object_details_async( + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ) + return details.value + def get_object_details( self, flag_key: str, @@ -244,26 +401,35 @@ def get_object_details( flag_evaluation_options, ) - def evaluate_flag_details( # noqa: PLR0915 + async def get_object_details_async( self, - flag_type: FlagType, flag_key: str, - default_value: typing.Any, + default_value: typing.Union[dict, list], evaluation_context: typing.Optional[EvaluationContext] = None, flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, - ) -> FlagEvaluationDetails[typing.Any]: - """ - Evaluate the flag requested by the user from the clients provider. - - :param flag_type: the type of the flag being returned - :param flag_key: the string key of the selected flag - :param default_value: backup value returned if no result found by the provider - :param evaluation_context: Information for the purposes of flag evaluation - :param flag_evaluation_options: Additional flag evaluation information - :return: a FlagEvaluationDetails object with the fully evaluated flag from a - provider - """ + ) -> FlagEvaluationDetails[typing.Union[dict, list]]: + return await self.evaluate_flag_details_async( + FlagType.OBJECT, + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ) + def _establish_hooks_and_provider( + self, + flag_type: FlagType, + flag_key: str, + default_value: typing.Any, + evaluation_context: typing.Optional[EvaluationContext], + flag_evaluation_options: typing.Optional[FlagEvaluationOptions], + ) -> tuple[ + FeatureProvider, + HookContext, + HookHints, + list[Hook], + list[Hook], + ]: if evaluation_context is None: evaluation_context = EvaluationContext() @@ -295,54 +461,179 @@ def evaluate_flag_details( # noqa: PLR0915 reversed_merged_hooks = merged_hooks[:] reversed_merged_hooks.reverse() + return provider, hook_context, hook_hints, merged_hooks, reversed_merged_hooks + + def _assert_provider_status( + self, + ) -> None: + status = self.get_provider_status() + if status == ProviderStatus.NOT_READY: + raise ProviderNotReadyError() + if status == ProviderStatus.FATAL: + raise ProviderFatalError() + return None + + def _before_hooks_and_merge_context( + self, + flag_type: FlagType, + hook_context: HookContext, + merged_hooks: list[Hook], + hook_hints: HookHints, + evaluation_context: typing.Optional[EvaluationContext], + ) -> EvaluationContext: + # https://github.com/open-feature/spec/blob/main/specification/sections/03-evaluation-context.md + # Any resulting evaluation context from a before hook will overwrite + # duplicate fields defined globally, on the client, or in the invocation. + # Requirement 3.2.2, 4.3.4: API.context->client.context->invocation.context + invocation_context = before_hooks( + flag_type, hook_context, merged_hooks, hook_hints + ) + if evaluation_context: + invocation_context = invocation_context.merge(ctx2=evaluation_context) + + # Requirement 3.2.2 merge: API.context->transaction.context->client.context->invocation.context + merged_context = ( + api.get_evaluation_context() + .merge(api.get_transaction_context()) + .merge(self.context) + .merge(invocation_context) + ) + return merged_context + + async def evaluate_flag_details_async( + self, + flag_type: FlagType, + flag_key: str, + default_value: typing.Any, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails[typing.Any]: + """ + Evaluate the flag requested by the user from the clients provider. + + :param flag_type: the type of the flag being returned + :param flag_key: the string key of the selected flag + :param default_value: backup value returned if no result found by the provider + :param evaluation_context: Information for the purposes of flag evaluation + :param flag_evaluation_options: Additional flag evaluation information + :return: a typing.Awaitable[FlagEvaluationDetails] object with the fully evaluated flag from a + provider + """ + provider, hook_context, hook_hints, merged_hooks, reversed_merged_hooks = ( + self._establish_hooks_and_provider( + flag_type, + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ) + ) + try: - status = self.get_provider_status() - if status == ProviderStatus.NOT_READY: - error_hooks( - flag_type, - hook_context, - ProviderNotReadyError(), - reversed_merged_hooks, - hook_hints, - ) - flag_evaluation = FlagEvaluationDetails( - flag_key=flag_key, - value=default_value, - reason=Reason.ERROR, - error_code=ErrorCode.PROVIDER_NOT_READY, - ) - return flag_evaluation - if status == ProviderStatus.FATAL: - error_hooks( - flag_type, - hook_context, - ProviderFatalError(), - reversed_merged_hooks, - hook_hints, - ) - flag_evaluation = FlagEvaluationDetails( - flag_key=flag_key, - value=default_value, - reason=Reason.ERROR, - error_code=ErrorCode.PROVIDER_FATAL, - ) - return flag_evaluation - - # https://github.com/open-feature/spec/blob/main/specification/sections/03-evaluation-context.md - # Any resulting evaluation context from a before hook will overwrite - # duplicate fields defined globally, on the client, or in the invocation. - # Requirement 3.2.2, 4.3.4: API.context->client.context->invocation.context - invocation_context = before_hooks( - flag_type, hook_context, merged_hooks, hook_hints + self._assert_provider_status() + + merged_context = self._before_hooks_and_merge_context( + flag_type, + hook_context, + merged_hooks, + hook_hints, + evaluation_context, + ) + + flag_evaluation = await self._create_provider_evaluation_async( + provider, + flag_type, + flag_key, + default_value, + merged_context, ) - invocation_context = invocation_context.merge(ctx2=evaluation_context) - # Requirement 3.2.2 merge: API.context->transaction.context->client.context->invocation.context - merged_context = ( - api.get_evaluation_context() - .merge(api.get_transaction_context()) - .merge(self.context) - .merge(invocation_context) + after_hooks( + flag_type, + hook_context, + flag_evaluation, + reversed_merged_hooks, + hook_hints, + ) + + return flag_evaluation + + except OpenFeatureError as err: + error_hooks(flag_type, hook_context, err, reversed_merged_hooks, hook_hints) + flag_evaluation = FlagEvaluationDetails( + flag_key=flag_key, + value=default_value, + reason=Reason.ERROR, + error_code=err.error_code, + error_message=err.error_message, + ) + return flag_evaluation + # Catch any type of exception here since the user can provide any exception + # in the error hooks + except Exception as err: # pragma: no cover + logger.exception( + "Unable to correctly evaluate flag with key: '%s'", flag_key + ) + + error_hooks(flag_type, hook_context, err, reversed_merged_hooks, hook_hints) + + error_message = getattr(err, "error_message", str(err)) + flag_evaluation = FlagEvaluationDetails( + flag_key=flag_key, + value=default_value, + reason=Reason.ERROR, + error_code=ErrorCode.GENERAL, + error_message=error_message, + ) + return flag_evaluation + + finally: + after_all_hooks( + flag_type, + hook_context, + flag_evaluation, + reversed_merged_hooks, + hook_hints, + ) + + def evaluate_flag_details( + self, + flag_type: FlagType, + flag_key: str, + default_value: typing.Any, + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails[typing.Any]: + """ + Evaluate the flag requested by the user from the clients provider. + + :param flag_type: the type of the flag being returned + :param flag_key: the string key of the selected flag + :param default_value: backup value returned if no result found by the provider + :param evaluation_context: Information for the purposes of flag evaluation + :param flag_evaluation_options: Additional flag evaluation information + :return: a FlagEvaluationDetails object with the fully evaluated flag from a + provider + """ + provider, hook_context, hook_hints, merged_hooks, reversed_merged_hooks = ( + self._establish_hooks_and_provider( + flag_type, + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ) + ) + + try: + self._assert_provider_status() + + merged_context = self._before_hooks_and_merge_context( + flag_type, + hook_context, + merged_hooks, + hook_hints, + evaluation_context, ) flag_evaluation = self._create_provider_evaluation( @@ -402,6 +693,48 @@ def evaluate_flag_details( # noqa: PLR0915 hook_hints, ) + async def _create_provider_evaluation_async( + self, + provider: FeatureProvider, + flag_type: FlagType, + flag_key: str, + default_value: typing.Any, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagEvaluationDetails[typing.Any]: + args = ( + flag_key, + default_value, + evaluation_context, + ) + get_details_callables_async: typing.Mapping[ + FlagType, GetDetailCallableAsync + ] = { + FlagType.BOOLEAN: provider.resolve_boolean_details_async, + FlagType.INTEGER: provider.resolve_integer_details_async, + FlagType.FLOAT: provider.resolve_float_details_async, + FlagType.OBJECT: provider.resolve_object_details_async, + FlagType.STRING: provider.resolve_string_details_async, + } + get_details_callable = get_details_callables_async.get(flag_type) + if not get_details_callable: + raise GeneralError(error_message="Unknown flag type") + + resolution = await get_details_callable(*args) + resolution.raise_for_error() + + # we need to check the get_args to be compatible with union types. + _typecheck_flag_value(resolution.value, flag_type) + + return FlagEvaluationDetails( + flag_key=flag_key, + value=resolution.value, + variant=resolution.variant, + flag_metadata=resolution.flag_metadata or {}, + reason=resolution.reason, + error_code=resolution.error_code, + error_message=resolution.error_message, + ) + def _create_provider_evaluation( self, provider: FeatureProvider, diff --git a/openfeature/event.py b/openfeature/event.py index 23210771..ec8d3ce6 100644 --- a/openfeature/event.py +++ b/openfeature/event.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Callable, Dict, List, Optional, Union +from typing import Callable, Optional, Union from openfeature.exception import ErrorCode @@ -18,19 +18,19 @@ class ProviderEvent(Enum): @dataclass class ProviderEventDetails: - flags_changed: Optional[List[str]] = None + flags_changed: Optional[list[str]] = None message: Optional[str] = None error_code: Optional[ErrorCode] = None - metadata: Dict[str, Union[bool, str, int, float]] = field(default_factory=dict) + metadata: dict[str, Union[bool, str, int, float]] = field(default_factory=dict) @dataclass class EventDetails(ProviderEventDetails): provider_name: str = "" - flags_changed: Optional[List[str]] = None + flags_changed: Optional[list[str]] = None message: Optional[str] = None error_code: Optional[ErrorCode] = None - metadata: Dict[str, Union[bool, str, int, float]] = field(default_factory=dict) + metadata: dict[str, Union[bool, str, int, float]] = field(default_factory=dict) @classmethod def from_provider_event_details( diff --git a/openfeature/flag_evaluation.py b/openfeature/flag_evaluation.py index 1f83fd1b..5cab623c 100644 --- a/openfeature/flag_evaluation.py +++ b/openfeature/flag_evaluation.py @@ -58,7 +58,7 @@ class FlagEvaluationDetails(typing.Generic[T_co]): @dataclass class FlagEvaluationOptions: - hooks: typing.List[Hook] = field(default_factory=list) + hooks: list[Hook] = field(default_factory=list) hook_hints: HookHints = field(default_factory=dict) diff --git a/openfeature/hook/__init__.py b/openfeature/hook/__init__.py index 03d8c865..16fa4bdd 100644 --- a/openfeature/hook/__init__.py +++ b/openfeature/hook/__init__.py @@ -60,8 +60,8 @@ def __setattr__(self, key: str, value: typing.Any) -> None: float, str, datetime, - typing.List[typing.Any], - typing.Dict[str, typing.Any], + list[typing.Any], + dict[str, typing.Any], ], ] diff --git a/openfeature/hook/_hook_support.py b/openfeature/hook/_hook_support.py index 2c151ae1..37b7e5b4 100644 --- a/openfeature/hook/_hook_support.py +++ b/openfeature/hook/_hook_support.py @@ -13,7 +13,7 @@ def error_hooks( flag_type: FlagType, hook_context: HookContext, exception: Exception, - hooks: typing.List[Hook], + hooks: list[Hook], hints: typing.Optional[HookHints] = None, ) -> None: kwargs = {"hook_context": hook_context, "exception": exception, "hints": hints} @@ -26,7 +26,7 @@ def after_all_hooks( flag_type: FlagType, hook_context: HookContext, details: FlagEvaluationDetails[typing.Any], - hooks: typing.List[Hook], + hooks: list[Hook], hints: typing.Optional[HookHints] = None, ) -> None: kwargs = {"hook_context": hook_context, "details": details, "hints": hints} @@ -39,7 +39,7 @@ def after_hooks( flag_type: FlagType, hook_context: HookContext, details: FlagEvaluationDetails[typing.Any], - hooks: typing.List[Hook], + hooks: list[Hook], hints: typing.Optional[HookHints] = None, ) -> None: kwargs = {"hook_context": hook_context, "details": details, "hints": hints} @@ -51,7 +51,7 @@ def after_hooks( def before_hooks( flag_type: FlagType, hook_context: HookContext, - hooks: typing.List[Hook], + hooks: list[Hook], hints: typing.Optional[HookHints] = None, ) -> EvaluationContext: kwargs = {"hook_context": hook_context, "hints": hints} @@ -68,7 +68,7 @@ def before_hooks( def _execute_hooks( flag_type: FlagType, - hooks: typing.List[Hook], + hooks: list[Hook], hook_method: HookType, **kwargs: typing.Any, ) -> list: @@ -91,10 +91,10 @@ def _execute_hooks( def _execute_hooks_unchecked( flag_type: FlagType, - hooks: typing.List[Hook], + hooks: list[Hook], hook_method: HookType, **kwargs: typing.Any, -) -> typing.List[typing.Optional[EvaluationContext]]: +) -> list[typing.Optional[EvaluationContext]]: """ Execute a single hook without checking whether an exception is thrown. This is used in the before and after hooks since any exception will be caught in the diff --git a/openfeature/provider/__init__.py b/openfeature/provider/__init__.py index b390f928..5b9ffd09 100644 --- a/openfeature/provider/__init__.py +++ b/openfeature/provider/__init__.py @@ -38,7 +38,7 @@ def shutdown(self) -> None: ... def get_metadata(self) -> Metadata: ... - def get_provider_hooks(self) -> typing.List[Hook]: ... + def get_provider_hooks(self) -> list[Hook]: ... def resolve_boolean_details( self, @@ -47,6 +47,13 @@ def resolve_boolean_details( evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[bool]: ... + async def resolve_boolean_details_async( + self, + flag_key: str, + default_value: bool, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: ... + def resolve_string_details( self, flag_key: str, @@ -54,6 +61,13 @@ def resolve_string_details( evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[str]: ... + async def resolve_string_details_async( + self, + flag_key: str, + default_value: str, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[str]: ... + def resolve_integer_details( self, flag_key: str, @@ -61,6 +75,13 @@ def resolve_integer_details( evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[int]: ... + async def resolve_integer_details_async( + self, + flag_key: str, + default_value: int, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[int]: ... + def resolve_float_details( self, flag_key: str, @@ -68,6 +89,13 @@ def resolve_float_details( evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[float]: ... + async def resolve_float_details_async( + self, + flag_key: str, + default_value: float, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[float]: ... + def resolve_object_details( self, flag_key: str, @@ -75,6 +103,13 @@ def resolve_object_details( evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[typing.Union[dict, list]]: ... + async def resolve_object_details_async( + self, + flag_key: str, + default_value: typing.Union[dict, list], + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[typing.Union[dict, list]]: ... + class AbstractProvider(FeatureProvider): def attach( @@ -99,7 +134,7 @@ def shutdown(self) -> None: def get_metadata(self) -> Metadata: pass - def get_provider_hooks(self) -> typing.List[Hook]: + def get_provider_hooks(self) -> list[Hook]: return [] @abstractmethod @@ -111,6 +146,14 @@ def resolve_boolean_details( ) -> FlagResolutionDetails[bool]: pass + async def resolve_boolean_details_async( + self, + flag_key: str, + default_value: bool, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: + return self.resolve_boolean_details(flag_key, default_value, evaluation_context) + @abstractmethod def resolve_string_details( self, @@ -120,6 +163,14 @@ def resolve_string_details( ) -> FlagResolutionDetails[str]: pass + async def resolve_string_details_async( + self, + flag_key: str, + default_value: str, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[str]: + return self.resolve_string_details(flag_key, default_value, evaluation_context) + @abstractmethod def resolve_integer_details( self, @@ -129,6 +180,14 @@ def resolve_integer_details( ) -> FlagResolutionDetails[int]: pass + async def resolve_integer_details_async( + self, + flag_key: str, + default_value: int, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[int]: + return self.resolve_integer_details(flag_key, default_value, evaluation_context) + @abstractmethod def resolve_float_details( self, @@ -138,6 +197,14 @@ def resolve_float_details( ) -> FlagResolutionDetails[float]: pass + async def resolve_float_details_async( + self, + flag_key: str, + default_value: float, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[float]: + return self.resolve_float_details(flag_key, default_value, evaluation_context) + @abstractmethod def resolve_object_details( self, @@ -147,6 +214,14 @@ def resolve_object_details( ) -> FlagResolutionDetails[typing.Union[dict, list]]: pass + async def resolve_object_details_async( + self, + flag_key: str, + default_value: typing.Union[dict, list], + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[typing.Union[dict, list]]: + return self.resolve_object_details(flag_key, default_value, evaluation_context) + def emit_provider_ready(self, details: ProviderEventDetails) -> None: self.emit(ProviderEvent.PROVIDER_READY, details) diff --git a/openfeature/provider/_registry.py b/openfeature/provider/_registry.py index e2ec2e53..78412f1e 100644 --- a/openfeature/provider/_registry.py +++ b/openfeature/provider/_registry.py @@ -13,8 +13,8 @@ class ProviderRegistry: _default_provider: FeatureProvider - _providers: typing.Dict[str, FeatureProvider] - _provider_status: typing.Dict[FeatureProvider, ProviderStatus] + _providers: dict[str, FeatureProvider] + _provider_status: dict[FeatureProvider, ProviderStatus] def __init__(self) -> None: self._default_provider = NoOpProvider() diff --git a/openfeature/provider/in_memory_provider.py b/openfeature/provider/in_memory_provider.py index 322f4ed6..3bd3fa1b 100644 --- a/openfeature/provider/in_memory_provider.py +++ b/openfeature/provider/in_memory_provider.py @@ -26,7 +26,7 @@ class State(StrEnum): DISABLED = "DISABLED" default_variant: str - variants: typing.Dict[str, T_co] + variants: dict[str, T_co] flag_metadata: FlagMetadata = field(default_factory=dict) state: State = State.ENABLED context_evaluator: typing.Optional[ @@ -51,7 +51,7 @@ def resolve( ) -FlagStorage = typing.Dict[str, InMemoryFlag[typing.Any]] +FlagStorage = dict[str, InMemoryFlag[typing.Any]] V = typing.TypeVar("V") @@ -65,7 +65,7 @@ def __init__(self, flags: FlagStorage) -> None: def get_metadata(self) -> Metadata: return InMemoryMetadata() - def get_provider_hooks(self) -> typing.List[Hook]: + def get_provider_hooks(self) -> list[Hook]: return [] def resolve_boolean_details( @@ -76,6 +76,14 @@ def resolve_boolean_details( ) -> FlagResolutionDetails[bool]: return self._resolve(flag_key, evaluation_context) + async def resolve_boolean_details_async( + self, + flag_key: str, + default_value: bool, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: + return await self._resolve_async(flag_key, evaluation_context) + def resolve_string_details( self, flag_key: str, @@ -84,6 +92,14 @@ def resolve_string_details( ) -> FlagResolutionDetails[str]: return self._resolve(flag_key, evaluation_context) + async def resolve_string_details_async( + self, + flag_key: str, + default_value: str, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[str]: + return await self._resolve_async(flag_key, evaluation_context) + def resolve_integer_details( self, flag_key: str, @@ -92,6 +108,14 @@ def resolve_integer_details( ) -> FlagResolutionDetails[int]: return self._resolve(flag_key, evaluation_context) + async def resolve_integer_details_async( + self, + flag_key: str, + default_value: int, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[int]: + return await self._resolve_async(flag_key, evaluation_context) + def resolve_float_details( self, flag_key: str, @@ -100,6 +124,14 @@ def resolve_float_details( ) -> FlagResolutionDetails[float]: return self._resolve(flag_key, evaluation_context) + async def resolve_float_details_async( + self, + flag_key: str, + default_value: float, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[float]: + return await self._resolve_async(flag_key, evaluation_context) + def resolve_object_details( self, flag_key: str, @@ -108,6 +140,14 @@ def resolve_object_details( ) -> FlagResolutionDetails[typing.Union[dict, list]]: return self._resolve(flag_key, evaluation_context) + async def resolve_object_details_async( + self, + flag_key: str, + default_value: typing.Union[dict, list], + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[typing.Union[dict, list]]: + return await self._resolve_async(flag_key, evaluation_context) + def _resolve( self, flag_key: str, @@ -117,3 +157,10 @@ def _resolve( if flag is None: raise FlagNotFoundError(f"Flag '{flag_key}' not found") return flag.resolve(evaluation_context) + + async def _resolve_async( + self, + flag_key: str, + evaluation_context: typing.Optional[EvaluationContext], + ) -> FlagResolutionDetails[V]: + return self._resolve(flag_key, evaluation_context) diff --git a/openfeature/provider/no_op_provider.py b/openfeature/provider/no_op_provider.py index 070945c9..68de7167 100644 --- a/openfeature/provider/no_op_provider.py +++ b/openfeature/provider/no_op_provider.py @@ -13,7 +13,7 @@ class NoOpProvider(AbstractProvider): def get_metadata(self) -> Metadata: return NoOpMetadata() - def get_provider_hooks(self) -> typing.List[Hook]: + def get_provider_hooks(self) -> list[Hook]: return [] def resolve_boolean_details( diff --git a/openfeature/version.py b/openfeature/version.py index ab55bb1a..777f190d 100644 --- a/openfeature/version.py +++ b/openfeature/version.py @@ -1 +1 @@ -__version__ = "0.7.5" +__version__ = "0.8.0" diff --git a/pyproject.toml b/pyproject.toml index 03180743..3cb853e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "openfeature_sdk" -version = "0.7.5" +version = "0.8.0" description = "Standardizing Feature Flagging for Everyone" readme = "README.md" authors = [{ name = "OpenFeature", email = "openfeature-core@groups.io" }] @@ -22,7 +22,7 @@ keywords = [ "toggles", ] dependencies = [] -requires-python = ">=3.8" +requires-python = ">=3.9" [project.urls] Homepage = "https://github.com/open-feature/python-sdk" @@ -66,6 +66,8 @@ packages = ["openfeature"] [tool.mypy] files = "openfeature" + +python_version = "3.9" # should be identical to the minimum supported version namespace_packages = true explicit_package_bases = true local_partial_types = true # will become the new default from version 2 @@ -73,6 +75,9 @@ pretty = true strict = true disallow_any_generics = false +[tool.pytest.ini_options] +asyncio_default_fixture_loop_scope = "function" + [tool.ruff] exclude = [ ".git", @@ -80,7 +85,7 @@ exclude = [ "__pycache__", "venv", ] -target-version = "py38" +target-version = "py39" [tool.ruff.lint] select = [ diff --git a/renovate.json b/renovate.json index 6c501126..61b929ce 100644 --- a/renovate.json +++ b/renovate.json @@ -1,30 +1,12 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ - "config:recommended" + "github>open-feature/community-tooling" ], - "semanticCommits": "enabled", "pep621": { "enabled": true }, "pre-commit": { "enabled": true - }, - "packageRules": [ - { - "description": "Automerge non-major updates", - "matchUpdateTypes": [ - "minor", - "patch" - ], - "matchCurrentVersion": "!/^0/", - "automerge": true - }, - { - "matchManagers": [ - "github-actions" - ], - "automerge": true - } - ] + } } diff --git a/tests/features/steps/flag_steps.py b/tests/features/steps/flag_steps.py index 574d6791..7f6cd153 100644 --- a/tests/features/steps/flag_steps.py +++ b/tests/features/steps/flag_steps.py @@ -1,8 +1,17 @@ +import contextlib + from behave import given, when @given('a {flag_type}-flag with key "{flag_key}" and a default value "{default_value}"') def step_impl_flag(context, flag_type: str, flag_key, default_value): + if default_value.lower() == "true" or default_value.lower() == "false": + default_value = bool(default_value) + try: + default_value = int(default_value) + except ValueError: + with contextlib.suppress(ValueError): + default_value = float(default_value) context.flag = (flag_type, flag_key, default_value) diff --git a/tests/features/steps/hooks_steps.py b/tests/features/steps/hooks_steps.py index bc7e156b..d93ac643 100644 --- a/tests/features/steps/hooks_steps.py +++ b/tests/features/steps/hooks_steps.py @@ -1,12 +1,13 @@ from unittest.mock import MagicMock -from behave import then, when +from behave import given, then from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import Reason from openfeature.hook import Hook -@when("a hook is added to the client") +@given("a client with added hook") def step_impl_add_hook(context): hook = MagicMock(spec=Hook) hook.before = MagicMock() @@ -17,18 +18,23 @@ def step_impl_add_hook(context): context.client.add_hooks([hook]) -@then("error hooks should be called") -def step_impl_call_error(context): - assert context.hook.before.called - assert context.hook.error.called - assert context.hook.finally_after.called +@then('the "{hook_name}" hook should have been executed') +def step_impl_should_called(context, hook_name): + hook = get_hook_from_name(context, hook_name) + assert hook.called -@then("non-error hooks should be called") -def step_impl_call_non_error(context): - assert context.hook.before.called - assert context.hook.after.called - assert context.hook.finally_after.called +@then('the "{hook_names}" hooks should be called with evaluation details') +def step_impl_should_have_eval_details(context, hook_names): + for hook_name in hook_names.split(", "): + hook = get_hook_from_name(context, hook_name) + for row in context.table: + flag_type, key, value = row + + value = convert_value_from_key_and_flag_type(value, key, flag_type) + actual = hook.call_args[1]["details"].__dict__[key] + + assert actual == value def get_hook_from_name(context, hook_name): @@ -44,8 +50,8 @@ def get_hook_from_name(context, hook_name): raise ValueError(str(hook_name) + " is not a valid hook name") -def convert_value_from_flag_type(value, flag_type): - if value == "None": +def convert_value_from_key_and_flag_type(value, key, flag_type): + if value in ("None", "null"): return None if flag_type.lower() == "boolean": return bool(value) @@ -53,20 +59,8 @@ def convert_value_from_flag_type(value, flag_type): return int(value) elif flag_type.lower() == "float": return float(value) + elif key == "reason": + return Reason(value) + elif key == "error_code": + return ErrorCode(value) return value - - -@then('"{hook_names}" hooks should have evaluation details') -def step_impl_should_have_eval_details(context, hook_names): - for hook_name in hook_names.split(", "): - hook = get_hook_from_name(context, hook_name) - for row in context.table: - flag_type, key, value = row - - value = convert_value_from_flag_type(value, flag_type) - - actual = hook.call_args[1]["details"].__dict__[key] - if isinstance(actual, ErrorCode): - actual = str(actual) - - assert actual == value diff --git a/tests/provider/test_in_memory_provider.py b/tests/provider/test_in_memory_provider.py index 66d5239e..cdcea7bf 100644 --- a/tests/provider/test_in_memory_provider.py +++ b/tests/provider/test_in_memory_provider.py @@ -17,16 +17,20 @@ def test_should_return_in_memory_provider_metadata(): assert metadata.name == "In-Memory Provider" -def test_should_handle_unknown_flags_correctly(): +@pytest.mark.asyncio +async def test_should_handle_unknown_flags_correctly(): # Given provider = InMemoryProvider({}) # When with pytest.raises(FlagNotFoundError): provider.resolve_boolean_details(flag_key="Key", default_value=True) + with pytest.raises(FlagNotFoundError): + await provider.resolve_integer_details_async(flag_key="Key", default_value=1) # Then -def test_calls_context_evaluator_if_present(): +@pytest.mark.asyncio +async def test_calls_context_evaluator_if_present(): # Given def context_evaluator(flag: InMemoryFlag, evaluation_context: dict): return FlagResolutionDetails( @@ -44,57 +48,81 @@ def context_evaluator(flag: InMemoryFlag, evaluation_context: dict): } ) # When - flag = provider.resolve_boolean_details(flag_key="Key", default_value=False) + flag_sync = provider.resolve_boolean_details(flag_key="Key", default_value=False) + flag_async = await provider.resolve_boolean_details_async( + flag_key="Key", default_value=False + ) # Then - assert flag is not None - assert flag.value is False - assert isinstance(flag.value, bool) - assert flag.reason == Reason.TARGETING_MATCH + assert flag_sync == flag_async + for flag in [flag_sync, flag_async]: + assert flag is not None + assert flag.value is False + assert isinstance(flag.value, bool) + assert flag.reason == Reason.TARGETING_MATCH -def test_should_resolve_boolean_flag_from_in_memory(): +@pytest.mark.asyncio +async def test_should_resolve_boolean_flag_from_in_memory(): # Given provider = InMemoryProvider( {"Key": InMemoryFlag("true", {"true": True, "false": False})} ) # When - flag = provider.resolve_boolean_details(flag_key="Key", default_value=False) + flag_sync = provider.resolve_boolean_details(flag_key="Key", default_value=False) + flag_async = await provider.resolve_boolean_details_async( + flag_key="Key", default_value=False + ) # Then - assert flag is not None - assert flag.value is True - assert isinstance(flag.value, bool) - assert flag.variant == "true" + assert flag_sync == flag_async + for flag in [flag_sync, flag_async]: + assert flag is not None + assert flag.value is True + assert isinstance(flag.value, bool) + assert flag.variant == "true" -def test_should_resolve_integer_flag_from_in_memory(): +@pytest.mark.asyncio +async def test_should_resolve_integer_flag_from_in_memory(): # Given provider = InMemoryProvider( {"Key": InMemoryFlag("hundred", {"zero": 0, "hundred": 100})} ) # When - flag = provider.resolve_integer_details(flag_key="Key", default_value=0) + flag_sync = provider.resolve_integer_details(flag_key="Key", default_value=0) + flag_async = await provider.resolve_integer_details_async( + flag_key="Key", default_value=0 + ) # Then - assert flag is not None - assert flag.value == 100 - assert isinstance(flag.value, Number) - assert flag.variant == "hundred" + assert flag_sync == flag_async + for flag in [flag_sync, flag_async]: + assert flag is not None + assert flag.value == 100 + assert isinstance(flag.value, Number) + assert flag.variant == "hundred" -def test_should_resolve_float_flag_from_in_memory(): +@pytest.mark.asyncio +async def test_should_resolve_float_flag_from_in_memory(): # Given provider = InMemoryProvider( {"Key": InMemoryFlag("ten", {"zero": 0.0, "ten": 10.23})} ) # When - flag = provider.resolve_float_details(flag_key="Key", default_value=0.0) + flag_sync = provider.resolve_float_details(flag_key="Key", default_value=0.0) + flag_async = await provider.resolve_float_details_async( + flag_key="Key", default_value=0.0 + ) # Then - assert flag is not None - assert flag.value == 10.23 - assert isinstance(flag.value, Number) - assert flag.variant == "ten" + assert flag_sync == flag_async + for flag in [flag_sync, flag_async]: + assert flag is not None + assert flag.value == 10.23 + assert isinstance(flag.value, Number) + assert flag.variant == "ten" -def test_should_resolve_string_flag_from_in_memory(): +@pytest.mark.asyncio +async def test_should_resolve_string_flag_from_in_memory(): # Given provider = InMemoryProvider( { @@ -105,29 +133,41 @@ def test_should_resolve_string_flag_from_in_memory(): } ) # When - flag = provider.resolve_string_details(flag_key="Key", default_value="Default") + flag_sync = provider.resolve_string_details(flag_key="Key", default_value="Default") + flag_async = await provider.resolve_string_details_async( + flag_key="Key", default_value="Default" + ) # Then - assert flag is not None - assert flag.value == "String" - assert isinstance(flag.value, str) - assert flag.variant == "stringVariant" + assert flag_sync == flag_async + for flag in [flag_sync, flag_async]: + assert flag is not None + assert flag.value == "String" + assert isinstance(flag.value, str) + assert flag.variant == "stringVariant" -def test_should_resolve_list_flag_from_in_memory(): +@pytest.mark.asyncio +async def test_should_resolve_list_flag_from_in_memory(): # Given provider = InMemoryProvider( {"Key": InMemoryFlag("twoItems", {"empty": [], "twoItems": ["item1", "item2"]})} ) # When - flag = provider.resolve_object_details(flag_key="Key", default_value=[]) + flag_sync = provider.resolve_object_details(flag_key="Key", default_value=[]) + flag_async = await provider.resolve_object_details_async( + flag_key="Key", default_value=[] + ) # Then - assert flag is not None - assert flag.value == ["item1", "item2"] - assert isinstance(flag.value, list) - assert flag.variant == "twoItems" + assert flag_sync == flag_async + for flag in [flag_sync, flag_async]: + assert flag is not None + assert flag.value == ["item1", "item2"] + assert isinstance(flag.value, list) + assert flag.variant == "twoItems" -def test_should_resolve_object_flag_from_in_memory(): +@pytest.mark.asyncio +async def test_should_resolve_object_flag_from_in_memory(): # Given return_value = { "String": "string", @@ -138,9 +178,12 @@ def test_should_resolve_object_flag_from_in_memory(): {"Key": InMemoryFlag("obj", {"obj": return_value, "empty": {}})} ) # When - flag = provider.resolve_object_details(flag_key="Key", default_value={}) + flag_sync = provider.resolve_object_details(flag_key="Key", default_value={}) + flag_async = provider.resolve_object_details(flag_key="Key", default_value={}) # Then - assert flag is not None - assert flag.value == return_value - assert isinstance(flag.value, dict) - assert flag.variant == "obj" + assert flag_sync == flag_async + for flag in [flag_sync, flag_async]: + assert flag is not None + assert flag.value == return_value + assert isinstance(flag.value, dict) + assert flag.variant == "obj" diff --git a/tests/provider/test_provider_compatibility.py b/tests/provider/test_provider_compatibility.py new file mode 100644 index 00000000..aad87db4 --- /dev/null +++ b/tests/provider/test_provider_compatibility.py @@ -0,0 +1,197 @@ +from typing import Optional, Union + +import pytest + +from openfeature.api import get_client, set_provider +from openfeature.evaluation_context import EvaluationContext +from openfeature.flag_evaluation import FlagResolutionDetails +from openfeature.provider import AbstractProvider, Metadata + + +class SynchronousProvider(AbstractProvider): + def get_metadata(self): + return Metadata(name="SynchronousProvider") + + def get_provider_hooks(self): + return [] + + def resolve_boolean_details( + self, + flag_key: str, + default_value: bool, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: + return FlagResolutionDetails(value=True) + + def resolve_string_details( + self, + flag_key: str, + default_value: str, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[str]: + return FlagResolutionDetails(value="string") + + def resolve_integer_details( + self, + flag_key: str, + default_value: int, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[int]: + return FlagResolutionDetails(value=1) + + def resolve_float_details( + self, + flag_key: str, + default_value: float, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[float]: + return FlagResolutionDetails(value=10.0) + + def resolve_object_details( + self, + flag_key: str, + default_value: Union[dict, list], + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[Union[dict, list]]: + return FlagResolutionDetails(value={"key": "value"}) + + +@pytest.mark.parametrize( + "flag_type, default_value, get_method", + ( + (bool, True, "get_boolean_value_async"), + (str, "string", "get_string_value_async"), + (int, 1, "get_integer_value_async"), + (float, 10.0, "get_float_value_async"), + ( + dict, + {"key": "value"}, + "get_object_value_async", + ), + ), +) +@pytest.mark.asyncio +async def test_sync_provider_can_be_called_async(flag_type, default_value, get_method): + # Given + set_provider(SynchronousProvider(), "SynchronousProvider") + client = get_client("SynchronousProvider") + # When + async_callable = getattr(client, get_method) + flag = await async_callable(flag_key="Key", default_value=default_value) + # Then + assert flag is not None + assert flag == default_value + assert isinstance(flag, flag_type) + + +@pytest.mark.asyncio +async def test_sync_provider_can_be_extended_async(): + # Given + class ExtendedAsyncProvider(SynchronousProvider): + async def resolve_boolean_details_async( + self, + flag_key: str, + default_value: bool, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: + return FlagResolutionDetails(value=False) + + set_provider(ExtendedAsyncProvider(), "ExtendedAsyncProvider") + client = get_client("ExtendedAsyncProvider") + # When + flag = await client.get_boolean_value_async(flag_key="Key", default_value=True) + # Then + assert flag is not None + assert flag is False + + +# We're not allowing providers to only have async methods +def test_sync_methods_enforced_for_async_providers(): + # Given + class AsyncProvider(AbstractProvider): + def get_metadata(self): + return Metadata(name="AsyncProvider") + + async def resolve_boolean_details_async( + self, + flag_key: str, + default_value: bool, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: + return FlagResolutionDetails(value=True) + + # When + with pytest.raises(TypeError) as exception: + set_provider(AsyncProvider(), "AsyncProvider") + + # Then + # assert + exception_message = str(exception.value) + assert exception_message.startswith( + "Can't instantiate abstract class AsyncProvider" + ) + assert exception_message.__contains__("resolve_boolean_details") + + +@pytest.mark.asyncio +async def test_async_provider_not_implemented_exception_workaround(): + # Given + class SyncNotImplementedProvider(AbstractProvider): + def get_metadata(self): + return Metadata(name="AsyncProvider") + + async def resolve_boolean_details_async( + self, + flag_key: str, + default_value: bool, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: + return FlagResolutionDetails(value=True) + + def resolve_boolean_details( + self, + flag_key: str, + default_value: bool, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: + raise NotImplementedError("Use the async method") + + def resolve_string_details( + self, + flag_key: str, + default_value: str, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[str]: + raise NotImplementedError("Use the async method") + + def resolve_integer_details( + self, + flag_key: str, + default_value: int, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[int]: + raise NotImplementedError("Use the async method") + + def resolve_float_details( + self, + flag_key: str, + default_value: float, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[float]: + raise NotImplementedError("Use the async method") + + def resolve_object_details( + self, + flag_key: str, + default_value: Union[dict, list], + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[Union[dict, list]]: + raise NotImplementedError("Use the async method") + + # When + set_provider(SyncNotImplementedProvider(), "SyncNotImplementedProvider") + client = get_client("SyncNotImplementedProvider") + flag = await client.get_boolean_value_async(flag_key="Key", default_value=False) + # Then + assert flag is not None + assert flag is True diff --git a/tests/test_client.py b/tests/test_client.py index f6002c18..5d333993 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,3 +1,4 @@ +import asyncio import time import uuid from concurrent.futures import ThreadPoolExecutor @@ -7,7 +8,7 @@ from openfeature import api from openfeature.api import add_hooks, clear_hooks, get_client, set_provider -from openfeature.client import OpenFeatureClient +from openfeature.client import GeneralError, OpenFeatureClient, _typecheck_flag_value from openfeature.evaluation_context import EvaluationContext from openfeature.event import EventDetails, ProviderEvent, ProviderEventDetails from openfeature.exception import ErrorCode, OpenFeatureError @@ -23,9 +24,13 @@ "flag_type, default_value, get_method", ( (bool, True, "get_boolean_value"), + (bool, True, "get_boolean_value_async"), (str, "String", "get_string_value"), + (str, "String", "get_string_value_async"), (int, 100, "get_integer_value"), + (int, 100, "get_integer_value_async"), (float, 10.23, "get_float_value"), + (float, 10.23, "get_float_value_async"), ( dict, { @@ -35,21 +40,38 @@ }, "get_object_value", ), + ( + dict, + { + "String": "string", + "Number": 2, + "Boolean": True, + }, + "get_object_value_async", + ), ( list, ["string1", "string2"], "get_object_value", ), + ( + list, + ["string1", "string2"], + "get_object_value_async", + ), ), ) -def test_should_get_flag_value_based_on_method_type( +@pytest.mark.asyncio +async def test_should_get_flag_value_based_on_method_type( flag_type, default_value, get_method, no_op_provider_client ): # Given # When - flag = getattr(no_op_provider_client, get_method)( - flag_key="Key", default_value=default_value - ) + method = getattr(no_op_provider_client, get_method) + if asyncio.iscoroutinefunction(method): + flag = await method(flag_key="Key", default_value=default_value) + else: + flag = method(flag_key="Key", default_value=default_value) # Then assert flag is not None assert flag == default_value @@ -60,9 +82,13 @@ def test_should_get_flag_value_based_on_method_type( "flag_type, default_value, get_method", ( (bool, True, "get_boolean_details"), + (bool, True, "get_boolean_details_async"), (str, "String", "get_string_details"), + (str, "String", "get_string_details_async"), (int, 100, "get_integer_details"), + (int, 100, "get_integer_details_async"), (float, 10.23, "get_float_details"), + (float, 10.23, "get_float_details_async"), ( dict, { @@ -72,38 +98,62 @@ def test_should_get_flag_value_based_on_method_type( }, "get_object_details", ), + ( + dict, + { + "String": "string", + "Number": 2, + "Boolean": True, + }, + "get_object_details_async", + ), ( list, ["string1", "string2"], "get_object_details", ), + ( + list, + ["string1", "string2"], + "get_object_details_async", + ), ), ) -def test_should_get_flag_detail_based_on_method_type( +@pytest.mark.asyncio +async def test_should_get_flag_detail_based_on_method_type( flag_type, default_value, get_method, no_op_provider_client ): # Given # When - flag = getattr(no_op_provider_client, get_method)( - flag_key="Key", default_value=default_value - ) + method = getattr(no_op_provider_client, get_method) + if asyncio.iscoroutinefunction(method): + flag = await method(flag_key="Key", default_value=default_value) + else: + flag = method(flag_key="Key", default_value=default_value) # Then assert flag is not None assert flag.value == default_value assert isinstance(flag.value, flag_type) -def test_should_raise_exception_when_invalid_flag_type_provided(no_op_provider_client): +@pytest.mark.asyncio +async def test_should_raise_exception_when_invalid_flag_type_provided( + no_op_provider_client, +): # Given # When - flag = no_op_provider_client.evaluate_flag_details( + flag_sync = no_op_provider_client.evaluate_flag_details( + flag_type=None, flag_key="Key", default_value=True + ) + flag_async = await no_op_provider_client.evaluate_flag_details_async( flag_type=None, flag_key="Key", default_value=True ) # Then - assert flag.value - assert flag.error_message == "Unknown flag type" - assert flag.error_code == ErrorCode.GENERAL - assert flag.reason == Reason.ERROR + for flag in [flag_sync, flag_async]: + assert flag.value + assert flag.error_message == "Unknown flag type" + assert flag.error_code == ErrorCode.GENERAL + assert flag.reason == Reason.ERROR def test_should_pass_flag_metadata_from_resolution_to_evaluation_details(): @@ -202,7 +252,8 @@ def test_should_define_a_provider_status_accessor(no_op_provider_client): # Requirement 1.7.6 -def test_should_shortcircuit_if_provider_is_not_ready( +@pytest.mark.asyncio +async def test_should_shortcircuit_if_provider_is_not_ready( no_op_provider_client, monkeypatch ): # Given @@ -212,20 +263,27 @@ def test_should_shortcircuit_if_provider_is_not_ready( spy_hook = MagicMock(spec=Hook) no_op_provider_client.add_hooks([spy_hook]) # When - flag_details = no_op_provider_client.get_boolean_details( + flag_details_sync = no_op_provider_client.get_boolean_details( + flag_key="Key", default_value=True + ) + spy_hook.error.assert_called_once() + spy_hook.reset_mock() + flag_details_async = await no_op_provider_client.get_boolean_details_async( flag_key="Key", default_value=True ) # Then - assert flag_details is not None - assert flag_details.value - assert flag_details.reason == Reason.ERROR - assert flag_details.error_code == ErrorCode.PROVIDER_NOT_READY + for flag_details in [flag_details_sync, flag_details_async]: + assert flag_details is not None + assert flag_details.value + assert flag_details.reason == Reason.ERROR + assert flag_details.error_code == ErrorCode.PROVIDER_NOT_READY spy_hook.error.assert_called_once() spy_hook.finally_after.assert_called_once() # Requirement 1.7.7 -def test_should_shortcircuit_if_provider_is_in_irrecoverable_error_state( +@pytest.mark.asyncio +async def test_should_shortcircuit_if_provider_is_in_irrecoverable_error_state( no_op_provider_client, monkeypatch ): # Given @@ -235,42 +293,88 @@ def test_should_shortcircuit_if_provider_is_in_irrecoverable_error_state( spy_hook = MagicMock(spec=Hook) no_op_provider_client.add_hooks([spy_hook]) # When - flag_details = no_op_provider_client.get_boolean_details( + flag_details_sync = no_op_provider_client.get_boolean_details( + flag_key="Key", default_value=True + ) + spy_hook.error.assert_called_once() + spy_hook.reset_mock() + flag_details_async = await no_op_provider_client.get_boolean_details_async( flag_key="Key", default_value=True ) # Then - assert flag_details is not None - assert flag_details.value - assert flag_details.reason == Reason.ERROR - assert flag_details.error_code == ErrorCode.PROVIDER_FATAL + for flag_details in [flag_details_sync, flag_details_async]: + assert flag_details is not None + assert flag_details.value + assert flag_details.reason == Reason.ERROR + assert flag_details.error_code == ErrorCode.PROVIDER_FATAL spy_hook.error.assert_called_once() spy_hook.finally_after.assert_called_once() -def test_should_run_error_hooks_if_provider_returns_resolution_with_error_code(): +@pytest.mark.asyncio +async def test_should_run_error_hooks_if_provider_returns_resolution_with_error_code(): # Given spy_hook = MagicMock(spec=Hook) provider = MagicMock(spec=FeatureProvider) provider.get_provider_hooks.return_value = [] - provider.resolve_boolean_details.return_value = FlagResolutionDetails( + mock_resolution = FlagResolutionDetails( value=True, reason=Reason.ERROR, error_code=ErrorCode.PROVIDER_FATAL, error_message="This is an error message", ) + provider.resolve_boolean_details.return_value = mock_resolution + provider.resolve_boolean_details_async.return_value = mock_resolution set_provider(provider) client = get_client() client.add_hooks([spy_hook]) # When - flag_details = client.get_boolean_details(flag_key="Key", default_value=True) + flag_details_sync = client.get_boolean_details(flag_key="Key", default_value=True) + spy_hook.error.assert_called_once() + spy_hook.reset_mock() + flag_details_async = await client.get_boolean_details_async( + flag_key="Key", default_value=True + ) # Then - assert flag_details is not None - assert flag_details.value - assert flag_details.reason == Reason.ERROR - assert flag_details.error_code == ErrorCode.PROVIDER_FATAL + for flag_details in [flag_details_sync, flag_details_async]: + assert flag_details is not None + assert flag_details.value + assert flag_details.reason == Reason.ERROR + assert flag_details.error_code == ErrorCode.PROVIDER_FATAL spy_hook.error.assert_called_once() +@pytest.mark.asyncio +async def test_client_type_mismatch_exceptions(): + # Given + client = get_client() + # When + flag_details_sync = client.get_boolean_details( + flag_key="Key", default_value="type mismatch" + ) + flag_details_async = await client.get_boolean_details_async( + flag_key="Key", default_value="type mismatch" + ) + # Then + for flag_details in [flag_details_sync, flag_details_async]: + assert flag_details is not None + assert flag_details.value + assert flag_details.reason == Reason.ERROR + assert flag_details.error_code == ErrorCode.TYPE_MISMATCH + + +@pytest.mark.asyncio +async def test_client_general_exception(): + # Given + flag_value = "A" + flag_type = None + # When + with pytest.raises(GeneralError) as e: + flag_type = _typecheck_flag_value(flag_value, flag_type) + # Then + assert e.value.error_message == "Unknown flag type" + + def test_provider_events(): # Given provider = NoOpProvider()