From 4d1b07878972f25a9619466462f3d6b12d8974df Mon Sep 17 00:00:00 2001 From: Federico Bond Date: Tue, 18 Jul 2023 00:59:21 -0300 Subject: [PATCH 1/3] feat: implement InMemoryProvider Signed-off-by: Federico Bond --- open_feature/provider/in_memory_provider.py | 122 ++++++++++++++++ tests/provider/test_in_memory_provider.py | 154 ++++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 open_feature/provider/in_memory_provider.py create mode 100644 tests/provider/test_in_memory_provider.py diff --git a/open_feature/provider/in_memory_provider.py b/open_feature/provider/in_memory_provider.py new file mode 100644 index 00000000..e60e3ad2 --- /dev/null +++ b/open_feature/provider/in_memory_provider.py @@ -0,0 +1,122 @@ +from dataclasses import dataclass +import typing + +from open_feature.evaluation_context.evaluation_context import EvaluationContext +from open_feature.exception.error_code import ErrorCode +from open_feature.flag_evaluation.reason import Reason +from open_feature.flag_evaluation.resolution_details import FlagResolutionDetails +from open_feature.hooks.hook import Hook +from open_feature.provider.metadata import Metadata +from open_feature.provider.provider import AbstractProvider + +PASSED_IN_DEFAULT = "Passed in default" + + +@dataclass +class InMemoryMetadata(Metadata): + name: str = "In-Memory Provider" + + +T = typing.TypeVar("T", covariant=True) + + +@dataclass +class InMemoryFlag(typing.Generic[T]): + flag_key: str + default_variant: str + variants: typing.Dict[str, T] + reason: typing.Optional[Reason] = Reason.STATIC + error_code: typing.Optional[ErrorCode] = None + error_message: typing.Optional[str] = None + context_evaluator: typing.Optional[ + typing.Callable[["InMemoryFlag", EvaluationContext], FlagResolutionDetails[T]] + ] = None + + def resolve( + self, evaluation_context: typing.Optional[EvaluationContext] + ) -> FlagResolutionDetails[T]: + if self.context_evaluator: + return self.context_evaluator( + self, evaluation_context or EvaluationContext() + ) + + return FlagResolutionDetails( + value=self.variants[self.default_variant], + reason=self.reason, + variant=self.default_variant, + error_code=self.error_code, + error_message=self.error_message, + ) + + +FlagStorage = typing.Dict[str, InMemoryFlag] + +V = typing.TypeVar("V") + + +class InMemoryProvider(AbstractProvider): + _flags: FlagStorage + + def __init__(self, flags: FlagStorage): + self._flags = flags + + def get_metadata(self) -> Metadata: + return InMemoryMetadata() + + def get_provider_hooks(self) -> typing.List[Hook]: + return [] + + def resolve_boolean_details( + self, + flag_key: str, + default_value: bool, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: + return self._resolve(flag_key, default_value, evaluation_context) + + def resolve_string_details( + self, + flag_key: str, + default_value: str, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[str]: + return self._resolve(flag_key, default_value, evaluation_context) + + def resolve_integer_details( + self, + flag_key: str, + default_value: int, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[int]: + return self._resolve(flag_key, default_value, evaluation_context) + + def resolve_float_details( + self, + flag_key: str, + default_value: float, + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[float]: + return self._resolve(flag_key, default_value, evaluation_context) + + def resolve_object_details( + self, + flag_key: str, + default_value: typing.Union[dict, list], + evaluation_context: typing.Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[typing.Union[dict, list]]: + return self._resolve(flag_key, default_value, evaluation_context) + + def _resolve( + self, + flag_key: str, + default_value: V, + evaluation_context: typing.Optional[EvaluationContext], + ) -> FlagResolutionDetails[V]: + flag = self._flags.get(flag_key) + if flag is None: + return FlagResolutionDetails( + value=default_value, + reason=Reason.DEFAULT, + variant=PASSED_IN_DEFAULT, + ) + return flag.resolve(evaluation_context) diff --git a/tests/provider/test_in_memory_provider.py b/tests/provider/test_in_memory_provider.py new file mode 100644 index 00000000..e2c51ef1 --- /dev/null +++ b/tests/provider/test_in_memory_provider.py @@ -0,0 +1,154 @@ +from numbers import Number + +from open_feature.flag_evaluation.reason import Reason +from open_feature.flag_evaluation.resolution_details import FlagResolutionDetails +from open_feature.provider.in_memory_provider import InMemoryProvider, InMemoryFlag + + +def test_should_return_in_memory_provider_metadata(): + # Given + provider = InMemoryProvider({}) + # When + metadata = provider.get_metadata() + # Then + assert metadata is not None + assert metadata.name == "In-Memory Provider" + + +def test_should_handle_unknown_flags_correctly(): + # Given + provider = InMemoryProvider({}) + # When + flag = provider.resolve_boolean_details(flag_key="Key", default_value=True) + # Then + assert flag is not None + assert flag.value is True + assert isinstance(flag.value, bool) + assert flag.reason == Reason.DEFAULT + assert flag.variant == "Passed in default" + + +def test_calls_context_evaluator_if_present(): + # Given + def context_evaluator(flag: InMemoryFlag, evaluation_context: dict): + return FlagResolutionDetails( + value=False, + reason=Reason.TARGETING_MATCH, + ) + + provider = InMemoryProvider( + { + "Key": InMemoryFlag( + "Key", + "true", + {"true": True, "false": False}, + context_evaluator=context_evaluator, + ) + } + ) + # When + flag = provider.resolve_boolean_details(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 + + +def test_should_resolve_boolean_flag_from_in_memory(): + # Given + provider = InMemoryProvider( + {"Key": InMemoryFlag("Key", "true", {"true": True, "false": False})} + ) + # When + flag = provider.resolve_boolean_details(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" + + +def test_should_resolve_integer_flag_from_in_memory(): + # Given + provider = InMemoryProvider( + {"Key": InMemoryFlag("Key", "hundred", {"zero": 0, "hundred": 100})} + ) + # When + flag = provider.resolve_integer_details(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" + + +def test_should_resolve_float_flag_from_in_memory(): + # Given + provider = InMemoryProvider( + {"Key": InMemoryFlag("Key", "ten", {"zero": 0.0, "ten": 10.23})} + ) + # When + flag = provider.resolve_float_details(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" + + +def test_should_resolve_string_flag_from_in_memory(): + # Given + provider = InMemoryProvider( + { + "Key": InMemoryFlag( + "Key", + "stringVariant", + {"defaultVariant": "Default", "stringVariant": "String"}, + ) + } + ) + # When + flag = provider.resolve_string_details(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" + + +def test_should_resolve_list_flag_from_in_memory(): + # Given + provider = InMemoryProvider( + { + "Key": InMemoryFlag( + "Key", "twoItems", {"empty": [], "twoItems": ["item1", "item2"]} + ) + } + ) + # When + flag = provider.resolve_object_details(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" + + +def test_should_resolve_object_flag_from_in_memory(): + # Given + return_value = { + "String": "string", + "Number": 2, + "Boolean": True, + } + provider = InMemoryProvider( + {"Key": InMemoryFlag("Key", "obj", {"obj": return_value, "empty": {}})} + ) + # When + flag = 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" From 77f43f92e252cf1c3bb6a3e9235a0e612c9f5ffc Mon Sep 17 00:00:00 2001 From: Federico Bond Date: Tue, 18 Jul 2023 15:14:14 -0300 Subject: [PATCH 2/3] fix: address review comments Signed-off-by: Federico Bond --- open_feature/provider/in_memory_provider.py | 5 +++-- tests/provider/test_in_memory_provider.py | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/open_feature/provider/in_memory_provider.py b/open_feature/provider/in_memory_provider.py index e60e3ad2..0b71749f 100644 --- a/open_feature/provider/in_memory_provider.py +++ b/open_feature/provider/in_memory_provider.py @@ -116,7 +116,8 @@ def _resolve( if flag is None: return FlagResolutionDetails( value=default_value, - reason=Reason.DEFAULT, - variant=PASSED_IN_DEFAULT, + reason=Reason.ERROR, + error_code=ErrorCode.FLAG_NOT_FOUND, + error_message=f"Flag '{flag_key}' not found", ) return flag.resolve(evaluation_context) diff --git a/tests/provider/test_in_memory_provider.py b/tests/provider/test_in_memory_provider.py index e2c51ef1..40969115 100644 --- a/tests/provider/test_in_memory_provider.py +++ b/tests/provider/test_in_memory_provider.py @@ -1,5 +1,6 @@ from numbers import Number +from open_feature.exception.error_code import ErrorCode from open_feature.flag_evaluation.reason import Reason from open_feature.flag_evaluation.resolution_details import FlagResolutionDetails from open_feature.provider.in_memory_provider import InMemoryProvider, InMemoryFlag @@ -24,8 +25,9 @@ def test_should_handle_unknown_flags_correctly(): assert flag is not None assert flag.value is True assert isinstance(flag.value, bool) - assert flag.reason == Reason.DEFAULT - assert flag.variant == "Passed in default" + assert flag.reason == Reason.ERROR + assert flag.error_code == ErrorCode.FLAG_NOT_FOUND + assert flag.error_message == "Flag 'Key' not found" def test_calls_context_evaluator_if_present(): From 8288e26001dc999f99cf6778ce37121195659031 Mon Sep 17 00:00:00 2001 From: Federico Bond Date: Tue, 18 Jul 2023 22:56:30 -0300 Subject: [PATCH 3/3] feat: clone InMemoryProvider flag storage to prevent mutations Signed-off-by: Federico Bond --- open_feature/provider/in_memory_provider.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/open_feature/provider/in_memory_provider.py b/open_feature/provider/in_memory_provider.py index 0b71749f..0a375282 100644 --- a/open_feature/provider/in_memory_provider.py +++ b/open_feature/provider/in_memory_provider.py @@ -20,7 +20,7 @@ class InMemoryMetadata(Metadata): T = typing.TypeVar("T", covariant=True) -@dataclass +@dataclass(frozen=True) class InMemoryFlag(typing.Generic[T]): flag_key: str default_variant: str @@ -58,7 +58,7 @@ class InMemoryProvider(AbstractProvider): _flags: FlagStorage def __init__(self, flags: FlagStorage): - self._flags = flags + self._flags = flags.copy() def get_metadata(self) -> Metadata: return InMemoryMetadata()