devcycle-python-server-sdk 3.3.6__py3-none-any.whl → 3.6.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 (28) hide show
  1. devcycle_python_sdk/VERSION.txt +1 -1
  2. devcycle_python_sdk/__init__.py +1 -0
  3. devcycle_python_sdk/api/config_client.py +18 -6
  4. devcycle_python_sdk/bucketing-lib.release.wasm +0 -0
  5. devcycle_python_sdk/cloud_client.py +19 -2
  6. devcycle_python_sdk/devcycle_client.py +40 -0
  7. devcycle_python_sdk/local_client.py +16 -2
  8. devcycle_python_sdk/managers/config_manager.py +7 -2
  9. devcycle_python_sdk/models/bucketed_config.py +10 -6
  10. devcycle_python_sdk/models/user.py +86 -3
  11. devcycle_python_sdk/open_feature_provider/__init__.py +0 -0
  12. devcycle_python_sdk/open_feature_provider/provider.py +125 -0
  13. {devcycle_python_server_sdk-3.3.6.dist-info → devcycle_python_server_sdk-3.6.0.dist-info}/METADATA +3 -2
  14. {devcycle_python_server_sdk-3.3.6.dist-info → devcycle_python_server_sdk-3.6.0.dist-info}/RECORD +28 -20
  15. {devcycle_python_server_sdk-3.3.6.dist-info → devcycle_python_server_sdk-3.6.0.dist-info}/WHEEL +1 -1
  16. example/local_bucketing_client_example.py +1 -1
  17. example/openfeature_example.py +77 -0
  18. test/api/test_config_client.py +24 -6
  19. test/api/test_local_bucketing.py +15 -12
  20. test/managers/test_config_manager.py +32 -10
  21. test/models/test_user.py +188 -0
  22. test/openfeature_test/__init__.py +0 -0
  23. test/openfeature_test/test_provider.py +219 -0
  24. test/openfeature_test/test_provider_local_sdk.py +136 -0
  25. test/test_local_client.py +10 -0
  26. test/util/test_version.py +1 -1
  27. {devcycle_python_server_sdk-3.3.6.dist-info → devcycle_python_server_sdk-3.6.0.dist-info}/LICENSE +0 -0
  28. {devcycle_python_server_sdk-3.3.6.dist-info → devcycle_python_server_sdk-3.6.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,77 @@
1
+ import logging
2
+ import time
3
+ import os
4
+
5
+ from devcycle_python_sdk import DevCycleLocalClient, DevCycleLocalOptions
6
+
7
+ from openfeature import api
8
+ from openfeature.evaluation_context import EvaluationContext
9
+
10
+ FLAG_KEY = "test-boolean-variable"
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def main():
16
+ """
17
+ Sample usage of the DevCycle OpenFeature Provider along with the Python Server SDK using Local Bucketing.
18
+ """
19
+ logging.basicConfig(level="INFO", format="%(levelname)s: %(message)s")
20
+
21
+ # create an instance of the DevCycle Client object
22
+ server_sdk_key = os.environ["DEVCYCLE_SERVER_SDK_KEY"]
23
+ devcycle_client = DevCycleLocalClient(server_sdk_key, DevCycleLocalOptions())
24
+
25
+ # Wait for DevCycle to initialize and load the configuration
26
+ for i in range(10):
27
+ if devcycle_client.is_initialized():
28
+ break
29
+ logger.info("Waiting for DevCycle to initialize...")
30
+ time.sleep(0.5)
31
+ else:
32
+ logger.error("DevCycle failed to initialize")
33
+ exit(1)
34
+
35
+ # set the provider for OpenFeature
36
+ api.set_provider(devcycle_client.get_openfeature_provider())
37
+
38
+ # get the OpenFeature client
39
+ open_feature_client = api.get_client()
40
+
41
+ # create the request context for the user
42
+ context = EvaluationContext(
43
+ targeting_key="test-1234",
44
+ attributes={
45
+ "email": "test-user@domain.com",
46
+ "name": "Test User",
47
+ "language": "en",
48
+ "country": "CA",
49
+ "appVersion": "1.0.11",
50
+ "appBuild": 1000,
51
+ "customData": {"custom": "data"},
52
+ "privateCustomData": {"private": "data"},
53
+ },
54
+ )
55
+
56
+ # Look up the value of the flag
57
+ if open_feature_client.get_boolean_value(FLAG_KEY, False, context):
58
+ logger.info(f"Variable {FLAG_KEY} is enabled")
59
+ else:
60
+ logger.info(f"Variable {FLAG_KEY} is not enabled")
61
+
62
+ # Fetch a JSON object variable
63
+ json_object = open_feature_client.get_object_value(
64
+ "test-json-variable", {"default": "value"}, context
65
+ )
66
+ logger.info(f"JSON Object Value: {json_object}")
67
+
68
+ # Retrieve a string variable along with resolution details
69
+ details = open_feature_client.get_string_details("doesnt-exist", "default", context)
70
+ logger.info(f"String Value: {details.value}")
71
+ logger.info(f"Eval Reason: {details.reason}")
72
+
73
+ devcycle_client.close()
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()
@@ -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
@@ -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)
@@ -5,10 +5,7 @@ import unittest
5
5
  from devcycle_python_sdk.api.local_bucketing import LocalBucketing, WASMAbortError
6
6
  from devcycle_python_sdk.models.bucketed_config import (
7
7
  Environment,
8
- EdgeDBSettings,
9
8
  FeatureVariation,
10
- OptInColors,
11
- OptInSettings,
12
9
  Project,
13
10
  ProjectSettings,
14
11
  )
@@ -197,27 +194,29 @@ class LocalBucketingTest(unittest.TestCase):
197
194
  defaultValue=None,
198
195
  evalReason=None,
199
196
  ),
197
+ "float-var": Variable(
198
+ _id="61200363125123fca69d3a7a",
199
+ key="float-var",
200
+ type="Number",
201
+ value=3.14159,
202
+ isDefaulted=None,
203
+ defaultValue=None,
204
+ evalReason=None,
205
+ ),
200
206
  }
201
207
 
202
208
  self.assertIsNotNone(result)
203
209
  self.assertEqual(result.user, user)
204
210
  self.assertEqual(
205
- result.project,
206
211
  Project(
207
212
  id="61f97628ff4afcb6d057dbf0",
208
213
  key="emma-project",
209
214
  a0_organization="org_tPyJN5dvNNirKar7",
210
215
  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
- ),
216
+ edge_db=None, opt_in=None, disable_passthrough_rollouts=False
219
217
  ),
220
218
  ),
219
+ result.project,
221
220
  )
222
221
  self.assertEqual(
223
222
  result.environment,
@@ -250,6 +249,10 @@ class LocalBucketingTest(unittest.TestCase):
250
249
  feature="62fbf6566f1ba302829f9e32",
251
250
  variation="62fbf6566f1ba302829f9e39",
252
251
  ),
252
+ "float-var": FeatureVariation(
253
+ feature="62fbf6566f1ba302829f9e32",
254
+ variation="62fbf6566f1ba302829f9e39",
255
+ ),
253
256
  },
254
257
  )
255
258
  self.assertEqual(
@@ -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()
@@ -0,0 +1,188 @@
1
+ import logging
2
+ import unittest
3
+
4
+ from devcycle_python_sdk.models.user import DevCycleUser
5
+
6
+ from openfeature.provider import EvaluationContext
7
+ from openfeature.exception import TargetingKeyMissingError, InvalidContextError
8
+
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class DevCycleUserTest(unittest.TestCase):
14
+ def test_create_user_from_context_no_context(self):
15
+ with self.assertRaises(TargetingKeyMissingError):
16
+ DevCycleUser.create_user_from_context(None)
17
+
18
+ def test_create_user_from_context_no_user_id(self):
19
+ with self.assertRaises(TargetingKeyMissingError):
20
+ DevCycleUser.create_user_from_context(
21
+ EvaluationContext(targeting_key=None, attributes={})
22
+ )
23
+
24
+ with self.assertRaises(TargetingKeyMissingError):
25
+ DevCycleUser.create_user_from_context(
26
+ EvaluationContext(targeting_key=None, attributes=None)
27
+ )
28
+
29
+ with self.assertRaises(TargetingKeyMissingError):
30
+ DevCycleUser.create_user_from_context(
31
+ EvaluationContext(targeting_key=None, attributes={"user_id": None})
32
+ )
33
+
34
+ def test_create_user_from_context_only_user_id(self):
35
+ test_user_id = "12345"
36
+ context = EvaluationContext(targeting_key=test_user_id, attributes=None)
37
+ user = DevCycleUser.create_user_from_context(context)
38
+ self.assertIsNotNone(user)
39
+ self.assertEqual(user.user_id, test_user_id)
40
+
41
+ context = EvaluationContext(
42
+ targeting_key=None, attributes={"user_id": test_user_id}
43
+ )
44
+ user = DevCycleUser.create_user_from_context(context)
45
+ self.assertIsNotNone(user)
46
+ self.assertEqual(user.user_id, test_user_id)
47
+
48
+ # ensure that targeting_key takes precedence over user_id
49
+ context = EvaluationContext(
50
+ targeting_key=test_user_id, attributes={"user_id": "99999"}
51
+ )
52
+ user = DevCycleUser.create_user_from_context(context)
53
+ self.assertIsNotNone(user)
54
+ self.assertEqual(user.user_id, test_user_id)
55
+
56
+ def test_create_user_from_context_with_attributes(self):
57
+ test_user_id = "12345"
58
+ context = EvaluationContext(
59
+ targeting_key=test_user_id,
60
+ attributes={
61
+ "user_id": "1234",
62
+ "email": "someone@example.com",
63
+ "name": "John Doe",
64
+ "language": "en",
65
+ "country": "US",
66
+ "appVersion": "1.0.0",
67
+ "appBuild": "1",
68
+ "deviceModel": "iPhone X21",
69
+ },
70
+ )
71
+ user = DevCycleUser.create_user_from_context(context)
72
+ self.assertIsNotNone(user)
73
+ self.assertEqual(user.user_id, test_user_id)
74
+ self.assertEqual(user.email, context.attributes["email"])
75
+ self.assertEqual(user.name, context.attributes["name"])
76
+ self.assertEqual(user.language, context.attributes["language"])
77
+ self.assertEqual(user.country, context.attributes["country"])
78
+ self.assertEqual(user.appVersion, context.attributes["appVersion"])
79
+ self.assertEqual(user.appBuild, context.attributes["appBuild"])
80
+ self.assertEqual(user.deviceModel, context.attributes["deviceModel"])
81
+
82
+ def test_create_user_from_context_with_custom_data(self):
83
+ test_user_id = "12345"
84
+ context = EvaluationContext(
85
+ targeting_key=test_user_id,
86
+ attributes={
87
+ "customData": {
88
+ "strValue": "hello",
89
+ "intValue": 123,
90
+ "floatValue": 3.1456,
91
+ "boolValue": True,
92
+ },
93
+ "privateCustomData": {
94
+ "strValue": "world",
95
+ "intValue": 789,
96
+ "floatValue": 0.0001,
97
+ "BoolValue": False,
98
+ },
99
+ },
100
+ )
101
+ user = DevCycleUser.create_user_from_context(context)
102
+ self.assertIsNotNone(user)
103
+ self.assertEqual(user.user_id, test_user_id)
104
+
105
+ self.assertEqual(
106
+ user.customData,
107
+ {
108
+ "strValue": "hello",
109
+ "intValue": 123,
110
+ "floatValue": 3.1456,
111
+ "boolValue": True,
112
+ },
113
+ )
114
+ self.assertEqual(
115
+ user.privateCustomData,
116
+ {
117
+ "strValue": "world",
118
+ "intValue": 789,
119
+ "floatValue": 0.0001,
120
+ "BoolValue": False,
121
+ },
122
+ )
123
+
124
+ def test_create_user_from_context_with_unknown_data_fields(self):
125
+ test_user_id = "12345"
126
+ context = EvaluationContext(
127
+ targeting_key=test_user_id,
128
+ attributes={
129
+ "strValue": "hello",
130
+ "intValue": 123,
131
+ "floatValue": 3.1456,
132
+ "boolValue": True,
133
+ },
134
+ )
135
+ user = DevCycleUser.create_user_from_context(context)
136
+ self.assertIsNotNone(user)
137
+ self.assertEqual(user.user_id, test_user_id)
138
+
139
+ # the fields should get reassigned to custom_data
140
+ self.assertEqual(
141
+ user.customData,
142
+ {
143
+ "strValue": "hello",
144
+ "intValue": 123,
145
+ "floatValue": 3.1456,
146
+ "boolValue": True,
147
+ },
148
+ )
149
+ self.assertEqual(user.privateCustomData, None)
150
+
151
+ def test_set_custom_value(self):
152
+ custom_data = {}
153
+ with self.assertRaises(InvalidContextError):
154
+ DevCycleUser._set_custom_value(custom_data, None, None)
155
+
156
+ custom_data = {}
157
+ DevCycleUser._set_custom_value(custom_data, "key1", None)
158
+ self.assertDictEqual(custom_data, {"key1": None})
159
+
160
+ custom_data = {}
161
+ DevCycleUser._set_custom_value(custom_data, "key1", "value1")
162
+ self.assertDictEqual(custom_data, {"key1": "value1"})
163
+
164
+ custom_data = {}
165
+ DevCycleUser._set_custom_value(custom_data, "key1", 1)
166
+ self.assertDictEqual(custom_data, {"key1": 1})
167
+
168
+ custom_data = {}
169
+ DevCycleUser._set_custom_value(custom_data, "key1", 3.1456)
170
+ self.assertDictEqual(custom_data, {"key1": 3.1456})
171
+
172
+ custom_data = {}
173
+ DevCycleUser._set_custom_value(custom_data, "key1", True)
174
+ self.assertDictEqual(custom_data, {"key1": True})
175
+
176
+ custom_data = {}
177
+ with self.assertRaises(InvalidContextError):
178
+ DevCycleUser._set_custom_value(custom_data, "map_data", {"hello": "world"})
179
+
180
+ custom_data = {}
181
+ with self.assertRaises(InvalidContextError):
182
+ DevCycleUser._set_custom_value(
183
+ custom_data, "list_data", ["one", "two", "three"]
184
+ )
185
+
186
+
187
+ if __name__ == "__main__":
188
+ unittest.main()
File without changes