devcycle-python-server-sdk 3.3.5__py3-none-any.whl → 3.5.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.
- devcycle_python_sdk/VERSION.txt +1 -1
- devcycle_python_sdk/__init__.py +1 -0
- devcycle_python_sdk/api/bucketing_client.py +2 -2
- devcycle_python_sdk/api/config_client.py +4 -2
- devcycle_python_sdk/api/event_client.py +2 -2
- devcycle_python_sdk/bucketing-lib.release.wasm +0 -0
- devcycle_python_sdk/cloud_client.py +19 -2
- devcycle_python_sdk/devcycle_client.py +40 -0
- devcycle_python_sdk/local_client.py +16 -2
- devcycle_python_sdk/models/bucketed_config.py +2 -0
- devcycle_python_sdk/models/user.py +86 -3
- devcycle_python_sdk/open_feature_provider/__init__.py +0 -0
- devcycle_python_sdk/open_feature_provider/provider.py +123 -0
- devcycle_python_sdk/util/strings.py +6 -0
- {devcycle_python_server_sdk-3.3.5.dist-info → devcycle_python_server_sdk-3.5.0.dist-info}/METADATA +2 -1
- {devcycle_python_server_sdk-3.3.5.dist-info → devcycle_python_server_sdk-3.5.0.dist-info}/RECORD +33 -23
- {devcycle_python_server_sdk-3.3.5.dist-info → devcycle_python_server_sdk-3.5.0.dist-info}/WHEEL +1 -1
- example/local_bucketing_client_example.py +1 -1
- example/openfeature_example.py +77 -0
- test/api/test_bucketing_client.py +5 -1
- test/api/test_config_client.py +5 -2
- test/api/test_event_client.py +3 -0
- test/api/test_local_bucketing.py +15 -12
- test/fixture/data.py +5 -5
- test/models/test_user.py +188 -0
- test/openfeature_test/__init__.py +0 -0
- test/openfeature_test/test_provider.py +219 -0
- test/openfeature_test/test_provider_local_sdk.py +136 -0
- test/test_local_client.py +10 -0
- test/util/test_strings.py +25 -0
- test/util/test_version.py +1 -1
- {devcycle_python_server_sdk-3.3.5.dist-info → devcycle_python_server_sdk-3.5.0.dist-info}/LICENSE +0 -0
- {devcycle_python_server_sdk-3.3.5.dist-info → devcycle_python_server_sdk-3.5.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()
|
|
@@ -28,6 +28,10 @@ class BucketingClientTest(unittest.TestCase):
|
|
|
28
28
|
self.test_client = BucketingAPIClient(sdk_key, options)
|
|
29
29
|
self.test_user = DevCycleUser(user_id="test_user_id")
|
|
30
30
|
|
|
31
|
+
def test_url(self):
|
|
32
|
+
result = self.test_client._url("variable", "ABC")
|
|
33
|
+
self.assertEqual(result, "https://bucketing-api.devcycle.com/v1/variable/ABC")
|
|
34
|
+
|
|
31
35
|
@responses.activate
|
|
32
36
|
def test_variable(self):
|
|
33
37
|
responses.add(
|
|
@@ -224,7 +228,7 @@ class BucketingClientTest(unittest.TestCase):
|
|
|
224
228
|
)
|
|
225
229
|
self.assertEqual(result, "success")
|
|
226
230
|
data = json.loads(responses.calls[0].request.body)
|
|
227
|
-
self.assertTrue(
|
|
231
|
+
self.assertTrue(isinstance(data["events"][0]["date"], int))
|
|
228
232
|
self.assertEqual(len(data["events"]), 1)
|
|
229
233
|
self.assertEqual(data["events"][0]["type"], "sample-event")
|
|
230
234
|
|
test/api/test_config_client.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import unittest
|
|
3
3
|
import uuid
|
|
4
|
-
from os.path import join
|
|
5
4
|
from http import HTTPStatus
|
|
6
5
|
|
|
7
6
|
import requests
|
|
@@ -15,6 +14,7 @@ from devcycle_python_sdk.exceptions import (
|
|
|
15
14
|
APIClientUnauthorizedError,
|
|
16
15
|
NotFoundError,
|
|
17
16
|
)
|
|
17
|
+
from devcycle_python_sdk.util.strings import slash_join
|
|
18
18
|
from test.fixture.data import small_config_json
|
|
19
19
|
|
|
20
20
|
logger = logging.getLogger(__name__)
|
|
@@ -24,7 +24,7 @@ class ConfigAPIClientTest(unittest.TestCase):
|
|
|
24
24
|
def setUp(self) -> None:
|
|
25
25
|
self.sdk_key = "dvc_server_" + str(uuid.uuid4())
|
|
26
26
|
self.config_url = (
|
|
27
|
-
|
|
27
|
+
slash_join(
|
|
28
28
|
"https://config-cdn.devcycle.com/",
|
|
29
29
|
"config",
|
|
30
30
|
"v1",
|
|
@@ -39,6 +39,9 @@ class ConfigAPIClientTest(unittest.TestCase):
|
|
|
39
39
|
self.test_etag = str(uuid.uuid4())
|
|
40
40
|
self.test_config_json: dict = small_config_json()
|
|
41
41
|
|
|
42
|
+
def test_url(self):
|
|
43
|
+
self.assertEqual(self.test_client.config_file_url, self.config_url)
|
|
44
|
+
|
|
42
45
|
@responses.activate
|
|
43
46
|
def test_get_config(self):
|
|
44
47
|
new_etag = str(uuid.uuid4())
|
test/api/test_event_client.py
CHANGED
|
@@ -42,6 +42,9 @@ class EventAPIClientTest(unittest.TestCase):
|
|
|
42
42
|
]
|
|
43
43
|
self.test_batch = [UserEventsBatchRecord(user=self.test_user, events=events)]
|
|
44
44
|
|
|
45
|
+
def test_url(self):
|
|
46
|
+
self.assertEqual(self.test_client.batch_url, self.test_batch_url)
|
|
47
|
+
|
|
45
48
|
@responses.activate
|
|
46
49
|
def test_publish_events(self):
|
|
47
50
|
responses.add(
|
test/api/test_local_bucketing.py
CHANGED
|
@@ -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=
|
|
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(
|
test/fixture/data.py
CHANGED
|
@@ -6,7 +6,7 @@ def small_config() -> str:
|
|
|
6
6
|
config_filename = os.path.join(
|
|
7
7
|
os.path.dirname(__file__), "fixture_small_config.json"
|
|
8
8
|
)
|
|
9
|
-
with open(config_filename, "r") as f:
|
|
9
|
+
with open(config_filename, "r", encoding="utf-8") as f:
|
|
10
10
|
return f.read()
|
|
11
11
|
|
|
12
12
|
|
|
@@ -19,7 +19,7 @@ def special_character_config() -> str:
|
|
|
19
19
|
config_filename = os.path.join(
|
|
20
20
|
os.path.dirname(__file__), "fixture_small_config_special_characters.json"
|
|
21
21
|
)
|
|
22
|
-
with open(config_filename, "r") as f:
|
|
22
|
+
with open(config_filename, "r", encoding="utf-8") as f:
|
|
23
23
|
return f.read()
|
|
24
24
|
|
|
25
25
|
|
|
@@ -32,7 +32,7 @@ def large_config() -> str:
|
|
|
32
32
|
config_filename = os.path.join(
|
|
33
33
|
os.path.dirname(__file__), "fixture_large_config.json"
|
|
34
34
|
)
|
|
35
|
-
with open(config_filename, "r") as f:
|
|
35
|
+
with open(config_filename, "r", encoding="utf-8") as f:
|
|
36
36
|
return f.read()
|
|
37
37
|
|
|
38
38
|
|
|
@@ -45,7 +45,7 @@ def bucketed_config() -> str:
|
|
|
45
45
|
config_filename = os.path.join(
|
|
46
46
|
os.path.dirname(__file__), "fixture_bucketed_config.json"
|
|
47
47
|
)
|
|
48
|
-
with open(config_filename, "r") as f:
|
|
48
|
+
with open(config_filename, "r", encoding="utf-8") as f:
|
|
49
49
|
return f.read()
|
|
50
50
|
|
|
51
51
|
|
|
@@ -53,5 +53,5 @@ def bucketed_config_minimal() -> str:
|
|
|
53
53
|
config_filename = os.path.join(
|
|
54
54
|
os.path.dirname(__file__), "fixture_bucketed_config_minimal.json"
|
|
55
55
|
)
|
|
56
|
-
with open(config_filename, "r") as f:
|
|
56
|
+
with open(config_filename, "r", encoding="utf-8") as f:
|
|
57
57
|
return f.read()
|
test/models/test_user.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import unittest
|
|
3
|
+
from unittest.mock import MagicMock
|
|
4
|
+
|
|
5
|
+
from openfeature.provider import EvaluationContext
|
|
6
|
+
from openfeature.flag_evaluation import Reason
|
|
7
|
+
from openfeature.exception import (
|
|
8
|
+
ErrorCode,
|
|
9
|
+
TargetingKeyMissingError,
|
|
10
|
+
InvalidContextError,
|
|
11
|
+
TypeMismatchError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from devcycle_python_sdk.models.variable import Variable, TypeEnum
|
|
15
|
+
|
|
16
|
+
from devcycle_python_sdk.open_feature_provider.provider import (
|
|
17
|
+
DevCycleProvider,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DevCycleProviderTest(unittest.TestCase):
|
|
25
|
+
def setUp(self):
|
|
26
|
+
self.client = MagicMock()
|
|
27
|
+
self.client.is_initialized.return_value = True
|
|
28
|
+
self.provider = DevCycleProvider(self.client)
|
|
29
|
+
|
|
30
|
+
def test_resolve_details_client_not_ready(self):
|
|
31
|
+
self.client.is_initialized.return_value = False
|
|
32
|
+
|
|
33
|
+
details = self.provider._resolve(
|
|
34
|
+
"test-flag", False, EvaluationContext("test-user")
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
self.assertIsNotNone(details)
|
|
38
|
+
self.assertEqual(details.value, False)
|
|
39
|
+
self.assertEqual(details.reason, Reason.ERROR)
|
|
40
|
+
self.assertEqual(details.error_code, ErrorCode.PROVIDER_NOT_READY)
|
|
41
|
+
|
|
42
|
+
def test_resolve_details_client_no_user_in_context(self):
|
|
43
|
+
context = EvaluationContext(targeting_key=None, attributes={})
|
|
44
|
+
|
|
45
|
+
with self.assertRaises(TargetingKeyMissingError):
|
|
46
|
+
self.provider._resolve("test-flag", False, context)
|
|
47
|
+
|
|
48
|
+
def test_resolve_details_no_key(self):
|
|
49
|
+
self.client.variable.side_effect = ValueError("test error")
|
|
50
|
+
context = EvaluationContext(targeting_key="user-1234")
|
|
51
|
+
with self.assertRaises(InvalidContextError):
|
|
52
|
+
self.provider._resolve(None, False, context)
|
|
53
|
+
|
|
54
|
+
def test_resolve_details_no_default_value(self):
|
|
55
|
+
self.client.variable.side_effect = ValueError("test error")
|
|
56
|
+
context = EvaluationContext(targeting_key="user-1234")
|
|
57
|
+
with self.assertRaises(InvalidContextError):
|
|
58
|
+
self.provider._resolve("test-flag", None, context)
|
|
59
|
+
|
|
60
|
+
def test_resolve_details_client_returns_none(self):
|
|
61
|
+
self.client.variable.return_value = None
|
|
62
|
+
context = EvaluationContext(targeting_key="user-1234")
|
|
63
|
+
|
|
64
|
+
details = self.provider._resolve("test-flag", False, context)
|
|
65
|
+
|
|
66
|
+
self.assertIsNotNone(details)
|
|
67
|
+
self.assertEqual(details.value, False)
|
|
68
|
+
self.assertEqual(details.reason, Reason.DEFAULT)
|
|
69
|
+
|
|
70
|
+
def test_resolve_details_client_returns_default_variable(self):
|
|
71
|
+
self.client.variable.return_value = Variable.create_default_variable(
|
|
72
|
+
key="test-flag", default_value=False
|
|
73
|
+
)
|
|
74
|
+
context = EvaluationContext(targeting_key="user-1234")
|
|
75
|
+
details = self.provider._resolve("test-flag", False, context)
|
|
76
|
+
|
|
77
|
+
self.assertIsNotNone(details)
|
|
78
|
+
self.assertEqual(details.value, False)
|
|
79
|
+
self.assertEqual(details.reason, Reason.DEFAULT)
|
|
80
|
+
|
|
81
|
+
def test_resolve_boolean_details(self):
|
|
82
|
+
key = "test-flag"
|
|
83
|
+
variable_value = True
|
|
84
|
+
default_value = False
|
|
85
|
+
|
|
86
|
+
self.client.variable.return_value = Variable(
|
|
87
|
+
_id=None,
|
|
88
|
+
value=variable_value,
|
|
89
|
+
key=key,
|
|
90
|
+
type=TypeEnum.BOOLEAN,
|
|
91
|
+
isDefaulted=False,
|
|
92
|
+
defaultValue=False,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
context = EvaluationContext(targeting_key="user-1234")
|
|
96
|
+
details = self.provider.resolve_boolean_details(key, default_value, context)
|
|
97
|
+
|
|
98
|
+
self.assertIsNotNone(details)
|
|
99
|
+
self.assertEqual(details.value, variable_value)
|
|
100
|
+
self.assertEqual(details.reason, Reason.TARGETING_MATCH)
|
|
101
|
+
|
|
102
|
+
def test_resolve_string_details(self):
|
|
103
|
+
key = "test-flag"
|
|
104
|
+
variable_value = "some string"
|
|
105
|
+
default_value = "default string"
|
|
106
|
+
|
|
107
|
+
self.client.variable.return_value = Variable(
|
|
108
|
+
_id=None,
|
|
109
|
+
value=variable_value,
|
|
110
|
+
key=key,
|
|
111
|
+
type=TypeEnum.STRING,
|
|
112
|
+
isDefaulted=False,
|
|
113
|
+
defaultValue=False,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
context = EvaluationContext(targeting_key="user-1234")
|
|
117
|
+
details = self.provider.resolve_string_details(key, default_value, context)
|
|
118
|
+
|
|
119
|
+
self.assertIsNotNone(details)
|
|
120
|
+
self.assertEqual(details.value, variable_value)
|
|
121
|
+
self.assertIsInstance(details.value, str)
|
|
122
|
+
self.assertEqual(details.reason, Reason.TARGETING_MATCH)
|
|
123
|
+
|
|
124
|
+
def test_resolve_integer_details(self):
|
|
125
|
+
key = "test-flag"
|
|
126
|
+
variable_value = 12345
|
|
127
|
+
default_value = 0
|
|
128
|
+
|
|
129
|
+
self.client.variable.return_value = Variable(
|
|
130
|
+
_id=None,
|
|
131
|
+
value=variable_value,
|
|
132
|
+
key=key,
|
|
133
|
+
type=TypeEnum.STRING,
|
|
134
|
+
isDefaulted=False,
|
|
135
|
+
defaultValue=False,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
context = EvaluationContext(targeting_key="user-1234")
|
|
139
|
+
details = self.provider.resolve_integer_details(key, default_value, context)
|
|
140
|
+
|
|
141
|
+
self.assertIsNotNone(details)
|
|
142
|
+
self.assertIsInstance(details.value, int)
|
|
143
|
+
self.assertEqual(details.value, variable_value)
|
|
144
|
+
self.assertEqual(details.reason, Reason.TARGETING_MATCH)
|
|
145
|
+
|
|
146
|
+
def test_resolve_float_details(self):
|
|
147
|
+
key = "test-flag"
|
|
148
|
+
variable_value = 3.145
|
|
149
|
+
default_value = 0.0
|
|
150
|
+
|
|
151
|
+
self.client.variable.return_value = Variable(
|
|
152
|
+
_id=None,
|
|
153
|
+
value=variable_value,
|
|
154
|
+
key=key,
|
|
155
|
+
type=TypeEnum.STRING,
|
|
156
|
+
isDefaulted=False,
|
|
157
|
+
defaultValue=False,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
context = EvaluationContext(targeting_key="user-1234")
|
|
161
|
+
details = self.provider.resolve_float_details(key, default_value, context)
|
|
162
|
+
|
|
163
|
+
self.assertIsNotNone(details)
|
|
164
|
+
self.assertIsInstance(details.value, float)
|
|
165
|
+
self.assertEqual(details.value, variable_value)
|
|
166
|
+
self.assertEqual(details.reason, Reason.TARGETING_MATCH)
|
|
167
|
+
|
|
168
|
+
def test_resolve_object_details_verify_default_value(self):
|
|
169
|
+
key = "test-flag"
|
|
170
|
+
context = EvaluationContext(targeting_key="user-1234")
|
|
171
|
+
|
|
172
|
+
# Only flat dictionaries are supported as objects
|
|
173
|
+
# lists, primitives and nested objects are not supported
|
|
174
|
+
with self.assertRaises(TypeMismatchError):
|
|
175
|
+
self.provider.resolve_object_details(key, [], context)
|
|
176
|
+
|
|
177
|
+
with self.assertRaises(TypeMismatchError):
|
|
178
|
+
self.provider.resolve_object_details(key, 1234, context)
|
|
179
|
+
|
|
180
|
+
with self.assertRaises(TypeMismatchError):
|
|
181
|
+
self.provider.resolve_object_details(key, 3.14, context)
|
|
182
|
+
|
|
183
|
+
with self.assertRaises(TypeMismatchError):
|
|
184
|
+
self.provider.resolve_object_details(key, False, context)
|
|
185
|
+
|
|
186
|
+
with self.assertRaises(TypeMismatchError):
|
|
187
|
+
self.provider.resolve_object_details(key, "some string", context)
|
|
188
|
+
|
|
189
|
+
# test dictionaries with nested objects
|
|
190
|
+
with self.assertRaises(TypeMismatchError):
|
|
191
|
+
self.provider.resolve_object_details(
|
|
192
|
+
key, {"nestedMap": {"someProp": "some value"}}, context
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def test_resolve_object_details(self):
|
|
196
|
+
key = "test-flag"
|
|
197
|
+
variable_value = {"some": "value"}
|
|
198
|
+
default_value = {}
|
|
199
|
+
|
|
200
|
+
self.client.variable.return_value = Variable(
|
|
201
|
+
_id=None,
|
|
202
|
+
value=variable_value,
|
|
203
|
+
key=key,
|
|
204
|
+
type=TypeEnum.STRING,
|
|
205
|
+
isDefaulted=False,
|
|
206
|
+
defaultValue=False,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
context = EvaluationContext(targeting_key="user-1234")
|
|
210
|
+
details = self.provider.resolve_object_details(key, default_value, context)
|
|
211
|
+
|
|
212
|
+
self.assertIsNotNone(details)
|
|
213
|
+
self.assertIsInstance(details.value, dict)
|
|
214
|
+
self.assertDictEqual(details.value, variable_value)
|
|
215
|
+
self.assertEqual(details.reason, Reason.TARGETING_MATCH)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
unittest.main()
|