diff --git a/devcycle_python_sdk/cloud_client.py b/devcycle_python_sdk/cloud_client.py index a7591e6..dd21e63 100644 --- a/devcycle_python_sdk/cloud_client.py +++ b/devcycle_python_sdk/cloud_client.py @@ -9,6 +9,13 @@ NotFoundError, CloudClientUnauthorizedError, ) +from devcycle_python_sdk.managers.eval_hooks_manager import ( + EvalHooksManager, + BeforeHookError, + AfterHookError, +) +from devcycle_python_sdk.models.eval_hook import EvalHook +from devcycle_python_sdk.models.eval_hook_context import HookContext from devcycle_python_sdk.models.user import DevCycleUser from devcycle_python_sdk.models.event import DevCycleEvent from devcycle_python_sdk.models.variable import Variable @@ -45,6 +52,9 @@ def __init__(self, sdk_key: str, options: DevCycleCloudOptions): self.sdk_type = "server" self.bucketing_api = BucketingAPIClient(sdk_key, self.options) self._openfeature_provider = DevCycleProvider(self) + self.eval_hooks_manager = EvalHooksManager( + None if options is None else options.eval_hooks + ) def get_sdk_platform(self) -> str: return "Cloud" @@ -87,8 +97,24 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable if default_value is None: raise ValueError("Missing parameter: defaultValue") + context = HookContext(key, user, default_value) + variable = Variable.create_default_variable( + key=key, default_value=default_value + ) + try: - variable = self.bucketing_api.variable(key, user) + before_hook_error = None + try: + changed_context = self.eval_hooks_manager.run_before(context) + if changed_context is not None: + context = changed_context + except BeforeHookError as e: + before_hook_error = e + variable = self.bucketing_api.variable(key, context.user) + if before_hook_error is None: + self.eval_hooks_manager.run_after(context, variable) + else: + raise before_hook_error except CloudClientUnauthorizedError as e: logger.warning("DevCycle: SDK key is invalid, unable to make cloud request") raise e @@ -97,11 +123,17 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable return Variable.create_default_variable( key=key, default_value=default_value ) + except BeforeHookError as e: + self.eval_hooks_manager.run_error(context, e) + except AfterHookError as e: + self.eval_hooks_manager.run_error(context, e) except Exception as e: logger.error(f"DevCycle: Error evaluating variable: {e}") return Variable.create_default_variable( key=key, default_value=default_value ) + finally: + self.eval_hooks_manager.run_finally(context, variable) variable.defaultValue = default_value @@ -189,6 +221,12 @@ def close(self) -> None: # Cloud client doesn't need to release any resources logger.debug("DevCycle: Cloud client closed") + def add_hook(self, hook: EvalHook) -> None: + self.eval_hooks_manager.add_hook(hook) + + def clear_hooks(self) -> None: + self.eval_hooks_manager.clear_hooks() + def _validate_sdk_key(sdk_key: str) -> None: if sdk_key is None or len(sdk_key) == 0: diff --git a/devcycle_python_sdk/local_client.py b/devcycle_python_sdk/local_client.py index a41f4b9..cacaac2 100644 --- a/devcycle_python_sdk/local_client.py +++ b/devcycle_python_sdk/local_client.py @@ -8,8 +8,15 @@ from devcycle_python_sdk.api.local_bucketing import LocalBucketing from devcycle_python_sdk.exceptions import VariableTypeMismatchError from devcycle_python_sdk.managers.config_manager import EnvironmentConfigManager +from devcycle_python_sdk.managers.eval_hooks_manager import ( + EvalHooksManager, + BeforeHookError, + AfterHookError, +) from devcycle_python_sdk.managers.event_queue_manager import EventQueueManager from devcycle_python_sdk.models.bucketed_config import BucketedConfig +from devcycle_python_sdk.models.eval_hook import EvalHook +from devcycle_python_sdk.models.eval_hook_context import HookContext from devcycle_python_sdk.models.event import DevCycleEvent, EventType from devcycle_python_sdk.models.feature import Feature from devcycle_python_sdk.models.platform_data import default_platform_data @@ -51,6 +58,7 @@ def __init__(self, sdk_key: str, options: DevCycleLocalOptions): ) self._openfeature_provider: Optional[DevCycleProvider] = None + self.eval_hooks_manager = EvalHooksManager(self.options.eval_hooks) def get_sdk_platform(self) -> str: return "Local" @@ -133,18 +141,44 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable ) return Variable.create_default_variable(key, default_value) + context = HookContext(key, user, default_value) + variable = Variable.create_default_variable( + key=key, default_value=default_value + ) + try: - variable = self.local_bucketing.get_variable_for_user_protobuf( + before_hook_error = None + try: + changed_context = self.eval_hooks_manager.run_before(context) + if changed_context is not None: + context = changed_context + except BeforeHookError as e: + before_hook_error = e + bucketed_variable = self.local_bucketing.get_variable_for_user_protobuf( user, key, default_value ) - if variable: - return variable + if bucketed_variable is not None: + variable = bucketed_variable + + if before_hook_error is None: + self.eval_hooks_manager.run_after(context, variable) + else: + raise before_hook_error except VariableTypeMismatchError: logger.debug("DevCycle: Variable type mismatch, returning default value") + return variable + except BeforeHookError as e: + self.eval_hooks_manager.run_error(context, e) + return variable + except AfterHookError as e: + self.eval_hooks_manager.run_error(context, e) + return variable except Exception as e: logger.warning(f"DevCycle: Error retrieving variable for user: {e}") - - return Variable.create_default_variable(key, default_value) + return variable + finally: + self.eval_hooks_manager.run_finally(context, variable) + return variable def _generate_bucketed_config(self, user: DevCycleUser) -> BucketedConfig: """ @@ -169,8 +203,6 @@ def all_variables(self, user: DevCycleUser) -> Dict[str, Variable]: ) return {} - variable_map: Dict[str, Variable] = {} - try: return self.local_bucketing.generate_bucketed_config(user).variables except Exception as e: @@ -179,8 +211,6 @@ def all_variables(self, user: DevCycleUser) -> Dict[str, Variable]: ) return {} - return variable_map - def all_features(self, user: DevCycleUser) -> Dict[str, Feature]: """ 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: self.config_manager.close() self.event_queue_manager.close() + def add_hook(self, eval_hook: EvalHook) -> None: + self.eval_hooks_manager.add_hook(eval_hook) + + def clear_hooks(self) -> None: + self.eval_hooks_manager.clear_hooks() + def _validate_sdk_key(sdk_key: str) -> None: if sdk_key is None or len(sdk_key) == 0: diff --git a/devcycle_python_sdk/managers/eval_hooks_manager.py b/devcycle_python_sdk/managers/eval_hooks_manager.py new file mode 100644 index 0000000..9953476 --- /dev/null +++ b/devcycle_python_sdk/managers/eval_hooks_manager.py @@ -0,0 +1,73 @@ +from typing import List, Optional + +from devcycle_python_sdk.models.eval_hook import EvalHook +from devcycle_python_sdk.models.eval_hook_context import HookContext +from devcycle_python_sdk.models.variable import Variable +from devcycle_python_sdk.options import logger + + +class BeforeHookError(Exception): + """Exception raised when a before hook fails""" + + def __init__(self, message: str, original_error: Exception): + self.message = message + self.original_error = original_error + super().__init__(self.message) + + +class AfterHookError(Exception): + """Exception raised when an after hook fails""" + + def __init__(self, message: str, original_error: Exception): + self.message = message + self.original_error = original_error + super().__init__(self.message) + + +class EvalHooksManager: + def __init__(self, hooks: Optional[List[EvalHook]] = None): + self.hooks: List[EvalHook] = hooks if hooks is not None else [] + + def add_hook(self, hook: EvalHook) -> None: + """Add an evaluation hook to be executed""" + self.hooks.append(hook) + + def clear_hooks(self) -> None: + """Clear all evaluation hooks""" + self.hooks = [] + + def run_before(self, context: HookContext) -> Optional[HookContext]: + """Run before hooks and return modified context if any""" + modified_context = context + for hook in self.hooks: + try: + result = hook.before(modified_context) + if result: + modified_context = result + except Exception as e: + raise BeforeHookError(f"Before hook failed: {e}", e) + return modified_context + + def run_after(self, context: HookContext, variable: Variable) -> None: + """Run after hooks with the evaluation result""" + for hook in self.hooks: + try: + hook.after(context, variable) + except Exception as e: + raise AfterHookError(f"After hook failed: {e}", e) + + def run_finally(self, context: HookContext, variable: Optional[Variable]) -> None: + """Run finally hooks after evaluation completes""" + for hook in self.hooks: + try: + hook.on_finally(context, variable) + except Exception as e: + logger.error(f"Error running finally hook: {e}") + + def run_error(self, context: HookContext, error: Exception) -> None: + """Run error hooks when an error occurs""" + for hook in self.hooks: + try: + hook.error(context, error) + except Exception as e: + logger.error(f"Error running error hook: {e}") diff --git a/devcycle_python_sdk/models/bucketed_config.py b/devcycle_python_sdk/models/bucketed_config.py index 8893f8a..af1e0e2 100644 --- a/devcycle_python_sdk/models/bucketed_config.py +++ b/devcycle_python_sdk/models/bucketed_config.py @@ -1,4 +1,3 @@ -# ruff: noqa: N815 from dataclasses import dataclass from typing import Dict, List from typing import Optional diff --git a/devcycle_python_sdk/models/eval_hook.py b/devcycle_python_sdk/models/eval_hook.py new file mode 100644 index 0000000..36d1df0 --- /dev/null +++ b/devcycle_python_sdk/models/eval_hook.py @@ -0,0 +1,18 @@ +from typing import Callable, Optional + +from devcycle_python_sdk.models.eval_hook_context import HookContext +from devcycle_python_sdk.models.variable import Variable + + +class EvalHook: + def __init__( + self, + before: Callable[[HookContext], Optional[HookContext]], + after: Callable[[HookContext, Variable], None], + on_finally: Callable[[HookContext, Optional[Variable]], None], + error: Callable[[HookContext, Exception], None], + ): + self.before = before + self.after = after + self.on_finally = on_finally + self.error = error diff --git a/devcycle_python_sdk/models/eval_hook_context.py b/devcycle_python_sdk/models/eval_hook_context.py new file mode 100644 index 0000000..5c3ee96 --- /dev/null +++ b/devcycle_python_sdk/models/eval_hook_context.py @@ -0,0 +1,10 @@ +from typing import Any + +from devcycle_python_sdk.models.user import DevCycleUser + + +class HookContext: + def __init__(self, key: str, user: DevCycleUser, default_value: Any): + self.key = key + self.default_value = default_value + self.user = user diff --git a/devcycle_python_sdk/options.py b/devcycle_python_sdk/options.py index c934ee9..55a265e 100644 --- a/devcycle_python_sdk/options.py +++ b/devcycle_python_sdk/options.py @@ -1,5 +1,7 @@ import logging -from typing import Callable, Optional, Dict, Any +from typing import Callable, Optional, Dict, Any, List + +from devcycle_python_sdk.models.eval_hook import EvalHook logger = logging.getLogger(__name__) @@ -16,12 +18,14 @@ def __init__( request_timeout: int = 5, # seconds request_retries: int = 5, retry_delay: int = 200, # milliseconds + eval_hooks: Optional[List[EvalHook]] = None, ): self.enable_edge_db = enable_edge_db self.bucketing_api_uri = bucketing_api_uri self.request_timeout = request_timeout self.request_retries = request_retries self.retry_delay = retry_delay + self.eval_hooks = eval_hooks if eval_hooks is not None else [] class DevCycleLocalOptions: @@ -47,6 +51,7 @@ def __init__( disable_custom_event_logging: bool = False, enable_beta_realtime_updates: bool = False, disable_realtime_updates: bool = False, + eval_hooks: Optional[List[EvalHook]] = None, ): self.events_api_uri = events_api_uri self.config_cdn_uri = config_cdn_uri @@ -69,6 +74,8 @@ def __init__( "DevCycle: `enable_beta_realtime_updates` is deprecated and will be removed in a future release.", ) + self.eval_hooks = eval_hooks if eval_hooks is not None else [] + if self.flush_event_queue_size >= self.max_event_queue_size: logger.warning( f"DevCycle: flush_event_queue_size: {self.flush_event_queue_size} must be smaller than max_event_queue_size: {self.max_event_queue_size}" diff --git a/test/test_cloud_client.py b/test/test_cloud_client.py index 0cc3d97..5a1c24e 100644 --- a/test/test_cloud_client.py +++ b/test/test_cloud_client.py @@ -6,6 +6,7 @@ from unittest.mock import patch from devcycle_python_sdk import DevCycleCloudClient, DevCycleCloudOptions +from devcycle_python_sdk.models.eval_hook import EvalHook from devcycle_python_sdk.models.user import DevCycleUser from devcycle_python_sdk.models.variable import Variable, TypeEnum from devcycle_python_sdk.models.event import DevCycleEvent @@ -27,6 +28,7 @@ def setUp(self) -> None: self.test_user_empty_id = DevCycleUser(user_id="") def tearDown(self) -> None: + self.test_client.clear_hooks() pass def test_create_client_invalid_sdk_key(self): @@ -281,6 +283,86 @@ def test_track_exceptions(self, mock_track_call): ), ) + @patch("devcycle_python_sdk.api.bucketing_client.BucketingAPIClient.variable") + def test_hooks(self, mock_variable_call): + mock_variable_call.return_value = Variable( + _id="123", key="strKey", value=999, type=TypeEnum.NUMBER + ) + # Test adding hooks + hook_called = { + "before": False, + "after": False, + "finally": False, + "error": False, + } + + def before_hook(context): + hook_called["before"] = True + return context + + def after_hook(context, variable): + hook_called["after"] = True + + def finally_hook(context, variable): + hook_called["finally"] = True + + def error_hook(context, error): + hook_called["error"] = True + + self.test_client.add_hook( + EvalHook(before_hook, after_hook, finally_hook, error_hook) + ) + + # Test hooks called during variable evaluation + variable = self.test_client.variable(self.test_user, "strKey", 42) + self.assertTrue(variable.value == 999) + self.assertFalse(variable.isDefaulted) + + self.assertTrue(hook_called["before"]) + self.assertTrue(hook_called["after"]) + self.assertTrue(hook_called["finally"]) + self.assertFalse(hook_called["error"]) + + @patch("devcycle_python_sdk.api.bucketing_client.BucketingAPIClient.variable") + def test_hook_exceptions(self, mock_variable_call): + mock_variable_call.return_value = Variable( + _id="123", key="strKey", value=999, type=TypeEnum.NUMBER + ) + # Test adding hooks + hook_called = { + "before": False, + "after": False, + "finally": False, + "error": False, + } + + def before_hook(context): + hook_called["before"] = True + raise Exception("Before hook failed") + + def after_hook(context, variable): + hook_called["after"] = True + + def finally_hook(context, variable): + hook_called["finally"] = True + + def error_hook(context, error): + hook_called["error"] = True + + self.test_client.add_hook( + EvalHook(before_hook, after_hook, finally_hook, error_hook) + ) + + # Test hooks called during variable evaluation + variable = self.test_client.variable(self.test_user, "strKey", 42) + self.assertTrue(variable.value == 999) + self.assertFalse(variable.isDefaulted) + + self.assertTrue(hook_called["before"]) + self.assertFalse(hook_called["after"]) + self.assertTrue(hook_called["finally"]) + self.assertTrue(hook_called["error"]) + if __name__ == "__main__": unittest.main() diff --git a/test/test_local_client.py b/test/test_local_client.py index a6acf35..2e033e1 100644 --- a/test/test_local_client.py +++ b/test/test_local_client.py @@ -9,6 +9,7 @@ from devcycle_python_sdk import DevCycleLocalClient, DevCycleLocalOptions from devcycle_python_sdk.local_client import _validate_user, _validate_sdk_key from devcycle_python_sdk.exceptions import MalformedConfigError +from devcycle_python_sdk.models.eval_hook import EvalHook from devcycle_python_sdk.models.event import DevCycleEvent from devcycle_python_sdk.models.feature import Feature from devcycle_python_sdk.api.local_bucketing import LocalBucketing @@ -361,6 +362,86 @@ def test_all_variables_exception(self, _): result = self.client.all_variables(user) self.assertEqual(result, {}) + @responses.activate + def test_hooks(self): + self.setup_client() + # Test adding hooks + hook_called = { + "before": False, + "after": False, + "finally": False, + "error": False, + } + + def before_hook(context): + hook_called["before"] = True + return context + + def after_hook(context, variable): + hook_called["after"] = True + + def finally_hook(context, variable): + hook_called["finally"] = True + + def error_hook(context, error): + hook_called["error"] = True + + self.client.add_hook( + EvalHook(before_hook, after_hook, finally_hook, error_hook) + ) + + user = DevCycleUser(user_id="1234") + + # Test hooks called during variable evaluation + variable = self.client.variable(user, "num-var", 42) + self.assertTrue(variable.value == 12345) + self.assertFalse(variable.isDefaulted) + + self.assertTrue(hook_called["before"]) + self.assertTrue(hook_called["after"]) + self.assertTrue(hook_called["finally"]) + self.assertFalse(hook_called["error"]) + + @responses.activate + def test_hook_exceptions(self): + self.setup_client() + # Test adding hooks + hook_called = { + "before": False, + "after": False, + "finally": False, + "error": False, + } + + def before_hook(context): + hook_called["before"] = True + raise Exception("Before hook failed") + + def after_hook(context, variable): + hook_called["after"] = True + + def finally_hook(context, variable): + hook_called["finally"] = True + + def error_hook(context, error): + hook_called["error"] = True + + self.client.add_hook( + EvalHook(before_hook, after_hook, finally_hook, error_hook) + ) + + user = DevCycleUser(user_id="1234") + + # Test hooks called during variable evaluation + variable = self.client.variable(user, "num-var", 42) + self.assertTrue(variable.value == 12345) + self.assertFalse(variable.isDefaulted) + + self.assertTrue(hook_called["before"]) + self.assertFalse(hook_called["after"]) + self.assertTrue(hook_called["finally"]) + self.assertTrue(hook_called["error"]) + def _benchmark_variable_call(client: DevCycleLocalClient, user: DevCycleUser, key: str): return client.variable(user, key, "default_value")