devcycle-python-server-sdk 2.0.1__py3-none-any.whl → 3.0.0__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.
Files changed (50) hide show
  1. devcycle_python_sdk/VERSION.txt +1 -1
  2. devcycle_python_sdk/__init__.py +3 -3
  3. devcycle_python_sdk/api/backoff.py +11 -0
  4. devcycle_python_sdk/api/bucketing_client.py +17 -25
  5. devcycle_python_sdk/api/config_client.py +116 -0
  6. devcycle_python_sdk/api/event_client.py +95 -0
  7. devcycle_python_sdk/api/local_bucketing.py +429 -2
  8. devcycle_python_sdk/{dvc_cloud_client.py → cloud_client.py} +69 -41
  9. devcycle_python_sdk/exceptions.py +25 -3
  10. devcycle_python_sdk/local_client.py +220 -0
  11. devcycle_python_sdk/managers/config_manager.py +83 -15
  12. devcycle_python_sdk/managers/event_queue_manager.py +213 -8
  13. devcycle_python_sdk/models/bucketed_config.py +150 -0
  14. devcycle_python_sdk/models/event.py +111 -8
  15. devcycle_python_sdk/models/feature.py +12 -0
  16. devcycle_python_sdk/models/platform_data.py +33 -0
  17. devcycle_python_sdk/models/user.py +52 -7
  18. devcycle_python_sdk/models/variable.py +17 -5
  19. devcycle_python_sdk/options.py +106 -0
  20. devcycle_python_sdk/protobuf/__init__.py +0 -0
  21. devcycle_python_sdk/protobuf/utils.py +129 -0
  22. devcycle_python_sdk/protobuf/variableForUserParams_pb2.py +46 -0
  23. devcycle_python_sdk/util/version.py +1 -1
  24. {devcycle_python_server_sdk-2.0.1.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/METADATA +3 -1
  25. devcycle_python_server_sdk-3.0.0.dist-info/RECORD +55 -0
  26. example/{example.py → cloud_client_example.py} +24 -20
  27. example/local_bucketing_client_example.py +74 -0
  28. test/api/test_bucketing_client.py +10 -5
  29. test/api/test_config_client.py +133 -0
  30. test/api/test_event_client.py +97 -0
  31. test/api/test_local_bucketing.py +379 -4
  32. test/fixture/__init__.py +0 -0
  33. test/fixture/data.py +57 -0
  34. test/managers/test_config_manager.py +120 -5
  35. test/managers/test_event_queue_manager.py +259 -5
  36. test/models/__init__.py +0 -0
  37. test/models/test_bucketed_config.py +26 -0
  38. test/{test_dvc_cloud_client.py → test_cloud_client.py} +11 -17
  39. test/test_local_client.py +397 -0
  40. test/util/test_utils.py +183 -0
  41. test/util/test_version.py +2 -2
  42. devcycle_python_sdk/dvc_local_client.py +0 -152
  43. devcycle_python_sdk/dvc_options.py +0 -45
  44. devcycle_python_server_sdk-2.0.1.dist-info/RECORD +0 -41
  45. test/test_bucketing_client.py +0 -228
  46. test/test_dvc_local_client.py +0 -143
  47. test/test_version.py +0 -18
  48. {devcycle_python_server_sdk-2.0.1.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/LICENSE +0 -0
  49. {devcycle_python_server_sdk-2.0.1.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/WHEEL +0 -0
  50. {devcycle_python_server_sdk-2.0.1.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/top_level.txt +0 -0
@@ -1,17 +1,392 @@
1
+ import json
1
2
  import logging
2
-
3
3
  import unittest
4
4
 
5
- from devcycle_python_sdk.api.local_bucketing import LocalBucketing
5
+ from devcycle_python_sdk.api.local_bucketing import LocalBucketing, WASMAbortError
6
+ from devcycle_python_sdk.models.bucketed_config import (
7
+ Environment,
8
+ EdgeDBSettings,
9
+ FeatureVariation,
10
+ OptInColors,
11
+ OptInSettings,
12
+ Project,
13
+ ProjectSettings,
14
+ )
15
+ from devcycle_python_sdk.models.feature import Feature
16
+ from devcycle_python_sdk.models.variable import Variable
17
+ from devcycle_python_sdk.models.platform_data import default_platform_data
18
+ from devcycle_python_sdk.models.user import DevCycleUser
19
+ from devcycle_python_sdk.models.event import DevCycleEvent, EventType
20
+ from devcycle_python_sdk.models.variable import TypeEnum
21
+ from test.fixture.data import small_config, large_config, special_character_config
6
22
 
7
23
  logger = logging.getLogger(__name__)
8
24
 
9
25
 
10
26
  class LocalBucketingTest(unittest.TestCase):
27
+ def setUp(self) -> None:
28
+ self.test_sdk_key = "dvc_server_testkey"
29
+ self.local_bucketing = LocalBucketing(self.test_sdk_key)
30
+
11
31
  def test_init(self):
12
- local_bucketing = LocalBucketing()
32
+ local_bucketing = LocalBucketing(self.test_sdk_key)
13
33
  self.assertIsNotNone(local_bucketing)
14
34
 
35
+ def test_write_read_string(self) -> None:
36
+ # Test that we can safely pass ASCII strings to the WASM module and read them back
37
+ test_string = "test_bytes"
38
+ arg_pointer = self.local_bucketing._new_assembly_script_string(test_string)
39
+
40
+ echo_func = self.local_bucketing._get_export("echoString")
41
+
42
+ result_pointer = echo_func(self.local_bucketing.wasm_store, arg_pointer)
43
+
44
+ result_bytes = self.local_bucketing._read_assembly_script_string(result_pointer)
45
+ self.assertEqual(result_bytes, test_string)
46
+
47
+ def test_write_read_bytes_array(self) -> None:
48
+ # Test that we can safely pass byte arrays to the WASM module and read them back
49
+ test_bytes = "test_bytes".encode("utf-8")
50
+ arg_pointer = self.local_bucketing._new_assembly_script_byte_array(test_bytes)
51
+
52
+ echo_func = self.local_bucketing._get_export("echoUint8Array")
53
+
54
+ result_pointer = echo_func(self.local_bucketing.wasm_store, arg_pointer)
55
+
56
+ result_bytes = self.local_bucketing._read_assembly_script_byte_array(
57
+ result_pointer
58
+ )
59
+ self.assertEqual(result_bytes, test_bytes)
60
+
61
+ def test_abort(self) -> None:
62
+ abort_func = self.local_bucketing._get_export("triggerAbort")
63
+
64
+ with self.assertRaises(WASMAbortError) as context:
65
+ abort_func(self.local_bucketing.wasm_store)
66
+
67
+ self.assertRegex(
68
+ context.exception.args[0],
69
+ r"Abort in '[^']+':[0-9]+:[0-9]+ -- 'Manual abort triggered'",
70
+ )
71
+
72
+ def test_store_config(self) -> None:
73
+ # should store each config with any errors
74
+ self.local_bucketing.store_config(small_config())
75
+ self.local_bucketing.store_config(large_config())
76
+ self.local_bucketing.store_config(special_character_config())
77
+
78
+ def test_set_platform_data(self):
79
+ # should set the data without any errors
80
+ platform_json = json.dumps(default_platform_data().to_json())
81
+ self.local_bucketing.set_platform_data(platform_json)
82
+
83
+ def test_set_client_custom_data(self):
84
+ # should set the data without any errors
85
+ client_custom_data = {
86
+ "strProp": "strVal",
87
+ "intProp": 1,
88
+ "floatProp": 1.1,
89
+ "boolProp": True,
90
+ "nullProp": None,
91
+ }
92
+ data_str = json.dumps(client_custom_data)
93
+ self.local_bucketing.set_client_custom_data(data_str)
94
+
95
+ def test_get_variable_for_user_protobuf(self):
96
+ self.local_bucketing.store_config(small_config())
97
+ platform_json = json.dumps(default_platform_data().to_json())
98
+ self.local_bucketing.set_platform_data(platform_json)
99
+ self.local_bucketing.init_event_queue("{}")
100
+ user = DevCycleUser(user_id="test_user_id")
101
+ result = self.local_bucketing.get_variable_for_user_protobuf(
102
+ user=user, key="string-var", default_value="default value"
103
+ )
104
+ self.assertIsNotNone(result)
105
+ self.assertEqual(result.key, "string-var")
106
+ self.assertEqual(result.type, TypeEnum.STRING)
107
+ self.assertEqual(result.value, "variationOn")
108
+ self.assertFalse(result.isDefaulted)
109
+
110
+ def test_get_variable_for_user_protobuf_special_characters(self):
111
+ self.local_bucketing.store_config(special_character_config())
112
+ platform_json = json.dumps(default_platform_data().to_json())
113
+ self.local_bucketing.set_platform_data(platform_json)
114
+ self.local_bucketing.init_event_queue("{}")
115
+ user = DevCycleUser(user_id="test_user_id")
116
+ result = self.local_bucketing.get_variable_for_user_protobuf(
117
+ user=user, key="string-var", default_value="default value"
118
+ )
119
+ self.assertIsNotNone(result)
120
+ self.assertEqual(result.key, "string-var")
121
+ self.assertEqual(result.type, TypeEnum.STRING)
122
+ self.assertEqual(result.value, "öé 🐍 ¥ variationOn")
123
+ self.assertFalse(result.isDefaulted)
124
+
125
+ def test_get_variable_for_user_protobuf_type_mismatch(self):
126
+ self.local_bucketing.store_config(small_config())
127
+ platform_json = json.dumps(default_platform_data().to_json())
128
+ self.local_bucketing.set_platform_data(platform_json)
129
+ self.local_bucketing.init_event_queue("{}")
130
+ user = DevCycleUser(user_id="test_user_id")
131
+
132
+ # type mismatch is handled inside the WASM and will return
133
+ # no data if the type is not correct
134
+ result = self.local_bucketing.get_variable_for_user_protobuf(
135
+ user=user, key="string-var", default_value=9999
136
+ )
137
+ self.assertIsNone(result)
138
+
139
+ def test_generate_bucketed_config(self):
140
+ self.local_bucketing.store_config(small_config())
141
+ platform_json = json.dumps(default_platform_data().to_json())
142
+ self.local_bucketing.set_platform_data(platform_json)
143
+ self.local_bucketing.init_event_queue("{}")
144
+ user = DevCycleUser(user_id="test_user_id")
145
+
146
+ result = self.local_bucketing.generate_bucketed_config(user=user)
147
+
148
+ expected_features = {
149
+ "a-cool-new-feature": Feature(
150
+ _id="62fbf6566f1ba302829f9e32",
151
+ key="a-cool-new-feature",
152
+ type="release",
153
+ _variation="62fbf6566f1ba302829f9e39",
154
+ variationName="VariationOn",
155
+ variationKey="variation-on",
156
+ evalReason=None,
157
+ )
158
+ }
159
+ expected_variables = {
160
+ "a-cool-new-feature": Variable(
161
+ _id="62fbf6566f1ba302829f9e34",
162
+ key="a-cool-new-feature",
163
+ type="Boolean",
164
+ value=True,
165
+ isDefaulted=None,
166
+ defaultValue=None,
167
+ evalReason=None,
168
+ ),
169
+ "string-var": Variable(
170
+ _id="63125320a4719939fd57cb2b",
171
+ key="string-var",
172
+ type="String",
173
+ value="variationOn",
174
+ isDefaulted=None,
175
+ defaultValue=None,
176
+ evalReason=None,
177
+ ),
178
+ "json-var": Variable(
179
+ _id="64372363125123fca69d3f7b",
180
+ key="json-var",
181
+ type="JSON",
182
+ value={
183
+ "displayText": "This variation is on",
184
+ "showDialog": True,
185
+ "maxUsers": 100,
186
+ },
187
+ isDefaulted=None,
188
+ defaultValue=None,
189
+ evalReason=None,
190
+ ),
191
+ "num-var": Variable(
192
+ _id="65272363125123fca69d3a7d",
193
+ key="num-var",
194
+ type="Number",
195
+ value=12345,
196
+ isDefaulted=None,
197
+ defaultValue=None,
198
+ evalReason=None,
199
+ ),
200
+ }
201
+
202
+ self.assertIsNotNone(result)
203
+ self.assertEqual(result.user, user)
204
+ self.assertEqual(
205
+ result.project,
206
+ Project(
207
+ id="61f97628ff4afcb6d057dbf0",
208
+ key="emma-project",
209
+ a0_organization="org_tPyJN5dvNNirKar7",
210
+ settings=ProjectSettings(
211
+ edge_db=EdgeDBSettings(enabled=False),
212
+ opt_in=OptInSettings(
213
+ enabled=True,
214
+ title="EarlyAccess",
215
+ description="Getearlyaccesstobetafeaturesbelow!",
216
+ image_url="",
217
+ colors=OptInColors(primary="#531cd9", secondary="#16dec0"),
218
+ ),
219
+ ),
220
+ ),
221
+ )
222
+ self.assertEqual(
223
+ result.environment,
224
+ Environment(id="61f97628ff4afcb6d057dbf2", key="development"),
225
+ )
226
+ self.assertEqual(
227
+ result.features,
228
+ expected_features,
229
+ )
230
+ self.assertEqual(
231
+ result.feature_variation_map,
232
+ {"62fbf6566f1ba302829f9e32": "62fbf6566f1ba302829f9e39"},
233
+ )
234
+ self.assertEqual(
235
+ result.variable_variation_map,
236
+ {
237
+ "a-cool-new-feature": FeatureVariation(
238
+ feature="62fbf6566f1ba302829f9e32",
239
+ variation="62fbf6566f1ba302829f9e39",
240
+ ),
241
+ "string-var": FeatureVariation(
242
+ feature="62fbf6566f1ba302829f9e32",
243
+ variation="62fbf6566f1ba302829f9e39",
244
+ ),
245
+ "json-var": FeatureVariation(
246
+ feature="62fbf6566f1ba302829f9e32",
247
+ variation="62fbf6566f1ba302829f9e39",
248
+ ),
249
+ "num-var": FeatureVariation(
250
+ feature="62fbf6566f1ba302829f9e32",
251
+ variation="62fbf6566f1ba302829f9e39",
252
+ ),
253
+ },
254
+ )
255
+ self.assertEqual(
256
+ result.variables,
257
+ expected_variables,
258
+ )
259
+ self.assertEqual(result.known_variable_keys, [])
260
+
261
+ def test_get_event_queue_size(self):
262
+ self.local_bucketing.store_config(small_config())
263
+ platform_json = json.dumps(default_platform_data().to_json())
264
+ self.local_bucketing.set_platform_data(platform_json)
265
+ self.local_bucketing.init_event_queue("{}")
266
+
267
+ result = self.local_bucketing.get_event_queue_size()
268
+ self.assertEqual(result, 0)
269
+
270
+ def test_flush_event_queue_empty(self):
271
+ self.local_bucketing.store_config(small_config())
272
+ platform_json = json.dumps(default_platform_data().to_json())
273
+ self.local_bucketing.set_platform_data(platform_json)
274
+
275
+ self.local_bucketing.init_event_queue(
276
+ json.dumps(
277
+ {
278
+ "disableAutomaticEventLogging": False,
279
+ "disableCustomEventLogging": False,
280
+ "minEventsPerFlush": 1,
281
+ }
282
+ )
283
+ )
284
+ results = self.local_bucketing.flush_event_queue()
285
+ self.assertIsNotNone(results)
286
+ self.assertListEqual(results, [])
287
+
288
+ def test_flush_event_queue(self):
289
+ self.local_bucketing.store_config(small_config())
290
+ platform_json = json.dumps(default_platform_data().to_json())
291
+ self.local_bucketing.set_platform_data(platform_json)
292
+
293
+ self.local_bucketing.init_event_queue(
294
+ json.dumps(
295
+ {
296
+ "disableAutomaticEventLogging": False,
297
+ "disableCustomEventLogging": False,
298
+ "minEventsPerFlush": 1,
299
+ }
300
+ )
301
+ )
302
+
303
+ # trigger two events for a single user
304
+ user = DevCycleUser(user_id="test_user_id")
305
+ self.local_bucketing.get_variable_for_user_protobuf(
306
+ user=user, key="string-var", default_value="default value"
307
+ )
308
+ self.local_bucketing.get_variable_for_user_protobuf(
309
+ user=user, key="bool-var", default_value=False
310
+ )
311
+ results = self.local_bucketing.flush_event_queue()
312
+ self.assertIsNotNone(results)
313
+ self.assertEqual(len(results), 1)
314
+ self.assertEqual(results[0].eventCount, 2)
315
+ self.assertIsNotNone(results[0].payloadId)
316
+ self.assertTrue(len(results[0].payloadId) > 0)
317
+ self.assertEqual(len(results[0].records), 1)
318
+ self.assertEqual(len(results[0].records[0].events), 2)
319
+
320
+ def test_on_event_payload_failure_unknown_payload_id(self):
321
+ self.local_bucketing.store_config(small_config())
322
+ platform_json = json.dumps(default_platform_data().to_json())
323
+ self.local_bucketing.set_platform_data(platform_json)
324
+
325
+ self.local_bucketing.init_event_queue(
326
+ json.dumps(
327
+ {
328
+ "disableAutomaticEventLogging": False,
329
+ "disableCustomEventLogging": False,
330
+ "minEventsPerFlush": 1,
331
+ }
332
+ )
333
+ )
334
+
335
+ with self.assertRaises(WASMAbortError):
336
+ self.local_bucketing.on_event_payload_failure("test_payload_id", True)
337
+
338
+ def test_on_event_payload_success_unknown_payload_id(self):
339
+ self.local_bucketing.store_config(small_config())
340
+ platform_json = json.dumps(default_platform_data().to_json())
341
+ self.local_bucketing.set_platform_data(platform_json)
342
+
343
+ self.local_bucketing.init_event_queue(
344
+ json.dumps(
345
+ {
346
+ "disableAutomaticEventLogging": False,
347
+ "disableCustomEventLogging": False,
348
+ "minEventsPerFlush": 1,
349
+ }
350
+ )
351
+ )
352
+
353
+ with self.assertRaises(WASMAbortError):
354
+ self.local_bucketing.on_event_payload_success("test_payload_id")
355
+
356
+ def test_queue_event(self):
357
+ self.local_bucketing.store_config(small_config())
358
+ platform_json = json.dumps(default_platform_data().to_json())
359
+ self.local_bucketing.set_platform_data(platform_json)
360
+ self.local_bucketing.init_event_queue("{}")
361
+ user = DevCycleUser(user_id="test_user_id")
362
+ event = DevCycleEvent(
363
+ type=EventType.CustomEvent,
364
+ target="string-var",
365
+ value=1,
366
+ metaData={"test": "test"},
367
+ )
368
+ user_json = json.dumps(user.to_json())
369
+ event_json = json.dumps(event.to_json())
370
+ self.local_bucketing.queue_event(user_json, event_json)
371
+
372
+ def test_queue_aggregate_event(self):
373
+ self.local_bucketing.store_config(small_config())
374
+ platform_json = json.dumps(default_platform_data().to_json())
375
+ self.local_bucketing.set_platform_data(platform_json)
376
+ self.local_bucketing.init_event_queue("{}")
377
+
378
+ event = DevCycleEvent(
379
+ type=EventType.AggVariableDefaulted,
380
+ target="string-var",
381
+ value=1,
382
+ metaData={"test": "test"},
383
+ )
384
+ event_json = json.dumps(event.to_json())
385
+ variable_variation_map_json = "{}"
386
+ self.local_bucketing.queue_aggregate_event(
387
+ event_json, variable_variation_map_json
388
+ )
389
+
15
390
 
16
- if __name__ == '__main__':
391
+ if __name__ == "__main__":
17
392
  unittest.main()
File without changes
test/fixture/data.py ADDED
@@ -0,0 +1,57 @@
1
+ import os
2
+ import json
3
+
4
+
5
+ def small_config() -> str:
6
+ config_filename = os.path.join(
7
+ os.path.dirname(__file__), "fixture_small_config.json"
8
+ )
9
+ with open(config_filename, "r") as f:
10
+ return f.read()
11
+
12
+
13
+ def small_config_json() -> dict:
14
+ data = small_config()
15
+ return json.loads(data)
16
+
17
+
18
+ def special_character_config() -> str:
19
+ config_filename = os.path.join(
20
+ os.path.dirname(__file__), "fixture_small_config_special_characters.json"
21
+ )
22
+ with open(config_filename, "r") as f:
23
+ return f.read()
24
+
25
+
26
+ def special_character_config_json() -> dict:
27
+ data = special_character_config()
28
+ return json.loads(data)
29
+
30
+
31
+ def large_config() -> str:
32
+ config_filename = os.path.join(
33
+ os.path.dirname(__file__), "fixture_large_config.json"
34
+ )
35
+ with open(config_filename, "r") as f:
36
+ return f.read()
37
+
38
+
39
+ def large_config_json() -> dict:
40
+ data = large_config()
41
+ return json.loads(data)
42
+
43
+
44
+ def bucketed_config() -> str:
45
+ config_filename = os.path.join(
46
+ os.path.dirname(__file__), "fixture_bucketed_config.json"
47
+ )
48
+ with open(config_filename, "r") as f:
49
+ return f.read()
50
+
51
+
52
+ def bucketed_config_minimal() -> str:
53
+ config_filename = os.path.join(
54
+ os.path.dirname(__file__), "fixture_bucketed_config_minimal.json"
55
+ )
56
+ with open(config_filename, "r") as f:
57
+ return f.read()
@@ -1,17 +1,132 @@
1
1
  import logging
2
-
2
+ import json
3
+ import time
4
+ import uuid
3
5
  import unittest
6
+ from unittest.mock import patch, MagicMock
4
7
 
8
+ from devcycle_python_sdk import DevCycleLocalOptions
5
9
  from devcycle_python_sdk.managers.config_manager import EnvironmentConfigManager
10
+ from test.fixture.data import small_config_json
6
11
 
7
12
  logger = logging.getLogger(__name__)
8
13
 
9
14
 
10
15
  class EnvironmentConfigManagerTest(unittest.TestCase):
11
- def test_init(self):
12
- config_manager = EnvironmentConfigManager(None, None, None)
13
- self.assertIsNotNone(config_manager)
16
+ def setUp(self) -> None:
17
+ self.sdk_key = "dvc_server_" + str(uuid.uuid4())
18
+ self.test_local_bucketing = MagicMock()
19
+ self.test_options = DevCycleLocalOptions(config_polling_interval_ms=500)
20
+
21
+ self.test_etag = str(uuid.uuid4())
22
+ self.test_config_json = small_config_json()
23
+ self.test_config_string = json.dumps(self.test_config_json)
24
+
25
+ @patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
26
+ def test_init(self, mock_get_config):
27
+ mock_get_config.return_value = (self.test_config_json, self.test_etag)
28
+ config_manager = EnvironmentConfigManager(
29
+ self.sdk_key, self.test_options, self.test_local_bucketing
30
+ )
31
+
32
+ # sleep to allow polling thread to load the config
33
+ time.sleep(0.1)
34
+ mock_get_config.assert_called_once_with(config_etag=None)
35
+
36
+ self.assertTrue(config_manager._polling_enabled)
37
+ self.assertTrue(config_manager.is_alive())
38
+ self.assertTrue(config_manager.daemon)
39
+ self.assertEqual(config_manager._config_etag, self.test_etag)
40
+ self.assertDictEqual(config_manager._config, self.test_config_json)
41
+ self.test_local_bucketing.store_config.assert_called_once_with(
42
+ self.test_config_string
43
+ )
44
+ self.assertTrue(config_manager.is_initialized())
45
+
46
+ @patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
47
+ def test_init_with_client_callback(self, mock_get_config):
48
+ mock_get_config.return_value = (self.test_config_json, self.test_etag)
49
+
50
+ mock_callback = MagicMock()
51
+
52
+ self.test_options.on_client_initialized = mock_callback
53
+
54
+ config_manager = EnvironmentConfigManager(
55
+ self.sdk_key, self.test_options, self.test_local_bucketing
56
+ )
57
+ time.sleep(0.1)
58
+ mock_get_config.assert_called_once_with(config_etag=None)
59
+ self.assertEqual(config_manager._config_etag, self.test_etag)
60
+ self.assertDictEqual(config_manager._config, self.test_config_json)
61
+ self.test_local_bucketing.store_config.assert_called_once_with(
62
+ self.test_config_string
63
+ )
64
+ self.assertTrue(config_manager.is_initialized())
65
+ mock_callback.assert_called_once()
66
+
67
+ @patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
68
+ 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)
70
+ mock_callback = MagicMock()
71
+ mock_callback.side_effect = Exception(
72
+ "Badly written callback generates an exception"
73
+ )
74
+
75
+ self.test_options.on_client_initialized = mock_callback
76
+
77
+ config_manager = EnvironmentConfigManager(
78
+ self.sdk_key, self.test_options, self.test_local_bucketing
79
+ )
80
+ # the callback error should not negatively impact initialization of the config manager
81
+ time.sleep(0.1)
82
+ mock_get_config.assert_called_once_with(config_etag=None)
83
+ self.assertEqual(config_manager._config_etag, self.test_etag)
84
+ self.assertDictEqual(config_manager._config, self.test_config_json)
85
+ self.test_local_bucketing.store_config.assert_called_once_with(
86
+ self.test_config_string
87
+ )
88
+ self.assertTrue(config_manager.is_initialized())
89
+ mock_callback.assert_called_once()
90
+
91
+ @patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
92
+ def test_close(self, mock_get_config):
93
+ mock_get_config.return_value = (self.test_config_json, self.test_etag)
94
+ self.test_options.config_polling_interval_ms = 500
95
+
96
+ config_manager = EnvironmentConfigManager(
97
+ self.sdk_key, self.test_options, self.test_local_bucketing
98
+ )
99
+
100
+ # sleep to allow polling thread to load the config
101
+ time.sleep(0.1)
102
+
103
+ config_manager.close()
104
+ self.assertFalse(config_manager._polling_enabled)
105
+
106
+ @patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
107
+ def test_get_config_unchanged(self, mock_get_config):
108
+ mock_get_config.return_value = (self.test_config_json, self.test_etag)
109
+
110
+ self.test_options.config_polling_interval_ms = 200
111
+ config_manager = EnvironmentConfigManager(
112
+ self.sdk_key, self.test_options, self.test_local_bucketing
113
+ )
114
+
115
+ time.sleep(0.1)
116
+ # stop the polling
117
+ config_manager.close()
118
+
119
+ self.test_local_bucketing.store_config.reset_mock()
120
+ mock_get_config.return_value = (None, config_manager._config_etag)
121
+
122
+ # trigger refresh of the config directly
123
+ config_manager._get_config()
124
+
125
+ # verify that the config was not updated
126
+ self.assertEqual(config_manager._config_etag, self.test_etag)
127
+ self.assertDictEqual(config_manager._config, self.test_config_json)
128
+ self.test_local_bucketing.store_config.assert_not_called()
14
129
 
15
130
 
16
- if __name__ == '__main__':
131
+ if __name__ == "__main__":
17
132
  unittest.main()