devcycle-python-server-sdk 3.13.4__tar.gz → 3.13.6__tar.gz
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.
- {devcycle_python_server_sdk-3.13.4/devcycle_python_server_sdk.egg-info → devcycle_python_server_sdk-3.13.6}/PKG-INFO +1 -1
- devcycle_python_server_sdk-3.13.6/devcycle_python_sdk/VERSION.txt +1 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/managers/config_manager.py +100 -9
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/managers/sse_manager.py +8 -3
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6/devcycle_python_server_sdk.egg-info}/PKG-INFO +1 -1
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/example/cloud_client_example.py +1 -1
- devcycle_python_server_sdk-3.13.6/test/managers/test_config_manager.py +396 -0
- devcycle_python_server_sdk-3.13.4/devcycle_python_sdk/VERSION.txt +0 -1
- devcycle_python_server_sdk-3.13.4/test/managers/test_config_manager.py +0 -156
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/LICENSE +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/README.md +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/__init__.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/api/__init__.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/api/backoff.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/api/bucketing_client.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/api/config_client.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/api/event_client.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/api/local_bucketing.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/bucketing-lib.release.wasm +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/cloud_client.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/devcycle_client.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/exceptions.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/local_client.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/managers/__init__.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/managers/eval_hooks_manager.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/managers/event_queue_manager.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/models/__init__.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/models/bucketed_config.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/models/config_metadata.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/models/environment_metadata.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/models/error_response.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/models/eval_hook.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/models/eval_hook_context.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/models/eval_reason.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/models/event.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/models/feature.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/models/platform_data.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/models/project_metadata.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/models/user.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/models/variable.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/models/variable_metadata.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/open_feature_provider/__init__.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/open_feature_provider/provider.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/options.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/protobuf/__init__.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/protobuf/utils.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/protobuf/variableForUserParams_pb2.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/protobuf/variableForUserParams_pb2.pyi +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/py.typed +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/util/__init__.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/util/strings.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/util/version.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_server_sdk.egg-info/SOURCES.txt +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_server_sdk.egg-info/dependency_links.txt +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_server_sdk.egg-info/requires.txt +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_server_sdk.egg-info/top_level.txt +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/example/__init__.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/example/local_bucketing_client_example.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/example/openfeature_example.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/pyproject.toml +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/setup.cfg +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/setup.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/__init__.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/api/__init__.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/api/test_bucketing_client.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/api/test_config_client.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/api/test_event_client.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/api/test_local_bucketing.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/fixture/__init__.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/fixture/data.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/managers/__init__.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/managers/test_event_queue_manager.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/models/__init__.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/models/test_bucketed_config.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/models/test_user.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/openfeature_test/__init__.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/openfeature_test/test_provider.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/openfeature_test/test_provider_local_sdk.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/test_cloud_client.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/test_local_client.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/util/__init__.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/util/test_strings.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/util/test_utils.py +0 -0
- {devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/util/test_version.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13.6
|
|
@@ -40,6 +40,12 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
40
40
|
self._config_etag: Optional[str] = None
|
|
41
41
|
self._config_lastmodified: Optional[str] = None
|
|
42
42
|
|
|
43
|
+
# Exponential backoff configuration
|
|
44
|
+
self._sse_reconnect_attempts = 0
|
|
45
|
+
self._min_reconnect_interval = 5.0 # Start at 5 seconds
|
|
46
|
+
self._max_reconnect_interval = 300.0 # Cap at 5 minutes
|
|
47
|
+
self._last_reconnect_attempt_time: Optional[float] = None
|
|
48
|
+
self._sse_reconnecting = False
|
|
43
49
|
self._config_api_client = ConfigAPIClient(self._sdk_key, self._options)
|
|
44
50
|
|
|
45
51
|
self._polling_enabled = True
|
|
@@ -49,6 +55,48 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
49
55
|
def is_initialized(self) -> bool:
|
|
50
56
|
return self._config is not None
|
|
51
57
|
|
|
58
|
+
def _recreate_sse_connection(self):
|
|
59
|
+
"""Recreate the SSE connection with the current config."""
|
|
60
|
+
if self._config is None or self._options.disable_realtime_updates:
|
|
61
|
+
logger.debug(
|
|
62
|
+
"DevCycle: Skipping SSE recreation - no config or updates disabled"
|
|
63
|
+
)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
# Update timestamp right before attempting connection
|
|
67
|
+
self._last_reconnect_attempt_time = time.time()
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
# Close existing connection if present
|
|
71
|
+
if self._sse_manager is not None and self._sse_manager.client is not None:
|
|
72
|
+
self._sse_manager.client.close()
|
|
73
|
+
if self._sse_manager.read_thread.is_alive():
|
|
74
|
+
self._sse_manager.read_thread.join(timeout=1.0)
|
|
75
|
+
|
|
76
|
+
# Create new SSE manager
|
|
77
|
+
self._sse_manager = SSEManager(
|
|
78
|
+
self.sse_state,
|
|
79
|
+
self.sse_error,
|
|
80
|
+
self.sse_message,
|
|
81
|
+
)
|
|
82
|
+
self._sse_manager.update(self._config)
|
|
83
|
+
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.debug(f"DevCycle: Failed to recreate SSE connection: {e}")
|
|
86
|
+
|
|
87
|
+
def _delayed_sse_reconnect(self, delay_seconds: float):
|
|
88
|
+
"""Delayed SSE reconnection with configurable backoff."""
|
|
89
|
+
try:
|
|
90
|
+
logger.debug(
|
|
91
|
+
f"DevCycle: Waiting {delay_seconds}s before reconnecting SSE..."
|
|
92
|
+
)
|
|
93
|
+
time.sleep(delay_seconds)
|
|
94
|
+
self._recreate_sse_connection()
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.error(f"DevCycle: Error during delayed SSE reconnection: {e}")
|
|
97
|
+
finally:
|
|
98
|
+
self._sse_reconnecting = False
|
|
99
|
+
|
|
52
100
|
def _get_config(self, last_modified: Optional[float] = None):
|
|
53
101
|
try:
|
|
54
102
|
lm_header = self._config_lastmodified
|
|
@@ -84,14 +132,13 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
84
132
|
if not self._options.disable_realtime_updates:
|
|
85
133
|
if (
|
|
86
134
|
self._sse_manager is None
|
|
87
|
-
or
|
|
135
|
+
or self._sse_manager.client is None
|
|
136
|
+
or not self._sse_manager.read_thread.is_alive()
|
|
88
137
|
):
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
self.sse_error,
|
|
92
|
-
self.sse_message,
|
|
138
|
+
logger.info(
|
|
139
|
+
"DevCycle: SSE connection not active, creating new connection"
|
|
93
140
|
)
|
|
94
|
-
|
|
141
|
+
self._recreate_sse_connection()
|
|
95
142
|
|
|
96
143
|
if (
|
|
97
144
|
trigger_on_client_initialized
|
|
@@ -100,7 +147,6 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
100
147
|
try:
|
|
101
148
|
self._options.on_client_initialized()
|
|
102
149
|
except Exception as e:
|
|
103
|
-
# consume any error
|
|
104
150
|
logger.warning(
|
|
105
151
|
f"DevCycle: Error received from on_client_initialized callback: {str(e)}"
|
|
106
152
|
)
|
|
@@ -121,7 +167,6 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
121
167
|
self._get_config()
|
|
122
168
|
except Exception as e:
|
|
123
169
|
if self._polling_enabled:
|
|
124
|
-
# Only log a warning if we're still polling
|
|
125
170
|
logger.warning(
|
|
126
171
|
f"DevCycle: Error polling for config changes: {str(e)}"
|
|
127
172
|
)
|
|
@@ -136,6 +181,7 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
136
181
|
self.sse_state(None)
|
|
137
182
|
logger.info(f"DevCycle: Received message: {message.data}")
|
|
138
183
|
sse_message = json.loads(message.data)
|
|
184
|
+
|
|
139
185
|
dvc_data = json.loads(sse_message.get("data"))
|
|
140
186
|
if (
|
|
141
187
|
dvc_data.get("type") == "refetchConfig"
|
|
@@ -144,15 +190,60 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
144
190
|
):
|
|
145
191
|
logger.info("DevCycle: Received refetchConfig message - updating config")
|
|
146
192
|
self._get_config(dvc_data["lastModified"] / 1000.0)
|
|
193
|
+
# SSE connection healthy, reconnect attempts reset.
|
|
194
|
+
if dvc_data.get("type") == "ping" or dvc_data.get("type") == "refetchConfig":
|
|
195
|
+
self._sse_reconnect_attempts = 0
|
|
147
196
|
|
|
148
197
|
def sse_error(self, error: ld_eventsource.actions.Fault):
|
|
149
198
|
self._sse_connected = False
|
|
150
|
-
logger.debug(f"DevCycle:
|
|
199
|
+
logger.debug(f"DevCycle: SSE connection error: {error.error}")
|
|
200
|
+
current_time = time.time()
|
|
201
|
+
|
|
202
|
+
if self._sse_reconnecting:
|
|
203
|
+
logger.debug("DevCycle: Reconnection already in progress, skipping")
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
# Calculate exponential backoff interval (capped at max)
|
|
207
|
+
backoff_interval = min(
|
|
208
|
+
self._min_reconnect_interval * (2**self._sse_reconnect_attempts),
|
|
209
|
+
self._max_reconnect_interval,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Check if we need to wait for remaining backoff time
|
|
213
|
+
delay_seconds = backoff_interval
|
|
214
|
+
if self._last_reconnect_attempt_time is not None:
|
|
215
|
+
time_since_last_attempt = current_time - self._last_reconnect_attempt_time
|
|
216
|
+
if time_since_last_attempt < backoff_interval:
|
|
217
|
+
delay_seconds = backoff_interval - time_since_last_attempt
|
|
218
|
+
logger.debug(
|
|
219
|
+
f"DevCycle: Within backoff period, scheduling reconnection in {delay_seconds:.1f}s"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
self._sse_reconnecting = True
|
|
223
|
+
self._sse_reconnect_attempts += 1
|
|
224
|
+
|
|
225
|
+
logger.debug(
|
|
226
|
+
f"DevCycle: Attempting SSE reconnection (attempt #{self._sse_reconnect_attempts}, "
|
|
227
|
+
f"backoff: {delay_seconds:.1f}s)"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
reconnect_thread = threading.Thread(
|
|
231
|
+
target=self._delayed_sse_reconnect, args=(delay_seconds,), daemon=True
|
|
232
|
+
)
|
|
233
|
+
reconnect_thread.start()
|
|
151
234
|
|
|
152
235
|
def sse_state(self, state: Optional[ld_eventsource.actions.Start]):
|
|
153
236
|
if not self._sse_connected:
|
|
154
237
|
self._sse_connected = True
|
|
155
238
|
logger.info("DevCycle: Connected to SSE stream")
|
|
156
239
|
|
|
240
|
+
# Clear reconnection state
|
|
241
|
+
self._sse_reconnecting = False
|
|
242
|
+
self._last_reconnect_attempt_time = None
|
|
243
|
+
else:
|
|
244
|
+
logger.debug("DevCycle: SSE keepalive received")
|
|
245
|
+
|
|
157
246
|
def close(self):
|
|
158
247
|
self._polling_enabled = False
|
|
248
|
+
if self._sse_manager is not None and self._sse_manager.client is not None:
|
|
249
|
+
self._sse_manager.client.close()
|
|
@@ -33,8 +33,9 @@ class SSEManager:
|
|
|
33
33
|
handle_error: Callable[[ld_eventsource.actions.Fault], None],
|
|
34
34
|
handle_message: Callable[[ld_eventsource.actions.Event], None],
|
|
35
35
|
):
|
|
36
|
-
self.client.start()
|
|
37
36
|
try:
|
|
37
|
+
self.client.start()
|
|
38
|
+
logger.info("DevCycle: SSE connection created successfully")
|
|
38
39
|
for event in self.client.all:
|
|
39
40
|
if isinstance(event, ld_eventsource.actions.Start):
|
|
40
41
|
handle_state(event)
|
|
@@ -45,7 +46,11 @@ class SSEManager:
|
|
|
45
46
|
elif isinstance(event, ld_eventsource.actions.Comment):
|
|
46
47
|
handle_state(None)
|
|
47
48
|
except Exception as e:
|
|
48
|
-
logger.debug(f"DevCycle:
|
|
49
|
+
logger.debug(f"DevCycle SSE: Error in read loop: {e}")
|
|
50
|
+
fault_event = ld_eventsource.actions.Fault(error=e)
|
|
51
|
+
handle_error(fault_event)
|
|
52
|
+
finally:
|
|
53
|
+
logger.debug("DevCycle SSE: Connection closed")
|
|
49
54
|
|
|
50
55
|
def update(self, config: dict):
|
|
51
56
|
if self.use_new_config(config["sse"]):
|
|
@@ -66,6 +71,6 @@ class SSEManager:
|
|
|
66
71
|
|
|
67
72
|
def use_new_config(self, config: dict) -> bool:
|
|
68
73
|
new_url = config["hostname"] + config["path"]
|
|
69
|
-
if self.url == "" or self.url is None and new_url != "":
|
|
74
|
+
if (self.url == "" or self.url is None) and new_url != "":
|
|
70
75
|
return True
|
|
71
76
|
return self.url != new_url
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
import unittest
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from email.utils import formatdate
|
|
8
|
+
from time import mktime
|
|
9
|
+
from unittest.mock import patch, MagicMock
|
|
10
|
+
|
|
11
|
+
import ld_eventsource.actions
|
|
12
|
+
|
|
13
|
+
from devcycle_python_sdk import DevCycleLocalOptions
|
|
14
|
+
from devcycle_python_sdk.managers.config_manager import EnvironmentConfigManager
|
|
15
|
+
from test.fixture.data import small_config_json
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EnvironmentConfigManagerTest(unittest.TestCase):
|
|
21
|
+
def setUp(self) -> None:
|
|
22
|
+
self.sdk_key = "dvc_server_" + str(uuid.uuid4())
|
|
23
|
+
self.test_local_bucketing = MagicMock()
|
|
24
|
+
self.test_options = DevCycleLocalOptions(
|
|
25
|
+
config_polling_interval_ms=500, disable_realtime_updates=True
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
now = datetime.now()
|
|
29
|
+
stamp = mktime(now.timetuple())
|
|
30
|
+
self.test_lastmodified = formatdate(timeval=stamp, localtime=False, usegmt=True)
|
|
31
|
+
self.test_etag = str(uuid.uuid4())
|
|
32
|
+
self.test_config_json = small_config_json()
|
|
33
|
+
self.test_config_string = json.dumps(self.test_config_json)
|
|
34
|
+
|
|
35
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
36
|
+
def test_init(self, mock_get_config):
|
|
37
|
+
mock_get_config.return_value = (
|
|
38
|
+
self.test_config_json,
|
|
39
|
+
self.test_etag,
|
|
40
|
+
self.test_lastmodified,
|
|
41
|
+
)
|
|
42
|
+
config_manager = EnvironmentConfigManager(
|
|
43
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# sleep to allow polling thread to load the config
|
|
47
|
+
time.sleep(0.1)
|
|
48
|
+
mock_get_config.assert_called_once_with(config_etag=None, last_modified=None)
|
|
49
|
+
|
|
50
|
+
self.assertTrue(config_manager._polling_enabled)
|
|
51
|
+
self.assertTrue(config_manager.is_alive())
|
|
52
|
+
self.assertTrue(config_manager.daemon)
|
|
53
|
+
self.assertEqual(config_manager._config_etag, self.test_etag)
|
|
54
|
+
self.assertDictEqual(config_manager._config, self.test_config_json)
|
|
55
|
+
self.test_local_bucketing.store_config.assert_called_once_with(
|
|
56
|
+
self.test_config_string
|
|
57
|
+
)
|
|
58
|
+
self.assertTrue(config_manager.is_initialized())
|
|
59
|
+
|
|
60
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
61
|
+
def test_init_with_client_callback(self, mock_get_config):
|
|
62
|
+
mock_get_config.return_value = (
|
|
63
|
+
self.test_config_json,
|
|
64
|
+
self.test_etag,
|
|
65
|
+
self.test_lastmodified,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
mock_callback = MagicMock()
|
|
69
|
+
|
|
70
|
+
self.test_options.on_client_initialized = mock_callback
|
|
71
|
+
|
|
72
|
+
config_manager = EnvironmentConfigManager(
|
|
73
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
74
|
+
)
|
|
75
|
+
time.sleep(0.1)
|
|
76
|
+
mock_get_config.assert_called_once_with(config_etag=None, last_modified=None)
|
|
77
|
+
self.assertEqual(config_manager._config_etag, self.test_etag)
|
|
78
|
+
self.assertDictEqual(config_manager._config, self.test_config_json)
|
|
79
|
+
self.test_local_bucketing.store_config.assert_called_once_with(
|
|
80
|
+
self.test_config_string
|
|
81
|
+
)
|
|
82
|
+
self.assertTrue(config_manager.is_initialized())
|
|
83
|
+
mock_callback.assert_called_once()
|
|
84
|
+
|
|
85
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
86
|
+
def test_init_with_client_callback_with_error(self, mock_get_config):
|
|
87
|
+
mock_get_config.return_value = (
|
|
88
|
+
self.test_config_json,
|
|
89
|
+
self.test_etag,
|
|
90
|
+
self.test_lastmodified,
|
|
91
|
+
)
|
|
92
|
+
mock_callback = MagicMock()
|
|
93
|
+
mock_callback.side_effect = Exception(
|
|
94
|
+
"Badly written callback generates an exception"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
self.test_options.on_client_initialized = mock_callback
|
|
98
|
+
|
|
99
|
+
config_manager = EnvironmentConfigManager(
|
|
100
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
101
|
+
)
|
|
102
|
+
# the callback error should not negatively impact initialization of the config manager
|
|
103
|
+
time.sleep(0.1)
|
|
104
|
+
mock_get_config.assert_called_once_with(config_etag=None, last_modified=None)
|
|
105
|
+
self.assertEqual(config_manager._config_etag, self.test_etag)
|
|
106
|
+
self.assertDictEqual(config_manager._config, self.test_config_json)
|
|
107
|
+
self.test_local_bucketing.store_config.assert_called_once_with(
|
|
108
|
+
self.test_config_string
|
|
109
|
+
)
|
|
110
|
+
self.assertTrue(config_manager.is_initialized())
|
|
111
|
+
mock_callback.assert_called_once()
|
|
112
|
+
|
|
113
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
114
|
+
def test_close(self, mock_get_config):
|
|
115
|
+
mock_get_config.return_value = (self.test_config_json, self.test_etag)
|
|
116
|
+
self.test_options.config_polling_interval_ms = 500
|
|
117
|
+
|
|
118
|
+
config_manager = EnvironmentConfigManager(
|
|
119
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# sleep to allow polling thread to load the config
|
|
123
|
+
time.sleep(0.1)
|
|
124
|
+
|
|
125
|
+
config_manager.close()
|
|
126
|
+
self.assertFalse(config_manager._polling_enabled)
|
|
127
|
+
|
|
128
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
129
|
+
def test_get_config_unchanged(self, mock_get_config):
|
|
130
|
+
mock_get_config.return_value = (
|
|
131
|
+
self.test_config_json,
|
|
132
|
+
self.test_etag,
|
|
133
|
+
self.test_lastmodified,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
self.test_options.config_polling_interval_ms = 200
|
|
137
|
+
config_manager = EnvironmentConfigManager(
|
|
138
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
time.sleep(0.1)
|
|
142
|
+
# stop the polling
|
|
143
|
+
config_manager.close()
|
|
144
|
+
|
|
145
|
+
self.test_local_bucketing.store_config.reset_mock()
|
|
146
|
+
mock_get_config.return_value = (None, config_manager._config_etag, None)
|
|
147
|
+
|
|
148
|
+
# trigger refresh of the config directly
|
|
149
|
+
config_manager._get_config()
|
|
150
|
+
|
|
151
|
+
# verify that the config was not updated
|
|
152
|
+
self.assertEqual(config_manager._config_etag, self.test_etag)
|
|
153
|
+
self.assertDictEqual(config_manager._config, self.test_config_json)
|
|
154
|
+
self.test_local_bucketing.store_config.assert_not_called()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class SSEReconnectionBackoffTest(unittest.TestCase):
|
|
158
|
+
"""Tests for SSE exponential backoff reconnection behavior"""
|
|
159
|
+
|
|
160
|
+
def setUp(self) -> None:
|
|
161
|
+
self.sdk_key = "dvc_server_" + str(uuid.uuid4())
|
|
162
|
+
self.test_local_bucketing = MagicMock()
|
|
163
|
+
self.test_options = DevCycleLocalOptions(
|
|
164
|
+
config_polling_interval_ms=500, disable_realtime_updates=False
|
|
165
|
+
)
|
|
166
|
+
self.test_config_json = small_config_json()
|
|
167
|
+
|
|
168
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
169
|
+
@patch("devcycle_python_sdk.managers.config_manager.SSEManager")
|
|
170
|
+
@patch("threading.Thread")
|
|
171
|
+
@patch("time.time")
|
|
172
|
+
def test_first_sse_error_triggers_immediate_reconnection(
|
|
173
|
+
self, mock_time, mock_thread, mock_sse_manager, mock_get_config
|
|
174
|
+
):
|
|
175
|
+
"""First error should trigger reconnection with min backoff (5s)"""
|
|
176
|
+
mock_time.return_value = 1000.0
|
|
177
|
+
mock_get_config.return_value = (self.test_config_json, "etag", None)
|
|
178
|
+
|
|
179
|
+
config_manager = EnvironmentConfigManager(
|
|
180
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
181
|
+
)
|
|
182
|
+
time.sleep(0.1)
|
|
183
|
+
|
|
184
|
+
# Simulate first SSE error
|
|
185
|
+
error = ld_eventsource.actions.Fault(error=Exception("Connection failed"))
|
|
186
|
+
config_manager.sse_error(error)
|
|
187
|
+
|
|
188
|
+
# Verify reconnection attempt counter incremented
|
|
189
|
+
self.assertEqual(config_manager._sse_reconnect_attempts, 1)
|
|
190
|
+
# Verify reconnecting flag set
|
|
191
|
+
self.assertTrue(config_manager._sse_reconnecting)
|
|
192
|
+
# Verify thread spawned with min backoff (5.0s)
|
|
193
|
+
mock_thread.assert_called_once()
|
|
194
|
+
call_args = mock_thread.call_args
|
|
195
|
+
self.assertEqual(call_args[1]["args"][0], 5.0) # backoff_interval
|
|
196
|
+
|
|
197
|
+
config_manager.close()
|
|
198
|
+
|
|
199
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
200
|
+
@patch("devcycle_python_sdk.managers.config_manager.SSEManager")
|
|
201
|
+
@patch("threading.Thread")
|
|
202
|
+
@patch("time.time")
|
|
203
|
+
def test_exponential_backoff_calculation(
|
|
204
|
+
self, mock_time, mock_thread, mock_sse_manager, mock_get_config
|
|
205
|
+
):
|
|
206
|
+
"""Verify exponential backoff: 5s, 10s, 20s, 40s, etc."""
|
|
207
|
+
mock_time.return_value = 1000.0
|
|
208
|
+
mock_get_config.return_value = (self.test_config_json, "etag", None)
|
|
209
|
+
|
|
210
|
+
config_manager = EnvironmentConfigManager(
|
|
211
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
212
|
+
)
|
|
213
|
+
time.sleep(0.1)
|
|
214
|
+
|
|
215
|
+
error = ld_eventsource.actions.Fault(error=Exception("Connection failed"))
|
|
216
|
+
|
|
217
|
+
# First error: 5s backoff (2^0 * 5)
|
|
218
|
+
config_manager.sse_error(error)
|
|
219
|
+
self.assertEqual(config_manager._sse_reconnect_attempts, 1)
|
|
220
|
+
self.assertEqual(mock_thread.call_args[1]["args"][0], 5.0)
|
|
221
|
+
|
|
222
|
+
# Simulate reconnect completing
|
|
223
|
+
config_manager._sse_reconnecting = False
|
|
224
|
+
mock_time.return_value += 10.0 # Advance time beyond backoff
|
|
225
|
+
|
|
226
|
+
# Second error: 10s backoff (2^1 * 5)
|
|
227
|
+
config_manager.sse_error(error)
|
|
228
|
+
self.assertEqual(config_manager._sse_reconnect_attempts, 2)
|
|
229
|
+
self.assertEqual(mock_thread.call_args[1]["args"][0], 10.0)
|
|
230
|
+
|
|
231
|
+
# Simulate reconnect completing
|
|
232
|
+
config_manager._sse_reconnecting = False
|
|
233
|
+
mock_time.return_value += 15.0
|
|
234
|
+
|
|
235
|
+
# Third error: 20s backoff (2^2 * 5)
|
|
236
|
+
config_manager.sse_error(error)
|
|
237
|
+
self.assertEqual(config_manager._sse_reconnect_attempts, 3)
|
|
238
|
+
self.assertEqual(mock_thread.call_args[1]["args"][0], 20.0)
|
|
239
|
+
|
|
240
|
+
config_manager.close()
|
|
241
|
+
|
|
242
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
243
|
+
@patch("devcycle_python_sdk.managers.config_manager.SSEManager")
|
|
244
|
+
@patch("threading.Thread")
|
|
245
|
+
@patch("time.time")
|
|
246
|
+
def test_backoff_caps_at_max_interval(
|
|
247
|
+
self, mock_time, mock_thread, mock_sse_manager, mock_get_config
|
|
248
|
+
):
|
|
249
|
+
"""Verify backoff caps at max interval (300s)"""
|
|
250
|
+
mock_time.return_value = 1000.0
|
|
251
|
+
mock_get_config.return_value = (self.test_config_json, "etag", None)
|
|
252
|
+
|
|
253
|
+
config_manager = EnvironmentConfigManager(
|
|
254
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
255
|
+
)
|
|
256
|
+
time.sleep(0.1)
|
|
257
|
+
|
|
258
|
+
error = ld_eventsource.actions.Fault(error=Exception("Connection failed"))
|
|
259
|
+
|
|
260
|
+
# Simulate many failures to reach max backoff
|
|
261
|
+
# 2^6 * 5 = 320s > 300s (max), so should cap at 300s
|
|
262
|
+
config_manager._sse_reconnect_attempts = 6
|
|
263
|
+
config_manager.sse_error(error)
|
|
264
|
+
|
|
265
|
+
# Should be capped at 300s
|
|
266
|
+
self.assertEqual(mock_thread.call_args[1]["args"][0], 300.0)
|
|
267
|
+
|
|
268
|
+
config_manager.close()
|
|
269
|
+
|
|
270
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
271
|
+
@patch("devcycle_python_sdk.managers.config_manager.SSEManager")
|
|
272
|
+
@patch("threading.Thread")
|
|
273
|
+
def test_concurrent_errors_only_spawn_one_reconnection(
|
|
274
|
+
self, mock_thread, mock_sse_manager, mock_get_config
|
|
275
|
+
):
|
|
276
|
+
"""Multiple rapid errors should only spawn one reconnection thread"""
|
|
277
|
+
mock_get_config.return_value = (self.test_config_json, "etag", None)
|
|
278
|
+
|
|
279
|
+
config_manager = EnvironmentConfigManager(
|
|
280
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
281
|
+
)
|
|
282
|
+
time.sleep(0.1)
|
|
283
|
+
|
|
284
|
+
error = ld_eventsource.actions.Fault(error=Exception("Connection failed"))
|
|
285
|
+
|
|
286
|
+
# First error spawns reconnection
|
|
287
|
+
config_manager.sse_error(error)
|
|
288
|
+
self.assertEqual(mock_thread.call_count, 1)
|
|
289
|
+
self.assertTrue(config_manager._sse_reconnecting)
|
|
290
|
+
|
|
291
|
+
# Second error while reconnecting should be skipped
|
|
292
|
+
config_manager.sse_error(error)
|
|
293
|
+
# Still only 1 thread spawned
|
|
294
|
+
self.assertEqual(mock_thread.call_count, 1)
|
|
295
|
+
|
|
296
|
+
config_manager.close()
|
|
297
|
+
|
|
298
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
299
|
+
@patch("devcycle_python_sdk.managers.config_manager.SSEManager")
|
|
300
|
+
@patch("threading.Thread")
|
|
301
|
+
@patch("time.time")
|
|
302
|
+
def test_error_within_backoff_uses_remaining_time(
|
|
303
|
+
self, mock_time, mock_thread, mock_sse_manager, mock_get_config
|
|
304
|
+
):
|
|
305
|
+
"""Error within backoff period should schedule reconnect with remaining time"""
|
|
306
|
+
mock_time.return_value = 1000.0
|
|
307
|
+
mock_get_config.return_value = (self.test_config_json, "etag", None)
|
|
308
|
+
|
|
309
|
+
config_manager = EnvironmentConfigManager(
|
|
310
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
311
|
+
)
|
|
312
|
+
time.sleep(0.1)
|
|
313
|
+
|
|
314
|
+
error = ld_eventsource.actions.Fault(error=Exception("Connection failed"))
|
|
315
|
+
|
|
316
|
+
# First error at t=1000, backoff=5s
|
|
317
|
+
config_manager.sse_error(error)
|
|
318
|
+
self.assertEqual(mock_thread.call_args[1]["args"][0], 5.0)
|
|
319
|
+
|
|
320
|
+
# Simulate reconnect completing and updating timestamp
|
|
321
|
+
config_manager._sse_reconnecting = False
|
|
322
|
+
config_manager._last_reconnect_attempt_time = (
|
|
323
|
+
1005.0 # Simulates reconnect at t=1005
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Second error at t=1008 (3s after reconnect, within 10s backoff)
|
|
327
|
+
mock_time.return_value = 1008.0
|
|
328
|
+
config_manager.sse_error(error)
|
|
329
|
+
|
|
330
|
+
# Should use remaining time: 10s backoff - 3s elapsed = 7s
|
|
331
|
+
self.assertAlmostEqual(mock_thread.call_args[1]["args"][0], 7.0, places=1)
|
|
332
|
+
|
|
333
|
+
config_manager.close()
|
|
334
|
+
|
|
335
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
336
|
+
@patch("devcycle_python_sdk.managers.config_manager.SSEManager")
|
|
337
|
+
def test_successful_connection_resets_attempts(
|
|
338
|
+
self, mock_sse_manager, mock_get_config
|
|
339
|
+
):
|
|
340
|
+
"""Successful SSE connection should reset reconnection attempts"""
|
|
341
|
+
mock_get_config.return_value = (self.test_config_json, "etag", None)
|
|
342
|
+
|
|
343
|
+
config_manager = EnvironmentConfigManager(
|
|
344
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
345
|
+
)
|
|
346
|
+
time.sleep(0.1)
|
|
347
|
+
|
|
348
|
+
# Simulate multiple failures
|
|
349
|
+
config_manager._sse_reconnect_attempts = 5
|
|
350
|
+
config_manager._sse_reconnecting = True
|
|
351
|
+
config_manager._last_reconnect_attempt_time = 1000.0
|
|
352
|
+
|
|
353
|
+
# Simulate successful ping message (which resets attempts)
|
|
354
|
+
message = ld_eventsource.actions.Event(
|
|
355
|
+
event="message",
|
|
356
|
+
data='{"data": "{\\"type\\": \\"ping\\", \\"lastModified\\": 1234567890000}"}',
|
|
357
|
+
)
|
|
358
|
+
config_manager.sse_message(message)
|
|
359
|
+
|
|
360
|
+
# Attempts should be reset
|
|
361
|
+
self.assertEqual(config_manager._sse_reconnect_attempts, 0)
|
|
362
|
+
|
|
363
|
+
config_manager.close()
|
|
364
|
+
|
|
365
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
366
|
+
@patch("devcycle_python_sdk.managers.config_manager.SSEManager")
|
|
367
|
+
def test_successful_state_resets_reconnection_flags(
|
|
368
|
+
self, mock_sse_manager, mock_get_config
|
|
369
|
+
):
|
|
370
|
+
"""Successful SSE state should clear reconnection flags"""
|
|
371
|
+
mock_get_config.return_value = (self.test_config_json, "etag", None)
|
|
372
|
+
|
|
373
|
+
config_manager = EnvironmentConfigManager(
|
|
374
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
375
|
+
)
|
|
376
|
+
time.sleep(0.1)
|
|
377
|
+
|
|
378
|
+
# Simulate reconnection in progress
|
|
379
|
+
config_manager._sse_reconnecting = True
|
|
380
|
+
config_manager._last_reconnect_attempt_time = 1000.0
|
|
381
|
+
config_manager._sse_connected = False
|
|
382
|
+
|
|
383
|
+
# Simulate successful connection
|
|
384
|
+
state = ld_eventsource.actions.Start()
|
|
385
|
+
config_manager.sse_state(state)
|
|
386
|
+
|
|
387
|
+
# Should clear reconnection state
|
|
388
|
+
self.assertFalse(config_manager._sse_reconnecting)
|
|
389
|
+
self.assertIsNone(config_manager._last_reconnect_attempt_time)
|
|
390
|
+
self.assertTrue(config_manager._sse_connected)
|
|
391
|
+
|
|
392
|
+
config_manager.close()
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
if __name__ == "__main__":
|
|
396
|
+
unittest.main()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
3.13.4
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import logging
|
|
3
|
-
import time
|
|
4
|
-
import unittest
|
|
5
|
-
import uuid
|
|
6
|
-
from datetime import datetime
|
|
7
|
-
from email.utils import formatdate
|
|
8
|
-
from time import mktime
|
|
9
|
-
from unittest.mock import patch, MagicMock
|
|
10
|
-
|
|
11
|
-
from devcycle_python_sdk import DevCycleLocalOptions
|
|
12
|
-
from devcycle_python_sdk.managers.config_manager import EnvironmentConfigManager
|
|
13
|
-
from test.fixture.data import small_config_json
|
|
14
|
-
|
|
15
|
-
logger = logging.getLogger(__name__)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class EnvironmentConfigManagerTest(unittest.TestCase):
|
|
19
|
-
def setUp(self) -> None:
|
|
20
|
-
self.sdk_key = "dvc_server_" + str(uuid.uuid4())
|
|
21
|
-
self.test_local_bucketing = MagicMock()
|
|
22
|
-
self.test_options = DevCycleLocalOptions(
|
|
23
|
-
config_polling_interval_ms=500, disable_realtime_updates=True
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
now = datetime.now()
|
|
27
|
-
stamp = mktime(now.timetuple())
|
|
28
|
-
self.test_lastmodified = formatdate(timeval=stamp, localtime=False, usegmt=True)
|
|
29
|
-
self.test_etag = str(uuid.uuid4())
|
|
30
|
-
self.test_config_json = small_config_json()
|
|
31
|
-
self.test_config_string = json.dumps(self.test_config_json)
|
|
32
|
-
|
|
33
|
-
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
34
|
-
def test_init(self, mock_get_config):
|
|
35
|
-
mock_get_config.return_value = (
|
|
36
|
-
self.test_config_json,
|
|
37
|
-
self.test_etag,
|
|
38
|
-
self.test_lastmodified,
|
|
39
|
-
)
|
|
40
|
-
config_manager = EnvironmentConfigManager(
|
|
41
|
-
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
# sleep to allow polling thread to load the config
|
|
45
|
-
time.sleep(0.1)
|
|
46
|
-
mock_get_config.assert_called_once_with(config_etag=None, last_modified=None)
|
|
47
|
-
|
|
48
|
-
self.assertTrue(config_manager._polling_enabled)
|
|
49
|
-
self.assertTrue(config_manager.is_alive())
|
|
50
|
-
self.assertTrue(config_manager.daemon)
|
|
51
|
-
self.assertEqual(config_manager._config_etag, self.test_etag)
|
|
52
|
-
self.assertDictEqual(config_manager._config, self.test_config_json)
|
|
53
|
-
self.test_local_bucketing.store_config.assert_called_once_with(
|
|
54
|
-
self.test_config_string
|
|
55
|
-
)
|
|
56
|
-
self.assertTrue(config_manager.is_initialized())
|
|
57
|
-
|
|
58
|
-
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
59
|
-
def test_init_with_client_callback(self, mock_get_config):
|
|
60
|
-
mock_get_config.return_value = (
|
|
61
|
-
self.test_config_json,
|
|
62
|
-
self.test_etag,
|
|
63
|
-
self.test_lastmodified,
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
mock_callback = MagicMock()
|
|
67
|
-
|
|
68
|
-
self.test_options.on_client_initialized = mock_callback
|
|
69
|
-
|
|
70
|
-
config_manager = EnvironmentConfigManager(
|
|
71
|
-
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
72
|
-
)
|
|
73
|
-
time.sleep(0.1)
|
|
74
|
-
mock_get_config.assert_called_once_with(config_etag=None, last_modified=None)
|
|
75
|
-
self.assertEqual(config_manager._config_etag, self.test_etag)
|
|
76
|
-
self.assertDictEqual(config_manager._config, self.test_config_json)
|
|
77
|
-
self.test_local_bucketing.store_config.assert_called_once_with(
|
|
78
|
-
self.test_config_string
|
|
79
|
-
)
|
|
80
|
-
self.assertTrue(config_manager.is_initialized())
|
|
81
|
-
mock_callback.assert_called_once()
|
|
82
|
-
|
|
83
|
-
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
84
|
-
def test_init_with_client_callback_with_error(self, mock_get_config):
|
|
85
|
-
mock_get_config.return_value = (
|
|
86
|
-
self.test_config_json,
|
|
87
|
-
self.test_etag,
|
|
88
|
-
self.test_lastmodified,
|
|
89
|
-
)
|
|
90
|
-
mock_callback = MagicMock()
|
|
91
|
-
mock_callback.side_effect = Exception(
|
|
92
|
-
"Badly written callback generates an exception"
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
self.test_options.on_client_initialized = mock_callback
|
|
96
|
-
|
|
97
|
-
config_manager = EnvironmentConfigManager(
|
|
98
|
-
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
99
|
-
)
|
|
100
|
-
# the callback error should not negatively impact initialization of the config manager
|
|
101
|
-
time.sleep(0.1)
|
|
102
|
-
mock_get_config.assert_called_once_with(config_etag=None, last_modified=None)
|
|
103
|
-
self.assertEqual(config_manager._config_etag, self.test_etag)
|
|
104
|
-
self.assertDictEqual(config_manager._config, self.test_config_json)
|
|
105
|
-
self.test_local_bucketing.store_config.assert_called_once_with(
|
|
106
|
-
self.test_config_string
|
|
107
|
-
)
|
|
108
|
-
self.assertTrue(config_manager.is_initialized())
|
|
109
|
-
mock_callback.assert_called_once()
|
|
110
|
-
|
|
111
|
-
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
112
|
-
def test_close(self, mock_get_config):
|
|
113
|
-
mock_get_config.return_value = (self.test_config_json, self.test_etag)
|
|
114
|
-
self.test_options.config_polling_interval_ms = 500
|
|
115
|
-
|
|
116
|
-
config_manager = EnvironmentConfigManager(
|
|
117
|
-
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
# sleep to allow polling thread to load the config
|
|
121
|
-
time.sleep(0.1)
|
|
122
|
-
|
|
123
|
-
config_manager.close()
|
|
124
|
-
self.assertFalse(config_manager._polling_enabled)
|
|
125
|
-
|
|
126
|
-
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
127
|
-
def test_get_config_unchanged(self, mock_get_config):
|
|
128
|
-
mock_get_config.return_value = (
|
|
129
|
-
self.test_config_json,
|
|
130
|
-
self.test_etag,
|
|
131
|
-
self.test_lastmodified,
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
self.test_options.config_polling_interval_ms = 200
|
|
135
|
-
config_manager = EnvironmentConfigManager(
|
|
136
|
-
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
137
|
-
)
|
|
138
|
-
|
|
139
|
-
time.sleep(0.1)
|
|
140
|
-
# stop the polling
|
|
141
|
-
config_manager.close()
|
|
142
|
-
|
|
143
|
-
self.test_local_bucketing.store_config.reset_mock()
|
|
144
|
-
mock_get_config.return_value = (None, config_manager._config_etag, None)
|
|
145
|
-
|
|
146
|
-
# trigger refresh of the config directly
|
|
147
|
-
config_manager._get_config()
|
|
148
|
-
|
|
149
|
-
# verify that the config was not updated
|
|
150
|
-
self.assertEqual(config_manager._config_etag, self.test_etag)
|
|
151
|
-
self.assertDictEqual(config_manager._config, self.test_config_json)
|
|
152
|
-
self.test_local_bucketing.store_config.assert_not_called()
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if __name__ == "__main__":
|
|
156
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/devcycle_python_sdk/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/api/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/fixture/__init__.py
RENAMED
|
File without changes
|
{devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/fixture/data.py
RENAMED
|
File without changes
|
{devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/managers/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/models/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/models/test_user.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/test_cloud_client.py
RENAMED
|
File without changes
|
{devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/test_local_client.py
RENAMED
|
File without changes
|
{devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/util/__init__.py
RENAMED
|
File without changes
|
{devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/util/test_strings.py
RENAMED
|
File without changes
|
{devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/util/test_utils.py
RENAMED
|
File without changes
|
{devcycle_python_server_sdk-3.13.4 → devcycle_python_server_sdk-3.13.6}/test/util/test_version.py
RENAMED
|
File without changes
|