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 @@
-
-
+
+
@@ -34,7 +34,7 @@
-
+
@@ -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()