Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 10 additions & 0 deletions devcycle_python_sdk/api/local_bucketing.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from devcycle_python_sdk.models.user import DevCycleUser
from devcycle_python_sdk.models.variable import Variable, determine_variable_type
from devcycle_python_sdk.models.event import FlushPayload
from devcycle_python_sdk.models.config_metadata import ConfigMetadata

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -140,6 +141,7 @@ def __console_log_func(message_ptr) -> None:
"generateBucketedConfigForUserUTF8"
)
self.VariableForUserProtobuf = self._get_export("variableForUser_PB")
self.getConfigMetadata = self._get_export("getConfigMetadata")

# Extract variable type enum values from WASM
self.variable_type_map = {
Expand Down Expand Up @@ -357,6 +359,14 @@ def store_config(self, config_json: str) -> None:
config_addr = self._new_assembly_script_byte_array(data)
self.setConfigDataUTF8(self.wasm_store, self.sdk_key_addr, config_addr)

def get_config_metadata(self) -> dict:
with self.wasm_lock:
config_addr = self.getConfigMetadata(self.wasm_store, self.sdk_key_addr)
config_bytes = self._read_assembly_script_string(config_addr)
config_data = json.loads(config_bytes.encode("utf-8"))

return ConfigMetadata.from_json(config_data)

def set_platform_data(self, platform_json: str) -> None:
with self.wasm_lock:
data = platform_json.encode("utf-8")
Expand Down
Binary file modified devcycle_python_sdk/bucketing-lib.release.wasm
Binary file not shown.
4 changes: 3 additions & 1 deletion devcycle_python_sdk/local_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable
key, default_value, DefaultReasonDetails.MISSING_CONFIG
)

context = HookContext(key, user, default_value)
config_metadata = self.local_bucketing.get_config_metadata()

context = HookContext(key, user, default_value, config_metadata)
variable = Variable.create_default_variable(
key=key, default_value=default_value
)
Expand Down
3 changes: 3 additions & 0 deletions devcycle_python_sdk/managers/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ def _get_config(self, last_modified: Optional[float] = None):
)
self._polling_enabled = False

def get_config_metadata(self) -> dict:
return self._local_bucketing.get_config_metadata()

def run(self):
while self._polling_enabled:
try:
Expand Down
25 changes: 25 additions & 0 deletions devcycle_python_sdk/models/config_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from devcycle_python_sdk.models.environment_metadata import EnvironmentMetadata
from devcycle_python_sdk.models.project_metadata import ProjectMetadata
import json


class ConfigMetadata:
def __init__(
self,
project: ProjectMetadata,
environment: EnvironmentMetadata,
):
self.project = project
self.environment = environment

def to_json(self) -> str:
return json.dumps(self, default=lambda o: o.__dict__)

@staticmethod
def from_json(json_str: str) -> "ConfigMetadata":
if json_str is None:
return None
return ConfigMetadata(
project=ProjectMetadata.from_json(json_str["project"]),
environment=EnvironmentMetadata.from_json(json_str["environment"]),
)
17 changes: 17 additions & 0 deletions devcycle_python_sdk/models/environment_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class EnvironmentMetadata:
def __init__(
self,
id: str,
key: str,
):
self.id = id
self.key = key

@staticmethod
def from_json(json_str: str) -> "EnvironmentMetadata":
if json_str is None:
return None
return EnvironmentMetadata(
id=json_str["id"],
key=json_str["key"],
)
5 changes: 3 additions & 2 deletions devcycle_python_sdk/models/eval_hook_context.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from typing import Any

from devcycle_python_sdk.models.user import DevCycleUser

from devcycle_python_sdk.models.config_metadata import ConfigMetadata

class HookContext:
def __init__(self, key: str, user: DevCycleUser, default_value: Any):
def __init__(self, key: str, user: DevCycleUser, default_value: Any, config_metadata: ConfigMetadata = None):
self.key = key
self.default_value = default_value
self.user = user
self.config_metadata = config_metadata
17 changes: 17 additions & 0 deletions devcycle_python_sdk/models/project_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class ProjectMetadata:
def __init__(
self,
id: str,
key: str,
):
self.id = id
self.key = key

@staticmethod
def from_json(json_str: str) -> "ProjectMetadata":
if json_str is None:
return None
return ProjectMetadata(
id=json_str["id"],
key=json_str["key"],
)
22 changes: 6 additions & 16 deletions devcycle_python_sdk/protobuf/variableForUserParams_pb2.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 11 additions & 11 deletions devcycle_python_sdk/protobuf/variableForUserParams_pb2.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Opti
DESCRIPTOR: _descriptor.FileDescriptor

class VariableType_PB(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
__slots__ = []
Boolean: _ClassVar[VariableType_PB]
Number: _ClassVar[VariableType_PB]
String: _ClassVar[VariableType_PB]
JSON: _ClassVar[VariableType_PB]

class CustomDataType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
__slots__ = []
Bool: _ClassVar[CustomDataType]
Num: _ClassVar[CustomDataType]
Str: _ClassVar[CustomDataType]
Expand All @@ -29,23 +29,23 @@ Str: CustomDataType
Null: CustomDataType

class NullableString(_message.Message):
__slots__ = ("value", "isNull")
__slots__ = ["value", "isNull"]
VALUE_FIELD_NUMBER: _ClassVar[int]
ISNULL_FIELD_NUMBER: _ClassVar[int]
value: str
isNull: bool
def __init__(self, value: _Optional[str] = ..., isNull: bool = ...) -> None: ...

class NullableDouble(_message.Message):
__slots__ = ("value", "isNull")
__slots__ = ["value", "isNull"]
VALUE_FIELD_NUMBER: _ClassVar[int]
ISNULL_FIELD_NUMBER: _ClassVar[int]
value: float
isNull: bool
def __init__(self, value: _Optional[float] = ..., isNull: bool = ...) -> None: ...

class CustomDataValue(_message.Message):
__slots__ = ("type", "boolValue", "doubleValue", "stringValue")
__slots__ = ["type", "boolValue", "doubleValue", "stringValue"]
TYPE_FIELD_NUMBER: _ClassVar[int]
BOOLVALUE_FIELD_NUMBER: _ClassVar[int]
DOUBLEVALUE_FIELD_NUMBER: _ClassVar[int]
Expand All @@ -57,9 +57,9 @@ class CustomDataValue(_message.Message):
def __init__(self, type: _Optional[_Union[CustomDataType, str]] = ..., boolValue: bool = ..., doubleValue: _Optional[float] = ..., stringValue: _Optional[str] = ...) -> None: ...

class NullableCustomData(_message.Message):
__slots__ = ("value", "isNull")
__slots__ = ["value", "isNull"]
class ValueEntry(_message.Message):
__slots__ = ("key", "value")
__slots__ = ["key", "value"]
KEY_FIELD_NUMBER: _ClassVar[int]
VALUE_FIELD_NUMBER: _ClassVar[int]
key: str
Expand All @@ -72,7 +72,7 @@ class NullableCustomData(_message.Message):
def __init__(self, value: _Optional[_Mapping[str, CustomDataValue]] = ..., isNull: bool = ...) -> None: ...

class VariableForUserParams_PB(_message.Message):
__slots__ = ("sdkKey", "variableKey", "variableType", "user", "shouldTrackEvent")
__slots__ = ["sdkKey", "variableKey", "variableType", "user", "shouldTrackEvent"]
SDKKEY_FIELD_NUMBER: _ClassVar[int]
VARIABLEKEY_FIELD_NUMBER: _ClassVar[int]
VARIABLETYPE_FIELD_NUMBER: _ClassVar[int]
Expand All @@ -86,7 +86,7 @@ class VariableForUserParams_PB(_message.Message):
def __init__(self, sdkKey: _Optional[str] = ..., variableKey: _Optional[str] = ..., variableType: _Optional[_Union[VariableType_PB, str]] = ..., user: _Optional[_Union[DVCUser_PB, _Mapping]] = ..., shouldTrackEvent: bool = ...) -> None: ...

class DVCUser_PB(_message.Message):
__slots__ = ("user_id", "email", "name", "language", "country", "appBuild", "appVersion", "deviceModel", "customData", "privateCustomData")
__slots__ = ["user_id", "email", "name", "language", "country", "appBuild", "appVersion", "deviceModel", "customData", "privateCustomData"]
USER_ID_FIELD_NUMBER: _ClassVar[int]
EMAIL_FIELD_NUMBER: _ClassVar[int]
NAME_FIELD_NUMBER: _ClassVar[int]
Expand All @@ -110,7 +110,7 @@ class DVCUser_PB(_message.Message):
def __init__(self, user_id: _Optional[str] = ..., email: _Optional[_Union[NullableString, _Mapping]] = ..., name: _Optional[_Union[NullableString, _Mapping]] = ..., language: _Optional[_Union[NullableString, _Mapping]] = ..., country: _Optional[_Union[NullableString, _Mapping]] = ..., appBuild: _Optional[_Union[NullableDouble, _Mapping]] = ..., appVersion: _Optional[_Union[NullableString, _Mapping]] = ..., deviceModel: _Optional[_Union[NullableString, _Mapping]] = ..., customData: _Optional[_Union[NullableCustomData, _Mapping]] = ..., privateCustomData: _Optional[_Union[NullableCustomData, _Mapping]] = ...) -> None: ...

class SDKVariable_PB(_message.Message):
__slots__ = ("_id", "type", "key", "boolValue", "doubleValue", "stringValue", "evalReason", "_feature", "eval")
__slots__ = ["_id", "type", "key", "boolValue", "doubleValue", "stringValue", "evalReason", "_feature", "eval"]
_ID_FIELD_NUMBER: _ClassVar[int]
TYPE_FIELD_NUMBER: _ClassVar[int]
KEY_FIELD_NUMBER: _ClassVar[int]
Expand All @@ -132,7 +132,7 @@ class SDKVariable_PB(_message.Message):
def __init__(self, _id: _Optional[str] = ..., type: _Optional[_Union[VariableType_PB, str]] = ..., key: _Optional[str] = ..., boolValue: bool = ..., doubleValue: _Optional[float] = ..., stringValue: _Optional[str] = ..., evalReason: _Optional[_Union[NullableString, _Mapping]] = ..., _feature: _Optional[_Union[NullableString, _Mapping]] = ..., eval: _Optional[_Union[EvalReason_PB, _Mapping]] = ...) -> None: ...

class EvalReason_PB(_message.Message):
__slots__ = ("reason", "details", "target_id")
__slots__ = ["reason", "details", "target_id"]
REASON_FIELD_NUMBER: _ClassVar[int]
DETAILS_FIELD_NUMBER: _ClassVar[int]
TARGET_ID_FIELD_NUMBER: _ClassVar[int]
Expand Down
34 changes: 34 additions & 0 deletions test/test_cloud_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,40 @@ def error_hook(context, error):
self.assertTrue(hook_called["finally"])
self.assertTrue(hook_called["error"])

@patch("devcycle_python_sdk.api.bucketing_client.BucketingAPIClient.variable")
def test_context_has_null_config_metadata(self, mock_variable_call):
mock_variable_call.return_value = Variable(
_id="123", key="strKey", value=999, type=TypeEnum.NUMBER
)

context_received = None

def before_hook(context):
nonlocal context_received
context_received = context
return context

def after_hook(context, variable):
pass

def finally_hook(context, variable):
pass

def error_hook(context, error):
pass

self.test_client.add_hook(
EvalHook(before_hook, after_hook, finally_hook, error_hook)
)

# Test that context has config_metadata field but it's null for cloud client
variable = self.test_client.variable(self.test_user, "strKey", 42)

self.assertIsNotNone(context_received)
self.assertTrue(hasattr(context_received, 'config_metadata'))
# Cloud client should have null config_metadata since it's not implemented
self.assertIsNone(context_received.config_metadata)


if __name__ == "__main__":
unittest.main()
41 changes: 41 additions & 0 deletions test/test_local_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,47 @@ def error_hook(context, error):
self.assertTrue(hook_called["finally"])
self.assertTrue(hook_called["error"])

@responses.activate
def test_context_has_config_metadata(self):
self.setup_client()

context_received = None

def before_hook(context):
nonlocal context_received
context_received = context
return context

def after_hook(context, variable):
pass

def finally_hook(context, variable):
pass

def error_hook(context, error):
pass

self.client.add_hook(
EvalHook(before_hook, after_hook, finally_hook, error_hook)
)

user = DevCycleUser(user_id="1234")

# Test that context has config_metadata field
variable = self.client.variable(user, "num-var", 42)

self.assertIsNotNone(context_received)
self.assertTrue(hasattr(context_received, 'config_metadata'))
# For local client, config_metadata should be populated
self.assertIsNotNone(context_received.config_metadata)
self.assertTrue(hasattr(context_received.config_metadata, 'project'))
self.assertTrue(hasattr(context_received.config_metadata, 'environment'))
# Verify the project and environment data
self.assertEqual(context_received.config_metadata.project.id, "61f97628ff4afcb6d057dbf0")
self.assertEqual(context_received.config_metadata.project.key, "emma-project")
self.assertEqual(context_received.config_metadata.environment.id, "61f97628ff4afcb6d057dbf2")
self.assertEqual(context_received.config_metadata.environment.key, "development")


def _benchmark_variable_call(client: DevCycleLocalClient, user: DevCycleUser, key: str):
return client.variable(user, key, "default_value")
Expand Down
2 changes: 1 addition & 1 deletion update_wasm_lib.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/bin/bash

BUCKETING_LIB_VERSION="1.41.0"
BUCKETING_LIB_VERSION="1.42.1"

if [[ -n "$1" ]]; then
BUCKETING_LIB_VERSION="$1"
Expand Down
Loading