Skip to content

feat: added variable evaluation hooks #89

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 3, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion devcycle_python_sdk/cloud_client.py
Original file line number Diff line number Diff line change
@@ -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:
54 changes: 45 additions & 9 deletions devcycle_python_sdk/local_client.py
Original file line number Diff line number Diff line change
@@ -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:
73 changes: 73 additions & 0 deletions devcycle_python_sdk/managers/eval_hooks_manager.py
Original file line number Diff line number Diff line change
@@ -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}")
1 change: 0 additions & 1 deletion devcycle_python_sdk/models/bucketed_config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# ruff: noqa: N815
from dataclasses import dataclass
from typing import Dict, List
from typing import Optional
18 changes: 18 additions & 0 deletions devcycle_python_sdk/models/eval_hook.py
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions devcycle_python_sdk/models/eval_hook_context.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 8 additions & 1 deletion devcycle_python_sdk/options.py
Original file line number Diff line number Diff line change
@@ -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}"
82 changes: 82 additions & 0 deletions test/test_cloud_client.py
Original file line number Diff line number Diff line change
@@ -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()
81 changes: 81 additions & 0 deletions test/test_local_client.py
Original file line number Diff line number Diff line change
@@ -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")