devcycle-python-server-sdk 2.0.0__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 (51) hide show
  1. devcycle_python_sdk/VERSION.txt +1 -1
  2. devcycle_python_sdk/__init__.py +3 -2
  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 +436 -0
  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/__init__.py +0 -0
  12. devcycle_python_sdk/managers/config_manager.py +95 -0
  13. devcycle_python_sdk/managers/event_queue_manager.py +223 -0
  14. devcycle_python_sdk/models/bucketed_config.py +150 -0
  15. devcycle_python_sdk/models/event.py +111 -8
  16. devcycle_python_sdk/models/feature.py +12 -0
  17. devcycle_python_sdk/models/platform_data.py +33 -0
  18. devcycle_python_sdk/models/user.py +52 -7
  19. devcycle_python_sdk/models/variable.py +17 -5
  20. devcycle_python_sdk/options.py +106 -0
  21. devcycle_python_sdk/protobuf/__init__.py +0 -0
  22. devcycle_python_sdk/protobuf/utils.py +129 -0
  23. devcycle_python_sdk/protobuf/variableForUserParams_pb2.py +46 -0
  24. devcycle_python_sdk/py.typed +0 -0
  25. devcycle_python_sdk/util/version.py +1 -1
  26. {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/METADATA +3 -2
  27. devcycle_python_server_sdk-3.0.0.dist-info/RECORD +55 -0
  28. example/{example.py → cloud_client_example.py} +24 -20
  29. example/local_bucketing_client_example.py +74 -0
  30. test/api/__init__.py +0 -0
  31. test/{test_bucketing_client.py → api/test_bucketing_client.py} +10 -5
  32. test/api/test_config_client.py +133 -0
  33. test/api/test_event_client.py +97 -0
  34. test/api/test_local_bucketing.py +392 -0
  35. test/fixture/__init__.py +0 -0
  36. test/fixture/data.py +57 -0
  37. test/managers/__init__.py +0 -0
  38. test/managers/test_config_manager.py +132 -0
  39. test/managers/test_event_queue_manager.py +271 -0
  40. test/models/__init__.py +0 -0
  41. test/models/test_bucketed_config.py +26 -0
  42. test/{test_dvc_cloud_client.py → test_cloud_client.py} +11 -17
  43. test/test_local_client.py +397 -0
  44. test/util/__init__.py +0 -0
  45. test/util/test_utils.py +183 -0
  46. test/{test_version.py → util/test_version.py} +2 -2
  47. devcycle_python_sdk/dvc_options.py +0 -18
  48. devcycle_python_server_sdk-2.0.0.dist-info/RECORD +0 -26
  49. {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/LICENSE +0 -0
  50. {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/WHEEL +0 -0
  51. {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,271 @@
1
+ import logging
2
+ import time
3
+ import uuid
4
+ import unittest
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ from devcycle_python_sdk import DevCycleLocalOptions
8
+ from devcycle_python_sdk.managers.event_queue_manager import (
9
+ EventQueueManager,
10
+ QueueFullError,
11
+ )
12
+ from devcycle_python_sdk.models.event import (
13
+ FlushPayload,
14
+ UserEventsBatchRecord,
15
+ RequestEvent,
16
+ DevCycleEvent,
17
+ EventType,
18
+ )
19
+ from devcycle_python_sdk.models.user import DevCycleUser
20
+
21
+ from devcycle_python_sdk.exceptions import APIClientError, APIClientUnauthorizedError
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class EventQueueManagerTest(unittest.TestCase):
27
+ def setUp(self) -> None:
28
+ self.sdk_key = "dvc_server_" + str(uuid.uuid4())
29
+
30
+ # mock out the local bucketing to test event queue logic directly
31
+ self.test_local_bucketing = MagicMock()
32
+ self.test_local_bucketing.flush_event_queue = MagicMock()
33
+ self.test_local_bucketing.flush_event_queue.return_value = []
34
+ self.test_local_bucketing.get_event_queue_size = MagicMock()
35
+ self.test_local_bucketing.get_event_queue_size.return_value = 0
36
+ self.test_local_bucketing.on_event_payload_success = MagicMock()
37
+ self.test_local_bucketing.on_event_payload_failure = MagicMock()
38
+
39
+ self.test_options = DevCycleLocalOptions(
40
+ disable_automatic_event_logging=False, disable_custom_event_logging=False
41
+ )
42
+ self.test_options_no_thread = DevCycleLocalOptions(
43
+ disable_automatic_event_logging=True, disable_custom_event_logging=True
44
+ )
45
+ self.test_payload = FlushPayload(
46
+ payloadId="123",
47
+ records=[
48
+ UserEventsBatchRecord(
49
+ user=DevCycleUser.from_json(
50
+ {
51
+ "user_id": "999-000-111",
52
+ "createdDate": "1970-01-20T12:50:19.871Z",
53
+ "lastSeenDate": "1970-01-20T12:50:19.871Z",
54
+ "platform": "Python",
55
+ "platformVersion": "3.11.4",
56
+ "sdkType": "local",
57
+ "sdkVersion": "2.0.1",
58
+ "hostname": "Chris-DevCycle-MacBook.local",
59
+ }
60
+ ),
61
+ events=[
62
+ RequestEvent.from_json(
63
+ {
64
+ "type": "aggVariableEvaluated",
65
+ "target": "string-var",
66
+ "user_id": "999-000-111",
67
+ "date": "1970-01-20T12:50:19.871Z",
68
+ "clientDate": "1970-01-20T12:50:19.871Z",
69
+ "value": 1.0,
70
+ "featureVars": {},
71
+ "metaData": {
72
+ "_feature": "62fbf6566f1ba302829f9e32",
73
+ "_variation": "62fbf6566f1ba302829f9e39",
74
+ },
75
+ }
76
+ )
77
+ ],
78
+ )
79
+ ],
80
+ eventCount=1,
81
+ )
82
+
83
+ def test_init(self):
84
+ self.test_local_bucketing.flush_event_queue.return_value = []
85
+ self.test_options.event_flush_interval_ms = 100
86
+ manager = EventQueueManager(
87
+ self.sdk_key, self.test_options, self.test_local_bucketing
88
+ )
89
+ self.assertTrue(manager._should_run())
90
+ self.assertTrue(manager.is_alive())
91
+ self.assertTrue(manager.daemon)
92
+
93
+ def test_close(self):
94
+ self.test_local_bucketing.flush_event_queue.return_value = []
95
+ self.test_options.event_flush_interval_ms = 100
96
+ manager = EventQueueManager(
97
+ self.sdk_key, self.test_options, self.test_local_bucketing
98
+ )
99
+
100
+ manager.close()
101
+ # let the thread wake up from sleep and react to the close
102
+ time.sleep(0.5)
103
+ self.assertFalse(manager._should_run())
104
+ self.assertFalse(manager.is_alive())
105
+
106
+ @patch("devcycle_python_sdk.api.event_client.EventAPIClient.publish_events")
107
+ def test_publish_event_payload_retryable_api_error(self, mock_publish_events):
108
+ self.test_local_bucketing.on_event_payload_success = MagicMock()
109
+ self.test_local_bucketing.on_event_payload_failure = MagicMock()
110
+
111
+ mock_publish_events.side_effect = APIClientError("Some retryable error")
112
+
113
+ manager = EventQueueManager(
114
+ self.sdk_key, self.test_options_no_thread, self.test_local_bucketing
115
+ )
116
+ manager._publish_event_payload(self.test_payload)
117
+
118
+ # should try and publish events, get the error and then report the failure to local bucketing
119
+ mock_publish_events.assert_called_once()
120
+ self.test_local_bucketing.on_event_payload_failure.assert_called_with(
121
+ self.test_payload.payloadId, True
122
+ )
123
+ self.test_local_bucketing.on_event_payload_success.assert_not_called()
124
+
125
+ @patch("devcycle_python_sdk.api.event_client.EventAPIClient.publish_events")
126
+ def test_publish_event_payload_non_retryable_api_error(self, mock_publish_events):
127
+ mock_publish_events.side_effect = APIClientUnauthorizedError(
128
+ "Some retryable error"
129
+ )
130
+
131
+ manager = EventQueueManager(
132
+ self.sdk_key, self.test_options_no_thread, self.test_local_bucketing
133
+ )
134
+ manager._publish_event_payload(self.test_payload)
135
+
136
+ # should try and publish events, get the error and then report the failure to local bucketing
137
+ mock_publish_events.assert_called_once()
138
+ self.test_local_bucketing.on_event_payload_failure.assert_called_with(
139
+ self.test_payload.payloadId, False
140
+ )
141
+ self.test_local_bucketing.on_event_payload_success.assert_not_called()
142
+
143
+ @patch("devcycle_python_sdk.api.event_client.EventAPIClient.publish_events")
144
+ def test_flush_events_no_events(self, mock_publish_events):
145
+ self.test_local_bucketing.flush_event_queue = MagicMock()
146
+ self.test_local_bucketing.flush_event_queue.return_value = []
147
+
148
+ manager = EventQueueManager(
149
+ self.sdk_key, self.test_options_no_thread, self.test_local_bucketing
150
+ )
151
+ manager._flush_events()
152
+
153
+ self.test_local_bucketing.flush_event_queue.assert_called_once()
154
+ mock_publish_events.assert_not_called()
155
+
156
+ @patch("devcycle_python_sdk.api.event_client.EventAPIClient.publish_events")
157
+ def test_flush_events(self, mock_publish_events):
158
+ self.test_local_bucketing.flush_event_queue.return_value = [self.test_payload]
159
+
160
+ manager = EventQueueManager(
161
+ self.sdk_key, self.test_options_no_thread, self.test_local_bucketing
162
+ )
163
+ result = manager._flush_events()
164
+
165
+ self.assertEqual(result, 1)
166
+ self.test_local_bucketing.flush_event_queue.assert_called_once()
167
+ mock_publish_events.assert_called_once()
168
+ self.test_local_bucketing.on_event_payload_success.assert_called_once()
169
+
170
+ def test_queue_event_bad_data(self):
171
+ manager = EventQueueManager(
172
+ self.sdk_key, self.test_options_no_thread, self.test_local_bucketing
173
+ )
174
+ with self.assertRaises(ValueError):
175
+ manager.queue_event(user=None, event=None)
176
+
177
+ with self.assertRaises(ValueError):
178
+ manager.queue_event(user=DevCycleUser(user_id="test"), event=None)
179
+
180
+ # Events must have a type
181
+ with self.assertRaises(ValueError):
182
+ manager.queue_event(
183
+ user=DevCycleUser(user_id="test"), event=DevCycleEvent()
184
+ )
185
+
186
+ def test_queue_event(self):
187
+ self.test_local_bucketing.queue_event = MagicMock()
188
+ self.test_local_bucketing.get_event_queue_size.return_value = 1
189
+
190
+ manager = EventQueueManager(
191
+ self.sdk_key, self.test_options_no_thread, self.test_local_bucketing
192
+ )
193
+ DevCycleUser(user_id="test_user_id")
194
+ event = DevCycleEvent(
195
+ type=EventType.CustomEvent,
196
+ target="string-var",
197
+ value=1,
198
+ metaData={"test": "test"},
199
+ )
200
+ manager.queue_event(DevCycleUser(user_id="test"), event)
201
+
202
+ self.test_local_bucketing.queue_event.assert_called_once()
203
+ self.assertEqual(self.test_local_bucketing.get_event_queue_size.call_count, 2)
204
+
205
+ def test_check_queue_status(self):
206
+ self.test_options_no_thread.flush_event_queue_size = 5
207
+ self.test_options_no_thread.max_event_queue_size = 100
208
+
209
+ manager = EventQueueManager(
210
+ self.sdk_key, self.test_options_no_thread, self.test_local_bucketing
211
+ )
212
+
213
+ # queue empty, no issues
214
+ self.test_local_bucketing.get_event_queue_size.return_value = 0
215
+ manager._check_queue_status()
216
+ self.test_local_bucketing.flush_event_queue.assert_not_called()
217
+
218
+ # queue needs flush
219
+ self.test_local_bucketing.flush_event_queue.reset_mock()
220
+ self.test_local_bucketing.get_event_queue_size.return_value = 6
221
+ manager._check_queue_status()
222
+ self.test_local_bucketing.flush_event_queue.assert_called_once()
223
+
224
+ # queue full - should flush and then throw error if queue is still full
225
+ self.test_local_bucketing.flush_event_queue.reset_mock()
226
+
227
+ self.test_local_bucketing.get_event_queue_size.return_value = 100
228
+ with self.assertRaises(QueueFullError):
229
+ manager._check_queue_status()
230
+ self.test_local_bucketing.flush_event_queue.assert_called_once()
231
+
232
+ def test_queue_aggregate_event_bad_data(self):
233
+ manager = EventQueueManager(
234
+ self.sdk_key, self.test_options_no_thread, self.test_local_bucketing
235
+ )
236
+ with self.assertRaises(ValueError):
237
+ manager.queue_aggregate_event(event=None, bucketed_config=None)
238
+
239
+ # agg event must have a type
240
+ with self.assertRaises(ValueError):
241
+ manager.queue_aggregate_event(event=DevCycleEvent(), bucketed_config=None)
242
+
243
+ # agg event must have a target
244
+ with self.assertRaises(ValueError):
245
+ manager.queue_aggregate_event(
246
+ event=DevCycleEvent(type=EventType.AggVariableDefaulted),
247
+ bucketed_config=None,
248
+ )
249
+
250
+ def test_queue_aggregate_event(self):
251
+ self.test_local_bucketing.queue_aggregate_event = MagicMock()
252
+ self.test_local_bucketing.get_event_queue_size.return_value = 1
253
+
254
+ manager = EventQueueManager(
255
+ self.sdk_key, self.test_options_no_thread, self.test_local_bucketing
256
+ )
257
+ event = DevCycleEvent(
258
+ type=EventType.AggVariableDefaulted,
259
+ target="string-var",
260
+ value=1,
261
+ metaData={"test": "test"},
262
+ )
263
+
264
+ manager.queue_aggregate_event(event, None)
265
+
266
+ self.test_local_bucketing.queue_aggregate_event.assert_called_once()
267
+ self.assertEqual(self.test_local_bucketing.get_event_queue_size.call_count, 2)
268
+
269
+
270
+ if __name__ == "__main__":
271
+ unittest.main()
File without changes
@@ -0,0 +1,26 @@
1
+ import json
2
+ import logging
3
+ import unittest
4
+
5
+ from devcycle_python_sdk.models.bucketed_config import BucketedConfig
6
+ from test.fixture.data import bucketed_config, bucketed_config_minimal
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class BucketedConfigTest(unittest.TestCase):
12
+ def test_bucketed_config_all_fields(self) -> None:
13
+ bucketed_config_parsed = json.loads(bucketed_config())
14
+ result = BucketedConfig.from_json(bucketed_config_parsed)
15
+
16
+ # Fields are already checked in the local bucketing tests, no need to repeat here
17
+ self.assertIsNotNone(result)
18
+
19
+ def test_bucketed_config_minimal(self) -> None:
20
+ bucketed_config_parsed = json.loads(bucketed_config_minimal())
21
+ result = BucketedConfig.from_json(bucketed_config_parsed)
22
+ self.assertIsNotNone(result)
23
+
24
+
25
+ if __name__ == "__main__":
26
+ unittest.main()
@@ -6,9 +6,9 @@ from time import time
6
6
  from unittest.mock import patch
7
7
 
8
8
  from devcycle_python_sdk import DevCycleCloudClient, DevCycleCloudOptions
9
- from devcycle_python_sdk.models.user import User
9
+ from devcycle_python_sdk.models.user import DevCycleUser
10
10
  from devcycle_python_sdk.models.variable import Variable, TypeEnum
11
- from devcycle_python_sdk.models.event import Event
11
+ from devcycle_python_sdk.models.event import DevCycleEvent
12
12
  from devcycle_python_sdk.exceptions import (
13
13
  CloudClientUnauthorizedError,
14
14
  NotFoundError,
@@ -17,14 +17,14 @@ from devcycle_python_sdk.exceptions import (
17
17
  logger = logging.getLogger(__name__)
18
18
 
19
19
 
20
- class DVCCloudClientTest(unittest.TestCase):
20
+ class DevCycleCloudClientTest(unittest.TestCase):
21
21
  def setUp(self) -> None:
22
22
  sdk_key = "dvc_server_" + str(uuid.uuid4())
23
23
  options = DevCycleCloudOptions()
24
24
  self.test_client = DevCycleCloudClient(sdk_key, options)
25
- self.test_user = User(user_id="test_user_id")
26
- self.test_user_no_id = User(user_id=None)
27
- self.test_user_empty_id = User(user_id="")
25
+ self.test_user = DevCycleUser(user_id="test_user_id")
26
+ self.test_user_no_id = DevCycleUser(user_id=None) # type: ignore
27
+ self.test_user_empty_id = DevCycleUser(user_id="")
28
28
 
29
29
  def tearDown(self) -> None:
30
30
  pass
@@ -55,8 +55,6 @@ class DVCCloudClientTest(unittest.TestCase):
55
55
  option_with_data = DevCycleCloudOptions(
56
56
  enable_edge_db=False,
57
57
  bucketing_api_uri="https://localhost:8080",
58
- config_cdn_uri="https://localhost:8080",
59
- events_api_uri="https://localhost:8080",
60
58
  )
61
59
  client = DevCycleCloudClient(sdk_key, option_with_data)
62
60
  self.assertIsNotNone(client)
@@ -210,7 +208,7 @@ class DVCCloudClientTest(unittest.TestCase):
210
208
 
211
209
  @patch("devcycle_python_sdk.api.bucketing_client.BucketingAPIClient.track")
212
210
  def test_track_event_bad_user(self, mock_track_call):
213
- event = Event(
211
+ event = DevCycleEvent(
214
212
  type="user",
215
213
  target="test_target",
216
214
  date=time(),
@@ -236,10 +234,9 @@ class DVCCloudClientTest(unittest.TestCase):
236
234
  self.test_client.track(self.test_user, None)
237
235
  mock_track_call.assert_not_called()
238
236
 
239
- event = Event(
237
+ event = DevCycleEvent(
240
238
  type=None,
241
239
  target="test_target",
242
- date=time(),
243
240
  value=42,
244
241
  metaData={"key": "value"},
245
242
  )
@@ -247,10 +244,9 @@ class DVCCloudClientTest(unittest.TestCase):
247
244
  self.test_client.track(self.test_user, event)
248
245
  mock_track_call.assert_not_called()
249
246
 
250
- event = Event(
247
+ event = DevCycleEvent(
251
248
  type="",
252
249
  target="test_target",
253
- date=time(),
254
250
  value=42,
255
251
  metaData={"key": "value"},
256
252
  )
@@ -265,10 +261,9 @@ class DVCCloudClientTest(unittest.TestCase):
265
261
  with self.assertRaises(CloudClientUnauthorizedError):
266
262
  self.test_client.track(
267
263
  self.test_user,
268
- Event(
264
+ DevCycleEvent(
269
265
  type="user",
270
266
  target="test_target",
271
- date=time(),
272
267
  value=42,
273
268
  metaData={"key": "value"},
274
269
  ),
@@ -278,10 +273,9 @@ class DVCCloudClientTest(unittest.TestCase):
278
273
  mock_track_call.side_effect = Exception("Some error")
279
274
  self.test_client.track(
280
275
  self.test_user,
281
- Event(
276
+ DevCycleEvent(
282
277
  type="user",
283
278
  target="test_target",
284
- date=time(),
285
279
  value=42,
286
280
  metaData={"key": "value"},
287
281
  ),