Skip to content

Commit 1e676c5

Browse files
committed
feat: added variable evaluation hooks
1 parent df7cf40 commit 1e676c5

File tree

6 files changed

+203
-2
lines changed

6 files changed

+203
-2
lines changed

devcycle_python_sdk/cloud_client.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
NotFoundError,
1010
CloudClientUnauthorizedError,
1111
)
12+
from devcycle_python_sdk.managers.eval_hooks_manager import EvalHooksManager, BeforeHookError, AfterHookError
13+
from devcycle_python_sdk.models.eval_hook import EvalHook
14+
from devcycle_python_sdk.models.eval_hook_context import HookContext
1215
from devcycle_python_sdk.models.user import DevCycleUser
1316
from devcycle_python_sdk.models.event import DevCycleEvent
1417
from devcycle_python_sdk.models.variable import Variable
@@ -45,6 +48,7 @@ def __init__(self, sdk_key: str, options: DevCycleCloudOptions):
4548
self.sdk_type = "server"
4649
self.bucketing_api = BucketingAPIClient(sdk_key, self.options)
4750
self._openfeature_provider = DevCycleProvider(self)
51+
self.eval_hooks_manager = EvalHooksManager(None if options is None else options.eval_hooks)
4852

4953
def get_sdk_platform(self) -> str:
5054
return "Cloud"
@@ -87,8 +91,22 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable
8791
if default_value is None:
8892
raise ValueError("Missing parameter: defaultValue")
8993

94+
context = HookContext(key, user, default_value)
95+
variable = Variable.create_default_variable(
96+
key=key, default_value=default_value
97+
)
98+
9099
try:
91-
variable = self.bucketing_api.variable(key, user)
100+
before_hook_error = None
101+
try:
102+
context = self.eval_hooks_manager.run_before(context)
103+
except BeforeHookError as e:
104+
before_hook_error = e
105+
variable = self.bucketing_api.variable(key, context.user)
106+
if before_hook_error is None:
107+
self.eval_hooks_manager.run_after(context, variable)
108+
else :
109+
raise before_hook_error
92110
except CloudClientUnauthorizedError as e:
93111
logger.warning("DevCycle: SDK key is invalid, unable to make cloud request")
94112
raise e
@@ -97,11 +115,17 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable
97115
return Variable.create_default_variable(
98116
key=key, default_value=default_value
99117
)
118+
except BeforeHookError as e:
119+
self.eval_hooks_manager.run_error(context, e)
120+
except AfterHookError as e:
121+
self.eval_hooks_manager.run_error(context, e)
100122
except Exception as e:
101123
logger.error(f"DevCycle: Error evaluating variable: {e}")
102124
return Variable.create_default_variable(
103125
key=key, default_value=default_value
104126
)
127+
finally:
128+
self.eval_hooks_manager.run_finally(context, variable)
105129

106130
variable.defaultValue = default_value
107131

@@ -189,6 +213,12 @@ def close(self) -> None:
189213
# Cloud client doesn't need to release any resources
190214
logger.debug("DevCycle: Cloud client closed")
191215

216+
def add_hook(self, hook: EvalHook) -> None:
217+
self.eval_hooks_manager.add_hook(hook)
218+
219+
def clear_hooks(self) -> None:
220+
self.eval_hooks_manager.clear_hooks()
221+
192222

193223
def _validate_sdk_key(sdk_key: str) -> None:
194224
if sdk_key is None or len(sdk_key) == 0:
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from typing import List, Optional
2+
3+
from devcycle_python_sdk.models.eval_hook import EvalHook
4+
from devcycle_python_sdk.models.eval_hook_context import HookContext
5+
from devcycle_python_sdk.models.variable import Variable
6+
from devcycle_python_sdk.options import logger
7+
8+
9+
class BeforeHookError(Exception):
10+
"""Exception raised when a before hook fails"""
11+
def __init__(self, message: str, original_error: Exception = None):
12+
self.message = message
13+
self.original_error = original_error
14+
super().__init__(self.message)
15+
16+
17+
class AfterHookError(Exception):
18+
"""Exception raised when an after hook fails"""
19+
def __init__(self, message: str, original_error: Exception = None):
20+
self.message = message
21+
self.original_error = original_error
22+
super().__init__(self.message)
23+
24+
25+
class EvalHooksManager:
26+
def __init__(self, hooks: Optional[List[EvalHook]] = None):
27+
self.hooks: List[EvalHook] = hooks if hooks is not None else []
28+
29+
def add_hook(self, hook: EvalHook) -> None:
30+
"""Add an evaluation hook to be executed"""
31+
self.hooks.append(hook)
32+
33+
def clear_hooks(self) -> None:
34+
"""Clear all evaluation hooks"""
35+
self.hooks = []
36+
37+
def run_before(self, context: HookContext) -> Optional[HookContext]:
38+
"""Run before hooks and return modified context if any"""
39+
modified_context = context
40+
for hook in self.hooks:
41+
if hook.before:
42+
try:
43+
result = hook.before(modified_context)
44+
if result:
45+
modified_context = result
46+
except Exception as e:
47+
raise BeforeHookError(f"Before hook failed: {e}", e)
48+
return modified_context
49+
50+
def run_after(self, context: HookContext, variable: Variable) -> None:
51+
"""Run after hooks with the evaluation result"""
52+
for hook in self.hooks:
53+
if hook.after:
54+
try:
55+
hook.after(context, variable)
56+
except Exception as e:
57+
raise AfterHookError(f"After hook failed: {e}", e)
58+
59+
def run_finally(self, context: HookContext, variable: Optional[Variable]) -> None:
60+
"""Run finally hooks after evaluation completes"""
61+
for hook in self.hooks:
62+
if hook.on_finally:
63+
try:
64+
hook.on_finally(context, variable)
65+
except Exception as e:
66+
logger.error(f"Error running finally hook: {e}")
67+
68+
def run_error(self, context: HookContext, error: Exception) -> None:
69+
"""Run error hooks when an error occurs"""
70+
for hook in self.hooks:
71+
if hook.error:
72+
try:
73+
hook.error(context, error)
74+
except Exception as e:
75+
logger.error(f"Error running error hook: {e}")
76+
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from typing import Callable, Optional
2+
3+
from devcycle_python_sdk.models.eval_hook_context import HookContext
4+
from devcycle_python_sdk.models.variable import Variable
5+
6+
class EvalHook:
7+
def __init__(
8+
self,
9+
before: Callable[[HookContext], Optional[HookContext]] = None,
10+
after: Callable[[HookContext, Variable], None] = None,
11+
on_finally: Callable[[HookContext, Optional[Variable]], None] = None,
12+
error: Callable[[HookContext, Exception], None] = None
13+
):
14+
self.before = before
15+
self.after = after
16+
self.on_finally = on_finally
17+
self.error = error
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from typing import Any
2+
3+
from devcycle_python_sdk.models.user import DevCycleUser
4+
5+
6+
class HookContext:
7+
def __init__(self, key: str, user: DevCycleUser, default_value: Any):
8+
self.key = key
9+
self.default_value = default_value
10+
self.user = user

devcycle_python_sdk/options.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import logging
2-
from typing import Callable, Optional, Dict, Any
2+
from typing import Callable, Optional, Dict, Any, List
3+
4+
from devcycle_python_sdk.models.eval_hook import EvalHook
35

46
logger = logging.getLogger(__name__)
57

@@ -16,12 +18,14 @@ def __init__(
1618
request_timeout: int = 5, # seconds
1719
request_retries: int = 5,
1820
retry_delay: int = 200, # milliseconds
21+
eval_hooks: Optional[List[EvalHook]] = None,
1922
):
2023
self.enable_edge_db = enable_edge_db
2124
self.bucketing_api_uri = bucketing_api_uri
2225
self.request_timeout = request_timeout
2326
self.request_retries = request_retries
2427
self.retry_delay = retry_delay
28+
self.eval_hooks = eval_hooks if eval_hooks is not None else []
2529

2630

2731
class DevCycleLocalOptions:
@@ -47,6 +51,7 @@ def __init__(
4751
disable_custom_event_logging: bool = False,
4852
enable_beta_realtime_updates: bool = False,
4953
disable_realtime_updates: bool = False,
54+
eval_hooks: Optional[List[EvalHook]] = None,
5055
):
5156
self.events_api_uri = events_api_uri
5257
self.config_cdn_uri = config_cdn_uri
@@ -69,6 +74,8 @@ def __init__(
6974
"DevCycle: `enable_beta_realtime_updates` is deprecated and will be removed in a future release.",
7075
)
7176

77+
self.eval_hooks = eval_hooks if eval_hooks is not None else []
78+
7279
if self.flush_event_queue_size >= self.max_event_queue_size:
7380
logger.warning(
7481
f"DevCycle: flush_event_queue_size: {self.flush_event_queue_size} must be smaller than max_event_queue_size: {self.max_event_queue_size}"

test/test_cloud_client.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from unittest.mock import patch
77

88
from devcycle_python_sdk import DevCycleCloudClient, DevCycleCloudOptions
9+
from devcycle_python_sdk.models.eval_hook import EvalHook
910
from devcycle_python_sdk.models.user import DevCycleUser
1011
from devcycle_python_sdk.models.variable import Variable, TypeEnum
1112
from devcycle_python_sdk.models.event import DevCycleEvent
@@ -27,6 +28,7 @@ def setUp(self) -> None:
2728
self.test_user_empty_id = DevCycleUser(user_id="")
2829

2930
def tearDown(self) -> None:
31+
self.test_client.clear_hooks()
3032
pass
3133

3234
def test_create_client_invalid_sdk_key(self):
@@ -281,6 +283,65 @@ def test_track_exceptions(self, mock_track_call):
281283
),
282284
)
283285

286+
@patch("devcycle_python_sdk.api.bucketing_client.BucketingAPIClient.variable")
287+
def test_hooks(self, mock_variable_call):
288+
mock_variable_call.return_value = Variable(
289+
_id="123", key="strKey", value=999, type=TypeEnum.NUMBER
290+
)
291+
# Test adding hooks
292+
hook_called = {"before": False, "after": False, "finally": False, "error": False}
293+
294+
def before_hook(context):
295+
hook_called["before"] = True
296+
return context
297+
def after_hook(context, variable):
298+
hook_called["after"] = True
299+
def finally_hook(context, variable):
300+
hook_called["finally"] = True
301+
def error_hook(context, error):
302+
hook_called["error"] = True
303+
304+
self.test_client.add_hook(EvalHook(before_hook, after_hook, finally_hook, error_hook))
305+
306+
# Test hooks called during variable evaluation
307+
variable = self.test_client.variable(self.test_user, "strKey", 42)
308+
self.assertTrue(variable.value == 999)
309+
self.assertFalse(variable.isDefaulted)
310+
311+
self.assertTrue(hook_called["before"])
312+
self.assertTrue(hook_called["after"])
313+
self.assertTrue(hook_called["finally"])
314+
self.assertFalse(hook_called["error"])
315+
316+
@patch("devcycle_python_sdk.api.bucketing_client.BucketingAPIClient.variable")
317+
def test_hook_exceptions(self, mock_variable_call):
318+
mock_variable_call.return_value = Variable(
319+
_id="123", key="strKey", value=999, type=TypeEnum.NUMBER
320+
)
321+
# Test adding hooks
322+
hook_called = {"before": False, "after": False, "finally": False, "error": False}
323+
324+
def before_hook(context):
325+
hook_called["before"] = True
326+
raise Exception("Before hook failed")
327+
def after_hook(context, variable):
328+
hook_called["after"] = True
329+
def finally_hook(context, variable):
330+
hook_called["finally"] = True
331+
def error_hook(context, error):
332+
hook_called["error"] = True
333+
334+
self.test_client.add_hook(EvalHook(before_hook, after_hook, finally_hook, error_hook))
335+
336+
# Test hooks called during variable evaluation
337+
variable = self.test_client.variable(self.test_user, "strKey", 42)
338+
self.assertTrue(variable.value == 999)
339+
self.assertFalse(variable.isDefaulted)
340+
341+
self.assertTrue(hook_called["before"])
342+
self.assertFalse(hook_called["after"])
343+
self.assertTrue(hook_called["finally"])
344+
self.assertTrue(hook_called["error"])
284345

285346
if __name__ == "__main__":
286347
unittest.main()

0 commit comments

Comments
 (0)