devcycle-python-server-sdk 3.11.2__tar.gz → 3.11.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/PKG-INFO +1 -1
  2. devcycle_python_server_sdk-3.11.4/devcycle_python_sdk/VERSION.txt +1 -0
  3. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/cloud_client.py +39 -1
  4. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/local_client.py +45 -9
  5. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/managers/config_manager.py +1 -1
  6. devcycle_python_server_sdk-3.11.4/devcycle_python_sdk/managers/eval_hooks_manager.py +73 -0
  7. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/models/bucketed_config.py +0 -1
  8. devcycle_python_server_sdk-3.11.4/devcycle_python_sdk/models/eval_hook.py +18 -0
  9. devcycle_python_server_sdk-3.11.4/devcycle_python_sdk/models/eval_hook_context.py +10 -0
  10. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/models/user.py +16 -4
  11. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/options.py +8 -1
  12. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_server_sdk.egg-info/PKG-INFO +1 -1
  13. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_server_sdk.egg-info/SOURCES.txt +3 -0
  14. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/models/test_user.py +90 -0
  15. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/test_cloud_client.py +82 -0
  16. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/test_local_client.py +81 -0
  17. devcycle_python_server_sdk-3.11.2/devcycle_python_sdk/VERSION.txt +0 -1
  18. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/LICENSE +0 -0
  19. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/README.md +0 -0
  20. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/__init__.py +0 -0
  21. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/api/__init__.py +0 -0
  22. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/api/backoff.py +0 -0
  23. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/api/bucketing_client.py +0 -0
  24. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/api/config_client.py +0 -0
  25. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/api/event_client.py +0 -0
  26. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/api/local_bucketing.py +0 -0
  27. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/bucketing-lib.release.wasm +0 -0
  28. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/devcycle_client.py +0 -0
  29. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/exceptions.py +0 -0
  30. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/managers/__init__.py +0 -0
  31. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/managers/event_queue_manager.py +0 -0
  32. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/managers/sse_manager.py +0 -0
  33. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/models/__init__.py +0 -0
  34. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/models/error_response.py +0 -0
  35. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/models/event.py +0 -0
  36. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/models/feature.py +0 -0
  37. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/models/platform_data.py +0 -0
  38. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/models/variable.py +0 -0
  39. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/open_feature_provider/__init__.py +0 -0
  40. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/open_feature_provider/provider.py +0 -0
  41. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/protobuf/__init__.py +0 -0
  42. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/protobuf/utils.py +0 -0
  43. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/protobuf/variableForUserParams_pb2.py +0 -0
  44. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/protobuf/variableForUserParams_pb2.pyi +0 -0
  45. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/py.typed +0 -0
  46. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/util/__init__.py +0 -0
  47. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/util/strings.py +0 -0
  48. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_sdk/util/version.py +0 -0
  49. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_server_sdk.egg-info/dependency_links.txt +0 -0
  50. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_server_sdk.egg-info/requires.txt +0 -0
  51. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/devcycle_python_server_sdk.egg-info/top_level.txt +0 -0
  52. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/example/__init__.py +0 -0
  53. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/example/cloud_client_example.py +0 -0
  54. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/example/local_bucketing_client_example.py +0 -0
  55. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/example/openfeature_example.py +0 -0
  56. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/pyproject.toml +0 -0
  57. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/setup.cfg +0 -0
  58. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/setup.py +0 -0
  59. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/__init__.py +0 -0
  60. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/api/__init__.py +0 -0
  61. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/api/test_bucketing_client.py +0 -0
  62. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/api/test_config_client.py +0 -0
  63. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/api/test_event_client.py +0 -0
  64. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/api/test_local_bucketing.py +0 -0
  65. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/fixture/__init__.py +0 -0
  66. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/fixture/data.py +0 -0
  67. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/managers/__init__.py +0 -0
  68. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/managers/test_config_manager.py +0 -0
  69. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/managers/test_event_queue_manager.py +0 -0
  70. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/models/__init__.py +0 -0
  71. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/models/test_bucketed_config.py +0 -0
  72. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/openfeature_test/__init__.py +0 -0
  73. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/openfeature_test/test_provider.py +0 -0
  74. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/openfeature_test/test_provider_local_sdk.py +0 -0
  75. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/util/__init__.py +0 -0
  76. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/util/test_strings.py +0 -0
  77. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/util/test_utils.py +0 -0
  78. {devcycle_python_server_sdk-3.11.2 → devcycle_python_server_sdk-3.11.4}/test/util/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devcycle-python-server-sdk
3
- Version: 3.11.2
3
+ Version: 3.11.4
4
4
  Summary: DevCycle Python SDK
5
5
  Home-page: https://github.com/devcycleHQ/python-server-sdk
6
6
  Author-email:
@@ -9,6 +9,13 @@ from devcycle_python_sdk.exceptions import (
9
9
  NotFoundError,
10
10
  CloudClientUnauthorizedError,
11
11
  )
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
12
19
  from devcycle_python_sdk.models.user import DevCycleUser
13
20
  from devcycle_python_sdk.models.event import DevCycleEvent
14
21
  from devcycle_python_sdk.models.variable import Variable
@@ -45,6 +52,9 @@ class DevCycleCloudClient(AbstractDevCycleClient):
45
52
  self.sdk_type = "server"
46
53
  self.bucketing_api = BucketingAPIClient(sdk_key, self.options)
47
54
  self._openfeature_provider = DevCycleProvider(self)
55
+ self.eval_hooks_manager = EvalHooksManager(
56
+ None if options is None else options.eval_hooks
57
+ )
48
58
 
49
59
  def get_sdk_platform(self) -> str:
50
60
  return "Cloud"
@@ -87,8 +97,24 @@ class DevCycleCloudClient(AbstractDevCycleClient):
87
97
  if default_value is None:
88
98
  raise ValueError("Missing parameter: defaultValue")
89
99
 
100
+ context = HookContext(key, user, default_value)
101
+ variable = Variable.create_default_variable(
102
+ key=key, default_value=default_value
103
+ )
104
+
90
105
  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
92
118
  except CloudClientUnauthorizedError as e:
93
119
  logger.warning("DevCycle: SDK key is invalid, unable to make cloud request")
94
120
  raise e
@@ -97,11 +123,17 @@ class DevCycleCloudClient(AbstractDevCycleClient):
97
123
  return Variable.create_default_variable(
98
124
  key=key, default_value=default_value
99
125
  )
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)
100
130
  except Exception as e:
101
131
  logger.error(f"DevCycle: Error evaluating variable: {e}")
102
132
  return Variable.create_default_variable(
103
133
  key=key, default_value=default_value
104
134
  )
135
+ finally:
136
+ self.eval_hooks_manager.run_finally(context, variable)
105
137
 
106
138
  variable.defaultValue = default_value
107
139
 
@@ -189,6 +221,12 @@ class DevCycleCloudClient(AbstractDevCycleClient):
189
221
  # Cloud client doesn't need to release any resources
190
222
  logger.debug("DevCycle: Cloud client closed")
191
223
 
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
+
192
230
 
193
231
  def _validate_sdk_key(sdk_key: str) -> None:
194
232
  if sdk_key is None or len(sdk_key) == 0:
@@ -8,8 +8,15 @@ from devcycle_python_sdk import DevCycleLocalOptions, AbstractDevCycleClient
8
8
  from devcycle_python_sdk.api.local_bucketing import LocalBucketing
9
9
  from devcycle_python_sdk.exceptions import VariableTypeMismatchError
10
10
  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
+ )
11
16
  from devcycle_python_sdk.managers.event_queue_manager import EventQueueManager
12
17
  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
13
20
  from devcycle_python_sdk.models.event import DevCycleEvent, EventType
14
21
  from devcycle_python_sdk.models.feature import Feature
15
22
  from devcycle_python_sdk.models.platform_data import default_platform_data
@@ -51,6 +58,7 @@ class DevCycleLocalClient(AbstractDevCycleClient):
51
58
  )
52
59
 
53
60
  self._openfeature_provider: Optional[DevCycleProvider] = None
61
+ self.eval_hooks_manager = EvalHooksManager(self.options.eval_hooks)
54
62
 
55
63
  def get_sdk_platform(self) -> str:
56
64
  return "Local"
@@ -133,18 +141,44 @@ class DevCycleLocalClient(AbstractDevCycleClient):
133
141
  )
134
142
  return Variable.create_default_variable(key, default_value)
135
143
 
144
+ context = HookContext(key, user, default_value)
145
+ variable = Variable.create_default_variable(
146
+ key=key, default_value=default_value
147
+ )
148
+
136
149
  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(
138
158
  user, key, default_value
139
159
  )
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
142
167
  except VariableTypeMismatchError:
143
168
  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
144
176
  except Exception as e:
145
177
  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
148
182
 
149
183
  def _generate_bucketed_config(self, user: DevCycleUser) -> BucketedConfig:
150
184
  """
@@ -169,8 +203,6 @@ class DevCycleLocalClient(AbstractDevCycleClient):
169
203
  )
170
204
  return {}
171
205
 
172
- variable_map: Dict[str, Variable] = {}
173
-
174
206
  try:
175
207
  return self.local_bucketing.generate_bucketed_config(user).variables
176
208
  except Exception as e:
@@ -179,8 +211,6 @@ class DevCycleLocalClient(AbstractDevCycleClient):
179
211
  )
180
212
  return {}
181
213
 
182
- return variable_map
183
-
184
214
  def all_features(self, user: DevCycleUser) -> Dict[str, Feature]:
185
215
  """
186
216
  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 @@ class DevCycleLocalClient(AbstractDevCycleClient):
234
264
  self.config_manager.close()
235
265
  self.event_queue_manager.close()
236
266
 
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
+
237
273
 
238
274
  def _validate_sdk_key(sdk_key: str) -> None:
239
275
  if sdk_key is None or len(sdk_key) == 0:
@@ -139,7 +139,7 @@ class EnvironmentConfigManager(threading.Thread):
139
139
  self._get_config(dvc_data["lastModified"] / 1000.0)
140
140
 
141
141
  def sse_error(self, error: ld_eventsource.actions.Fault):
142
- logger.warning(f"DevCycle: Received SSE error: {error}")
142
+ logger.debug(f"DevCycle: Received SSE error: {error}")
143
143
 
144
144
  def sse_state(self, state: ld_eventsource.actions.Start):
145
145
  self._sse_connected = True
@@ -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}")
@@ -1,4 +1,3 @@
1
- # ruff: noqa: N815
2
1
  from dataclasses import dataclass
3
2
  from typing import Dict, List
4
3
  from typing import Optional
@@ -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
@@ -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
@@ -101,22 +101,33 @@ class DevCycleUser:
101
101
  ) -> "DevCycleUser":
102
102
  """
103
103
  Builds a DevCycleUser instance from the evaluation context. Will raise a TargetingKeyMissingError if
104
- the context does not contain a valid targeting key or user_id attribute
104
+ the context does not contain a valid targeting key, user_id, or userId attribute
105
105
 
106
106
  :param context: The evaluation context to build the user from
107
107
  :return: A DevCycleUser instance
108
108
  """
109
109
  user_id = None
110
+ user_id_source = None
110
111
 
111
112
  if context:
112
113
  if context.targeting_key:
113
114
  user_id = context.targeting_key
115
+ user_id_source = "targeting_key"
114
116
  elif context.attributes and "user_id" in context.attributes.keys():
115
117
  user_id = context.attributes["user_id"]
118
+ user_id_source = "user_id"
119
+ elif context.attributes and "userId" in context.attributes.keys():
120
+ user_id = context.attributes["userId"]
121
+ user_id_source = "userId"
116
122
 
117
- if not user_id or not isinstance(user_id, str):
123
+ if not user_id:
118
124
  raise TargetingKeyMissingError(
119
- "DevCycle: Evaluation context does not contain a valid targeting key or user_id attribute"
125
+ "DevCycle: Evaluation context does not contain a valid targeting key, user_id, or userId attribute"
126
+ )
127
+
128
+ if not isinstance(user_id, str):
129
+ raise TargetingKeyMissingError(
130
+ f"DevCycle: {user_id_source} must be a string, got {type(user_id).__name__}"
120
131
  )
121
132
 
122
133
  user = DevCycleUser(user_id=user_id)
@@ -124,7 +135,8 @@ class DevCycleUser:
124
135
  private_custom_data: Dict[str, Any] = {}
125
136
  if context and context.attributes:
126
137
  for key, value in context.attributes.items():
127
- if key == "user_id":
138
+ # Skip user_id, userId, and targeting_key - these are reserved for user ID mapping
139
+ if key in ("user_id", "userId", "targeting_key"):
128
140
  continue
129
141
 
130
142
  if value is not None:
@@ -1,5 +1,7 @@
1
1
  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
3
5
 
4
6
  logger = logging.getLogger(__name__)
5
7
 
@@ -16,12 +18,14 @@ class DevCycleCloudOptions:
16
18
  request_timeout: int = 5, # seconds
17
19
  request_retries: int = 5,
18
20
  retry_delay: int = 200, # milliseconds
21
+ eval_hooks: Optional[List[EvalHook]] = None,
19
22
  ):
20
23
  self.enable_edge_db = enable_edge_db
21
24
  self.bucketing_api_uri = bucketing_api_uri
22
25
  self.request_timeout = request_timeout
23
26
  self.request_retries = request_retries
24
27
  self.retry_delay = retry_delay
28
+ self.eval_hooks = eval_hooks if eval_hooks is not None else []
25
29
 
26
30
 
27
31
  class DevCycleLocalOptions:
@@ -47,6 +51,7 @@ class DevCycleLocalOptions:
47
51
  disable_custom_event_logging: bool = False,
48
52
  enable_beta_realtime_updates: bool = False,
49
53
  disable_realtime_updates: bool = False,
54
+ eval_hooks: Optional[List[EvalHook]] = None,
50
55
  ):
51
56
  self.events_api_uri = events_api_uri
52
57
  self.config_cdn_uri = config_cdn_uri
@@ -69,6 +74,8 @@ class DevCycleLocalOptions:
69
74
  "DevCycle: `enable_beta_realtime_updates` is deprecated and will be removed in a future release.",
70
75
  )
71
76
 
77
+ self.eval_hooks = eval_hooks if eval_hooks is not None else []
78
+
72
79
  if self.flush_event_queue_size >= self.max_event_queue_size:
73
80
  logger.warning(
74
81
  f"DevCycle: flush_event_queue_size: {self.flush_event_queue_size} must be smaller than max_event_queue_size: {self.max_event_queue_size}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devcycle-python-server-sdk
3
- Version: 3.11.2
3
+ Version: 3.11.4
4
4
  Summary: DevCycle Python SDK
5
5
  Home-page: https://github.com/devcycleHQ/python-server-sdk
6
6
  Author-email:
@@ -20,11 +20,14 @@ devcycle_python_sdk/api/event_client.py
20
20
  devcycle_python_sdk/api/local_bucketing.py
21
21
  devcycle_python_sdk/managers/__init__.py
22
22
  devcycle_python_sdk/managers/config_manager.py
23
+ devcycle_python_sdk/managers/eval_hooks_manager.py
23
24
  devcycle_python_sdk/managers/event_queue_manager.py
24
25
  devcycle_python_sdk/managers/sse_manager.py
25
26
  devcycle_python_sdk/models/__init__.py
26
27
  devcycle_python_sdk/models/bucketed_config.py
27
28
  devcycle_python_sdk/models/error_response.py
29
+ devcycle_python_sdk/models/eval_hook.py
30
+ devcycle_python_sdk/models/eval_hook_context.py
28
31
  devcycle_python_sdk/models/event.py
29
32
  devcycle_python_sdk/models/feature.py
30
33
  devcycle_python_sdk/models/platform_data.py
@@ -53,6 +53,96 @@ class DevCycleUserTest(unittest.TestCase):
53
53
  self.assertIsNotNone(user)
54
54
  self.assertEqual(user.user_id, test_user_id)
55
55
 
56
+ def test_create_user_from_context_with_user_id_attribute(self):
57
+ """Comprehensive test for userId attribute support including priority, functionality, and error cases"""
58
+ targeting_key_id = "targeting-12345"
59
+ user_id = "user_id-12345"
60
+ user_id_attr = "userId-12345"
61
+
62
+ # Test 1: userId as the only user ID source
63
+ context = EvaluationContext(
64
+ targeting_key=None, attributes={"userId": user_id_attr}
65
+ )
66
+ user = DevCycleUser.create_user_from_context(context)
67
+ self.assertIsNotNone(user)
68
+ self.assertEqual(user.user_id, user_id_attr)
69
+
70
+ # Test 2: Priority order - targeting_key > user_id > userId
71
+ context = EvaluationContext(
72
+ targeting_key=targeting_key_id,
73
+ attributes={"user_id": user_id, "userId": user_id_attr},
74
+ )
75
+ user = DevCycleUser.create_user_from_context(context)
76
+ self.assertEqual(user.user_id, targeting_key_id)
77
+
78
+ context = EvaluationContext(
79
+ targeting_key=None, attributes={"user_id": user_id, "userId": user_id_attr}
80
+ )
81
+ user = DevCycleUser.create_user_from_context(context)
82
+ self.assertEqual(user.user_id, user_id)
83
+
84
+ # Test 3: Error cases - invalid userId types
85
+ with self.assertRaises(TargetingKeyMissingError):
86
+ DevCycleUser.create_user_from_context(
87
+ EvaluationContext(targeting_key=None, attributes={"userId": 12345})
88
+ )
89
+
90
+ with self.assertRaises(TargetingKeyMissingError):
91
+ DevCycleUser.create_user_from_context(
92
+ EvaluationContext(targeting_key=None, attributes={"userId": None})
93
+ )
94
+
95
+ def test_create_user_from_context_user_id_exclusion(self):
96
+ """Test that all user ID fields (targeting_key, user_id, userId) are excluded from custom data"""
97
+ targeting_key_id = "targeting-12345"
98
+ user_id = "user_id-12345"
99
+ user_id_attr = "userId-12345"
100
+
101
+ # Test exclusion when targeting_key is used
102
+ context = EvaluationContext(
103
+ targeting_key=targeting_key_id,
104
+ attributes={
105
+ "user_id": user_id,
106
+ "userId": user_id_attr,
107
+ "targeting_key": "should-be-excluded",
108
+ "other_field": "value",
109
+ },
110
+ )
111
+ user = DevCycleUser.create_user_from_context(context)
112
+ self.assertEqual(user.user_id, targeting_key_id)
113
+ self.assertIsNotNone(user.customData)
114
+ self.assertNotIn("user_id", user.customData)
115
+ self.assertNotIn("userId", user.customData)
116
+ self.assertNotIn("targeting_key", user.customData)
117
+ self.assertEqual(user.customData["other_field"], "value")
118
+
119
+ # Test exclusion when user_id is used
120
+ context = EvaluationContext(
121
+ targeting_key=None,
122
+ attributes={
123
+ "user_id": user_id,
124
+ "userId": user_id_attr,
125
+ "other_field": "value",
126
+ },
127
+ )
128
+ user = DevCycleUser.create_user_from_context(context)
129
+ self.assertEqual(user.user_id, user_id)
130
+ self.assertIsNotNone(user.customData)
131
+ self.assertNotIn("user_id", user.customData)
132
+ self.assertNotIn("userId", user.customData)
133
+ self.assertEqual(user.customData["other_field"], "value")
134
+
135
+ # Test exclusion when userId is used
136
+ context = EvaluationContext(
137
+ targeting_key=None,
138
+ attributes={"userId": user_id_attr, "other_field": "value"},
139
+ )
140
+ user = DevCycleUser.create_user_from_context(context)
141
+ self.assertEqual(user.user_id, user_id_attr)
142
+ self.assertIsNotNone(user.customData)
143
+ self.assertNotIn("userId", user.customData)
144
+ self.assertEqual(user.customData["other_field"], "value")
145
+
56
146
  def test_create_user_from_context_with_attributes(self):
57
147
  test_user_id = "12345"
58
148
  context = EvaluationContext(
@@ -6,6 +6,7 @@ from time import time
6
6
  from unittest.mock import patch
7
7
 
8
8
  from devcycle_python_sdk import DevCycleCloudClient, DevCycleCloudOptions
9
+ from devcycle_python_sdk.models.eval_hook import EvalHook
9
10
  from devcycle_python_sdk.models.user import DevCycleUser
10
11
  from devcycle_python_sdk.models.variable import Variable, TypeEnum
11
12
  from devcycle_python_sdk.models.event import DevCycleEvent
@@ -27,6 +28,7 @@ class DevCycleCloudClientTest(unittest.TestCase):
27
28
  self.test_user_empty_id = DevCycleUser(user_id="")
28
29
 
29
30
  def tearDown(self) -> None:
31
+ self.test_client.clear_hooks()
30
32
  pass
31
33
 
32
34
  def test_create_client_invalid_sdk_key(self):
@@ -281,6 +283,86 @@ class DevCycleCloudClientTest(unittest.TestCase):
281
283
  ),
282
284
  )
283
285
 
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 = {
293
+ "before": False,
294
+ "after": False,
295
+ "finally": False,
296
+ "error": False,
297
+ }
298
+
299
+ def before_hook(context):
300
+ hook_called["before"] = True
301
+ return context
302
+
303
+ def after_hook(context, variable):
304
+ hook_called["after"] = True
305
+
306
+ def finally_hook(context, variable):
307
+ hook_called["finally"] = True
308
+
309
+ def error_hook(context, error):
310
+ hook_called["error"] = True
311
+
312
+ self.test_client.add_hook(
313
+ EvalHook(before_hook, after_hook, finally_hook, error_hook)
314
+ )
315
+
316
+ # Test hooks called during variable evaluation
317
+ variable = self.test_client.variable(self.test_user, "strKey", 42)
318
+ self.assertTrue(variable.value == 999)
319
+ self.assertFalse(variable.isDefaulted)
320
+
321
+ self.assertTrue(hook_called["before"])
322
+ self.assertTrue(hook_called["after"])
323
+ self.assertTrue(hook_called["finally"])
324
+ self.assertFalse(hook_called["error"])
325
+
326
+ @patch("devcycle_python_sdk.api.bucketing_client.BucketingAPIClient.variable")
327
+ def test_hook_exceptions(self, mock_variable_call):
328
+ mock_variable_call.return_value = Variable(
329
+ _id="123", key="strKey", value=999, type=TypeEnum.NUMBER
330
+ )
331
+ # Test adding hooks
332
+ hook_called = {
333
+ "before": False,
334
+ "after": False,
335
+ "finally": False,
336
+ "error": False,
337
+ }
338
+
339
+ def before_hook(context):
340
+ hook_called["before"] = True
341
+ raise Exception("Before hook failed")
342
+
343
+ def after_hook(context, variable):
344
+ hook_called["after"] = True
345
+
346
+ def finally_hook(context, variable):
347
+ hook_called["finally"] = True
348
+
349
+ def error_hook(context, error):
350
+ hook_called["error"] = True
351
+
352
+ self.test_client.add_hook(
353
+ EvalHook(before_hook, after_hook, finally_hook, error_hook)
354
+ )
355
+
356
+ # Test hooks called during variable evaluation
357
+ variable = self.test_client.variable(self.test_user, "strKey", 42)
358
+ self.assertTrue(variable.value == 999)
359
+ self.assertFalse(variable.isDefaulted)
360
+
361
+ self.assertTrue(hook_called["before"])
362
+ self.assertFalse(hook_called["after"])
363
+ self.assertTrue(hook_called["finally"])
364
+ self.assertTrue(hook_called["error"])
365
+
284
366
 
285
367
  if __name__ == "__main__":
286
368
  unittest.main()
@@ -9,6 +9,7 @@ import responses
9
9
  from devcycle_python_sdk import DevCycleLocalClient, DevCycleLocalOptions
10
10
  from devcycle_python_sdk.local_client import _validate_user, _validate_sdk_key
11
11
  from devcycle_python_sdk.exceptions import MalformedConfigError
12
+ from devcycle_python_sdk.models.eval_hook import EvalHook
12
13
  from devcycle_python_sdk.models.event import DevCycleEvent
13
14
  from devcycle_python_sdk.models.feature import Feature
14
15
  from devcycle_python_sdk.api.local_bucketing import LocalBucketing
@@ -361,6 +362,86 @@ class DevCycleLocalClientTest(unittest.TestCase):
361
362
  result = self.client.all_variables(user)
362
363
  self.assertEqual(result, {})
363
364
 
365
+ @responses.activate
366
+ def test_hooks(self):
367
+ self.setup_client()
368
+ # Test adding hooks
369
+ hook_called = {
370
+ "before": False,
371
+ "after": False,
372
+ "finally": False,
373
+ "error": False,
374
+ }
375
+
376
+ def before_hook(context):
377
+ hook_called["before"] = True
378
+ return context
379
+
380
+ def after_hook(context, variable):
381
+ hook_called["after"] = True
382
+
383
+ def finally_hook(context, variable):
384
+ hook_called["finally"] = True
385
+
386
+ def error_hook(context, error):
387
+ hook_called["error"] = True
388
+
389
+ self.client.add_hook(
390
+ EvalHook(before_hook, after_hook, finally_hook, error_hook)
391
+ )
392
+
393
+ user = DevCycleUser(user_id="1234")
394
+
395
+ # Test hooks called during variable evaluation
396
+ variable = self.client.variable(user, "num-var", 42)
397
+ self.assertTrue(variable.value == 12345)
398
+ self.assertFalse(variable.isDefaulted)
399
+
400
+ self.assertTrue(hook_called["before"])
401
+ self.assertTrue(hook_called["after"])
402
+ self.assertTrue(hook_called["finally"])
403
+ self.assertFalse(hook_called["error"])
404
+
405
+ @responses.activate
406
+ def test_hook_exceptions(self):
407
+ self.setup_client()
408
+ # Test adding hooks
409
+ hook_called = {
410
+ "before": False,
411
+ "after": False,
412
+ "finally": False,
413
+ "error": False,
414
+ }
415
+
416
+ def before_hook(context):
417
+ hook_called["before"] = True
418
+ raise Exception("Before hook failed")
419
+
420
+ def after_hook(context, variable):
421
+ hook_called["after"] = True
422
+
423
+ def finally_hook(context, variable):
424
+ hook_called["finally"] = True
425
+
426
+ def error_hook(context, error):
427
+ hook_called["error"] = True
428
+
429
+ self.client.add_hook(
430
+ EvalHook(before_hook, after_hook, finally_hook, error_hook)
431
+ )
432
+
433
+ user = DevCycleUser(user_id="1234")
434
+
435
+ # Test hooks called during variable evaluation
436
+ variable = self.client.variable(user, "num-var", 42)
437
+ self.assertTrue(variable.value == 12345)
438
+ self.assertFalse(variable.isDefaulted)
439
+
440
+ self.assertTrue(hook_called["before"])
441
+ self.assertFalse(hook_called["after"])
442
+ self.assertTrue(hook_called["finally"])
443
+ self.assertTrue(hook_called["error"])
444
+
364
445
 
365
446
  def _benchmark_variable_call(client: DevCycleLocalClient, user: DevCycleUser, key: str):
366
447
  return client.variable(user, key, "default_value")