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.
- devcycle_python_sdk/VERSION.txt +1 -1
- devcycle_python_sdk/api/bucketing_client.py +12 -0
- devcycle_python_sdk/api/local_bucketing.py +0 -8
- devcycle_python_sdk/bucketing-lib.release.wasm +0 -0
- devcycle_python_sdk/cloud_client.py +51 -4
- devcycle_python_sdk/local_client.py +65 -14
- devcycle_python_sdk/managers/eval_hooks_manager.py +73 -0
- devcycle_python_sdk/managers/sse_manager.py +1 -1
- devcycle_python_sdk/models/bucketed_config.py +0 -1
- devcycle_python_sdk/models/eval_hook.py +18 -0
- devcycle_python_sdk/models/eval_hook_context.py +10 -0
- devcycle_python_sdk/models/eval_reason.py +40 -0
- devcycle_python_sdk/models/user.py +16 -4
- devcycle_python_sdk/models/variable.py +44 -6
- devcycle_python_sdk/open_feature_provider/provider.py +10 -5
- devcycle_python_sdk/options.py +8 -1
- devcycle_python_sdk/protobuf/utils.py +18 -0
- devcycle_python_sdk/protobuf/variableForUserParams_pb2.py +43 -30
- devcycle_python_sdk/protobuf/variableForUserParams_pb2.pyi +100 -78
- {devcycle_python_server_sdk-3.11.3.dist-info → devcycle_python_server_sdk-3.12.0.dist-info}/METADATA +1 -1
- {devcycle_python_server_sdk-3.11.3.dist-info → devcycle_python_server_sdk-3.12.0.dist-info}/RECORD +30 -26
- test/api/test_local_bucketing.py +11 -0
- test/models/test_user.py +90 -0
- test/openfeature_test/test_provider.py +79 -2
- test/openfeature_test/test_provider_local_sdk.py +25 -0
- test/test_cloud_client.py +89 -0
- test/test_local_client.py +97 -1
- {devcycle_python_server_sdk-3.11.3.dist-info → devcycle_python_server_sdk-3.12.0.dist-info}/WHEEL +0 -0
- {devcycle_python_server_sdk-3.11.3.dist-info → devcycle_python_server_sdk-3.12.0.dist-info}/licenses/LICENSE +0 -0
- {devcycle_python_server_sdk-3.11.3.dist-info → devcycle_python_server_sdk-3.12.0.dist-info}/top_level.txt +0 -0
devcycle_python_sdk/VERSION.txt
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
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:
|
|
Binary file
|
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"]):
|
|
@@ -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,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
|
|
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
|
|
123
|
+
if not user_id:
|
|
118
124
|
raise TargetingKeyMissingError(
|
|
119
|
-
"DevCycle: Evaluation context does not contain a valid targeting key or
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if
|
|
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(
|
|
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
|
-
|
|
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
|
devcycle_python_sdk/options.py
CHANGED
|
@@ -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}"
|