diff --git a/openfeature/client.py b/openfeature/client.py index 9e4518ec..c61425ef 100644 --- a/openfeature/client.py +++ b/openfeature/client.py @@ -112,6 +112,20 @@ def get_boolean_value( evaluation_context, 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: + return await self.get_boolean_details_async( + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ).value def get_boolean_details( self, @@ -128,6 +142,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 +171,20 @@ 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: + return await self.get_string_details_async( + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ).value + def get_string_details( self, flag_key: str, @@ -156,6 +199,21 @@ def get_string_details( evaluation_context, 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, @@ -171,6 +229,20 @@ 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: + return await self.get_integer_details_async( + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ).value + def get_integer_details( self, flag_key: str, @@ -185,6 +257,21 @@ def get_integer_details( evaluation_context, 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, @@ -199,6 +286,20 @@ def get_float_value( evaluation_context, 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: + return await self.get_float_details_async( + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ).value def get_float_details( self, @@ -214,6 +315,21 @@ def get_float_details( evaluation_context, 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, @@ -228,6 +344,20 @@ def get_object_value( evaluation_context, 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]: + return await self.get_object_details_async( + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ).value def get_object_details( self, @@ -243,6 +373,21 @@ def get_object_details( evaluation_context, flag_evaluation_options, ) + + async def get_object_details_async( + self, + flag_key: str, + default_value: typing.Union[dict, list], + evaluation_context: typing.Optional[EvaluationContext] = None, + flag_evaluation_options: typing.Optional[FlagEvaluationOptions] = None, + ) -> FlagEvaluationDetails[typing.Union[dict, list]]: + return await self.evaluate_flag_details_async( + FlagType.OBJECT, + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ) def evaluate_flag_details( # noqa: PLR0915 self, @@ -391,6 +536,154 @@ def evaluate_flag_details( # noqa: PLR0915 finally: after_all_hooks(flag_type, hook_context, reversed_merged_hooks, hook_hints) + async def evaluate_flag_details_async( # noqa: PLR0915 + 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 + """ + + if evaluation_context is None: + evaluation_context = EvaluationContext() + + if flag_evaluation_options is None: + flag_evaluation_options = FlagEvaluationOptions() + + provider = self.provider # call this once to maintain a consistent reference + evaluation_hooks = flag_evaluation_options.hooks + hook_hints = flag_evaluation_options.hook_hints + + hook_context = HookContext( + flag_key=flag_key, + flag_type=flag_type, + default_value=default_value, + evaluation_context=evaluation_context, + client_metadata=self.get_metadata(), + provider_metadata=provider.get_metadata(), + ) + # Hooks need to be handled in different orders at different stages + # in the flag evaluation + # before: API, Client, Invocation, Provider + merged_hooks = ( + api.get_hooks() + + self.hooks + + evaluation_hooks + + provider.get_provider_hooks() + ) + # after, error, finally: Provider, Invocation, Client, API + reversed_merged_hooks = merged_hooks[:] + reversed_merged_hooks.reverse() + + status = self.get_provider_status() + if status == ProviderStatus.NOT_READY: + error_hooks( + flag_type, + hook_context, + ProviderNotReadyError(), + reversed_merged_hooks, + hook_hints, + ) + return FlagEvaluationDetails( + flag_key=flag_key, + value=default_value, + reason=Reason.ERROR, + error_code=ErrorCode.PROVIDER_NOT_READY, + ) + if status == ProviderStatus.FATAL: + error_hooks( + flag_type, + hook_context, + ProviderFatalError(), + reversed_merged_hooks, + hook_hints, + ) + return FlagEvaluationDetails( + flag_key=flag_key, + value=default_value, + reason=Reason.ERROR, + error_code=ErrorCode.PROVIDER_FATAL, + ) + + try: + # 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 + ) + invocation_context = invocation_context.merge(ctx2=evaluation_context) + + # Requirement 3.2.2 merge: API.context->client.context->invocation.context + merged_context = ( + api.get_evaluation_context() + .merge(self.context) + .merge(invocation_context) + ) + + flag_evaluation = await self._create_provider_evaluation( + provider, + flag_type, + flag_key, + default_value, + merged_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) + + return FlagEvaluationDetails( + flag_key=flag_key, + value=default_value, + reason=Reason.ERROR, + error_code=err.error_code, + error_message=err.error_message, + ) + # 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)) + return FlagEvaluationDetails( + flag_key=flag_key, + value=default_value, + reason=Reason.ERROR, + error_code=ErrorCode.GENERAL, + error_message=error_message, + ) + + finally: + after_all_hooks(flag_type, hook_context, reversed_merged_hooks, hook_hints) + + def _create_provider_evaluation( self, provider: FeatureProvider, @@ -443,6 +736,60 @@ def _create_provider_evaluation( error_message=resolution.error_message, ) + 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]: + """ + Asynchronous encapsulated method to create a FlagEvaluationDetail from a specific provider. + + :param flag_type: the type of the flag being returned + :param 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 + :return: a FlagEvaluationDetails object with the fully evaluated flag from a + provider + """ + args = ( + flag_key, + default_value, + evaluation_context, + ) + + get_details_callables: typing.Mapping[FlagType, GetDetailCallable] = { + 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.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 add_handler(self, event: ProviderEvent, handler: EventHandler) -> None: _event_support.add_client_handler(self, event, handler) diff --git a/openfeature/provider/__init__.py b/openfeature/provider/__init__.py index 8927551e..903d445f 100644 --- a/openfeature/provider/__init__.py +++ b/openfeature/provider/__init__.py @@ -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( @@ -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]: + raise NotImplementedError(f"{self.__class__.__name__} does not support async operations") + @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]: + raise NotImplementedError(f"{self.__class__.__name__} does not support async operations") + @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]: + raise NotImplementedError(f"{self.__class__.__name__} does not support async operations") + @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]: + raise NotImplementedError(f"{self.__class__.__name__} does not support async operations") + @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]]: + raise NotImplementedError(f"{self.__class__.__name__} does not support async operations") + def emit_provider_ready(self, details: ProviderEventDetails) -> None: self.emit(ProviderEvent.PROVIDER_READY, details)