Skip to content

Commit f5cbc90

Browse files
authored
feat: add eval reasons support (#95)
* update wasm * handle local bucketing eval reason * update test harness * fix lint * update unit tests * run black formatter * cloud eval reason * fix lint * fix mypy lint * fix capability * add some tests * fix lint
1 parent 1b448a5 commit f5cbc90

16 files changed

+321
-141
lines changed

.github/workflows/run-test-harness.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ jobs:
1313
runs-on: ubuntu-latest
1414
steps:
1515
- uses: DevCycleHQ/test-harness@main
16-
env:
17-
SDK_CAPABILITIES: '["clientCustomData","v2Config","EdgeDB","CloudBucketing"]'
1816
with:
1917
sdks-to-test: python
2018
sdk-github-sha: ${{github.event.pull_request.head.sha}}
19+
sdk-capabilities: '["cloud", "edgeDB", "clientCustomData","v2Config", "allVariables", "allFeatures", "evalReason", "cloudEvalReason"]'

devcycle_python_sdk/api/bucketing_client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from devcycle_python_sdk.models.feature import Feature
1616
from devcycle_python_sdk.models.user import DevCycleUser
1717
from devcycle_python_sdk.models.variable import Variable
18+
from devcycle_python_sdk.models.eval_reason import EvalReason
1819
from devcycle_python_sdk.util.strings import slash_join
1920

2021
logger = logging.getLogger(__name__)
@@ -91,11 +92,17 @@ def request(self, method: str, url: str, **kwargs) -> dict:
9192
def variable(self, key: str, user: DevCycleUser) -> Variable:
9293
data = self.request("POST", self._url("variables", key), json=user.to_json())
9394

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+
94100
return Variable(
95101
_id=data.get("_id"),
96102
key=data.get("key", ""),
97103
type=data.get("type", ""),
98104
value=data.get("value"),
105+
eval=eval_reason,
99106
)
100107

101108
def variables(self, user: DevCycleUser) -> Dict[str, Variable]:
@@ -109,6 +116,11 @@ def variables(self, user: DevCycleUser) -> Dict[str, Variable]:
109116
type=str(value.get("type")),
110117
value=value.get("value"),
111118
isDefaulted=None,
119+
eval=(
120+
EvalReason.from_json(value.get("eval"))
121+
if value.get("eval")
122+
else None
123+
),
112124
)
113125

114126
return result

devcycle_python_sdk/api/local_bucketing.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
import devcycle_python_sdk.protobuf.utils as pb_utils
2323
import devcycle_python_sdk.protobuf.variableForUserParams_pb2 as pb2
2424
from devcycle_python_sdk.exceptions import (
25-
VariableTypeMismatchError,
2625
MalformedConfigError,
2726
)
2827
from devcycle_python_sdk.models.bucketed_config import BucketedConfig
@@ -324,13 +323,6 @@ def get_variable_for_user_protobuf(
324323
sdk_variable = pb2.SDKVariable_PB()
325324
sdk_variable.ParseFromString(var_bytes)
326325

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-
)
334326
return pb_utils.create_variable(sdk_variable, default_value)
335327

336328
def generate_bucketed_config(self, user: DevCycleUser) -> BucketedConfig:
6.04 KB
Binary file not shown.

devcycle_python_sdk/cloud_client.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
BeforeHookError,
1515
AfterHookError,
1616
)
17+
from devcycle_python_sdk.models.eval_reason import (
18+
DefaultReasonDetails,
19+
)
1720
from devcycle_python_sdk.models.eval_hook import EvalHook
1821
from devcycle_python_sdk.models.eval_hook_context import HookContext
1922
from devcycle_python_sdk.models.user import DevCycleUser
@@ -121,7 +124,9 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable
121124
except NotFoundError:
122125
logger.warning(f"DevCycle: Variable not found: {key}")
123126
return Variable.create_default_variable(
124-
key=key, default_value=default_value
127+
key=key,
128+
default_value=default_value,
129+
default_reason_detail=DefaultReasonDetails.MISSING_VARIABLE,
125130
)
126131
except BeforeHookError as e:
127132
self.eval_hooks_manager.run_error(context, e)
@@ -130,7 +135,9 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable
130135
except Exception as e:
131136
logger.error(f"DevCycle: Error evaluating variable: {e}")
132137
return Variable.create_default_variable(
133-
key=key, default_value=default_value
138+
key=key,
139+
default_value=default_value,
140+
default_reason_detail=DefaultReasonDetails.ERROR,
134141
)
135142
finally:
136143
self.eval_hooks_manager.run_finally(context, variable)
@@ -143,7 +150,9 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable
143150
f"DevCycle: Variable {key} is type {type(variable.value)}, but default value is type {type(default_value)}",
144151
)
145152
return Variable.create_default_variable(
146-
key=key, default_value=default_value
153+
key=key,
154+
default_value=default_value,
155+
default_reason_detail=DefaultReasonDetails.TYPE_MISMATCH,
147156
)
148157

149158
return variable

devcycle_python_sdk/local_client.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
from devcycle_python_sdk import DevCycleLocalOptions, AbstractDevCycleClient
88
from devcycle_python_sdk.api.local_bucketing import LocalBucketing
9-
from devcycle_python_sdk.exceptions import VariableTypeMismatchError
109
from devcycle_python_sdk.managers.config_manager import EnvironmentConfigManager
1110
from devcycle_python_sdk.managers.eval_hooks_manager import (
1211
EvalHooksManager,
@@ -17,6 +16,11 @@
1716
from devcycle_python_sdk.models.bucketed_config import BucketedConfig
1817
from devcycle_python_sdk.models.eval_hook import EvalHook
1918
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+
)
2024
from devcycle_python_sdk.models.event import DevCycleEvent, EventType
2125
from devcycle_python_sdk.models.feature import Feature
2226
from devcycle_python_sdk.models.platform_data import default_platform_data
@@ -139,7 +143,9 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable
139143
logger.warning(
140144
f"DevCycle: Unable to track AggVariableDefaulted event for Variable {key}: {e}"
141145
)
142-
return Variable.create_default_variable(key, default_value)
146+
return Variable.create_default_variable(
147+
key, default_value, DefaultReasonDetails.MISSING_CONFIG
148+
)
143149

144150
context = HookContext(key, user, default_value)
145151
variable = Variable.create_default_variable(
@@ -159,22 +165,28 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable
159165
)
160166
if bucketed_variable is not None:
161167
variable = bucketed_variable
168+
else:
169+
variable.eval = EvalReason(
170+
reason=EvalReasons.DEFAULT,
171+
details=DefaultReasonDetails.USER_NOT_TARGETED,
172+
)
162173

163174
if before_hook_error is None:
164175
self.eval_hooks_manager.run_after(context, variable)
165176
else:
166177
raise before_hook_error
167-
except VariableTypeMismatchError:
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
176178
except Exception as e:
177-
logger.warning(f"DevCycle: Error retrieving variable for user: {e}")
179+
variable.eval = EvalReason(
180+
reason=EvalReasons.DEFAULT, details=DefaultReasonDetails.ERROR
181+
)
182+
183+
if isinstance(e, BeforeHookError):
184+
self.eval_hooks_manager.run_error(context, e)
185+
elif isinstance(e, AfterHookError):
186+
self.eval_hooks_manager.run_error(context, e)
187+
else:
188+
logger.warning(f"DevCycle: Error retrieving variable for user: {e}")
189+
178190
return variable
179191
finally:
180192
self.eval_hooks_manager.run_finally(context, variable)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
)

devcycle_python_sdk/models/variable.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from dataclasses import dataclass
33
from typing import Optional, Any
44

5+
from .eval_reason import EvalReason, EvalReasons
6+
57

68
class TypeEnum:
79
BOOLEAN = "Boolean"
@@ -32,16 +34,26 @@ class Variable:
3234
isDefaulted: Optional[bool] = False
3335
defaultValue: Any = None
3436
evalReason: Optional[str] = None
37+
eval: Optional[EvalReason] = None
3538

3639
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
4249

4350
@classmethod
4451
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+
4557
return cls(
4658
_id=data["_id"],
4759
key=data["key"],
@@ -50,16 +62,26 @@ def from_json(cls, data: dict) -> "Variable":
5062
isDefaulted=data.get("isDefaulted", None),
5163
defaultValue=data.get("defaultValue"),
5264
evalReason=data.get("evalReason"),
65+
eval=eval_reason,
5366
)
5467

5568
@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":
5772
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
5879
return Variable(
5980
_id=None,
6081
key=key,
6182
type=var_type,
6283
value=default_value,
6384
defaultValue=default_value,
6485
isDefaulted=True,
86+
eval=eval_reason,
6587
)

devcycle_python_sdk/protobuf/utils.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any, Optional
66

77
from devcycle_python_sdk.models.variable import TypeEnum, Variable
8+
from devcycle_python_sdk.models.eval_reason import EvalReason
89
from devcycle_python_sdk.models.user import DevCycleUser
910

1011
import devcycle_python_sdk.protobuf.variableForUserParams_pb2 as pb2
@@ -82,7 +83,20 @@ def create_dvcuser_pb(user: DevCycleUser) -> pb2.DVCUser_PB: # type: ignore
8283
)
8384

8485

86+
def create_eval_reason_from_pb(eval_reason_pb: pb2.EvalReason_PB) -> EvalReason: # type: ignore
87+
"""Convert EvalReason_PB protobuf message to EvalReason object"""
88+
return EvalReason(
89+
reason=eval_reason_pb.reason,
90+
details=eval_reason_pb.details if eval_reason_pb.details else None,
91+
target_id=eval_reason_pb.target_id if eval_reason_pb.target_id else None,
92+
)
93+
94+
8595
def create_variable(sdk_variable: pb2.SDKVariable_PB, default_value: Any) -> Variable: # type: ignore
96+
eval_reason_obj = None
97+
if sdk_variable.HasField("eval"):
98+
eval_reason_obj = create_eval_reason_from_pb(sdk_variable.eval)
99+
86100
if sdk_variable.type == pb2.VariableType_PB.Boolean: # type: ignore
87101
return Variable(
88102
_id=None,
@@ -91,6 +105,7 @@ def create_variable(sdk_variable: pb2.SDKVariable_PB, default_value: Any) -> Var
91105
type=TypeEnum.BOOLEAN,
92106
isDefaulted=False,
93107
defaultValue=default_value,
108+
eval=eval_reason_obj,
94109
)
95110

96111
elif sdk_variable.type == pb2.VariableType_PB.String: # type: ignore
@@ -101,6 +116,7 @@ def create_variable(sdk_variable: pb2.SDKVariable_PB, default_value: Any) -> Var
101116
type=TypeEnum.STRING,
102117
isDefaulted=False,
103118
defaultValue=default_value,
119+
eval=eval_reason_obj,
104120
)
105121

106122
elif sdk_variable.type == pb2.VariableType_PB.Number: # type: ignore
@@ -111,6 +127,7 @@ def create_variable(sdk_variable: pb2.SDKVariable_PB, default_value: Any) -> Var
111127
type=TypeEnum.NUMBER,
112128
isDefaulted=False,
113129
defaultValue=default_value,
130+
eval=eval_reason_obj,
114131
)
115132

116133
elif sdk_variable.type == pb2.VariableType_PB.JSON: # type: ignore
@@ -123,6 +140,7 @@ def create_variable(sdk_variable: pb2.SDKVariable_PB, default_value: Any) -> Var
123140
type=TypeEnum.JSON,
124141
isDefaulted=False,
125142
defaultValue=default_value,
143+
eval=eval_reason_obj,
126144
)
127145

128146
else:

0 commit comments

Comments
 (0)