Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/run-test-harness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ jobs:
steps:
- uses: DevCycleHQ/test-harness@main
env:
SDK_CAPABILITIES: '["clientCustomData","v2Config","EdgeDB","CloudBucketing"]'
SDK_CAPABILITIES:
with:
sdks-to-test: python
sdk-github-sha: ${{github.event.pull_request.head.sha}}
sdk-capabilities: '["cloud", "edgeDB", "clientCustomData","v2Config", "allVariables", "allFeatures", "evalReason", "cloudEvalReason"]'
12 changes: 12 additions & 0 deletions devcycle_python_sdk/api/bucketing_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from devcycle_python_sdk.models.feature import Feature
from devcycle_python_sdk.models.user import DevCycleUser
from devcycle_python_sdk.models.variable import Variable
from devcycle_python_sdk.models.eval_reason import EvalReason
from devcycle_python_sdk.util.strings import slash_join

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

eval_data = data.get("eval")
eval_reason = None
if eval_data is not None and isinstance(eval_data, dict):
eval_reason = EvalReason.from_json(eval_data)

return Variable(
_id=data.get("_id"),
key=data.get("key", ""),
type=data.get("type", ""),
value=data.get("value"),
eval=eval_reason,
)

def variables(self, user: DevCycleUser) -> Dict[str, Variable]:
Expand All @@ -109,6 +116,11 @@ def variables(self, user: DevCycleUser) -> Dict[str, Variable]:
type=str(value.get("type")),
value=value.get("value"),
isDefaulted=None,
eval=(
EvalReason.from_json(value.get("eval"))
if value.get("eval")
else None
),
)

return result
Expand Down
8 changes: 0 additions & 8 deletions devcycle_python_sdk/api/local_bucketing.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
import devcycle_python_sdk.protobuf.utils as pb_utils
import devcycle_python_sdk.protobuf.variableForUserParams_pb2 as pb2
from devcycle_python_sdk.exceptions import (
VariableTypeMismatchError,
MalformedConfigError,
)
from devcycle_python_sdk.models.bucketed_config import BucketedConfig
Expand Down Expand Up @@ -324,13 +323,6 @@ def get_variable_for_user_protobuf(
sdk_variable = pb2.SDKVariable_PB()
sdk_variable.ParseFromString(var_bytes)

if sdk_variable.type != pb_variable_type:
# this situation should never actually happen because the WASM handles
# it internally and returns a null value from the WASM function
# This check is here just in case that logic changes in the future
raise VariableTypeMismatchError(
f"Variable returned does not match requested type: {pb_variable_type}"
)
return pb_utils.create_variable(sdk_variable, default_value)

def generate_bucketed_config(self, user: DevCycleUser) -> BucketedConfig:
Expand Down
Binary file modified devcycle_python_sdk/bucketing-lib.release.wasm
Binary file not shown.
15 changes: 12 additions & 3 deletions devcycle_python_sdk/cloud_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
BeforeHookError,
AfterHookError,
)
from devcycle_python_sdk.models.eval_reason import (
DefaultReasonDetails,
)
from devcycle_python_sdk.models.eval_hook import EvalHook
from devcycle_python_sdk.models.eval_hook_context import HookContext
from devcycle_python_sdk.models.user import DevCycleUser
Expand Down Expand Up @@ -121,7 +124,9 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable
except NotFoundError:
logger.warning(f"DevCycle: Variable not found: {key}")
return Variable.create_default_variable(
key=key, default_value=default_value
key=key,
default_value=default_value,
default_reason_detail=DefaultReasonDetails.MISSING_VARIABLE,
)
except BeforeHookError as e:
self.eval_hooks_manager.run_error(context, e)
Expand All @@ -130,7 +135,9 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable
except Exception as e:
logger.error(f"DevCycle: Error evaluating variable: {e}")
return Variable.create_default_variable(
key=key, default_value=default_value
key=key,
default_value=default_value,
default_reason_detail=DefaultReasonDetails.ERROR,
)
finally:
self.eval_hooks_manager.run_finally(context, variable)
Expand All @@ -143,7 +150,9 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable
f"DevCycle: Variable {key} is type {type(variable.value)}, but default value is type {type(default_value)}",
)
return Variable.create_default_variable(
key=key, default_value=default_value
key=key,
default_value=default_value,
default_reason_detail=DefaultReasonDetails.TYPE_MISMATCH,
)

return variable
Expand Down
36 changes: 24 additions & 12 deletions devcycle_python_sdk/local_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

from devcycle_python_sdk import DevCycleLocalOptions, AbstractDevCycleClient
from devcycle_python_sdk.api.local_bucketing import LocalBucketing
from devcycle_python_sdk.exceptions import VariableTypeMismatchError
from devcycle_python_sdk.managers.config_manager import EnvironmentConfigManager
from devcycle_python_sdk.managers.eval_hooks_manager import (
EvalHooksManager,
Expand All @@ -17,6 +16,11 @@
from devcycle_python_sdk.models.bucketed_config import BucketedConfig
from devcycle_python_sdk.models.eval_hook import EvalHook
from devcycle_python_sdk.models.eval_hook_context import HookContext
from devcycle_python_sdk.models.eval_reason import (
DefaultReasonDetails,
EvalReason,
EvalReasons,
)
from devcycle_python_sdk.models.event import DevCycleEvent, EventType
from devcycle_python_sdk.models.feature import Feature
from devcycle_python_sdk.models.platform_data import default_platform_data
Expand Down Expand Up @@ -139,7 +143,9 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable
logger.warning(
f"DevCycle: Unable to track AggVariableDefaulted event for Variable {key}: {e}"
)
return Variable.create_default_variable(key, default_value)
return Variable.create_default_variable(
key, default_value, DefaultReasonDetails.MISSING_CONFIG
)

context = HookContext(key, user, default_value)
variable = Variable.create_default_variable(
Expand All @@ -159,22 +165,28 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable
)
if bucketed_variable is not None:
variable = bucketed_variable
else:
variable.eval = EvalReason(
reason=EvalReasons.DEFAULT,
details=DefaultReasonDetails.USER_NOT_TARGETED,
)

if before_hook_error is None:
self.eval_hooks_manager.run_after(context, variable)
else:
raise before_hook_error
except VariableTypeMismatchError:
logger.debug("DevCycle: Variable type mismatch, returning default value")
return variable
except BeforeHookError as e:
self.eval_hooks_manager.run_error(context, e)
return variable
except AfterHookError as e:
self.eval_hooks_manager.run_error(context, e)
return variable
except Exception as e:
logger.warning(f"DevCycle: Error retrieving variable for user: {e}")
variable.eval = EvalReason(
reason=EvalReasons.DEFAULT, details=DefaultReasonDetails.ERROR
)

if isinstance(e, BeforeHookError):
self.eval_hooks_manager.run_error(context, e)
elif isinstance(e, AfterHookError):
self.eval_hooks_manager.run_error(context, e)
else:
logger.warning(f"DevCycle: Error retrieving variable for user: {e}")

return variable
finally:
self.eval_hooks_manager.run_finally(context, variable)
Expand Down
40 changes: 40 additions & 0 deletions devcycle_python_sdk/models/eval_reason.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from dataclasses import dataclass
from typing import Optional


class EvalReasons:
"""Evaluation reasons constants"""

DEFAULT = "DEFAULT"


class DefaultReasonDetails:
"""Default reason details constants"""

MISSING_CONFIG = "Missing Config"
USER_NOT_TARGETED = "User Not Targeted"
TYPE_MISMATCH = "Variable Type Mismatch"
MISSING_VARIABLE = "Missing Variable"
ERROR = "Error"


@dataclass(order=False)
class EvalReason:
reason: str
details: Optional[str] = None
target_id: Optional[str] = None

def to_json(self):
return {
key: getattr(self, key)
for key in self.__dataclass_fields__
if getattr(self, key) is not None
}

@classmethod
def from_json(cls, data: dict) -> "EvalReason":
return cls(
reason=data["reason"],
details=data.get("details"),
target_id=data.get("target_id"),
)
34 changes: 28 additions & 6 deletions devcycle_python_sdk/models/variable.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from dataclasses import dataclass
from typing import Optional, Any

from .eval_reason import EvalReason, EvalReasons


class TypeEnum:
BOOLEAN = "Boolean"
Expand Down Expand Up @@ -32,16 +34,26 @@ class Variable:
isDefaulted: Optional[bool] = False
defaultValue: Any = None
evalReason: Optional[str] = None
eval: Optional[EvalReason] = None

def to_json(self):
return {
key: getattr(self, key)
for key in self.__dataclass_fields__
if getattr(self, key) is not None
}
result = {}
for key in self.__dataclass_fields__:
value = getattr(self, key)
if value is not None:
if key == "eval" and isinstance(value, EvalReason):
result[key] = value.to_json()
else:
result[key] = value
return result

@classmethod
def from_json(cls, data: dict) -> "Variable":
eval_data = data.get("eval")
eval_reason = None
if eval_data:
eval_reason = EvalReason.from_json(eval_data)

return cls(
_id=data["_id"],
key=data["key"],
Expand All @@ -50,16 +62,26 @@ def from_json(cls, data: dict) -> "Variable":
isDefaulted=data.get("isDefaulted", None),
defaultValue=data.get("defaultValue"),
evalReason=data.get("evalReason"),
eval=eval_reason,
)

@staticmethod
def create_default_variable(key: str, default_value: Any) -> "Variable":
def create_default_variable(
key: str, default_value: Any, default_reason_detail: Optional[str] = None
) -> "Variable":
var_type = determine_variable_type(default_value)
if default_reason_detail is not None:
eval_reason = EvalReason(
reason=EvalReasons.DEFAULT, details=default_reason_detail
)
else:
eval_reason = None
return Variable(
_id=None,
key=key,
type=var_type,
value=default_value,
defaultValue=default_value,
isDefaulted=True,
eval=eval_reason,
)
18 changes: 18 additions & 0 deletions devcycle_python_sdk/protobuf/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Any, Optional

from devcycle_python_sdk.models.variable import TypeEnum, Variable
from devcycle_python_sdk.models.eval_reason import EvalReason
from devcycle_python_sdk.models.user import DevCycleUser

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


def create_eval_reason_from_pb(eval_reason_pb: pb2.EvalReason_PB) -> EvalReason: # type: ignore
"""Convert EvalReason_PB protobuf message to EvalReason object"""
return EvalReason(
reason=eval_reason_pb.reason,
details=eval_reason_pb.details if eval_reason_pb.details else None,
target_id=eval_reason_pb.target_id if eval_reason_pb.target_id else None,
)


def create_variable(sdk_variable: pb2.SDKVariable_PB, default_value: Any) -> Variable: # type: ignore
eval_reason_obj = None
if sdk_variable.HasField("eval"):
eval_reason_obj = create_eval_reason_from_pb(sdk_variable.eval)

if sdk_variable.type == pb2.VariableType_PB.Boolean: # type: ignore
return Variable(
_id=None,
Expand All @@ -91,6 +105,7 @@ def create_variable(sdk_variable: pb2.SDKVariable_PB, default_value: Any) -> Var
type=TypeEnum.BOOLEAN,
isDefaulted=False,
defaultValue=default_value,
eval=eval_reason_obj,
)

elif sdk_variable.type == pb2.VariableType_PB.String: # type: ignore
Expand All @@ -101,6 +116,7 @@ def create_variable(sdk_variable: pb2.SDKVariable_PB, default_value: Any) -> Var
type=TypeEnum.STRING,
isDefaulted=False,
defaultValue=default_value,
eval=eval_reason_obj,
)

elif sdk_variable.type == pb2.VariableType_PB.Number: # type: ignore
Expand All @@ -111,6 +127,7 @@ def create_variable(sdk_variable: pb2.SDKVariable_PB, default_value: Any) -> Var
type=TypeEnum.NUMBER,
isDefaulted=False,
defaultValue=default_value,
eval=eval_reason_obj,
)

elif sdk_variable.type == pb2.VariableType_PB.JSON: # type: ignore
Expand All @@ -123,6 +140,7 @@ def create_variable(sdk_variable: pb2.SDKVariable_PB, default_value: Any) -> Var
type=TypeEnum.JSON,
isDefaulted=False,
defaultValue=default_value,
eval=eval_reason_obj,
)

else:
Expand Down
Loading
Loading