Skip to content
Merged
Show file tree
Hide file tree
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
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down
54 changes: 45 additions & 9 deletions devcycle_python_sdk/local_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
"""
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
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
Expand Down
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__)

Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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}"
Expand Down
Loading