Skip to content

Commit 3f2461a

Browse files
authored
feat: added variable evaluation hooks (#89)
* chore: revert local client changes * feat: added variable evaluation hooks * chore: added local client implementation for hooks
1 parent df7cf40 commit 3f2461a

File tree

9 files changed

+356
-12
lines changed

9 files changed

+356
-12
lines changed

devcycle_python_sdk/cloud_client.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
NotFoundError,
1010
CloudClientUnauthorizedError,
1111
)
12+
from devcycle_python_sdk.managers.eval_hooks_manager import (
13+
EvalHooksManager,
14+
BeforeHookError,
15+
AfterHookError,
16+
)
17+
from devcycle_python_sdk.models.eval_hook import EvalHook
18+
from devcycle_python_sdk.models.eval_hook_context import HookContext
1219
from devcycle_python_sdk.models.user import DevCycleUser
1320
from devcycle_python_sdk.models.event import DevCycleEvent
1421
from devcycle_python_sdk.models.variable import Variable
@@ -45,6 +52,9 @@ def __init__(self, sdk_key: str, options: DevCycleCloudOptions):
4552
self.sdk_type = "server"
4653
self.bucketing_api = BucketingAPIClient(sdk_key, self.options)
4754
self._openfeature_provider = DevCycleProvider(self)
55+
self.eval_hooks_manager = EvalHooksManager(
56+
None if options is None else options.eval_hooks
57+
)
4858

4959
def get_sdk_platform(self) -> str:
5060
return "Cloud"
@@ -87,8 +97,24 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable
8797
if default_value is None:
8898
raise ValueError("Missing parameter: defaultValue")
8999

100+
context = HookContext(key, user, default_value)
101+
variable = Variable.create_default_variable(
102+
key=key, default_value=default_value
103+
)
104+
90105
try:
91-
variable = self.bucketing_api.variable(key, user)
106+
before_hook_error = None
107+
try:
108+
changed_context = self.eval_hooks_manager.run_before(context)
109+
if changed_context is not None:
110+
context = changed_context
111+
except BeforeHookError as e:
112+
before_hook_error = e
113+
variable = self.bucketing_api.variable(key, context.user)
114+
if before_hook_error is None:
115+
self.eval_hooks_manager.run_after(context, variable)
116+
else:
117+
raise before_hook_error
92118
except CloudClientUnauthorizedError as e:
93119
logger.warning("DevCycle: SDK key is invalid, unable to make cloud request")
94120
raise e
@@ -97,11 +123,17 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable
97123
return Variable.create_default_variable(
98124
key=key, default_value=default_value
99125
)
126+
except BeforeHookError as e:
127+
self.eval_hooks_manager.run_error(context, e)
128+
except AfterHookError as e:
129+
self.eval_hooks_manager.run_error(context, e)
100130
except Exception as e:
101131
logger.error(f"DevCycle: Error evaluating variable: {e}")
102132
return Variable.create_default_variable(
103133
key=key, default_value=default_value
104134
)
135+
finally:
136+
self.eval_hooks_manager.run_finally(context, variable)
105137

106138
variable.defaultValue = default_value
107139

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

224+
def add_hook(self, hook: EvalHook) -> None:
225+
self.eval_hooks_manager.add_hook(hook)
226+
227+
def clear_hooks(self) -> None:
228+
self.eval_hooks_manager.clear_hooks()
229+
192230

193231
def _validate_sdk_key(sdk_key: str) -> None:
194232
if sdk_key is None or len(sdk_key) == 0:

devcycle_python_sdk/local_client.py

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,15 @@
88
from devcycle_python_sdk.api.local_bucketing import LocalBucketing
99
from devcycle_python_sdk.exceptions import VariableTypeMismatchError
1010
from devcycle_python_sdk.managers.config_manager import EnvironmentConfigManager
11+
from devcycle_python_sdk.managers.eval_hooks_manager import (
12+
EvalHooksManager,
13+
BeforeHookError,
14+
AfterHookError,
15+
)
1116
from devcycle_python_sdk.managers.event_queue_manager import EventQueueManager
1217
from devcycle_python_sdk.models.bucketed_config import BucketedConfig
18+
from devcycle_python_sdk.models.eval_hook import EvalHook
19+
from devcycle_python_sdk.models.eval_hook_context import HookContext
1320
from devcycle_python_sdk.models.event import DevCycleEvent, EventType
1421
from devcycle_python_sdk.models.feature import Feature
1522
from devcycle_python_sdk.models.platform_data import default_platform_data
@@ -51,6 +58,7 @@ def __init__(self, sdk_key: str, options: DevCycleLocalOptions):
5158
)
5259

5360
self._openfeature_provider: Optional[DevCycleProvider] = None
61+
self.eval_hooks_manager = EvalHooksManager(self.options.eval_hooks)
5462

5563
def get_sdk_platform(self) -> str:
5664
return "Local"
@@ -133,18 +141,44 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable
133141
)
134142
return Variable.create_default_variable(key, default_value)
135143

144+
context = HookContext(key, user, default_value)
145+
variable = Variable.create_default_variable(
146+
key=key, default_value=default_value
147+
)
148+
136149
try:
137-
variable = self.local_bucketing.get_variable_for_user_protobuf(
150+
before_hook_error = None
151+
try:
152+
changed_context = self.eval_hooks_manager.run_before(context)
153+
if changed_context is not None:
154+
context = changed_context
155+
except BeforeHookError as e:
156+
before_hook_error = e
157+
bucketed_variable = self.local_bucketing.get_variable_for_user_protobuf(
138158
user, key, default_value
139159
)
140-
if variable:
141-
return variable
160+
if bucketed_variable is not None:
161+
variable = bucketed_variable
162+
163+
if before_hook_error is None:
164+
self.eval_hooks_manager.run_after(context, variable)
165+
else:
166+
raise before_hook_error
142167
except VariableTypeMismatchError:
143168
logger.debug("DevCycle: Variable type mismatch, returning default value")
169+
return variable
170+
except BeforeHookError as e:
171+
self.eval_hooks_manager.run_error(context, e)
172+
return variable
173+
except AfterHookError as e:
174+
self.eval_hooks_manager.run_error(context, e)
175+
return variable
144176
except Exception as e:
145177
logger.warning(f"DevCycle: Error retrieving variable for user: {e}")
146-
147-
return Variable.create_default_variable(key, default_value)
178+
return variable
179+
finally:
180+
self.eval_hooks_manager.run_finally(context, variable)
181+
return variable
148182

149183
def _generate_bucketed_config(self, user: DevCycleUser) -> BucketedConfig:
150184
"""
@@ -169,8 +203,6 @@ def all_variables(self, user: DevCycleUser) -> Dict[str, Variable]:
169203
)
170204
return {}
171205

172-
variable_map: Dict[str, Variable] = {}
173-
174206
try:
175207
return self.local_bucketing.generate_bucketed_config(user).variables
176208
except Exception as e:
@@ -179,8 +211,6 @@ def all_variables(self, user: DevCycleUser) -> Dict[str, Variable]:
179211
)
180212
return {}
181213

182-
return variable_map
183-
184214
def all_features(self, user: DevCycleUser) -> Dict[str, Feature]:
185215
"""
186216
Returns all segmented and bucketed features for a user. This method will return an empty map if the client has not been initialized or if the user is not bucketed into any features
@@ -234,6 +264,12 @@ def close(self) -> None:
234264
self.config_manager.close()
235265
self.event_queue_manager.close()
236266

267+
def add_hook(self, eval_hook: EvalHook) -> None:
268+
self.eval_hooks_manager.add_hook(eval_hook)
269+
270+
def clear_hooks(self) -> None:
271+
self.eval_hooks_manager.clear_hooks()
272+
237273

238274
def _validate_sdk_key(sdk_key: str) -> None:
239275
if sdk_key is None or len(sdk_key) == 0:
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
12+
def __init__(self, message: str, original_error: Exception):
13+
self.message = message
14+
self.original_error = original_error
15+
super().__init__(self.message)
16+
17+
18+
class AfterHookError(Exception):
19+
"""Exception raised when an after hook fails"""
20+
21+
def __init__(self, message: str, original_error: Exception):
22+
self.message = message
23+
self.original_error = original_error
24+
super().__init__(self.message)
25+
26+
27+
class EvalHooksManager:
28+
def __init__(self, hooks: Optional[List[EvalHook]] = None):
29+
self.hooks: List[EvalHook] = hooks if hooks is not None else []
30+
31+
def add_hook(self, hook: EvalHook) -> None:
32+
"""Add an evaluation hook to be executed"""
33+
self.hooks.append(hook)
34+
35+
def clear_hooks(self) -> None:
36+
"""Clear all evaluation hooks"""
37+
self.hooks = []
38+
39+
def run_before(self, context: HookContext) -> Optional[HookContext]:
40+
"""Run before hooks and return modified context if any"""
41+
modified_context = context
42+
for hook in self.hooks:
43+
try:
44+
result = hook.before(modified_context)
45+
if result:
46+
modified_context = result
47+
except Exception as e:
48+
raise BeforeHookError(f"Before hook failed: {e}", e)
49+
return modified_context
50+
51+
def run_after(self, context: HookContext, variable: Variable) -> None:
52+
"""Run after hooks with the evaluation result"""
53+
for hook in self.hooks:
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+
try:
63+
hook.on_finally(context, variable)
64+
except Exception as e:
65+
logger.error(f"Error running finally hook: {e}")
66+
67+
def run_error(self, context: HookContext, error: Exception) -> None:
68+
"""Run error hooks when an error occurs"""
69+
for hook in self.hooks:
70+
try:
71+
hook.error(context, error)
72+
except Exception as e:
73+
logger.error(f"Error running error hook: {e}")

devcycle_python_sdk/models/bucketed_config.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
# ruff: noqa: N815
21
from dataclasses import dataclass
32
from typing import Dict, List
43
from typing import Optional
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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+
7+
class EvalHook:
8+
def __init__(
9+
self,
10+
before: Callable[[HookContext], Optional[HookContext]],
11+
after: Callable[[HookContext, Variable], None],
12+
on_finally: Callable[[HookContext, Optional[Variable]], None],
13+
error: Callable[[HookContext, Exception], None],
14+
):
15+
self.before = before
16+
self.after = after
17+
self.on_finally = on_finally
18+
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}"

0 commit comments

Comments
 (0)