Skip to content

Commit 9e9bb5c

Browse files
chrfwowgruebel
andauthored
feat: Add evaluation details to finally hook stage #403 (#423)
Signed-off-by: christian.lutnik <[email protected]> Co-authored-by: Anton Grübel <[email protected]>
1 parent 8f2caba commit 9e9bb5c

File tree

7 files changed

+101
-9
lines changed

7 files changed

+101
-9
lines changed

openfeature/client.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -366,13 +366,14 @@ def evaluate_flag_details( # noqa: PLR0915
366366
except OpenFeatureError as err:
367367
error_hooks(flag_type, hook_context, err, reversed_merged_hooks, hook_hints)
368368

369-
return FlagEvaluationDetails(
369+
flag_evaluation = FlagEvaluationDetails(
370370
flag_key=flag_key,
371371
value=default_value,
372372
reason=Reason.ERROR,
373373
error_code=err.error_code,
374374
error_message=err.error_message,
375375
)
376+
return flag_evaluation
376377
# Catch any type of exception here since the user can provide any exception
377378
# in the error hooks
378379
except Exception as err: # pragma: no cover
@@ -383,16 +384,23 @@ def evaluate_flag_details( # noqa: PLR0915
383384
error_hooks(flag_type, hook_context, err, reversed_merged_hooks, hook_hints)
384385

385386
error_message = getattr(err, "error_message", str(err))
386-
return FlagEvaluationDetails(
387+
flag_evaluation = FlagEvaluationDetails(
387388
flag_key=flag_key,
388389
value=default_value,
389390
reason=Reason.ERROR,
390391
error_code=ErrorCode.GENERAL,
391392
error_message=error_message,
392393
)
394+
return flag_evaluation
393395

394396
finally:
395-
after_all_hooks(flag_type, hook_context, reversed_merged_hooks, hook_hints)
397+
after_all_hooks(
398+
flag_type,
399+
hook_context,
400+
flag_evaluation,
401+
reversed_merged_hooks,
402+
hook_hints,
403+
)
396404

397405
def _create_provider_evaluation(
398406
self,

openfeature/hook/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,12 @@ def error(
109109
"""
110110
pass
111111

112-
def finally_after(self, hook_context: HookContext, hints: HookHints) -> None:
112+
def finally_after(
113+
self,
114+
hook_context: HookContext,
115+
details: FlagEvaluationDetails[typing.Any],
116+
hints: HookHints,
117+
) -> None:
113118
"""
114119
Run after flag evaluation, including any error processing.
115120
This will always run. Errors will be swallowed.

openfeature/hook/_hook_support.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@ def error_hooks(
2525
def after_all_hooks(
2626
flag_type: FlagType,
2727
hook_context: HookContext,
28+
details: FlagEvaluationDetails[typing.Any],
2829
hooks: typing.List[Hook],
2930
hints: typing.Optional[HookHints] = None,
3031
) -> None:
31-
kwargs = {"hook_context": hook_context, "hints": hints}
32+
kwargs = {"hook_context": hook_context, "details": details, "hints": hints}
3233
_execute_hooks(
3334
flag_type=flag_type, hooks=hooks, hook_method=HookType.FINALLY_AFTER, **kwargs
3435
)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ cov = [
4949
]
5050
e2e = [
5151
"git submodule add --force https://github.com/open-feature/spec.git spec",
52-
"cp -r spec/specification/assets/gherkin/evaluation.feature tests/features/",
52+
"cp spec/specification/assets/gherkin/* tests/features/",
5353
"behave tests/features/",
5454
"rm tests/features/*.feature",
5555
]
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from unittest.mock import MagicMock
2+
3+
from behave import then, when
4+
5+
from openfeature.exception import ErrorCode
6+
from openfeature.hook import Hook
7+
8+
9+
@when("a hook is added to the client")
10+
def step_impl_add_hook(context):
11+
hook = MagicMock(spec=Hook)
12+
hook.before = MagicMock()
13+
hook.after = MagicMock()
14+
hook.error = MagicMock()
15+
hook.finally_after = MagicMock()
16+
context.hook = hook
17+
context.client.add_hooks([hook])
18+
19+
20+
@then("error hooks should be called")
21+
def step_impl_call_error(context):
22+
assert context.hook.before.called
23+
assert context.hook.error.called
24+
assert context.hook.finally_after.called
25+
26+
27+
@then("non-error hooks should be called")
28+
def step_impl_call_non_error(context):
29+
assert context.hook.before.called
30+
assert context.hook.after.called
31+
assert context.hook.finally_after.called
32+
33+
34+
def get_hook_from_name(context, hook_name):
35+
if hook_name.lower() == "before":
36+
return context.hook.before
37+
elif hook_name.lower() == "after":
38+
return context.hook.after
39+
elif hook_name.lower() == "error":
40+
return context.hook.error
41+
elif hook_name.lower() == "finally":
42+
return context.hook.finally_after
43+
else:
44+
raise ValueError(str(hook_name) + " is not a valid hook name")
45+
46+
47+
def convert_value_from_flag_type(value, flag_type):
48+
if value == "None":
49+
return None
50+
if flag_type.lower() == "boolean":
51+
return bool(value)
52+
elif flag_type.lower() == "integer":
53+
return int(value)
54+
elif flag_type.lower() == "float":
55+
return float(value)
56+
return value
57+
58+
59+
@then('"{hook_names}" hooks should have evaluation details')
60+
def step_impl_should_have_eval_details(context, hook_names):
61+
for hook_name in hook_names.split(", "):
62+
hook = get_hook_from_name(context, hook_name)
63+
for row in context.table:
64+
flag_type, key, value = row
65+
66+
value = convert_value_from_flag_type(value, flag_type)
67+
68+
actual = hook.call_args[1]["details"].__dict__[key]
69+
if isinstance(actual, ErrorCode):
70+
actual = str(actual)
71+
72+
assert actual == value

tests/features/steps/steps.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ def step_impl_provider(context):
4343
'"{default_value}"'
4444
)
4545
def step_impl_evaluated_with_details(context, flag_type, key, default_value):
46-
context.client = get_client()
46+
if context.client is None:
47+
context.client = get_client()
4748
if flag_type == "boolean":
4849
context.boolean_flag_details = context.client.get_boolean_details(
4950
key, default_value

tests/hook/test_hook_support.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,12 +137,17 @@ def test_after_hooks_run_after_method(mock_hook):
137137
def test_finally_after_hooks_run_finally_after_method(mock_hook):
138138
# Given
139139
hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, "")
140+
flag_evaluation_details = FlagEvaluationDetails(
141+
hook_context.flag_key, "val", "unknown"
142+
)
140143
hook_hints = MappingProxyType({})
141144
# When
142-
after_all_hooks(FlagType.BOOLEAN, hook_context, [mock_hook], hook_hints)
145+
after_all_hooks(
146+
FlagType.BOOLEAN, hook_context, flag_evaluation_details, [mock_hook], hook_hints
147+
)
143148
# Then
144149
mock_hook.supports_flag_value_type.assert_called_once()
145150
mock_hook.finally_after.assert_called_once()
146151
mock_hook.finally_after.assert_called_with(
147-
hook_context=hook_context, hints=hook_hints
152+
hook_context=hook_context, details=flag_evaluation_details, hints=hook_hints
148153
)

0 commit comments

Comments
 (0)