devcycle-python-server-sdk 3.5.0__py3-none-any.whl → 3.7.1__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.
@@ -1 +1 @@
1
- 3.5.0
1
+ 3.7.1
@@ -2,7 +2,7 @@ import logging
2
2
  import time
3
3
  from http import HTTPStatus
4
4
  from typing import Optional, Tuple
5
-
5
+ import email.utils
6
6
  import requests
7
7
 
8
8
  from devcycle_python_sdk.api.backoff import exponential_backoff
@@ -30,21 +30,21 @@ class ConfigAPIClient:
30
30
  self.max_config_retries = 2
31
31
  self.config_file_url = (
32
32
  slash_join(
33
- self.options.config_cdn_uri, "config", "v1", "server", self.sdk_key
33
+ self.options.config_cdn_uri, "config", "v2", "server", self.sdk_key
34
34
  )
35
35
  + ".json"
36
36
  )
37
37
 
38
38
  def get_config(
39
- self, config_etag: Optional[str] = None
40
- ) -> Tuple[Optional[dict], Optional[str]]:
39
+ self, config_etag: Optional[str] = None, last_modified: Optional[str] = None
40
+ ) -> Tuple[Optional[dict], Optional[str], Optional[str]]:
41
41
  """
42
42
  Get the config from the server. If the config_etag is provided, the server will only return the config if it
43
43
  has changed since the last request. If the config hasn't changed, the server will return a 304 Not Modified
44
44
  response.
45
45
 
46
46
  :param config_etag: The etag of the last config request
47
-
47
+ :param last_modified: Last modified RFC1123 Timestamp of the stored config
48
48
  :return: A tuple containing the config and the etag of the config. If the config hasn't changed since the last
49
49
  request, the config will be None and the etag will be the same as the last request.
50
50
  """
@@ -56,6 +56,8 @@ class ConfigAPIClient:
56
56
  headers = {}
57
57
  if config_etag:
58
58
  headers["If-None-Match"] = config_etag
59
+ if last_modified:
60
+ headers["If-Modified-Since"] = last_modified
59
61
 
60
62
  attempts = 1
61
63
  while retries_remaining > 0:
@@ -74,7 +76,7 @@ class ConfigAPIClient:
74
76
  elif res.status_code == HTTPStatus.NOT_MODIFIED:
75
77
  # the config hasn't changed since the last request
76
78
  # don't return anything
77
- return None, config_etag
79
+ return None, config_etag, last_modified
78
80
  elif res.status_code == HTTPStatus.NOT_FOUND:
79
81
  # Not a retryable error
80
82
  raise NotFoundError(url)
@@ -113,6 +115,16 @@ class ConfigAPIClient:
113
115
  raise APIClientError(message="Retries exceeded", cause=request_error)
114
116
 
115
117
  new_etag = res.headers.get("ETag", None)
118
+ new_lastmodified = res.headers.get("Last-Modified", None)
119
+
120
+ if last_modified and new_lastmodified:
121
+ stored_lm = email.utils.parsedate_to_datetime(last_modified)
122
+ response_lm = email.utils.parsedate_to_datetime(new_lastmodified)
123
+ if stored_lm > response_lm:
124
+ logger.warning(
125
+ "Request returned a last modified header older than the current stored timestamp. not saving config"
126
+ )
127
+ return None, None, None
116
128
 
117
129
  data: dict = res.json()
118
- return data, new_etag
130
+ return data, new_etag, new_lastmodified
@@ -289,10 +289,13 @@ class LocalBucketing:
289
289
 
290
290
  return bytes(ret)
291
291
 
292
- def init_event_queue(self, options_json: str) -> None:
292
+ def init_event_queue(self, client_uuid, options_json: str) -> None:
293
293
  with self.wasm_lock:
294
+ client_uuid_addr = self._new_assembly_script_string(client_uuid)
294
295
  options_addr = self._new_assembly_script_string(options_json)
295
- self.initEventQueue(self.wasm_store, self.sdk_key_addr, options_addr)
296
+ self.initEventQueue(
297
+ self.wasm_store, self.sdk_key_addr, client_uuid_addr, options_addr
298
+ )
296
299
 
297
300
  def get_variable_for_user_protobuf(
298
301
  self, user: DevCycleUser, key: str, default_value: Any
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import logging
3
+ import uuid
3
4
  from numbers import Real
4
5
  from typing import Any, Dict, Union
5
6
 
@@ -28,6 +29,7 @@ class DevCycleLocalClient(AbstractDevCycleClient):
28
29
  def __init__(self, sdk_key: str, options: DevCycleLocalOptions):
29
30
  _validate_sdk_key(sdk_key)
30
31
  self._sdk_key = sdk_key
32
+ self.client_uuid = str(uuid.uuid4())
31
33
 
32
34
  if options is None:
33
35
  self.options: DevCycleLocalOptions = DevCycleLocalOptions()
@@ -45,7 +47,7 @@ class DevCycleLocalClient(AbstractDevCycleClient):
45
47
  sdk_key, self.options, self.local_bucketing
46
48
  )
47
49
  self.event_queue_manager: EventQueueManager = EventQueueManager(
48
- sdk_key, self.options, self.local_bucketing
50
+ sdk_key, self.client_uuid, self.options, self.local_bucketing
49
51
  )
50
52
 
51
53
  self._openfeature_provider = DevCycleProvider(self)
@@ -1,16 +1,21 @@
1
+ import json
2
+ import logging
1
3
  import threading
2
4
  import time
3
- import logging
4
- import json
5
+ from datetime import datetime
5
6
  from typing import Optional
6
7
 
7
- from devcycle_python_sdk.options import DevCycleLocalOptions
8
- from devcycle_python_sdk.api.local_bucketing import LocalBucketing
8
+ import ld_eventsource.actions
9
+
9
10
  from devcycle_python_sdk.api.config_client import ConfigAPIClient
11
+ from devcycle_python_sdk.api.local_bucketing import LocalBucketing
10
12
  from devcycle_python_sdk.exceptions import (
11
13
  CloudClientUnauthorizedError,
12
14
  CloudClientError,
13
15
  )
16
+ from wsgiref.handlers import format_date_time
17
+ from devcycle_python_sdk.options import DevCycleLocalOptions
18
+ from devcycle_python_sdk.managers.sse_manager import SSEManager
14
19
 
15
20
  logger = logging.getLogger(__name__)
16
21
 
@@ -27,9 +32,12 @@ class EnvironmentConfigManager(threading.Thread):
27
32
  self._sdk_key = sdk_key
28
33
  self._options = options
29
34
  self._local_bucketing = local_bucketing
30
-
35
+ self._sse_manager: Optional[SSEManager] = None
36
+ self._sse_polling_interval = 1000 * 60 * 15 * 60
37
+ self._sse_connected = False
31
38
  self._config: Optional[dict] = None
32
39
  self._config_etag: Optional[str] = None
40
+ self._config_lastmodified: Optional[str] = None
33
41
 
34
42
  self._config_api_client = ConfigAPIClient(self._sdk_key, self._options)
35
43
 
@@ -40,12 +48,20 @@ class EnvironmentConfigManager(threading.Thread):
40
48
  def is_initialized(self) -> bool:
41
49
  return self._config is not None
42
50
 
43
- def _get_config(self):
51
+ def _get_config(self, last_modified: Optional[float] = None):
44
52
  try:
45
- new_config, new_etag = self._config_api_client.get_config(
46
- config_etag=self._config_etag
53
+ lm_header = self._config_lastmodified
54
+ if last_modified is not None:
55
+ lm_timestamp = datetime.fromtimestamp(last_modified)
56
+ lm_header = format_date_time(time.mktime(lm_timestamp.timetuple()))
57
+
58
+ new_config, new_etag, new_lastmodified = self._config_api_client.get_config(
59
+ config_etag=self._config_etag, last_modified=lm_header
47
60
  )
48
61
 
62
+ # Abort early if the last modified is before the sent one.
63
+ if new_config is None and new_etag is None and new_lastmodified is None:
64
+ return
49
65
  if new_config is None and new_etag == self._config_etag:
50
66
  # api not returning data and the etag is the same
51
67
  # no change to the config since last request
@@ -60,9 +76,18 @@ class EnvironmentConfigManager(threading.Thread):
60
76
 
61
77
  self._config = new_config
62
78
  self._config_etag = new_etag
79
+ self._config_lastmodified = new_lastmodified
63
80
 
64
81
  json_config = json.dumps(self._config)
65
82
  self._local_bucketing.store_config(json_config)
83
+ if self._options.enable_beta_realtime_updates:
84
+ if self._sse_manager is None:
85
+ self._sse_manager = SSEManager(
86
+ self.sse_state,
87
+ self.sse_error,
88
+ self.sse_message,
89
+ )
90
+ self._sse_manager.update(self._config)
66
91
 
67
92
  if (
68
93
  trigger_on_client_initialized
@@ -93,7 +118,32 @@ class EnvironmentConfigManager(threading.Thread):
93
118
  logger.warning(
94
119
  f"DevCycle: Error polling for config changes: {str(e)}"
95
120
  )
96
- time.sleep(self._options.config_polling_interval_ms / 1000.0)
121
+ if self._sse_connected:
122
+ time.sleep(self._sse_polling_interval / 1000.0)
123
+ else:
124
+ time.sleep(self._options.config_polling_interval_ms / 1000.0)
125
+
126
+ def sse_message(self, message: ld_eventsource.actions.Event):
127
+ if self._sse_connected is False:
128
+ self._sse_connected = True
129
+ logger.info("DevCycle: Connected to SSE stream")
130
+ logger.info(f"DevCycle: Received message: {message.data}")
131
+ sse_message = json.loads(message.data)
132
+ dvc_data = json.loads(sse_message.get("data"))
133
+ if (
134
+ dvc_data.get("type") == "refetchConfig"
135
+ or dvc_data.get("type") == ""
136
+ or dvc_data.get("type") is None
137
+ ):
138
+ logger.info("DevCycle: Received refetchConfig message - updating config")
139
+ self._get_config(dvc_data["lastModified"])
140
+
141
+ def sse_error(self, error: ld_eventsource.actions.Fault):
142
+ logger.warning(f"DevCycle: Received SSE error: {error}")
143
+
144
+ def sse_state(self, state: ld_eventsource.actions.Start):
145
+ self._sse_connected = True
146
+ logger.info("DevCycle: Connected to SSE stream")
97
147
 
98
148
  def close(self):
99
149
  self._polling_enabled = False
@@ -31,6 +31,7 @@ class EventQueueManager(threading.Thread):
31
31
  def __init__(
32
32
  self,
33
33
  sdk_key: str,
34
+ client_uuid: str,
34
35
  options: DevCycleLocalOptions,
35
36
  local_bucketing: LocalBucketing,
36
37
  ):
@@ -49,7 +50,7 @@ class EventQueueManager(threading.Thread):
49
50
 
50
51
  # Setup the event queue inside the WASM module
51
52
  event_options_json = json.dumps(self._options.event_queue_options())
52
- self._local_bucketing.init_event_queue(event_options_json)
53
+ self._local_bucketing.init_event_queue(client_uuid, event_options_json)
53
54
 
54
55
  # Only start event processing thread if event logging is enabled
55
56
  if not (
@@ -0,0 +1,63 @@
1
+ import threading
2
+
3
+ import ld_eventsource
4
+ import ld_eventsource.actions
5
+ import ld_eventsource.config
6
+ from typing import Callable
7
+
8
+
9
+ class SSEManager:
10
+ def __init__(
11
+ self,
12
+ handlestate: Callable[[ld_eventsource.actions.Start], None],
13
+ handleerror: Callable[[ld_eventsource.actions.Fault], None],
14
+ handlemessage: Callable[[ld_eventsource.actions.Event], None],
15
+ ):
16
+ self.client: ld_eventsource.SSEClient = None
17
+ self.url = ""
18
+ self.handlestate = handlestate
19
+ self.handleerror = handleerror
20
+ self.handlemessage = handlemessage
21
+
22
+ self.read_thread = threading.Thread(
23
+ target=self.read_events,
24
+ args=(self.handlestate, self.handleerror, self.handlemessage),
25
+ )
26
+
27
+ def read_events(
28
+ self,
29
+ handlestate: Callable[[ld_eventsource.actions.Start], None],
30
+ handleerror: Callable[[ld_eventsource.actions.Fault], None],
31
+ handlemessage: Callable[[ld_eventsource.actions.Event], None],
32
+ ):
33
+ self.client.start()
34
+ for event in self.client.all:
35
+ if isinstance(event, ld_eventsource.actions.Start):
36
+ handlestate(event)
37
+ elif isinstance(event, ld_eventsource.actions.Fault):
38
+ handleerror(event)
39
+ elif isinstance(event, ld_eventsource.actions.Event):
40
+ handlemessage(event)
41
+
42
+ def update(self, config: dict):
43
+ if self.use_new_config(config["sse"]):
44
+ self.url = config["sse"]["hostname"] + config["sse"]["path"]
45
+ if self.client is not None:
46
+ self.client.close()
47
+ if self.read_thread.is_alive():
48
+ self.read_thread.join()
49
+ self.client = ld_eventsource.SSEClient(
50
+ connect=ld_eventsource.config.ConnectStrategy.http(self.url),
51
+ error_strategy=ld_eventsource.config.ErrorStrategy.CONTINUE,
52
+ )
53
+ self.read_thread = threading.Thread(
54
+ target=self.read_events,
55
+ args=(self.handlestate, self.handleerror, self.handlemessage),
56
+ )
57
+ self.read_thread.start()
58
+
59
+ def use_new_config(self, config: dict) -> bool:
60
+ new_url = config["hostname"] + config["path"]
61
+ if self.url == "" or self.url is None and new_url != "":
62
+ return True
63
+ return self.url != new_url
@@ -60,9 +60,9 @@ class ProjectSettings:
60
60
  @classmethod
61
61
  def from_json(cls, data: dict) -> "ProjectSettings":
62
62
  return cls(
63
- edge_db=EdgeDBSettings.from_json(data["edgeDB"])
64
- if "edgeDB" in data
65
- else None,
63
+ edge_db=(
64
+ EdgeDBSettings.from_json(data["edgeDB"]) if "edgeDB" in data else None
65
+ ),
66
66
  opt_in=OptInSettings.from_json(data["optIn"]) if "optIn" in data else None,
67
67
  disable_passthrough_rollouts=data.get("disablePassthroughRollouts", False),
68
68
  )
@@ -81,9 +81,11 @@ class Project:
81
81
  id=data["_id"],
82
82
  key=data["key"],
83
83
  a0_organization=data["a0_organization"],
84
- settings=ProjectSettings.from_json(data["settings"])
85
- if "settings" in data
86
- else None,
84
+ settings=(
85
+ ProjectSettings.from_json(data["settings"])
86
+ if "settings" in data
87
+ else None
88
+ ),
87
89
  )
88
90
 
89
91
 
@@ -58,9 +58,11 @@ class DevCycleProvider(AbstractProvider):
58
58
  else:
59
59
  return FlagResolutionDetails(
60
60
  value=variable.value,
61
- reason=Reason.DEFAULT
62
- if variable.isDefaulted
63
- else Reason.TARGETING_MATCH,
61
+ reason=(
62
+ Reason.DEFAULT
63
+ if variable.isDefaulted
64
+ else Reason.TARGETING_MATCH
65
+ ),
64
66
  )
65
67
  except ValueError as e:
66
68
  # occurs if the key or default value is None
@@ -45,6 +45,7 @@ class DevCycleLocalOptions:
45
45
  event_retry_delay_ms: int = 200, # milliseconds
46
46
  disable_automatic_event_logging: bool = False,
47
47
  disable_custom_event_logging: bool = False,
48
+ enable_beta_realtime_updates: bool = False,
48
49
  ):
49
50
  self.events_api_uri = events_api_uri
50
51
  self.config_cdn_uri = config_cdn_uri
@@ -60,6 +61,7 @@ class DevCycleLocalOptions:
60
61
  self.on_client_initialized = on_client_initialized
61
62
  self.event_request_timeout_ms = event_request_timeout_ms
62
63
  self.event_retry_delay_ms = event_retry_delay_ms
64
+ self.enable_beta_realtime_updates = enable_beta_realtime_updates
63
65
 
64
66
  if self.flush_event_queue_size >= self.max_event_queue_size:
65
67
  logger.warning(
@@ -0,0 +1,120 @@
1
+ from google.protobuf.internal import containers as _containers
2
+ from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
3
+ from google.protobuf import descriptor as _descriptor
4
+ from google.protobuf import message as _message
5
+ from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Optional, Union as _Union
6
+
7
+ Bool: CustomDataType
8
+ Boolean: VariableType_PB
9
+ DESCRIPTOR: _descriptor.FileDescriptor
10
+ JSON: VariableType_PB
11
+ Null: CustomDataType
12
+ Num: CustomDataType
13
+ Number: VariableType_PB
14
+ Str: CustomDataType
15
+ String: VariableType_PB
16
+
17
+ class CustomDataValue(_message.Message):
18
+ __slots__ = ["boolValue", "doubleValue", "stringValue", "type"]
19
+ BOOLVALUE_FIELD_NUMBER: _ClassVar[int]
20
+ DOUBLEVALUE_FIELD_NUMBER: _ClassVar[int]
21
+ STRINGVALUE_FIELD_NUMBER: _ClassVar[int]
22
+ TYPE_FIELD_NUMBER: _ClassVar[int]
23
+ boolValue: bool
24
+ doubleValue: float
25
+ stringValue: str
26
+ type: CustomDataType
27
+ def __init__(self, type: _Optional[_Union[CustomDataType, str]] = ..., boolValue: bool = ..., doubleValue: _Optional[float] = ..., stringValue: _Optional[str] = ...) -> None: ...
28
+
29
+ class DVCUser_PB(_message.Message):
30
+ __slots__ = ["appBuild", "appVersion", "country", "customData", "deviceModel", "email", "language", "name", "privateCustomData", "user_id"]
31
+ APPBUILD_FIELD_NUMBER: _ClassVar[int]
32
+ APPVERSION_FIELD_NUMBER: _ClassVar[int]
33
+ COUNTRY_FIELD_NUMBER: _ClassVar[int]
34
+ CUSTOMDATA_FIELD_NUMBER: _ClassVar[int]
35
+ DEVICEMODEL_FIELD_NUMBER: _ClassVar[int]
36
+ EMAIL_FIELD_NUMBER: _ClassVar[int]
37
+ LANGUAGE_FIELD_NUMBER: _ClassVar[int]
38
+ NAME_FIELD_NUMBER: _ClassVar[int]
39
+ PRIVATECUSTOMDATA_FIELD_NUMBER: _ClassVar[int]
40
+ USER_ID_FIELD_NUMBER: _ClassVar[int]
41
+ appBuild: NullableDouble
42
+ appVersion: NullableString
43
+ country: NullableString
44
+ customData: NullableCustomData
45
+ deviceModel: NullableString
46
+ email: NullableString
47
+ language: NullableString
48
+ name: NullableString
49
+ privateCustomData: NullableCustomData
50
+ user_id: str
51
+ 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: ...
52
+
53
+ class NullableCustomData(_message.Message):
54
+ __slots__ = ["isNull", "value"]
55
+ class ValueEntry(_message.Message):
56
+ __slots__ = ["key", "value"]
57
+ KEY_FIELD_NUMBER: _ClassVar[int]
58
+ VALUE_FIELD_NUMBER: _ClassVar[int]
59
+ key: str
60
+ value: CustomDataValue
61
+ def __init__(self, key: _Optional[str] = ..., value: _Optional[_Union[CustomDataValue, _Mapping]] = ...) -> None: ...
62
+ ISNULL_FIELD_NUMBER: _ClassVar[int]
63
+ VALUE_FIELD_NUMBER: _ClassVar[int]
64
+ isNull: bool
65
+ value: _containers.MessageMap[str, CustomDataValue]
66
+ def __init__(self, value: _Optional[_Mapping[str, CustomDataValue]] = ..., isNull: bool = ...) -> None: ...
67
+
68
+ class NullableDouble(_message.Message):
69
+ __slots__ = ["isNull", "value"]
70
+ ISNULL_FIELD_NUMBER: _ClassVar[int]
71
+ VALUE_FIELD_NUMBER: _ClassVar[int]
72
+ isNull: bool
73
+ value: float
74
+ def __init__(self, value: _Optional[float] = ..., isNull: bool = ...) -> None: ...
75
+
76
+ class NullableString(_message.Message):
77
+ __slots__ = ["isNull", "value"]
78
+ ISNULL_FIELD_NUMBER: _ClassVar[int]
79
+ VALUE_FIELD_NUMBER: _ClassVar[int]
80
+ isNull: bool
81
+ value: str
82
+ def __init__(self, value: _Optional[str] = ..., isNull: bool = ...) -> None: ...
83
+
84
+ class SDKVariable_PB(_message.Message):
85
+ __slots__ = ["_id", "boolValue", "doubleValue", "evalReason", "key", "stringValue", "type"]
86
+ BOOLVALUE_FIELD_NUMBER: _ClassVar[int]
87
+ DOUBLEVALUE_FIELD_NUMBER: _ClassVar[int]
88
+ EVALREASON_FIELD_NUMBER: _ClassVar[int]
89
+ KEY_FIELD_NUMBER: _ClassVar[int]
90
+ STRINGVALUE_FIELD_NUMBER: _ClassVar[int]
91
+ TYPE_FIELD_NUMBER: _ClassVar[int]
92
+ _ID_FIELD_NUMBER: _ClassVar[int]
93
+ _id: str
94
+ boolValue: bool
95
+ doubleValue: float
96
+ evalReason: NullableString
97
+ key: str
98
+ stringValue: str
99
+ type: VariableType_PB
100
+ 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]] = ...) -> None: ...
101
+
102
+ class VariableForUserParams_PB(_message.Message):
103
+ __slots__ = ["sdkKey", "shouldTrackEvent", "user", "variableKey", "variableType"]
104
+ SDKKEY_FIELD_NUMBER: _ClassVar[int]
105
+ SHOULDTRACKEVENT_FIELD_NUMBER: _ClassVar[int]
106
+ USER_FIELD_NUMBER: _ClassVar[int]
107
+ VARIABLEKEY_FIELD_NUMBER: _ClassVar[int]
108
+ VARIABLETYPE_FIELD_NUMBER: _ClassVar[int]
109
+ sdkKey: str
110
+ shouldTrackEvent: bool
111
+ user: DVCUser_PB
112
+ variableKey: str
113
+ variableType: VariableType_PB
114
+ def __init__(self, sdkKey: _Optional[str] = ..., variableKey: _Optional[str] = ..., variableType: _Optional[_Union[VariableType_PB, str]] = ..., user: _Optional[_Union[DVCUser_PB, _Mapping]] = ..., shouldTrackEvent: bool = ...) -> None: ...
115
+
116
+ class VariableType_PB(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
117
+ __slots__ = []
118
+
119
+ class CustomDataType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
120
+ __slots__ = []
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.1
2
+ Name: devcycle-python-server-sdk
3
+ Version: 3.7.1
4
+ Summary: DevCycle Python SDK
5
+ Home-page: https://github.com/devcycleHQ/python-server-sdk
6
+ Author-email:
7
+ Keywords: DevCycle
8
+ License-File: LICENSE
9
+ Requires-Dist: setuptools>=21.0.0
10
+ Requires-Dist: urllib3>=1.15.1
11
+ Requires-Dist: requests~=2.31
12
+ Requires-Dist: wasmtime==23.0.0
13
+ Requires-Dist: protobuf>=4.23.3
14
+ Requires-Dist: openfeature-sdk>=0.7.0
15
+ Requires-Dist: launchdarkly-eventsource>=1.2.0
16
+ Requires-Dist: responses~=0.23.1
17
+ Requires-Dist: dataclasses~=0.6
18
+
19
+ The DevCycle Python SDK used for feature management.
20
+
@@ -1,23 +1,24 @@
1
- devcycle_python_sdk/VERSION.txt,sha256=dhIMHyxDEcIVTCKmtY4A5BHg6-dq-uDa9gjZGWxh8RQ,6
1
+ devcycle_python_sdk/VERSION.txt,sha256=Tpnn6sIcK3GpcTnKzKaV2YyT-9fdJlBfBPUnGAmsS5Q,6
2
2
  devcycle_python_sdk/__init__.py,sha256=30TcYb9PbvVi97gwk16omCGG1UoUI9lmmU7xpvxNMvc,336
3
- devcycle_python_sdk/bucketing-lib.release.wasm,sha256=yrNDrAxZS1h9VlTzsdOrNLP4ngnM04PmKxcWop1yrt4,202623
3
+ devcycle_python_sdk/bucketing-lib.release.wasm,sha256=AJyYTuQs-PRjAiH9yzSgn-iESa9W7Vu0AQHLJHC5QS0,214842
4
4
  devcycle_python_sdk/cloud_client.py,sha256=UMfXvXwO4v3pZW9ZA52ivxPH1Lp4Goy5gpV_s0qCpxQ,7352
5
5
  devcycle_python_sdk/devcycle_client.py,sha256=W5MeOslkXjTqZtoSy_Bbbug8gVoicjxshpKjDteEr6E,917
6
6
  devcycle_python_sdk/exceptions.py,sha256=Et7fS8pEFbHkFY86T2pJoEUstwjh4_rzhJxVH8fzAns,1053
7
- devcycle_python_sdk/local_client.py,sha256=5PFEUmBs-QsgaiXWBQfhhjcN2TbB64JH3iVwpJteG7o,9373
8
- devcycle_python_sdk/options.py,sha256=Ejr6HbdyqzZajFVkEqWLk5XLfdtqCIkvsyWaRt_lgwU,4575
7
+ devcycle_python_sdk/local_client.py,sha256=WeXt-gWhFsaPjAp2UEmygQhzx_ygx2Jaq0z0yRHaah0,9448
8
+ devcycle_python_sdk/options.py,sha256=TRofo42DvtWlVlGiUfOygIBzidei5oaCZRWpZgDgr0Q,4700
9
9
  devcycle_python_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  devcycle_python_sdk/api/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
11
11
  devcycle_python_sdk/api/backoff.py,sha256=F6WYCloaZD9CjNu6mKBMq7K20S8-i2WVU6X05ZRoawE,303
12
12
  devcycle_python_sdk/api/bucketing_client.py,sha256=-S0y9uAffippCTfjYgxI5j4j7YNfMxQJYUba1219nps,5111
13
- devcycle_python_sdk/api/config_client.py,sha256=BbPAjvB8c4engCiVnrRDXAXabHJLKYc1uxCtoPpvffg,4293
13
+ devcycle_python_sdk/api/config_client.py,sha256=73aQAq130rTHpQrVDX6n_DwFKz0WuKxRoLDiE3vSoAM,5077
14
14
  devcycle_python_sdk/api/event_client.py,sha256=WxkuV6geEC9MgG4FN36HeEQQa22ilq2mkOVMZkpiEk4,3356
15
- devcycle_python_sdk/api/local_bucketing.py,sha256=-TfG3Jb0syVF3sqzzlFyKxHqLQ6a37C4mW0oj53J-yQ,16332
15
+ devcycle_python_sdk/api/local_bucketing.py,sha256=_MAMCHviIS1n4GiU-CnnLBeEVX8BUFRqwNbA1IQi84U,16470
16
16
  devcycle_python_sdk/managers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
- devcycle_python_sdk/managers/config_manager.py,sha256=_-1kWwyyAcwVF_DibXjnehXi3Qj6HR8orYfgNfgWJOc,3248
18
- devcycle_python_sdk/managers/event_queue_manager.py,sha256=06TcdlRoB89C7vGH1UTnnoDFsSnT71tPwf0gn1NK-W0,8071
17
+ devcycle_python_sdk/managers/config_manager.py,sha256=U8ok13HjEi5to-GxH6hDuHEq09O5e7h51Aeui05XbdQ,5627
18
+ devcycle_python_sdk/managers/event_queue_manager.py,sha256=uPy1vO6OAisZjUNAhWELE_g17alTiOcPv42z-WwkGck,8110
19
+ devcycle_python_sdk/managers/sse_manager.py,sha256=aJKHPmvv8SR4DU9IOi-KrUSCxQoHdMwoemb2lSVfmVQ,2369
19
20
  devcycle_python_sdk/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
- devcycle_python_sdk/models/bucketed_config.py,sha256=76bdF7pgUdIAn2y8YPs3AO7Qa2eyJQ_jf4QhG3IERaE,3951
21
+ devcycle_python_sdk/models/bucketed_config.py,sha256=NsdT-6RbEuDy5QuK5qlr6UqNKQQnwjoXVYN-uQp0P-E,3999
21
22
  devcycle_python_sdk/models/error_response.py,sha256=r_dRUDAbral9UGaMYcnBsZOr9Hmkyi1SQbhGmZilgpA,442
22
23
  devcycle_python_sdk/models/event.py,sha256=-6bhKp72mAgaoSxvhS__G4vvfQ9Psj83fc0Igelzw3o,3713
23
24
  devcycle_python_sdk/models/feature.py,sha256=m3wxxIjC5jxhKpPLI4RcsBaNLuy-zsMqoAgc8ySPe0I,852
@@ -25,42 +26,43 @@ devcycle_python_sdk/models/platform_data.py,sha256=kIFevH5trsxI32qp7BW6glUCpipgp
25
26
  devcycle_python_sdk/models/user.py,sha256=57d_n8Yy8g-zsGzVF0hmSf4McPtFPJJ0LLY_ql75FWk,6266
26
27
  devcycle_python_sdk/models/variable.py,sha256=qp00SyCNyfYiMlTwnDSDJ4ZetRF35JHTbYZQOBxFIqY,1711
27
28
  devcycle_python_sdk/open_feature_provider/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
- devcycle_python_sdk/open_feature_provider/provider.py,sha256=FjNxmyKrnUO4ma4c-Z39qWigLVC_P-L_Nly0mycA1Vc,4475
29
+ devcycle_python_sdk/open_feature_provider/provider.py,sha256=VjUMhCYiMLixXUSSIjQ7kVCQrls1QD_Nltooe5EjGks,4539
29
30
  devcycle_python_sdk/protobuf/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
31
  devcycle_python_sdk/protobuf/utils.py,sha256=zi-kVx6yvgS2c04cUobLd2IDc_ZqKkWduay9YcpjoGc,4684
31
32
  devcycle_python_sdk/protobuf/variableForUserParams_pb2.py,sha256=Zf4-8n67UnulC0Sz90aqLYsXCFEgywOqfiSHuXZomLk,4244
33
+ devcycle_python_sdk/protobuf/variableForUserParams_pb2.pyi,sha256=gjTD1KkBe5NaXixSq78eNTNbjdDjBQ0h5L4dUKKgDWQ,5659
32
34
  devcycle_python_sdk/util/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
33
35
  devcycle_python_sdk/util/strings.py,sha256=gH0Xm3gIXNqn6hczKPk10Jn5_9knuD7OB5MrB7bqztw,252
34
36
  devcycle_python_sdk/util/version.py,sha256=Cz7Us_EuXP-T8GI47g2WHYdBJj_zKo-5y545IDHJAjI,253
35
37
  example/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
38
  example/cloud_client_example.py,sha256=YD8ZsTv3dPYfbFUKraRpRT0VsJGrPwjm8ZCuA5H54yI,2463
37
- example/local_bucketing_client_example.py,sha256=TaIfy3VnW9fXii9NPYs5UOuP4fLKruXMjnZApveqzl4,2796
39
+ example/local_bucketing_client_example.py,sha256=4BKNp7KE_Co9xxRTbJOfG2jQFjtFagPYsvytu93Q5_A,2829
38
40
  example/openfeature_example.py,sha256=kO6F-Xk20l6CPiMfIxSRreshEGmHMFHRg3zbeLXa3AI,2410
39
41
  test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
40
42
  test/test_cloud_client.py,sha256=zjk302m7qKMCQryolkc1WKLFmbGWUxA0VtHlZk0rY-0,11442
41
- test/test_local_client.py,sha256=Ds6VIfLcaefboyvGrhQTGsdJj_DGB4IVnR2_16xDpSY,13022
43
+ test/test_local_client.py,sha256=kDB5-cPJGyQtVdHV4MUM-CBBeOpN-oScr6I0OuyGPmE,13022
42
44
  test/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
43
45
  test/api/test_bucketing_client.py,sha256=M2mjACowLvbsIjXdP-w8-fwY02xqv1X1r_h9v-pXa7A,7739
44
- test/api/test_config_client.py,sha256=oZMBlXGw4UHb5-rSNF_ARTrAjvbvkbFgb8GAK4ExR14,4413
46
+ test/api/test_config_client.py,sha256=3BwMm4y-4NQcdW2DDGppoJLIF-ffgjlUJti2Qjw1D1M,5129
45
47
  test/api/test_event_client.py,sha256=v4Xoum-PdywfczrfMV1llM5vrIJoqHybzxPLVokh09g,3310
46
- test/api/test_local_bucketing.py,sha256=lPL3ThZcGG-8-67jFxRvGrwL8NB6-sM5qGJb0p2O__Q,15254
48
+ test/api/test_local_bucketing.py,sha256=0mReZ4bsFUoJWPAIcQrWXDZ9UoC5mF9MuI-9BVB6qjM,15561
47
49
  test/fixture/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
48
50
  test/fixture/data.py,sha256=6ReLxWPMhCDbJ5Ex24_iR0PJNjKzPFV1I9j_v0FVbBU,1462
49
51
  test/managers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
50
- test/managers/test_config_manager.py,sha256=_MtvyxK06xpBzzEsN1zsLdYrk3DsPsbCmSj7XorINmI,5366
51
- test/managers/test_event_queue_manager.py,sha256=KquHBuYiDdoVStm5Ga9mIB-wvmJnChiFRBNC8sMKaxQ,11053
52
+ test/managers/test_config_manager.py,sha256=X0DxzWdjkvkUmarJSgzTDvO2Ma0TU6RNwL0WUwX2uA8,5963
53
+ test/managers/test_event_queue_manager.py,sha256=z850atk8zAlD7sYaA1OJwgCFcfC0ps4NEk4XUnNCq1U,11629
52
54
  test/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
53
55
  test/models/test_bucketed_config.py,sha256=8wCEyxaHxY21nl7ZbiFzp-IzZQBZIPIVPPzeREjumQk,858
54
56
  test/models/test_user.py,sha256=ZNTBSKLDrqrsqZuaw5AHtc_FW4empSTaeNPZHZaFlI4,6736
55
57
  test/openfeature_test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
56
58
  test/openfeature_test/test_provider.py,sha256=UPxAD5e7NvpIhmqNFCrTY_yrKrMyYxBc2xuMAgdSGvw,7653
57
- test/openfeature_test/test_provider_local_sdk.py,sha256=5NjfUDalW4F7Oe8n1740VO7l5OIBEOldnSryM98uO2w,4426
59
+ test/openfeature_test/test_provider_local_sdk.py,sha256=ad0QizKSS-lbqlIjiQvwtKHP7PCFfYam3OKHtvsFBkU,4426
58
60
  test/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
59
61
  test/util/test_strings.py,sha256=T5H5U0af7_ld1yJC8IdDmay_Mx-iJQ5Fi5fbvKcAc7A,862
60
62
  test/util/test_utils.py,sha256=tVBJm_qnqg-4PssLpXIxqlk0JehuiAIPs4zpi2oNVnY,6671
61
63
  test/util/test_version.py,sha256=3Xg0_84m0bdHXz6pxd2Z85Oz9DoUC1T0Ol_WqEH_jac,335
62
- devcycle_python_server_sdk-3.5.0.dist-info/LICENSE,sha256=tHQMqu-Jic2UpMGqb-vNFbGygysl8IG4l1clnnh0OFo,1071
63
- devcycle_python_server_sdk-3.5.0.dist-info/METADATA,sha256=IAUJWGuZDWtOoCfJ6Tj6aKURxJODAtUuutO1g94hLho,510
64
- devcycle_python_server_sdk-3.5.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
65
- devcycle_python_server_sdk-3.5.0.dist-info/top_level.txt,sha256=34f8zDzihAHoH7siAFlmscyO_QLNWWq9HApFlwtYL4k,33
66
- devcycle_python_server_sdk-3.5.0.dist-info/RECORD,,
64
+ devcycle_python_server_sdk-3.7.1.dist-info/LICENSE,sha256=tHQMqu-Jic2UpMGqb-vNFbGygysl8IG4l1clnnh0OFo,1071
65
+ devcycle_python_server_sdk-3.7.1.dist-info/METADATA,sha256=7iRcy6f-15UNPk8GalpC-RJM-Ls_qJOKtBxjZ0tSD9o,586
66
+ devcycle_python_server_sdk-3.7.1.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
67
+ devcycle_python_server_sdk-3.7.1.dist-info/top_level.txt,sha256=34f8zDzihAHoH7siAFlmscyO_QLNWWq9HApFlwtYL4k,33
68
+ devcycle_python_server_sdk-3.7.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.43.0)
2
+ Generator: setuptools (75.1.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -21,7 +21,7 @@ def main():
21
21
 
22
22
  # create an instance of the DevCycle Client object
23
23
  server_sdk_key = os.environ["DEVCYCLE_SERVER_SDK_KEY"]
24
- options = DevCycleLocalOptions()
24
+ options = DevCycleLocalOptions(enable_beta_realtime_updates=True)
25
25
  client = DevCycleLocalClient(server_sdk_key, options)
26
26
 
27
27
  # Wait for DevCycle to initialize and load the configuration
@@ -2,6 +2,9 @@ import logging
2
2
  import unittest
3
3
  import uuid
4
4
  from http import HTTPStatus
5
+ from datetime import datetime
6
+ from email.utils import formatdate
7
+ from time import mktime
5
8
 
6
9
  import requests
7
10
  import responses
@@ -27,7 +30,7 @@ class ConfigAPIClientTest(unittest.TestCase):
27
30
  slash_join(
28
31
  "https://config-cdn.devcycle.com/",
29
32
  "config",
30
- "v1",
33
+ "v2",
31
34
  "server",
32
35
  self.sdk_key,
33
36
  )
@@ -36,6 +39,9 @@ class ConfigAPIClientTest(unittest.TestCase):
36
39
 
37
40
  options = DevCycleLocalOptions(config_retry_delay_ms=0)
38
41
  self.test_client = ConfigAPIClient(self.sdk_key, options)
42
+ now = datetime.now()
43
+ stamp = mktime(now.timetuple())
44
+ self.test_lastmodified = formatdate(timeval=stamp, localtime=False, usegmt=True)
39
45
  self.test_etag = str(uuid.uuid4())
40
46
  self.test_config_json: dict = small_config_json()
41
47
 
@@ -45,14 +51,20 @@ class ConfigAPIClientTest(unittest.TestCase):
45
51
  @responses.activate
46
52
  def test_get_config(self):
47
53
  new_etag = str(uuid.uuid4())
54
+ now = datetime.now()
55
+ stamp = mktime(now.timetuple())
56
+ new_lastmodified = formatdate(timeval=stamp, localtime=False, usegmt=True)
48
57
  responses.add(
49
58
  responses.GET,
50
59
  self.config_url,
51
- headers={"ETag": new_etag},
60
+ headers={"ETag": new_etag, "Last-Modified": new_lastmodified},
52
61
  json=self.test_config_json,
53
62
  )
54
- result, etag = self.test_client.get_config(config_etag=self.test_etag)
63
+ result, etag, lastmodified = self.test_client.get_config(
64
+ config_etag=self.test_etag, last_modified=self.test_lastmodified
65
+ )
55
66
  self.assertDictEqual(result, self.test_config_json)
67
+ self.assertEqual(lastmodified, new_lastmodified)
56
68
  self.assertEqual(etag, new_etag)
57
69
 
58
70
  @responses.activate(registry=OrderedRegistry)
@@ -68,7 +80,9 @@ class ConfigAPIClientTest(unittest.TestCase):
68
80
  headers={"ETag": self.test_etag},
69
81
  json=self.test_config_json,
70
82
  )
71
- result, etag = self.test_client.get_config(config_etag=self.test_etag)
83
+ result, etag, lastmodified = self.test_client.get_config(
84
+ config_etag=self.test_etag
85
+ )
72
86
 
73
87
  self.assertDictEqual(result, self.test_config_json)
74
88
  self.assertEqual(etag, self.test_etag)
@@ -83,10 +97,12 @@ class ConfigAPIClientTest(unittest.TestCase):
83
97
  responses.add(
84
98
  responses.GET,
85
99
  self.config_url,
86
- headers={"ETag": self.test_etag},
100
+ headers={"ETag": self.test_etag, "Last-Modified": self.test_lastmodified},
87
101
  json=self.test_config_json,
88
102
  )
89
- result, etag = self.test_client.get_config(config_etag=self.test_etag)
103
+ result, etag, last_modified = self.test_client.get_config(
104
+ config_etag=self.test_etag
105
+ )
90
106
  self.assertDictEqual(result, self.test_config_json)
91
107
  self.assertEqual(etag, self.test_etag)
92
108
 
@@ -131,6 +147,8 @@ class ConfigAPIClientTest(unittest.TestCase):
131
147
  status=HTTPStatus.NOT_MODIFIED,
132
148
  )
133
149
 
134
- new_config, new_etag = self.test_client.get_config(config_etag=self.test_etag)
150
+ new_config, new_etag, last_modified = self.test_client.get_config(
151
+ config_etag=self.test_etag
152
+ )
135
153
  self.assertIsNone(new_config)
136
154
  self.assertEqual(new_etag, self.test_etag)
@@ -1,6 +1,7 @@
1
1
  import json
2
2
  import logging
3
3
  import unittest
4
+ import uuid
4
5
 
5
6
  from devcycle_python_sdk.api.local_bucketing import LocalBucketing, WASMAbortError
6
7
  from devcycle_python_sdk.models.bucketed_config import (
@@ -24,6 +25,7 @@ class LocalBucketingTest(unittest.TestCase):
24
25
  def setUp(self) -> None:
25
26
  self.test_sdk_key = "dvc_server_testkey"
26
27
  self.local_bucketing = LocalBucketing(self.test_sdk_key)
28
+ self.client_uuid = str(uuid.uuid4())
27
29
 
28
30
  def test_init(self):
29
31
  local_bucketing = LocalBucketing(self.test_sdk_key)
@@ -93,7 +95,7 @@ class LocalBucketingTest(unittest.TestCase):
93
95
  self.local_bucketing.store_config(small_config())
94
96
  platform_json = json.dumps(default_platform_data().to_json())
95
97
  self.local_bucketing.set_platform_data(platform_json)
96
- self.local_bucketing.init_event_queue("{}")
98
+ self.local_bucketing.init_event_queue(self.client_uuid, "{}")
97
99
  user = DevCycleUser(user_id="test_user_id")
98
100
  result = self.local_bucketing.get_variable_for_user_protobuf(
99
101
  user=user, key="string-var", default_value="default value"
@@ -108,7 +110,7 @@ class LocalBucketingTest(unittest.TestCase):
108
110
  self.local_bucketing.store_config(special_character_config())
109
111
  platform_json = json.dumps(default_platform_data().to_json())
110
112
  self.local_bucketing.set_platform_data(platform_json)
111
- self.local_bucketing.init_event_queue("{}")
113
+ self.local_bucketing.init_event_queue(self.client_uuid, "{}")
112
114
  user = DevCycleUser(user_id="test_user_id")
113
115
  result = self.local_bucketing.get_variable_for_user_protobuf(
114
116
  user=user, key="string-var", default_value="default value"
@@ -123,7 +125,7 @@ class LocalBucketingTest(unittest.TestCase):
123
125
  self.local_bucketing.store_config(small_config())
124
126
  platform_json = json.dumps(default_platform_data().to_json())
125
127
  self.local_bucketing.set_platform_data(platform_json)
126
- self.local_bucketing.init_event_queue("{}")
128
+ self.local_bucketing.init_event_queue(self.client_uuid, "{}")
127
129
  user = DevCycleUser(user_id="test_user_id")
128
130
 
129
131
  # type mismatch is handled inside the WASM and will return
@@ -137,7 +139,7 @@ class LocalBucketingTest(unittest.TestCase):
137
139
  self.local_bucketing.store_config(small_config())
138
140
  platform_json = json.dumps(default_platform_data().to_json())
139
141
  self.local_bucketing.set_platform_data(platform_json)
140
- self.local_bucketing.init_event_queue("{}")
142
+ self.local_bucketing.init_event_queue(self.client_uuid, "{}")
141
143
  user = DevCycleUser(user_id="test_user_id")
142
144
 
143
145
  result = self.local_bucketing.generate_bucketed_config(user=user)
@@ -265,7 +267,7 @@ class LocalBucketingTest(unittest.TestCase):
265
267
  self.local_bucketing.store_config(small_config())
266
268
  platform_json = json.dumps(default_platform_data().to_json())
267
269
  self.local_bucketing.set_platform_data(platform_json)
268
- self.local_bucketing.init_event_queue("{}")
270
+ self.local_bucketing.init_event_queue(self.client_uuid, "{}")
269
271
 
270
272
  result = self.local_bucketing.get_event_queue_size()
271
273
  self.assertEqual(result, 0)
@@ -276,13 +278,14 @@ class LocalBucketingTest(unittest.TestCase):
276
278
  self.local_bucketing.set_platform_data(platform_json)
277
279
 
278
280
  self.local_bucketing.init_event_queue(
281
+ self.client_uuid,
279
282
  json.dumps(
280
283
  {
281
284
  "disableAutomaticEventLogging": False,
282
285
  "disableCustomEventLogging": False,
283
286
  "minEventsPerFlush": 1,
284
287
  }
285
- )
288
+ ),
286
289
  )
287
290
  results = self.local_bucketing.flush_event_queue()
288
291
  self.assertIsNotNone(results)
@@ -294,13 +297,14 @@ class LocalBucketingTest(unittest.TestCase):
294
297
  self.local_bucketing.set_platform_data(platform_json)
295
298
 
296
299
  self.local_bucketing.init_event_queue(
300
+ self.client_uuid,
297
301
  json.dumps(
298
302
  {
299
303
  "disableAutomaticEventLogging": False,
300
304
  "disableCustomEventLogging": False,
301
305
  "minEventsPerFlush": 1,
302
306
  }
303
- )
307
+ ),
304
308
  )
305
309
 
306
310
  # trigger two events for a single user
@@ -326,13 +330,14 @@ class LocalBucketingTest(unittest.TestCase):
326
330
  self.local_bucketing.set_platform_data(platform_json)
327
331
 
328
332
  self.local_bucketing.init_event_queue(
333
+ self.client_uuid,
329
334
  json.dumps(
330
335
  {
331
336
  "disableAutomaticEventLogging": False,
332
337
  "disableCustomEventLogging": False,
333
338
  "minEventsPerFlush": 1,
334
339
  }
335
- )
340
+ ),
336
341
  )
337
342
 
338
343
  with self.assertRaises(WASMAbortError):
@@ -344,13 +349,14 @@ class LocalBucketingTest(unittest.TestCase):
344
349
  self.local_bucketing.set_platform_data(platform_json)
345
350
 
346
351
  self.local_bucketing.init_event_queue(
352
+ self.client_uuid,
347
353
  json.dumps(
348
354
  {
349
355
  "disableAutomaticEventLogging": False,
350
356
  "disableCustomEventLogging": False,
351
357
  "minEventsPerFlush": 1,
352
358
  }
353
- )
359
+ ),
354
360
  )
355
361
 
356
362
  with self.assertRaises(WASMAbortError):
@@ -360,7 +366,7 @@ class LocalBucketingTest(unittest.TestCase):
360
366
  self.local_bucketing.store_config(small_config())
361
367
  platform_json = json.dumps(default_platform_data().to_json())
362
368
  self.local_bucketing.set_platform_data(platform_json)
363
- self.local_bucketing.init_event_queue("{}")
369
+ self.local_bucketing.init_event_queue(self.client_uuid, "{}")
364
370
  user = DevCycleUser(user_id="test_user_id")
365
371
  event = DevCycleEvent(
366
372
  type=EventType.CustomEvent,
@@ -376,7 +382,7 @@ class LocalBucketingTest(unittest.TestCase):
376
382
  self.local_bucketing.store_config(small_config())
377
383
  platform_json = json.dumps(default_platform_data().to_json())
378
384
  self.local_bucketing.set_platform_data(platform_json)
379
- self.local_bucketing.init_event_queue("{}")
385
+ self.local_bucketing.init_event_queue(self.client_uuid, "{}")
380
386
 
381
387
  event = DevCycleEvent(
382
388
  type=EventType.AggVariableDefaulted,
@@ -1,8 +1,11 @@
1
- import logging
2
1
  import json
2
+ import logging
3
3
  import time
4
- import uuid
5
4
  import unittest
5
+ import uuid
6
+ from datetime import datetime
7
+ from email.utils import formatdate
8
+ from time import mktime
6
9
  from unittest.mock import patch, MagicMock
7
10
 
8
11
  from devcycle_python_sdk import DevCycleLocalOptions
@@ -18,20 +21,27 @@ class EnvironmentConfigManagerTest(unittest.TestCase):
18
21
  self.test_local_bucketing = MagicMock()
19
22
  self.test_options = DevCycleLocalOptions(config_polling_interval_ms=500)
20
23
 
24
+ now = datetime.now()
25
+ stamp = mktime(now.timetuple())
26
+ self.test_lastmodified = formatdate(timeval=stamp, localtime=False, usegmt=True)
21
27
  self.test_etag = str(uuid.uuid4())
22
28
  self.test_config_json = small_config_json()
23
29
  self.test_config_string = json.dumps(self.test_config_json)
24
30
 
25
31
  @patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
26
32
  def test_init(self, mock_get_config):
27
- mock_get_config.return_value = (self.test_config_json, self.test_etag)
33
+ mock_get_config.return_value = (
34
+ self.test_config_json,
35
+ self.test_etag,
36
+ self.test_lastmodified,
37
+ )
28
38
  config_manager = EnvironmentConfigManager(
29
39
  self.sdk_key, self.test_options, self.test_local_bucketing
30
40
  )
31
41
 
32
42
  # sleep to allow polling thread to load the config
33
43
  time.sleep(0.1)
34
- mock_get_config.assert_called_once_with(config_etag=None)
44
+ mock_get_config.assert_called_once_with(config_etag=None, last_modified=None)
35
45
 
36
46
  self.assertTrue(config_manager._polling_enabled)
37
47
  self.assertTrue(config_manager.is_alive())
@@ -45,7 +55,11 @@ class EnvironmentConfigManagerTest(unittest.TestCase):
45
55
 
46
56
  @patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
47
57
  def test_init_with_client_callback(self, mock_get_config):
48
- mock_get_config.return_value = (self.test_config_json, self.test_etag)
58
+ mock_get_config.return_value = (
59
+ self.test_config_json,
60
+ self.test_etag,
61
+ self.test_lastmodified,
62
+ )
49
63
 
50
64
  mock_callback = MagicMock()
51
65
 
@@ -55,7 +69,7 @@ class EnvironmentConfigManagerTest(unittest.TestCase):
55
69
  self.sdk_key, self.test_options, self.test_local_bucketing
56
70
  )
57
71
  time.sleep(0.1)
58
- mock_get_config.assert_called_once_with(config_etag=None)
72
+ mock_get_config.assert_called_once_with(config_etag=None, last_modified=None)
59
73
  self.assertEqual(config_manager._config_etag, self.test_etag)
60
74
  self.assertDictEqual(config_manager._config, self.test_config_json)
61
75
  self.test_local_bucketing.store_config.assert_called_once_with(
@@ -66,7 +80,11 @@ class EnvironmentConfigManagerTest(unittest.TestCase):
66
80
 
67
81
  @patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
68
82
  def test_init_with_client_callback_with_error(self, mock_get_config):
69
- mock_get_config.return_value = (self.test_config_json, self.test_etag)
83
+ mock_get_config.return_value = (
84
+ self.test_config_json,
85
+ self.test_etag,
86
+ self.test_lastmodified,
87
+ )
70
88
  mock_callback = MagicMock()
71
89
  mock_callback.side_effect = Exception(
72
90
  "Badly written callback generates an exception"
@@ -79,7 +97,7 @@ class EnvironmentConfigManagerTest(unittest.TestCase):
79
97
  )
80
98
  # the callback error should not negatively impact initialization of the config manager
81
99
  time.sleep(0.1)
82
- mock_get_config.assert_called_once_with(config_etag=None)
100
+ mock_get_config.assert_called_once_with(config_etag=None, last_modified=None)
83
101
  self.assertEqual(config_manager._config_etag, self.test_etag)
84
102
  self.assertDictEqual(config_manager._config, self.test_config_json)
85
103
  self.test_local_bucketing.store_config.assert_called_once_with(
@@ -105,7 +123,11 @@ class EnvironmentConfigManagerTest(unittest.TestCase):
105
123
 
106
124
  @patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
107
125
  def test_get_config_unchanged(self, mock_get_config):
108
- mock_get_config.return_value = (self.test_config_json, self.test_etag)
126
+ mock_get_config.return_value = (
127
+ self.test_config_json,
128
+ self.test_etag,
129
+ self.test_lastmodified,
130
+ )
109
131
 
110
132
  self.test_options.config_polling_interval_ms = 200
111
133
  config_manager = EnvironmentConfigManager(
@@ -117,7 +139,7 @@ class EnvironmentConfigManagerTest(unittest.TestCase):
117
139
  config_manager.close()
118
140
 
119
141
  self.test_local_bucketing.store_config.reset_mock()
120
- mock_get_config.return_value = (None, config_manager._config_etag)
142
+ mock_get_config.return_value = (None, config_manager._config_etag, None)
121
143
 
122
144
  # trigger refresh of the config directly
123
145
  config_manager._get_config()
@@ -26,6 +26,7 @@ logger = logging.getLogger(__name__)
26
26
  class EventQueueManagerTest(unittest.TestCase):
27
27
  def setUp(self) -> None:
28
28
  self.sdk_key = "dvc_server_" + str(uuid.uuid4())
29
+ self.client_uuid = str(uuid.uuid4())
29
30
 
30
31
  # mock out the local bucketing to test event queue logic directly
31
32
  self.test_local_bucketing = MagicMock()
@@ -84,7 +85,7 @@ class EventQueueManagerTest(unittest.TestCase):
84
85
  self.test_local_bucketing.flush_event_queue.return_value = []
85
86
  self.test_options.event_flush_interval_ms = 100
86
87
  manager = EventQueueManager(
87
- self.sdk_key, self.test_options, self.test_local_bucketing
88
+ self.sdk_key, self.client_uuid, self.test_options, self.test_local_bucketing
88
89
  )
89
90
  self.assertTrue(manager._should_run())
90
91
  self.assertTrue(manager.is_alive())
@@ -94,7 +95,7 @@ class EventQueueManagerTest(unittest.TestCase):
94
95
  self.test_local_bucketing.flush_event_queue.return_value = []
95
96
  self.test_options.event_flush_interval_ms = 100
96
97
  manager = EventQueueManager(
97
- self.sdk_key, self.test_options, self.test_local_bucketing
98
+ self.sdk_key, self.client_uuid, self.test_options, self.test_local_bucketing
98
99
  )
99
100
 
100
101
  manager.close()
@@ -111,7 +112,10 @@ class EventQueueManagerTest(unittest.TestCase):
111
112
  mock_publish_events.side_effect = APIClientError("Some retryable error")
112
113
 
113
114
  manager = EventQueueManager(
114
- self.sdk_key, self.test_options_no_thread, self.test_local_bucketing
115
+ self.sdk_key,
116
+ self.client_uuid,
117
+ self.test_options_no_thread,
118
+ self.test_local_bucketing,
115
119
  )
116
120
  manager._publish_event_payload(self.test_payload)
117
121
 
@@ -129,7 +133,10 @@ class EventQueueManagerTest(unittest.TestCase):
129
133
  )
130
134
 
131
135
  manager = EventQueueManager(
132
- self.sdk_key, self.test_options_no_thread, self.test_local_bucketing
136
+ self.sdk_key,
137
+ self.client_uuid,
138
+ self.test_options_no_thread,
139
+ self.test_local_bucketing,
133
140
  )
134
141
  manager._publish_event_payload(self.test_payload)
135
142
 
@@ -146,7 +153,10 @@ class EventQueueManagerTest(unittest.TestCase):
146
153
  self.test_local_bucketing.flush_event_queue.return_value = []
147
154
 
148
155
  manager = EventQueueManager(
149
- self.sdk_key, self.test_options_no_thread, self.test_local_bucketing
156
+ self.sdk_key,
157
+ self.client_uuid,
158
+ self.test_options_no_thread,
159
+ self.test_local_bucketing,
150
160
  )
151
161
  manager._flush_events()
152
162
 
@@ -158,7 +168,10 @@ class EventQueueManagerTest(unittest.TestCase):
158
168
  self.test_local_bucketing.flush_event_queue.return_value = [self.test_payload]
159
169
 
160
170
  manager = EventQueueManager(
161
- self.sdk_key, self.test_options_no_thread, self.test_local_bucketing
171
+ self.sdk_key,
172
+ self.client_uuid,
173
+ self.test_options_no_thread,
174
+ self.test_local_bucketing,
162
175
  )
163
176
  result = manager._flush_events()
164
177
 
@@ -169,7 +182,10 @@ class EventQueueManagerTest(unittest.TestCase):
169
182
 
170
183
  def test_queue_event_bad_data(self):
171
184
  manager = EventQueueManager(
172
- self.sdk_key, self.test_options_no_thread, self.test_local_bucketing
185
+ self.sdk_key,
186
+ self.client_uuid,
187
+ self.test_options_no_thread,
188
+ self.test_local_bucketing,
173
189
  )
174
190
  with self.assertRaises(ValueError):
175
191
  manager.queue_event(user=None, event=None)
@@ -188,7 +204,10 @@ class EventQueueManagerTest(unittest.TestCase):
188
204
  self.test_local_bucketing.get_event_queue_size.return_value = 1
189
205
 
190
206
  manager = EventQueueManager(
191
- self.sdk_key, self.test_options_no_thread, self.test_local_bucketing
207
+ self.sdk_key,
208
+ self.client_uuid,
209
+ self.test_options_no_thread,
210
+ self.test_local_bucketing,
192
211
  )
193
212
  DevCycleUser(user_id="test_user_id")
194
213
  event = DevCycleEvent(
@@ -207,7 +226,10 @@ class EventQueueManagerTest(unittest.TestCase):
207
226
  self.test_options_no_thread.max_event_queue_size = 100
208
227
 
209
228
  manager = EventQueueManager(
210
- self.sdk_key, self.test_options_no_thread, self.test_local_bucketing
229
+ self.sdk_key,
230
+ self.client_uuid,
231
+ self.test_options_no_thread,
232
+ self.test_local_bucketing,
211
233
  )
212
234
 
213
235
  # queue empty, no issues
@@ -231,7 +253,10 @@ class EventQueueManagerTest(unittest.TestCase):
231
253
 
232
254
  def test_queue_aggregate_event_bad_data(self):
233
255
  manager = EventQueueManager(
234
- self.sdk_key, self.test_options_no_thread, self.test_local_bucketing
256
+ self.sdk_key,
257
+ self.client_uuid,
258
+ self.test_options_no_thread,
259
+ self.test_local_bucketing,
235
260
  )
236
261
  with self.assertRaises(ValueError):
237
262
  manager.queue_aggregate_event(event=None, bucketed_config=None)
@@ -252,7 +277,10 @@ class EventQueueManagerTest(unittest.TestCase):
252
277
  self.test_local_bucketing.get_event_queue_size.return_value = 1
253
278
 
254
279
  manager = EventQueueManager(
255
- self.sdk_key, self.test_options_no_thread, self.test_local_bucketing
280
+ self.sdk_key,
281
+ self.client_uuid,
282
+ self.test_options_no_thread,
283
+ self.test_local_bucketing,
256
284
  )
257
285
  event = DevCycleEvent(
258
286
  type=EventType.AggVariableDefaulted,
@@ -23,7 +23,7 @@ class DevCycleProviderWithLocalSDKTest(unittest.TestCase):
23
23
  self.test_config_json = small_config_json()
24
24
  self.test_etag = "2f71454e-3279-4ca7-a8e7-802ce97bef43"
25
25
 
26
- config_url = "http://localhost/config/v1/server/" + self.sdk_key + ".json"
26
+ config_url = "http://localhost/config/v2/server/" + self.sdk_key + ".json"
27
27
 
28
28
  responses.add(
29
29
  responses.GET,
test/test_local_client.py CHANGED
@@ -25,7 +25,7 @@ class DevCycleLocalClientTest(unittest.TestCase):
25
25
  self.test_config_json = small_config_json()
26
26
  self.test_etag = "2f71454e-3279-4ca7-a8e7-802ce97bef43"
27
27
 
28
- config_url = "http://localhost/config/v1/server/" + self.sdk_key + ".json"
28
+ config_url = "http://localhost/config/v2/server/" + self.sdk_key + ".json"
29
29
 
30
30
  responses.add(
31
31
  responses.GET,
@@ -371,7 +371,7 @@ def test_benchmark_variable_call(benchmark):
371
371
  test_config_json = small_config_json()
372
372
  test_etag = "2f71454e-3279-4ca7-a8e7-802ce97bef43"
373
373
 
374
- config_url = "http://localhost/config/v1/server/" + sdk_key + ".json"
374
+ config_url = "http://localhost/config/v2/server/" + sdk_key + ".json"
375
375
 
376
376
  responses.add(
377
377
  responses.GET,
@@ -1,20 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: devcycle-python-server-sdk
3
- Version: 3.5.0
4
- Summary: DevCycle Python SDK
5
- Home-page: https://github.com/devcycleHQ/python-server-sdk
6
- Author: UNKNOWN
7
- Author-email: UNKNOWN
8
- License: UNKNOWN
9
- Keywords: DevCycle
10
- Platform: UNKNOWN
11
- Requires-Dist: setuptools >=21.0.0
12
- Requires-Dist: urllib3 >=1.15.1
13
- Requires-Dist: requests ~=2.31.0
14
- Requires-Dist: wasmtime ==9.0.0
15
- Requires-Dist: protobuf >=4.23.3
16
- Requires-Dist: openfeature-sdk >=0.7.0
17
-
18
- The DevCycle Python SDK used for feature management.
19
-
20
-