Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 1 addition & 2 deletions .github/workflows/run-test-harness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: DevCycleHQ/test-harness@main
env:
SDK_CAPABILITIES: '["clientCustomData","v2Config","EdgeDB","CloudBucketing"]'
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