Skip to content
Closed
8 changes: 7 additions & 1 deletion openfeature/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import typing

from openfeature import _event_support
from openfeature.client import OpenFeatureClient
from openfeature.client import AsyncOpenFeatureClient, OpenFeatureClient
from openfeature.evaluation_context import EvaluationContext
from openfeature.event import (
EventHandler,
Expand Down Expand Up @@ -49,6 +49,12 @@ def get_client(
return OpenFeatureClient(domain=domain, version=version)


def get_async_client(
domain: typing.Optional[str] = None, version: typing.Optional[str] = None
) -> AsyncOpenFeatureClient:
return AsyncOpenFeatureClient(domain=domain, version=version)


def set_provider(
provider: FeatureProvider, domain: typing.Optional[str] = None
) -> None:
Expand Down
351 changes: 351 additions & 0 deletions openfeature/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
__all__ = [
"ClientMetadata",
"OpenFeatureClient",
"AsyncOpenFeatureClient",
]

logger = logging.getLogger("openfeature")
Expand Down Expand Up @@ -464,3 +465,353 @@
raise GeneralError(error_message="Unknown flag type")
if not isinstance(value, _type):
raise TypeMismatchError(f"Expected type {_type} but got {type(value)}")


class AsyncOpenFeatureClient(OpenFeatureClient):
async def get_boolean_value(
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(
flag_key,
default_value,
evaluation_context,
flag_evaluation_options,
)
return details.value

async def get_boolean_details(
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(
FlagType.BOOLEAN,
flag_key,
default_value,
evaluation_context,
flag_evaluation_options,
)

async def get_string_value(
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(
flag_key,
default_value,
evaluation_context,
flag_evaluation_options,
)
return details.value

async def get_string_details(
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(
FlagType.STRING,
flag_key,
default_value,
evaluation_context,
flag_evaluation_options,
)

async def get_integer_value(
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(
flag_key,
default_value,
evaluation_context,
flag_evaluation_options,
)
return details.value

async def get_integer_details(
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(
FlagType.INTEGER,
flag_key,
default_value,
evaluation_context,
flag_evaluation_options,
)

async def get_float_value(
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(
flag_key,
default_value,
evaluation_context,
flag_evaluation_options,
)
return details.value

async def get_float_details(
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(
FlagType.FLOAT,
flag_key,
default_value,
evaluation_context,
flag_evaluation_options,
)

async def get_object_value(
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(
flag_key,
default_value,
evaluation_context,
flag_evaluation_options,
)
return details.value

async def get_object_details(
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(
FlagType.OBJECT,
flag_key,
default_value,
evaluation_context,
flag_evaluation_options,
)

async def evaluate_flag_details( # noqa: PLR0915
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there a lot of duplication of complex spec-compliance logic between the async and sync versions of evaluation_flag_details (~140 lines of code, with really the only difference being a single await when calling self._create_provider_evaluation).

Is there any way to share the implementations between the two?

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)

async def _create_provider_evaluation(
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,
FlagType.INTEGER: provider.resolve_integer_details,
FlagType.FLOAT: provider.resolve_float_details,
FlagType.OBJECT: provider.resolve_object_details,
FlagType.STRING: provider.resolve_string_details,
}

get_details_callable = get_details_callables.get(flag_type)
if not get_details_callable:
raise GeneralError(error_message="Unknown flag type")

Check warning on line 801 in openfeature/client.py

View check run for this annotation

Codecov / codecov/patch

openfeature/client.py#L801

Added line #L801 was not covered by tests

resolution = await get_details_callable(*args) # type: ignore[misc]
Copy link
Contributor

@ChihweiLHBird ChihweiLHBird Jan 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be helpful to check whether get_details_callable is awaitable here in order to make sync provider compatible with async clients?

It might look like this, but I didn't test it

Suggested change
resolution = await get_details_callable(*args) # type: ignore[misc]
resolution = get_details_callable(*args) # type: ignore[misc]
if isawaitable(resolution):
resolution = await resolution

Typing can be a headache here, though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually think this has to be done. If not I think we would get type errors right?

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,
)
Loading
Loading