feat: spec-0.2.0 (#38)

* fix/unit-tests: Add float and int flag methods
Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

* fix/unit-tests: Add the ability for a provider to have hooks
Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

* fix/unit-tests: Flag evaluation options added for hook merging
Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

* fix/unit-tests: Move numeric type methods to a private method within the client
Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

* feature/spec-0.2.0: Add tests for new numeric methods
Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

* feature/spec-0.2.0: Overwrite init on FlagEvaluationOptions
Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

* feature/spec-0.2.0: Overwrite init on FlagEvaluationOptions
Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

* feature/spec-0.2.0: Remove init on FlagEvaluationOptions
Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

* feature/spec-0.2.0: Ensure before_hooks are evaluated in the opposite order
Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

* feature/spec-0.2.0: Remove number flag evaluation in favour of strongly typed counterparts
Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

* feature/spec-0.2.0: Check flag type after provider response
Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

* feature/spec-0.2.0: Remove unnecessary static method
Signed-off-by: Matthew Elwell <matthew.elwell@flagsmith.com>

* feature/spec-0.2.0: Update docstring parameter name
Signed-off-by: Matthew Elwell <matthew.elwell@flagsmith.com>

* feature/spec-0.2.0: Fix typing of int and float methods
Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

* feature/spec-0.2.0: Change provider methods to resolves rather than gets
Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>

Signed-off-by: Andrew Helsby <ajhelsby@hotmail.com>
Co-authored-by: Matthew Elwell <matthew.elwell@flagsmith.com>
This commit is contained in:
Andrew Helsby 2022-11-09 19:05:35 +04:00 committed by GitHub
parent 06d0494331
commit 311b8eef53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 175 additions and 59 deletions

View File

@ -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)

View File

@ -2,7 +2,8 @@ from enum import Enum
class FlagType(Enum):
BOOLEAN = 1
STRING = 2
NUMBER = 3
OBJECT = 4
BOOLEAN = bool
STRING = str
OBJECT = dict
FLOAT = float
INTEGER = int

View File

@ -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.open_feature_evaluation_context import api_evaluation_context
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 @@ class OpenFeatureClient:
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 @@ class OpenFeatureClient:
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 @@ class OpenFeatureClient:
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 @@ class OpenFeatureClient:
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 @@ class OpenFeatureClient:
flag_evaluation_options,
)
def get_number_value(
def get_integer_value(
self,
flag_key: str,
default_value: Number,
default_value: int,
evaluation_context: EvaluationContext = None,
flag_evaluation_options: typing.Any = None,
) -> Number:
return self.evaluate_flag_details(
FlagType.NUMBER,
flag_evaluation_options: FlagEvaluationOptions = None,
) -> int:
return self.get_integer_details(
flag_key,
default_value,
evaluation_context,
flag_evaluation_options,
).value
def get_number_details(
def get_integer_details(
self,
flag_key: str,
default_value: Number,
default_value: int,
evaluation_context: EvaluationContext = None,
flag_evaluation_options: typing.Any = None,
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_float_details(
self,
flag_key: str,
default_value: float,
evaluation_context: EvaluationContext = None,
flag_evaluation_options: FlagEvaluationOptions = None,
) -> FlagEvaluationDetails:
return self.evaluate_flag_details(
FlagType.FLOAT,
flag_key,
default_value,
evaluation_context,
@ -134,7 +168,7 @@ class OpenFeatureClient:
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 @@ class OpenFeatureClient:
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 @@ class OpenFeatureClient:
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 @@ class OpenFeatureClient:
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 @@ class OpenFeatureClient:
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 @@ class OpenFeatureClient:
)
invocation_context = 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 @@ class OpenFeatureClient:
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 @@ class OpenFeatureClient:
# 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 @@ class OpenFeatureClient:
)
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 @@ class OpenFeatureClient:
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

View File

@ -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 @@ class NoOpProvider(AbstractProvider):
variant=PASSED_IN_DEFAULT,
)
def get_string_details(
def resolve_string_details(
self,
flag_key: str,
default_value: str,
@ -40,10 +44,10 @@ class NoOpProvider(AbstractProvider):
variant=PASSED_IN_DEFAULT,
)
def get_number_details(
def resolve_integer_details(
self,
flag_key: str,
default_value: Number,
default_value: int,
evaluation_context: EvaluationContext = None,
):
return FlagEvaluationDetails(
@ -53,7 +57,20 @@ class NoOpProvider(AbstractProvider):
variant=PASSED_IN_DEFAULT,
)
def get_object_details(
def resolve_float_details(
self,
flag_key: str,
default_value: float,
evaluation_context: EvaluationContext = None,
):
return FlagEvaluationDetails(
flag_key=flag_key,
value=default_value,
reason=Reason.DEFAULT,
variant=PASSED_IN_DEFAULT,
)
def resolve_object_details(
self,
flag_key: str,
default_value: dict,

View File

@ -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 @@ class AbstractProvider:
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 @@ class AbstractProvider:
pass
@abstractmethod
def get_string_details(
def resolve_string_details(
self,
flag_key: str,
default_value: str,
@ -29,16 +34,25 @@ class AbstractProvider:
pass
@abstractmethod
def get_number_details(
def resolve_integer_details(
self,
flag_key: str,
default_value: Number,
default_value: int,
evaluation_context: EvaluationContext = EvaluationContext(),
):
pass
@abstractmethod
def get_object_details(
def resolve_float_details(
self,
flag_key: str,
default_value: float,
evaluation_context: EvaluationContext = EvaluationContext(),
):
pass
@abstractmethod
def resolve_object_details(
self,
flag_key: str,
default_value: dict,

View File

@ -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={})
```

View File

@ -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

View File

@ -1,4 +1,3 @@
from numbers import Number
from unittest.mock import MagicMock
import pytest
@ -14,7 +13,8 @@ from open_feature.hooks.hook import Hook
(
(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,
{