diff --git a/open_feature/flag_evaluation/flag_evaluation_options.py b/open_feature/flag_evaluation/flag_evaluation_options.py new file mode 100644 index 00000000..1a35542d --- /dev/null +++ b/open_feature/flag_evaluation/flag_evaluation_options.py @@ -0,0 +1,10 @@ +import typing +from dataclasses import dataclass, field + +from open_feature.hooks.hook import Hook + + +@dataclass +class FlagEvaluationOptions: + hooks: typing.List[Hook] = field(default_factory=list) + hook_hints: dict = field(default_factory=dict) diff --git a/open_feature/flag_evaluation/flag_type.py b/open_feature/flag_evaluation/flag_type.py index 1fd52d7c..1deada9a 100644 --- a/open_feature/flag_evaluation/flag_type.py +++ b/open_feature/flag_evaluation/flag_type.py @@ -2,7 +2,8 @@ class FlagType(Enum): - BOOLEAN = 1 - STRING = 2 - NUMBER = 3 - OBJECT = 4 + BOOLEAN = bool + STRING = str + OBJECT = dict + FLOAT = float + INTEGER = int diff --git a/open_feature/open_feature_client.py b/open_feature/open_feature_client.py index 8be455ef..bd8c54ce 100644 --- a/open_feature/open_feature_client.py +++ b/open_feature/open_feature_client.py @@ -1,11 +1,15 @@ import logging import typing -from numbers import Number from open_feature.evaluation_context.evaluation_context import EvaluationContext -from open_feature.exception.exceptions import GeneralError, OpenFeatureError +from open_feature.exception.exceptions import ( + GeneralError, + OpenFeatureError, + TypeMismatchError, +) from open_feature.flag_evaluation.error_code import ErrorCode from open_feature.flag_evaluation.flag_evaluation_details import FlagEvaluationDetails +from open_feature.flag_evaluation.flag_evaluation_options import FlagEvaluationOptions from open_feature.flag_evaluation.flag_type import FlagType from open_feature.flag_evaluation.reason import Reason from open_feature.hooks.hook import Hook @@ -20,6 +24,8 @@ from open_feature.provider.no_op_provider import NoOpProvider from open_feature.provider.provider import AbstractProvider +NUMERIC_TYPES = [FlagType.FLOAT, FlagType.INTEGER] + class OpenFeatureClient: def __init__( @@ -44,7 +50,7 @@ def get_boolean_value( flag_key: str, default_value: bool, evaluation_context: EvaluationContext = None, - flag_evaluation_options: typing.Any = None, + flag_evaluation_options: FlagEvaluationOptions = None, ) -> bool: return self.evaluate_flag_details( FlagType.BOOLEAN, @@ -59,7 +65,7 @@ def get_boolean_details( flag_key: str, default_value: bool, evaluation_context: EvaluationContext = None, - flag_evaluation_options: typing.Any = None, + flag_evaluation_options: FlagEvaluationOptions = None, ) -> FlagEvaluationDetails: return self.evaluate_flag_details( FlagType.BOOLEAN, @@ -74,7 +80,7 @@ def get_string_value( flag_key: str, default_value: str, evaluation_context: EvaluationContext = None, - flag_evaluation_options: typing.Any = None, + flag_evaluation_options: FlagEvaluationOptions = None, ) -> str: return self.evaluate_flag_details( FlagType.STRING, @@ -89,7 +95,7 @@ def get_string_details( flag_key: str, default_value: str, evaluation_context: EvaluationContext = None, - flag_evaluation_options: typing.Any = None, + flag_evaluation_options: FlagEvaluationOptions = None, ) -> FlagEvaluationDetails: return self.evaluate_flag_details( FlagType.STRING, @@ -99,30 +105,58 @@ def get_string_details( flag_evaluation_options, ) - def get_number_value( + def get_integer_value( + self, + flag_key: str, + default_value: int, + evaluation_context: EvaluationContext = None, + flag_evaluation_options: FlagEvaluationOptions = None, + ) -> int: + return self.get_integer_details( + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ).value + + def get_integer_details( self, flag_key: str, - default_value: Number, + default_value: int, evaluation_context: EvaluationContext = None, - flag_evaluation_options: typing.Any = None, - ) -> Number: + flag_evaluation_options: FlagEvaluationOptions = None, + ) -> FlagEvaluationDetails: return self.evaluate_flag_details( - FlagType.NUMBER, + FlagType.INTEGER, + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ) + + def get_float_value( + self, + flag_key: str, + default_value: float, + evaluation_context: EvaluationContext = None, + flag_evaluation_options: FlagEvaluationOptions = None, + ) -> float: + return self.get_float_details( flag_key, default_value, evaluation_context, flag_evaluation_options, ).value - def get_number_details( + def get_float_details( self, flag_key: str, - default_value: Number, + default_value: float, evaluation_context: EvaluationContext = None, - flag_evaluation_options: typing.Any = None, + flag_evaluation_options: FlagEvaluationOptions = None, ) -> FlagEvaluationDetails: return self.evaluate_flag_details( - FlagType.NUMBER, + FlagType.FLOAT, flag_key, default_value, evaluation_context, @@ -134,7 +168,7 @@ def get_object_value( flag_key: str, default_value: dict, evaluation_context: EvaluationContext = None, - flag_evaluation_options: typing.Any = None, + flag_evaluation_options: FlagEvaluationOptions = None, ) -> dict: return self.evaluate_flag_details( FlagType.OBJECT, @@ -149,7 +183,7 @@ def get_object_details( flag_key: str, default_value: dict, evaluation_context: EvaluationContext = None, - flag_evaluation_options: typing.Any = None, + flag_evaluation_options: FlagEvaluationOptions = None, ) -> FlagEvaluationDetails: return self.evaluate_flag_details( FlagType.OBJECT, @@ -165,13 +199,13 @@ def evaluate_flag_details( flag_key: str, default_value: typing.Any, evaluation_context: EvaluationContext = None, - flag_evaluation_options: typing.Any = None, + flag_evaluation_options: FlagEvaluationOptions = None, ) -> FlagEvaluationDetails: """ Evaluate the flag requested by the user from the clients provider. :param flag_type: the type of the flag being returned - :param key: the string key of the selected flag + :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 @@ -182,6 +216,9 @@ def evaluate_flag_details( if evaluation_context is None: evaluation_context = EvaluationContext() + if flag_evaluation_options is None: + flag_evaluation_options = FlagEvaluationOptions() + hook_context = HookContext( flag_key=flag_key, flag_type=flag_type, @@ -190,7 +227,22 @@ def evaluate_flag_details( client_metadata=None, provider_metadata=None, ) - merged_hooks = self.hooks + # Todo add api level hooks + # https://github.com/open-feature/spec/blob/main/specification/sections/04-hooks.md#requirement-442 + # Hooks need to be handled in different orders at different stages + # in the flag evaluation + # before: API, Client, Invocation, Provider + merged_hooks = ( + self.hooks + + flag_evaluation_options.hooks + + self.provider.get_provider_hooks() + ) + # after, error, finally: Provider, Invocation, Client, API + reversed_merged_hooks = ( + self.provider.get_provider_hooks() + + flag_evaluation_options.hooks + + self.hooks + ) try: # https://github.com/open-feature/spec/blob/main/specification/sections/03-evaluation-context.md @@ -201,7 +253,7 @@ def evaluate_flag_details( ) invocation_context.merge(ctx2=evaluation_context) - # merge of: API.context, client.context, invocation.context + # Requirement 3.2.2 merge: API.context->client.context->invocation.context merged_context = ( api_evaluation_context().merge(self.context).merge(invocation_context) ) @@ -213,12 +265,14 @@ def evaluate_flag_details( merged_context, ) - after_hooks(type, hook_context, flag_evaluation, merged_hooks, None) + after_hooks( + flag_type, hook_context, flag_evaluation, reversed_merged_hooks, None + ) return flag_evaluation except OpenFeatureError as e: - error_hooks(flag_type, hook_context, e, merged_hooks, None) + error_hooks(flag_type, hook_context, e, reversed_merged_hooks, None) return FlagEvaluationDetails( flag_key=flag_key, value=default_value, @@ -229,7 +283,7 @@ def evaluate_flag_details( # Catch any type of exception here since the user can provide any exception # in the error hooks except Exception as e: # noqa - error_hooks(flag_type, hook_context, e, merged_hooks, None) + error_hooks(flag_type, hook_context, e, reversed_merged_hooks, None) error_message = getattr(e, "error_message", str(e)) return FlagEvaluationDetails( flag_key=flag_key, @@ -240,7 +294,7 @@ def evaluate_flag_details( ) finally: - after_all_hooks(flag_type, hook_context, merged_hooks, None) + after_all_hooks(flag_type, hook_context, reversed_merged_hooks, None) def _create_provider_evaluation( self, @@ -270,13 +324,19 @@ def _create_provider_evaluation( self.provider = NoOpProvider() get_details_callable = { - FlagType.BOOLEAN: self.provider.get_boolean_details, - FlagType.NUMBER: self.provider.get_number_details, - FlagType.OBJECT: self.provider.get_object_details, - FlagType.STRING: self.provider.get_string_details, + FlagType.BOOLEAN: self.provider.resolve_boolean_details, + FlagType.INTEGER: self.provider.resolve_integer_details, + FlagType.FLOAT: self.provider.resolve_float_details, + FlagType.OBJECT: self.provider.resolve_object_details, + FlagType.STRING: self.provider.resolve_string_details, }.get(flag_type) if not get_details_callable: raise GeneralError(error_message="Unknown flag type") - return get_details_callable(*args) + value = get_details_callable(*args) + + if not isinstance(value.value, flag_type.value): + raise TypeMismatchError() + + return value diff --git a/open_feature/provider/no_op_provider.py b/open_feature/provider/no_op_provider.py index 8e80786a..f3bdda4a 100644 --- a/open_feature/provider/no_op_provider.py +++ b/open_feature/provider/no_op_provider.py @@ -1,8 +1,9 @@ -from numbers import Number +import typing from open_feature.evaluation_context.evaluation_context import EvaluationContext from open_feature.flag_evaluation.flag_evaluation_details import FlagEvaluationDetails from open_feature.flag_evaluation.reason import Reason +from open_feature.hooks.hook import Hook from open_feature.provider.metadata import Metadata from open_feature.provider.no_op_metadata import NoOpMetadata from open_feature.provider.provider import AbstractProvider @@ -14,7 +15,10 @@ class NoOpProvider(AbstractProvider): def get_metadata(self) -> Metadata: return NoOpMetadata() - def get_boolean_details( + def get_provider_hooks(self) -> typing.List[Hook]: + return [] + + def resolve_boolean_details( self, flag_key: str, default_value: bool, @@ -27,7 +31,7 @@ def get_boolean_details( variant=PASSED_IN_DEFAULT, ) - def get_string_details( + def resolve_string_details( self, flag_key: str, default_value: str, @@ -40,10 +44,23 @@ def get_string_details( variant=PASSED_IN_DEFAULT, ) - def get_number_details( + def resolve_integer_details( + self, + flag_key: str, + default_value: int, + evaluation_context: EvaluationContext = None, + ): + return FlagEvaluationDetails( + flag_key=flag_key, + value=default_value, + reason=Reason.DEFAULT, + variant=PASSED_IN_DEFAULT, + ) + + def resolve_float_details( self, flag_key: str, - default_value: Number, + default_value: float, evaluation_context: EvaluationContext = None, ): return FlagEvaluationDetails( @@ -53,7 +70,7 @@ def get_number_details( variant=PASSED_IN_DEFAULT, ) - def get_object_details( + def resolve_object_details( self, flag_key: str, default_value: dict, diff --git a/open_feature/provider/provider.py b/open_feature/provider/provider.py index db99373d..b755374f 100644 --- a/open_feature/provider/provider.py +++ b/open_feature/provider/provider.py @@ -1,7 +1,8 @@ +import typing from abc import abstractmethod -from numbers import Number from open_feature.evaluation_context.evaluation_context import EvaluationContext +from open_feature.hooks.hook import Hook from open_feature.provider.metadata import Metadata @@ -11,7 +12,11 @@ def get_metadata(self) -> Metadata: pass @abstractmethod - def get_boolean_details( + def get_provider_hooks(self) -> typing.List[Hook]: + return [] + + @abstractmethod + def resolve_boolean_details( self, flag_key: str, default_value: bool, @@ -20,7 +25,7 @@ def get_boolean_details( pass @abstractmethod - def get_string_details( + def resolve_string_details( self, flag_key: str, default_value: str, @@ -29,16 +34,25 @@ def get_string_details( pass @abstractmethod - def get_number_details( + def resolve_integer_details( + self, + flag_key: str, + default_value: int, + evaluation_context: EvaluationContext = EvaluationContext(), + ): + pass + + @abstractmethod + def resolve_float_details( self, flag_key: str, - default_value: Number, + default_value: float, evaluation_context: EvaluationContext = EvaluationContext(), ): pass @abstractmethod - def get_object_details( + def resolve_object_details( self, flag_key: str, default_value: dict, diff --git a/readme.md b/readme.md index 64405d0a..9486b40d 100644 --- a/readme.md +++ b/readme.md @@ -19,7 +19,8 @@ While Boolean provides the simplest introduction, we offer a variety of flag typ # Depending on the flag type, use one of the methods below flag_key = "PROVIDER_FLAG" boolean_result = open_feature_client.get_boolean_value(key=flag_key,default_value=False) -number_result = open_feature_client.get_number_value(key=flag_key,default_value=-1) +integer_result = open_feature_client.get_integer_value(key=flag_key,default_value=-1) +float_result = open_feature_client.get_float_value(key=flag_key,default_value=-1) string_result = open_feature_client.get_string_value(key=flag_key,default_value="") object_result = open_feature_client.get_object_value(key=flag_key,default_value={}) ``` diff --git a/tests/provider/test_no_op_provider.py b/tests/provider/test_no_op_provider.py index 1e1e4b96..26e9a9dc 100644 --- a/tests/provider/test_no_op_provider.py +++ b/tests/provider/test_no_op_provider.py @@ -13,37 +13,47 @@ def test_should_return_no_op_provider_metadata(): assert metadata.is_default_provider -def test_should_get_boolean_flag_from_no_op(): +def test_should_resolve_boolean_flag_from_no_op(): # Given # When - flag = NoOpProvider().get_boolean_details(flag_key="Key", default_value=True) + flag = NoOpProvider().resolve_boolean_details(flag_key="Key", default_value=True) # Then assert flag is not None assert flag.value assert isinstance(flag.value, bool) -def test_should_get_number_flag_from_no_op(): +def test_should_resolve_integer_flag_from_no_op(): # Given # When - flag = NoOpProvider().get_number_details(flag_key="Key", default_value=100) + flag = NoOpProvider().resolve_integer_details(flag_key="Key", default_value=100) # Then assert flag is not None assert flag.value == 100 assert isinstance(flag.value, Number) -def test_should_get_string_flag_from_no_op(): +def test_should_resolve_float_flag_from_no_op(): # Given # When - flag = NoOpProvider().get_string_details(flag_key="Key", default_value="String") + flag = NoOpProvider().resolve_float_details(flag_key="Key", default_value=10.23) + # Then + assert flag is not None + assert flag.value == 10.23 + assert isinstance(flag.value, Number) + + +def test_should_resolve_string_flag_from_no_op(): + # Given + # When + flag = NoOpProvider().resolve_string_details(flag_key="Key", default_value="String") # Then assert flag is not None assert flag.value == "String" assert isinstance(flag.value, str) -def test_should_get_object_flag_from_no_op(): +def test_should_resolve_object_flag_from_no_op(): # Given return_value = { "String": "string", @@ -51,7 +61,9 @@ def test_should_get_object_flag_from_no_op(): "Boolean": True, } # When - flag = NoOpProvider().get_object_details(flag_key="Key", default_value=return_value) + flag = NoOpProvider().resolve_object_details( + flag_key="Key", default_value=return_value + ) # Then assert flag is not None assert flag.value == return_value diff --git a/tests/test_open_feature_client.py b/tests/test_open_feature_client.py index 9e7fb5ee..c5ff42f1 100644 --- a/tests/test_open_feature_client.py +++ b/tests/test_open_feature_client.py @@ -1,4 +1,3 @@ -from numbers import Number from unittest.mock import MagicMock import pytest @@ -14,7 +13,8 @@ ( (bool, True, "get_boolean_value"), (str, "String", "get_string_value"), - (Number, 100, "get_number_value"), + (int, 100, "get_integer_value"), + (float, 10.23, "get_float_value"), ( dict, { @@ -45,7 +45,8 @@ def test_should_get_flag_value_based_on_method_type( ( (bool, True, "get_boolean_details"), (str, "String", "get_string_details"), - (Number, 100, "get_number_details"), + (int, 100, "get_integer_details"), + (float, 10.23, "get_float_details"), ( dict, {