diff --git a/open_feature/flag_evaluation/error_code.py b/open_feature/exception/error_code.py similarity index 70% rename from open_feature/flag_evaluation/error_code.py rename to open_feature/exception/error_code.py index 79506ffd..96c59546 100644 --- a/open_feature/flag_evaluation/error_code.py +++ b/open_feature/exception/error_code.py @@ -6,4 +6,6 @@ class ErrorCode(Enum): FLAG_NOT_FOUND = "FLAG_NOT_FOUND" PARSE_ERROR = "PARSE_ERROR" TYPE_MISMATCH = "TYPE_MISMATCH" + TARGETING_KEY_MISSING = "TARGETING_KEY_MISSING" + INVALID_CONTEXT = "INVALID_CONTEXT" GENERAL = "GENERAL" diff --git a/open_feature/exception/exceptions.py b/open_feature/exception/exceptions.py index b23eee03..4366db12 100644 --- a/open_feature/exception/exceptions.py +++ b/open_feature/exception/exceptions.py @@ -1,4 +1,6 @@ -from open_feature.flag_evaluation.error_code import ErrorCode +import typing + +from open_feature.exception.error_code import ErrorCode class OpenFeatureError(Exception): @@ -7,13 +9,14 @@ class OpenFeatureError(Exception): the more specific exceptions extending this one should be used. """ - def __init__(self, error_message: str = None, error_code: ErrorCode = None): + def __init__( + self, error_message: typing.Optional[str] = None, error_code: ErrorCode = None + ): """ Constructor for the generic OpenFeatureError. - @param error_message: a string message representing why the error has been - raised + @param error_message: an optional string message representing why the + error has been raised @param error_code: the ErrorCode string enum value for the type of error - @return: the generic OpenFeatureError exception """ self.error_message = error_message self.error_code = error_code @@ -25,13 +28,12 @@ class FlagNotFoundError(OpenFeatureError): key provided by the user. """ - def __init__(self, error_message: str = None): + def __init__(self, error_message: typing.Optional[str] = None): """ Constructor for the FlagNotFoundError. The error code for this type of exception is ErrorCode.FLAG_NOT_FOUND. - @param error_message: a string message representing why the error has been - raised - @return: the generic FlagNotFoundError exception + @param error_message: an optional string message representing + why the error has been raised """ super().__init__(error_message, ErrorCode.FLAG_NOT_FOUND) @@ -42,13 +44,12 @@ class GeneralError(OpenFeatureError): feature python sdk. """ - def __init__(self, error_message: str = None): + def __init__(self, error_message: typing.Optional[str] = None): """ Constructor for the GeneralError. The error code for this type of exception is ErrorCode.GENERAL. - @param error_message: a string message representing why the error has been - raised - @return: the generic GeneralError exception + @param error_message: an optional string message representing why the error + has been raised """ super().__init__(error_message, ErrorCode.GENERAL) @@ -59,13 +60,12 @@ class ParseError(OpenFeatureError): be parsed into a FlagEvaluationDetails object. """ - def __init__(self, error_message: str = None): + def __init__(self, error_message: typing.Optional[str] = None): """ Constructor for the ParseError. The error code for this type of exception is ErrorCode.PARSE_ERROR. - @param error_message: a string message representing why the error has been - raised - @return: the generic ParseError exception + @param error_message: an optional string message representing why the + error has been raised """ super().__init__(error_message, ErrorCode.PARSE_ERROR) @@ -76,12 +76,11 @@ class TypeMismatchError(OpenFeatureError): not match the type requested by the user. """ - def __init__(self, error_message: str = None): + def __init__(self, error_message: typing.Optional[str] = None): """ Constructor for the TypeMismatchError. The error code for this type of exception is ErrorCode.TYPE_MISMATCH. - @param error_message: a string message representing why the error has been - raised - @return: the generic TypeMismatchError exception + @param error_message: an optional string message representing why the + error has been raised """ super().__init__(error_message, ErrorCode.TYPE_MISMATCH) diff --git a/open_feature/flag_evaluation/flag_evaluation_details.py b/open_feature/flag_evaluation/flag_evaluation_details.py index 0caf4c0c..63aa937f 100644 --- a/open_feature/flag_evaluation/flag_evaluation_details.py +++ b/open_feature/flag_evaluation/flag_evaluation_details.py @@ -1,7 +1,7 @@ import typing from dataclasses import dataclass -from open_feature.flag_evaluation.error_code import ErrorCode +from open_feature.exception.error_code import ErrorCode from open_feature.flag_evaluation.reason import Reason @@ -12,4 +12,4 @@ class FlagEvaluationDetails: variant: str = None reason: Reason = None error_code: ErrorCode = None - error_message: str = None + error_message: typing.Optional[str] = None 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..eec9604c 100644 --- a/open_feature/flag_evaluation/flag_type.py +++ b/open_feature/flag_evaluation/flag_type.py @@ -6,3 +6,5 @@ class FlagType(Enum): STRING = 2 NUMBER = 3 OBJECT = 4 + FLOAT = 5 + INTEGER = 6 diff --git a/open_feature/open_feature_client.py b/open_feature/open_feature_client.py index 8be455ef..69fef4b2 100644 --- a/open_feature/open_feature_client.py +++ b/open_feature/open_feature_client.py @@ -3,9 +3,14 @@ from numbers import Number from open_feature.evaluation_context.evaluation_context import EvaluationContext -from open_feature.exception.exceptions import GeneralError, OpenFeatureError -from open_feature.flag_evaluation.error_code import ErrorCode +from open_feature.exception.error_code import ErrorCode +from open_feature.exception.exceptions import ( + GeneralError, + OpenFeatureError, + TypeMismatchError, +) 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 +25,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__( @@ -129,6 +136,64 @@ def get_number_details( flag_evaluation_options, ) + def get_integer_value( + self, + flag_key: str, + default_value: int, + evaluation_context: EvaluationContext = None, + flag_evaluation_options: typing.Any = 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: int, + evaluation_context: EvaluationContext = None, + flag_evaluation_options: typing.Any = None, + ) -> FlagEvaluationDetails: + return self.evaluate_flag_details( + 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: typing.Any = None, + ) -> float: + return self.get_float_details( + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ).value + + def get_float_details( + self, + flag_key: str, + default_value: float, + evaluation_context: EvaluationContext = None, + flag_evaluation_options: typing.Any = None, + ) -> FlagEvaluationDetails: + return self.evaluate_flag_details( + FlagType.FLOAT, + flag_key, + default_value, + evaluation_context, + flag_evaluation_options, + ) + def get_object_value( self, flag_key: str, @@ -165,7 +230,7 @@ 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. @@ -182,6 +247,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,18 +258,21 @@ def evaluate_flag_details( client_metadata=None, provider_metadata=None, ) - merged_hooks = self.hooks + 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 # 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, None ) invocation_context.merge(ctx2=evaluation_context) - - # merge of: API.context, client.context, invocation.context merged_context = ( api_evaluation_context().merge(self.context).merge(invocation_context) ) @@ -213,7 +284,7 @@ def evaluate_flag_details( merged_context, ) - after_hooks(type, hook_context, flag_evaluation, merged_hooks, None) + after_hooks(flag_type, hook_context, flag_evaluation, merged_hooks, None) return flag_evaluation @@ -272,6 +343,8 @@ def _create_provider_evaluation( get_details_callable = { FlagType.BOOLEAN: self.provider.get_boolean_details, FlagType.NUMBER: self.provider.get_number_details, + FlagType.INTEGER: self.provider.get_number_details, + FlagType.FLOAT: self.provider.get_number_details, FlagType.OBJECT: self.provider.get_object_details, FlagType.STRING: self.provider.get_string_details, }.get(flag_type) @@ -279,4 +352,21 @@ def _create_provider_evaluation( if not get_details_callable: raise GeneralError(error_message="Unknown flag type") - return get_details_callable(*args) + value = get_details_callable(*args) + + if flag_type in NUMERIC_TYPES: + value.value = self._convert_numeric_types(flag_type, value.value) + + return value + + @staticmethod + def _convert_numeric_types(flag_type: FlagType, current_value: Number): + converter = { + FlagType.FLOAT: float, + FlagType.INTEGER: int, + }.get(flag_type) + + try: + return converter(current_value) + except ValueError: + raise TypeMismatchError() diff --git a/open_feature/provider/no_op_provider.py b/open_feature/provider/no_op_provider.py index 8e80786a..8dd210d5 100644 --- a/open_feature/provider/no_op_provider.py +++ b/open_feature/provider/no_op_provider.py @@ -1,8 +1,10 @@ +import typing from numbers import Number 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,6 +16,9 @@ class NoOpProvider(AbstractProvider): def get_metadata(self) -> Metadata: return NoOpMetadata() + def get_provider_hooks(self) -> typing.List[Hook]: + return [] + def get_boolean_details( self, flag_key: str, diff --git a/open_feature/provider/provider.py b/open_feature/provider/provider.py index db99373d..957beb14 100644 --- a/open_feature/provider/provider.py +++ b/open_feature/provider/provider.py @@ -1,7 +1,9 @@ +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 @@ -10,6 +12,10 @@ class AbstractProvider: def get_metadata(self) -> Metadata: pass + @abstractmethod + def get_provider_hooks(self) -> typing.List[Hook]: + return [] + @abstractmethod def get_boolean_details( self, diff --git a/tests/test_open_feature_api.py b/tests/test_open_feature_api.py index c3bee46d..ae687146 100644 --- a/tests/test_open_feature_api.py +++ b/tests/test_open_feature_api.py @@ -1,7 +1,7 @@ import pytest +from open_feature.exception.error_code import ErrorCode from open_feature.exception.exceptions import GeneralError -from open_feature.flag_evaluation.error_code import ErrorCode from open_feature.open_feature_api import get_client, get_provider, set_provider from open_feature.provider.no_op_provider import NoOpProvider diff --git a/tests/test_open_feature_client.py b/tests/test_open_feature_client.py index 9e7fb5ee..52fc04eb 100644 --- a/tests/test_open_feature_client.py +++ b/tests/test_open_feature_client.py @@ -3,8 +3,8 @@ import pytest +from open_feature.exception.error_code import ErrorCode from open_feature.exception.exceptions import OpenFeatureError -from open_feature.flag_evaluation.error_code import ErrorCode from open_feature.flag_evaluation.reason import Reason from open_feature.hooks.hook import Hook @@ -15,6 +15,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, { @@ -46,6 +48,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, { diff --git a/tests/test_open_feature_evaluation_context.py b/tests/test_open_feature_evaluation_context.py index fc353d96..41539a60 100644 --- a/tests/test_open_feature_evaluation_context.py +++ b/tests/test_open_feature_evaluation_context.py @@ -1,8 +1,8 @@ import pytest from open_feature.evaluation_context.evaluation_context import EvaluationContext +from open_feature.exception.error_code import ErrorCode from open_feature.exception.exceptions import GeneralError -from open_feature.flag_evaluation.error_code import ErrorCode from open_feature.open_feature_evaluation_context import ( api_evaluation_context, set_api_evaluation_context,