devcycle-python-server-sdk 3.11.3__py3-none-any.whl → 3.12.0__py3-none-any.whl

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 (30) hide show
  1. devcycle_python_sdk/VERSION.txt +1 -1
  2. devcycle_python_sdk/api/bucketing_client.py +12 -0
  3. devcycle_python_sdk/api/local_bucketing.py +0 -8
  4. devcycle_python_sdk/bucketing-lib.release.wasm +0 -0
  5. devcycle_python_sdk/cloud_client.py +51 -4
  6. devcycle_python_sdk/local_client.py +65 -14
  7. devcycle_python_sdk/managers/eval_hooks_manager.py +73 -0
  8. devcycle_python_sdk/managers/sse_manager.py +1 -1
  9. devcycle_python_sdk/models/bucketed_config.py +0 -1
  10. devcycle_python_sdk/models/eval_hook.py +18 -0
  11. devcycle_python_sdk/models/eval_hook_context.py +10 -0
  12. devcycle_python_sdk/models/eval_reason.py +40 -0
  13. devcycle_python_sdk/models/user.py +16 -4
  14. devcycle_python_sdk/models/variable.py +44 -6
  15. devcycle_python_sdk/open_feature_provider/provider.py +10 -5
  16. devcycle_python_sdk/options.py +8 -1
  17. devcycle_python_sdk/protobuf/utils.py +18 -0
  18. devcycle_python_sdk/protobuf/variableForUserParams_pb2.py +43 -30
  19. devcycle_python_sdk/protobuf/variableForUserParams_pb2.pyi +100 -78
  20. {devcycle_python_server_sdk-3.11.3.dist-info → devcycle_python_server_sdk-3.12.0.dist-info}/METADATA +1 -1
  21. {devcycle_python_server_sdk-3.11.3.dist-info → devcycle_python_server_sdk-3.12.0.dist-info}/RECORD +30 -26
  22. test/api/test_local_bucketing.py +11 -0
  23. test/models/test_user.py +90 -0
  24. test/openfeature_test/test_provider.py +79 -2
  25. test/openfeature_test/test_provider_local_sdk.py +25 -0
  26. test/test_cloud_client.py +89 -0
  27. test/test_local_client.py +97 -1
  28. {devcycle_python_server_sdk-3.11.3.dist-info → devcycle_python_server_sdk-3.12.0.dist-info}/WHEEL +0 -0
  29. {devcycle_python_server_sdk-3.11.3.dist-info → devcycle_python_server_sdk-3.12.0.dist-info}/licenses/LICENSE +0 -0
  30. {devcycle_python_server_sdk-3.11.3.dist-info → devcycle_python_server_sdk-3.12.0.dist-info}/top_level.txt +0 -0
@@ -1 +1 @@
1
- 3.11.3
1
+ 3.12.0
@@ -15,6 +15,7 @@ from devcycle_python_sdk.models.event import DevCycleEvent
15
15
  from devcycle_python_sdk.models.feature import Feature
16
16
  from devcycle_python_sdk.models.user import DevCycleUser
17
17
  from devcycle_python_sdk.models.variable import Variable
18
+ from devcycle_python_sdk.models.eval_reason import EvalReason
18
19
  from devcycle_python_sdk.util.strings import slash_join
19
20
 
20
21
  logger = logging.getLogger(__name__)
@@ -91,11 +92,17 @@ class BucketingAPIClient:
91
92
  def variable(self, key: str, user: DevCycleUser) -> Variable:
92
93
  data = self.request("POST", self._url("variables", key), json=user.to_json())
93
94
 
95
+ eval_data = data.get("eval")
96
+ eval_reason = None
97
+ if eval_data is not None and isinstance(eval_data, dict):
98
+ eval_reason = EvalReason.from_json(eval_data)
99
+
94
100
  return Variable(
95
101
  _id=data.get("_id"),
96
102
  key=data.get("key", ""),
97
103
  type=data.get("type", ""),
98
104
  value=data.get("value"),
105
+ eval=eval_reason,
99
106
  )
100
107
 
101
108
  def variables(self, user: DevCycleUser) -> Dict[str, Variable]:
@@ -109,6 +116,11 @@ class BucketingAPIClient:
109
116
  type=str(value.get("type")),
110
117
  value=value.get("value"),
111
118
  isDefaulted=None,
119
+ eval=(
120
+ EvalReason.from_json(value.get("eval"))
121
+ if value.get("eval")
122
+ else None
123
+ ),
112
124
  )
113
125
 
114
126
  return result
@@ -22,7 +22,6 @@ from wasmtime import (
22
22
  import devcycle_python_sdk.protobuf.utils as pb_utils
23
23
  import devcycle_python_sdk.protobuf.variableForUserParams_pb2 as pb2
24
24
  from devcycle_python_sdk.exceptions import (
25
- VariableTypeMismatchError,
26
25
  MalformedConfigError,
27
26
  )
28
27
  from devcycle_python_sdk.models.bucketed_config import BucketedConfig
@@ -324,13 +323,6 @@ class LocalBucketing:
324
323
  sdk_variable = pb2.SDKVariable_PB()
325
324
  sdk_variable.ParseFromString(var_bytes)
326
325
 
327
- if sdk_variable.type != pb_variable_type:
328
- # this situation should never actually happen because the WASM handles
329
- # it internally and returns a null value from the WASM function
330
- # This check is here just in case that logic changes in the future
331
- raise VariableTypeMismatchError(
332
- f"Variable returned does not match requested type: {pb_variable_type}"
333
- )
334
326
  return pb_utils.create_variable(sdk_variable, default_value)
335
327
 
336
328
  def generate_bucketed_config(self, user: DevCycleUser) -> BucketedConfig:
@@ -9,6 +9,16 @@ 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_reason import (
18
+ DefaultReasonDetails,
19
+ )
20
+ from devcycle_python_sdk.models.eval_hook import EvalHook
21
+ from devcycle_python_sdk.models.eval_hook_context import HookContext
12
22
  from devcycle_python_sdk.models.user import DevCycleUser
13
23
  from devcycle_python_sdk.models.event import DevCycleEvent
14
24
  from devcycle_python_sdk.models.variable import Variable
@@ -45,6 +55,9 @@ class DevCycleCloudClient(AbstractDevCycleClient):
45
55
  self.sdk_type = "server"
46
56
  self.bucketing_api = BucketingAPIClient(sdk_key, self.options)
47
57
  self._openfeature_provider = DevCycleProvider(self)
58
+ self.eval_hooks_manager = EvalHooksManager(
59
+ None if options is None else options.eval_hooks
60
+ )
48
61
 
49
62
  def get_sdk_platform(self) -> str:
50
63
  return "Cloud"
@@ -87,21 +100,47 @@ class DevCycleCloudClient(AbstractDevCycleClient):
87
100
  if default_value is None:
88
101
  raise ValueError("Missing parameter: defaultValue")
89
102
 
103
+ context = HookContext(key, user, default_value)
104
+ variable = Variable.create_default_variable(
105
+ key=key, default_value=default_value
106
+ )
107
+
90
108
  try:
91
- variable = self.bucketing_api.variable(key, user)
109
+ before_hook_error = None
110
+ try:
111
+ changed_context = self.eval_hooks_manager.run_before(context)
112
+ if changed_context is not None:
113
+ context = changed_context
114
+ except BeforeHookError as e:
115
+ before_hook_error = e
116
+ variable = self.bucketing_api.variable(key, context.user)
117
+ if before_hook_error is None:
118
+ self.eval_hooks_manager.run_after(context, variable)
119
+ else:
120
+ raise before_hook_error
92
121
  except CloudClientUnauthorizedError as e:
93
122
  logger.warning("DevCycle: SDK key is invalid, unable to make cloud request")
94
123
  raise e
95
124
  except NotFoundError:
96
125
  logger.warning(f"DevCycle: Variable not found: {key}")
97
126
  return Variable.create_default_variable(
98
- key=key, default_value=default_value
127
+ key=key,
128
+ default_value=default_value,
129
+ default_reason_detail=DefaultReasonDetails.MISSING_VARIABLE,
99
130
  )
131
+ except BeforeHookError as e:
132
+ self.eval_hooks_manager.run_error(context, e)
133
+ except AfterHookError as e:
134
+ self.eval_hooks_manager.run_error(context, e)
100
135
  except Exception as e:
101
136
  logger.error(f"DevCycle: Error evaluating variable: {e}")
102
137
  return Variable.create_default_variable(
103
- key=key, default_value=default_value
138
+ key=key,
139
+ default_value=default_value,
140
+ default_reason_detail=DefaultReasonDetails.ERROR,
104
141
  )
142
+ finally:
143
+ self.eval_hooks_manager.run_finally(context, variable)
105
144
 
106
145
  variable.defaultValue = default_value
107
146
 
@@ -111,7 +150,9 @@ class DevCycleCloudClient(AbstractDevCycleClient):
111
150
  f"DevCycle: Variable {key} is type {type(variable.value)}, but default value is type {type(default_value)}",
112
151
  )
113
152
  return Variable.create_default_variable(
114
- key=key, default_value=default_value
153
+ key=key,
154
+ default_value=default_value,
155
+ default_reason_detail=DefaultReasonDetails.TYPE_MISMATCH,
115
156
  )
116
157
 
117
158
  return variable
@@ -189,6 +230,12 @@ class DevCycleCloudClient(AbstractDevCycleClient):
189
230
  # Cloud client doesn't need to release any resources
190
231
  logger.debug("DevCycle: Cloud client closed")
191
232
 
233
+ def add_hook(self, hook: EvalHook) -> None:
234
+ self.eval_hooks_manager.add_hook(hook)
235
+
236
+ def clear_hooks(self) -> None:
237
+ self.eval_hooks_manager.clear_hooks()
238
+
192
239
 
193
240
  def _validate_sdk_key(sdk_key: str) -> None:
194
241
  if sdk_key is None or len(sdk_key) == 0:
@@ -6,10 +6,21 @@ from typing import Any, Dict, Union, Optional
6
6
 
7
7
  from devcycle_python_sdk import DevCycleLocalOptions, AbstractDevCycleClient
8
8
  from devcycle_python_sdk.api.local_bucketing import LocalBucketing
9
- from devcycle_python_sdk.exceptions import VariableTypeMismatchError
10
9
  from devcycle_python_sdk.managers.config_manager import EnvironmentConfigManager
10
+ from devcycle_python_sdk.managers.eval_hooks_manager import (
11
+ EvalHooksManager,
12
+ BeforeHookError,
13
+ AfterHookError,
14
+ )
11
15
  from devcycle_python_sdk.managers.event_queue_manager import EventQueueManager
12
16
  from devcycle_python_sdk.models.bucketed_config import BucketedConfig
17
+ from devcycle_python_sdk.models.eval_hook import EvalHook
18
+ from devcycle_python_sdk.models.eval_hook_context import HookContext
19
+ from devcycle_python_sdk.models.eval_reason import (
20
+ DefaultReasonDetails,
21
+ EvalReason,
22
+ EvalReasons,
23
+ )
13
24
  from devcycle_python_sdk.models.event import DevCycleEvent, EventType
14
25
  from devcycle_python_sdk.models.feature import Feature
15
26
  from devcycle_python_sdk.models.platform_data import default_platform_data
@@ -51,6 +62,7 @@ class DevCycleLocalClient(AbstractDevCycleClient):
51
62
  )
52
63
 
53
64
  self._openfeature_provider: Optional[DevCycleProvider] = None
65
+ self.eval_hooks_manager = EvalHooksManager(self.options.eval_hooks)
54
66
 
55
67
  def get_sdk_platform(self) -> str:
56
68
  return "Local"
@@ -123,7 +135,10 @@ class DevCycleLocalClient(AbstractDevCycleClient):
123
135
  try:
124
136
  self.event_queue_manager.queue_aggregate_event(
125
137
  event=DevCycleEvent(
126
- type=EventType.AggVariableDefaulted, target=key, value=1
138
+ type=EventType.AggVariableDefaulted,
139
+ target=key,
140
+ value=1,
141
+ metaData={"evalReason": EvalReasons.DEFAULT},
127
142
  ),
128
143
  bucketed_config=None,
129
144
  )
@@ -131,20 +146,54 @@ class DevCycleLocalClient(AbstractDevCycleClient):
131
146
  logger.warning(
132
147
  f"DevCycle: Unable to track AggVariableDefaulted event for Variable {key}: {e}"
133
148
  )
134
- return Variable.create_default_variable(key, default_value)
149
+ return Variable.create_default_variable(
150
+ key, default_value, DefaultReasonDetails.MISSING_CONFIG
151
+ )
152
+
153
+ context = HookContext(key, user, default_value)
154
+ variable = Variable.create_default_variable(
155
+ key=key, default_value=default_value
156
+ )
135
157
 
136
158
  try:
137
- variable = self.local_bucketing.get_variable_for_user_protobuf(
159
+ before_hook_error = None
160
+ try:
161
+ changed_context = self.eval_hooks_manager.run_before(context)
162
+ if changed_context is not None:
163
+ context = changed_context
164
+ except BeforeHookError as e:
165
+ before_hook_error = e
166
+ bucketed_variable = self.local_bucketing.get_variable_for_user_protobuf(
138
167
  user, key, default_value
139
168
  )
140
- if variable:
141
- return variable
142
- except VariableTypeMismatchError:
143
- logger.debug("DevCycle: Variable type mismatch, returning default value")
169
+ if bucketed_variable is not None:
170
+ variable = bucketed_variable
171
+ else:
172
+ variable.eval = EvalReason(
173
+ reason=EvalReasons.DEFAULT,
174
+ details=DefaultReasonDetails.USER_NOT_TARGETED,
175
+ )
176
+
177
+ if before_hook_error is None:
178
+ self.eval_hooks_manager.run_after(context, variable)
179
+ else:
180
+ raise before_hook_error
144
181
  except Exception as e:
145
- logger.warning(f"DevCycle: Error retrieving variable for user: {e}")
182
+ variable.eval = EvalReason(
183
+ reason=EvalReasons.DEFAULT, details=DefaultReasonDetails.ERROR
184
+ )
185
+
186
+ if isinstance(e, BeforeHookError):
187
+ self.eval_hooks_manager.run_error(context, e)
188
+ elif isinstance(e, AfterHookError):
189
+ self.eval_hooks_manager.run_error(context, e)
190
+ else:
191
+ logger.warning(f"DevCycle: Error retrieving variable for user: {e}")
146
192
 
147
- return Variable.create_default_variable(key, default_value)
193
+ return variable
194
+ finally:
195
+ self.eval_hooks_manager.run_finally(context, variable)
196
+ return variable
148
197
 
149
198
  def _generate_bucketed_config(self, user: DevCycleUser) -> BucketedConfig:
150
199
  """
@@ -169,8 +218,6 @@ class DevCycleLocalClient(AbstractDevCycleClient):
169
218
  )
170
219
  return {}
171
220
 
172
- variable_map: Dict[str, Variable] = {}
173
-
174
221
  try:
175
222
  return self.local_bucketing.generate_bucketed_config(user).variables
176
223
  except Exception as e:
@@ -179,8 +226,6 @@ class DevCycleLocalClient(AbstractDevCycleClient):
179
226
  )
180
227
  return {}
181
228
 
182
- return variable_map
183
-
184
229
  def all_features(self, user: DevCycleUser) -> Dict[str, Feature]:
185
230
  """
186
231
  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 +279,12 @@ class DevCycleLocalClient(AbstractDevCycleClient):
234
279
  self.config_manager.close()
235
280
  self.event_queue_manager.close()
236
281
 
282
+ def add_hook(self, eval_hook: EvalHook) -> None:
283
+ self.eval_hooks_manager.add_hook(eval_hook)
284
+
285
+ def clear_hooks(self) -> None:
286
+ self.eval_hooks_manager.clear_hooks()
287
+
237
288
 
238
289
  def _validate_sdk_key(sdk_key: str) -> None:
239
290
  if sdk_key is None or len(sdk_key) == 0:
@@ -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}")
@@ -43,7 +43,7 @@ class SSEManager:
43
43
  elif isinstance(event, ld_eventsource.actions.Event):
44
44
  handle_message(event)
45
45
  except Exception as e:
46
- logger.exception(f"DevCycle: failed to read SSE message: {e}")
46
+ logger.debug(f"DevCycle: failed to read SSE message: {e}")
47
47
 
48
48
  def update(self, config: dict):
49
49
  if self.use_new_config(config["sse"]):
@@ -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
@@ -0,0 +1,40 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+
5
+ class EvalReasons:
6
+ """Evaluation reasons constants"""
7
+
8
+ DEFAULT = "DEFAULT"
9
+
10
+
11
+ class DefaultReasonDetails:
12
+ """Default reason details constants"""
13
+
14
+ MISSING_CONFIG = "Missing Config"
15
+ USER_NOT_TARGETED = "User Not Targeted"
16
+ TYPE_MISMATCH = "Variable Type Mismatch"
17
+ MISSING_VARIABLE = "Missing Variable"
18
+ ERROR = "Error"
19
+
20
+
21
+ @dataclass(order=False)
22
+ class EvalReason:
23
+ reason: str
24
+ details: Optional[str] = None
25
+ target_id: Optional[str] = None
26
+
27
+ def to_json(self):
28
+ return {
29
+ key: getattr(self, key)
30
+ for key in self.__dataclass_fields__
31
+ if getattr(self, key) is not None
32
+ }
33
+
34
+ @classmethod
35
+ def from_json(cls, data: dict) -> "EvalReason":
36
+ return cls(
37
+ reason=data["reason"],
38
+ details=data.get("details"),
39
+ target_id=data.get("target_id"),
40
+ )
@@ -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:
@@ -2,6 +2,8 @@
2
2
  from dataclasses import dataclass
3
3
  from typing import Optional, Any
4
4
 
5
+ from .eval_reason import EvalReason, EvalReasons
6
+
5
7
 
6
8
  class TypeEnum:
7
9
  BOOLEAN = "Boolean"
@@ -32,16 +34,26 @@ class Variable:
32
34
  isDefaulted: Optional[bool] = False
33
35
  defaultValue: Any = None
34
36
  evalReason: Optional[str] = None
37
+ eval: Optional[EvalReason] = None
35
38
 
36
39
  def to_json(self):
37
- return {
38
- key: getattr(self, key)
39
- for key in self.__dataclass_fields__
40
- if getattr(self, key) is not None
41
- }
40
+ result = {}
41
+ for key in self.__dataclass_fields__:
42
+ value = getattr(self, key)
43
+ if value is not None:
44
+ if key == "eval" and isinstance(value, EvalReason):
45
+ result[key] = value.to_json()
46
+ else:
47
+ result[key] = value
48
+ return result
42
49
 
43
50
  @classmethod
44
51
  def from_json(cls, data: dict) -> "Variable":
52
+ eval_data = data.get("eval")
53
+ eval_reason = None
54
+ if eval_data:
55
+ eval_reason = EvalReason.from_json(eval_data)
56
+
45
57
  return cls(
46
58
  _id=data["_id"],
47
59
  key=data["key"],
@@ -50,11 +62,20 @@ class Variable:
50
62
  isDefaulted=data.get("isDefaulted", None),
51
63
  defaultValue=data.get("defaultValue"),
52
64
  evalReason=data.get("evalReason"),
65
+ eval=eval_reason,
53
66
  )
54
67
 
55
68
  @staticmethod
56
- def create_default_variable(key: str, default_value: Any) -> "Variable":
69
+ def create_default_variable(
70
+ key: str, default_value: Any, default_reason_detail: Optional[str] = None
71
+ ) -> "Variable":
57
72
  var_type = determine_variable_type(default_value)
73
+ if default_reason_detail is not None:
74
+ eval_reason = EvalReason(
75
+ reason=EvalReasons.DEFAULT, details=default_reason_detail
76
+ )
77
+ else:
78
+ eval_reason = None
58
79
  return Variable(
59
80
  _id=None,
60
81
  key=key,
@@ -62,4 +83,21 @@ class Variable:
62
83
  value=default_value,
63
84
  defaultValue=default_value,
64
85
  isDefaulted=True,
86
+ eval=eval_reason,
65
87
  )
88
+
89
+ def get_flag_meta_data(self) -> dict:
90
+ """
91
+ Returns metadata dictionary for OpenFeature flag resolution.
92
+
93
+ Returns:
94
+ dict: Dictionary containing evalReasonDetails and evalReasonTargetId
95
+ if they exist, empty dict otherwise.
96
+ """
97
+ meta_data = {}
98
+ if self.eval:
99
+ if self.eval.details:
100
+ meta_data["evalReasonDetails"] = self.eval.details
101
+ if self.eval.target_id:
102
+ meta_data["evalReasonTargetId"] = self.eval.target_id
103
+ return meta_data
@@ -80,13 +80,18 @@ class DevCycleProvider(AbstractProvider):
80
80
  reason=Reason.DEFAULT,
81
81
  )
82
82
  else:
83
+ # TODO: once eval enabled from cloud bucketing, eval reason won't be null unless defaulted
84
+ if variable.eval and variable.eval.reason:
85
+ reason = variable.eval.reason
86
+ elif variable.isDefaulted:
87
+ reason = Reason.DEFAULT
88
+ else:
89
+ reason = Reason.TARGETING_MATCH
90
+
83
91
  return FlagResolutionDetails(
84
92
  value=variable.value,
85
- reason=(
86
- Reason.DEFAULT
87
- if variable.isDefaulted
88
- else Reason.TARGETING_MATCH
89
- ),
93
+ reason=reason,
94
+ flag_metadata=variable.get_flag_meta_data(),
90
95
  )
91
96
  except ValueError as e:
92
97
  # occurs if the key or default value is 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}"