devcycle-python-server-sdk 3.3.3__tar.gz → 3.6.0__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.6.0/LICENSE +21 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/PKG-INFO +1 -1
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/README.md +17 -2
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/__init__.py +1 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/api/bucketing_client.py +2 -2
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/api/config_client.py +22 -8
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/api/event_client.py +2 -2
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/api/local_bucketing.py +1 -1
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/cloud_client.py +29 -12
- devcycle-python-server-sdk-3.6.0/devcycle_python_sdk/devcycle_client.py +40 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/local_client.py +36 -14
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/managers/config_manager.py +16 -7
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/managers/event_queue_manager.py +15 -11
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/models/bucketed_config.py +10 -6
- devcycle-python-server-sdk-3.6.0/devcycle_python_sdk/models/user.py +159 -0
- devcycle-python-server-sdk-3.6.0/devcycle_python_sdk/open_feature_provider/provider.py +125 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/options.py +5 -5
- devcycle-python-server-sdk-3.6.0/devcycle_python_sdk/util/strings.py +6 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_server_sdk.egg-info/PKG-INFO +1 -1
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_server_sdk.egg-info/SOURCES.txt +11 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_server_sdk.egg-info/requires.txt +2 -1
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/example/local_bucketing_client_example.py +1 -1
- devcycle-python-server-sdk-3.6.0/example/openfeature_example.py +77 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/pyproject.toml +17 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/api/test_bucketing_client.py +5 -1
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/api/test_config_client.py +29 -8
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/api/test_event_client.py +3 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/api/test_local_bucketing.py +15 -12
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/fixture/data.py +5 -5
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/managers/test_config_manager.py +32 -10
- devcycle-python-server-sdk-3.6.0/test/models/test_user.py +188 -0
- devcycle-python-server-sdk-3.6.0/test/openfeature_test/__init__.py +0 -0
- devcycle-python-server-sdk-3.6.0/test/openfeature_test/test_provider.py +219 -0
- devcycle-python-server-sdk-3.6.0/test/openfeature_test/test_provider_local_sdk.py +136 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/test_local_client.py +10 -0
- devcycle-python-server-sdk-3.6.0/test/util/__init__.py +0 -0
- devcycle-python-server-sdk-3.6.0/test/util/test_strings.py +25 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/util/test_version.py +1 -1
- devcycle-python-server-sdk-3.3.3/devcycle_python_sdk/models/user.py +0 -76
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/api/__init__.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/api/backoff.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/exceptions.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/managers/__init__.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/models/__init__.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/models/error_response.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/models/event.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/models/feature.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/models/platform_data.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/models/variable.py +0 -0
- {devcycle-python-server-sdk-3.3.3/devcycle_python_sdk/protobuf → devcycle-python-server-sdk-3.6.0/devcycle_python_sdk/open_feature_provider}/__init__.py +0 -0
- {devcycle-python-server-sdk-3.3.3/example → devcycle-python-server-sdk-3.6.0/devcycle_python_sdk/protobuf}/__init__.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/protobuf/utils.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/protobuf/variableForUserParams_pb2.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/util/__init__.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_sdk/util/version.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_server_sdk.egg-info/dependency_links.txt +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/devcycle_python_server_sdk.egg-info/top_level.txt +0 -0
- {devcycle-python-server-sdk-3.3.3/test → devcycle-python-server-sdk-3.6.0/example}/__init__.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/example/cloud_client_example.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/setup.cfg +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/setup.py +0 -0
- {devcycle-python-server-sdk-3.3.3/test/api → devcycle-python-server-sdk-3.6.0/test}/__init__.py +0 -0
- {devcycle-python-server-sdk-3.3.3/test/fixture → devcycle-python-server-sdk-3.6.0/test/api}/__init__.py +0 -0
- {devcycle-python-server-sdk-3.3.3/test/managers → devcycle-python-server-sdk-3.6.0/test/fixture}/__init__.py +0 -0
- {devcycle-python-server-sdk-3.3.3/test/models → devcycle-python-server-sdk-3.6.0/test/managers}/__init__.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/managers/test_event_queue_manager.py +0 -0
- {devcycle-python-server-sdk-3.3.3/test/util → devcycle-python-server-sdk-3.6.0/test/models}/__init__.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/models/test_bucketed_config.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/test_cloud_client.py +0 -0
- {devcycle-python-server-sdk-3.3.3 → devcycle-python-server-sdk-3.6.0}/test/util/test_utils.py +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 Taplytics Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -6,7 +6,7 @@ This SDK allows your application to interface with the [DevCycle Bucketing API](
|
|
|
6
6
|
|
|
7
7
|
## Requirements.
|
|
8
8
|
|
|
9
|
-
* Python 3.
|
|
9
|
+
* Python 3.8+
|
|
10
10
|
|
|
11
11
|
## Installation
|
|
12
12
|
|
|
@@ -39,13 +39,28 @@ value = client.variable_value(user, 'variable-key', 'default-value')
|
|
|
39
39
|
|
|
40
40
|
The DevCycle client is designed to work as a singleton in your application. You should create a single instance of the client during application initialization
|
|
41
41
|
|
|
42
|
+
## OpenFeature Support
|
|
43
|
+
|
|
44
|
+
This SDK provides an alpha implementation of the [OpenFeature](https://openfeature.dev/) Provider interface. Use the `get_openfeature_provider()` function on the DevCycle SDK client to obtain a provider for OpenFeature.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from openfeature import api
|
|
48
|
+
|
|
49
|
+
devcycle_client = DevCycleLocalClient('DEVCYCLE_SERVER_SDK_KEY', options)
|
|
50
|
+
api.set_provider(devcycle_client.get_openfeature_provider())
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
More details are in the [DevCycle Python SDK OpenFeature Provider](OpenFeature.md) guide.
|
|
54
|
+
|
|
55
|
+
> :warning: **OpenFeature support is in an early release and may have some rough edges**. Please report any issues to us and we'll be happy to help!
|
|
56
|
+
|
|
42
57
|
## Usage
|
|
43
58
|
|
|
44
59
|
To find usage documentation, visit our [docs](https://docs.devcycle.com/docs/sdk/server-side-sdks/python#usage).
|
|
45
60
|
|
|
46
61
|
## Development
|
|
47
62
|
|
|
48
|
-
When developing the SDK it is recommended that you have both a 3.
|
|
63
|
+
When developing the SDK it is recommended that you have both a 3.8 and 3.12 python interpreter installed in order to verify changes across different versions of python.
|
|
49
64
|
|
|
50
65
|
### Dependencies
|
|
51
66
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# Simplify imports for the SDK entry point objects
|
|
2
2
|
|
|
3
3
|
from devcycle_python_sdk.options import DevCycleCloudOptions, DevCycleLocalOptions
|
|
4
|
+
from devcycle_python_sdk.devcycle_client import AbstractDevCycleClient
|
|
4
5
|
from devcycle_python_sdk.cloud_client import DevCycleCloudClient
|
|
5
6
|
from devcycle_python_sdk.local_client import DevCycleLocalClient
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import time
|
|
3
|
-
from os.path import join
|
|
4
3
|
from typing import Dict, List, Optional
|
|
5
4
|
|
|
6
5
|
import requests
|
|
@@ -16,6 +15,7 @@ from devcycle_python_sdk.models.event import DevCycleEvent
|
|
|
16
15
|
from devcycle_python_sdk.models.feature import Feature
|
|
17
16
|
from devcycle_python_sdk.models.user import DevCycleUser
|
|
18
17
|
from devcycle_python_sdk.models.variable import Variable
|
|
18
|
+
from devcycle_python_sdk.util.strings import slash_join
|
|
19
19
|
|
|
20
20
|
logger = logging.getLogger(__name__)
|
|
21
21
|
|
|
@@ -33,7 +33,7 @@ class BucketingAPIClient:
|
|
|
33
33
|
self.session.max_redirects = 0
|
|
34
34
|
|
|
35
35
|
def _url(self, *path_args: str) -> str:
|
|
36
|
-
return
|
|
36
|
+
return slash_join(self.options.bucketing_api_uri, "v1", *path_args)
|
|
37
37
|
|
|
38
38
|
def request(self, method: str, url: str, **kwargs) -> dict:
|
|
39
39
|
retries_remaining = self.options.request_retries + 1
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import time
|
|
3
3
|
from http import HTTPStatus
|
|
4
|
-
from os.path import join
|
|
5
4
|
from typing import Optional, Tuple
|
|
6
|
-
|
|
5
|
+
import email.utils
|
|
7
6
|
import requests
|
|
8
7
|
|
|
9
8
|
from devcycle_python_sdk.api.backoff import exponential_backoff
|
|
@@ -13,6 +12,7 @@ from devcycle_python_sdk.exceptions import (
|
|
|
13
12
|
NotFoundError,
|
|
14
13
|
APIClientUnauthorizedError,
|
|
15
14
|
)
|
|
15
|
+
from devcycle_python_sdk.util.strings import slash_join
|
|
16
16
|
|
|
17
17
|
logger = logging.getLogger(__name__)
|
|
18
18
|
|
|
@@ -29,20 +29,22 @@ class ConfigAPIClient:
|
|
|
29
29
|
self.session.max_redirects = 0
|
|
30
30
|
self.max_config_retries = 2
|
|
31
31
|
self.config_file_url = (
|
|
32
|
-
|
|
32
|
+
slash_join(
|
|
33
|
+
self.options.config_cdn_uri, "config", "v1", "server", self.sdk_key
|
|
34
|
+
)
|
|
33
35
|
+ ".json"
|
|
34
36
|
)
|
|
35
37
|
|
|
36
38
|
def get_config(
|
|
37
|
-
self, config_etag: Optional[str] = None
|
|
38
|
-
) -> Tuple[Optional[dict], Optional[str]]:
|
|
39
|
+
self, config_etag: Optional[str] = None, last_modified: Optional[str] = None
|
|
40
|
+
) -> Tuple[Optional[dict], Optional[str], Optional[str]]:
|
|
39
41
|
"""
|
|
40
42
|
Get the config from the server. If the config_etag is provided, the server will only return the config if it
|
|
41
43
|
has changed since the last request. If the config hasn't changed, the server will return a 304 Not Modified
|
|
42
44
|
response.
|
|
43
45
|
|
|
44
46
|
:param config_etag: The etag of the last config request
|
|
45
|
-
|
|
47
|
+
:param last_modified: Last modified RFC1123 Timestamp of the stored config
|
|
46
48
|
:return: A tuple containing the config and the etag of the config. If the config hasn't changed since the last
|
|
47
49
|
request, the config will be None and the etag will be the same as the last request.
|
|
48
50
|
"""
|
|
@@ -54,6 +56,8 @@ class ConfigAPIClient:
|
|
|
54
56
|
headers = {}
|
|
55
57
|
if config_etag:
|
|
56
58
|
headers["If-None-Match"] = config_etag
|
|
59
|
+
if last_modified:
|
|
60
|
+
headers["If-Modified-Since"] = last_modified
|
|
57
61
|
|
|
58
62
|
attempts = 1
|
|
59
63
|
while retries_remaining > 0:
|
|
@@ -72,7 +76,7 @@ class ConfigAPIClient:
|
|
|
72
76
|
elif res.status_code == HTTPStatus.NOT_MODIFIED:
|
|
73
77
|
# the config hasn't changed since the last request
|
|
74
78
|
# don't return anything
|
|
75
|
-
return None, config_etag
|
|
79
|
+
return None, config_etag, last_modified
|
|
76
80
|
elif res.status_code == HTTPStatus.NOT_FOUND:
|
|
77
81
|
# Not a retryable error
|
|
78
82
|
raise NotFoundError(url)
|
|
@@ -111,6 +115,16 @@ class ConfigAPIClient:
|
|
|
111
115
|
raise APIClientError(message="Retries exceeded", cause=request_error)
|
|
112
116
|
|
|
113
117
|
new_etag = res.headers.get("ETag", None)
|
|
118
|
+
new_lastmodified = res.headers.get("Last-Modified", None)
|
|
119
|
+
|
|
120
|
+
if last_modified and new_lastmodified:
|
|
121
|
+
stored_lm = email.utils.parsedate_to_datetime(last_modified)
|
|
122
|
+
response_lm = email.utils.parsedate_to_datetime(new_lastmodified)
|
|
123
|
+
if stored_lm > response_lm:
|
|
124
|
+
logger.warning(
|
|
125
|
+
"Request returned a last modified header older than the current stored timestamp. not saving config"
|
|
126
|
+
)
|
|
127
|
+
return None, None, None
|
|
114
128
|
|
|
115
129
|
data: dict = res.json()
|
|
116
|
-
return data, new_etag
|
|
130
|
+
return data, new_etag, new_lastmodified
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import logging
|
|
3
3
|
import time
|
|
4
|
-
from os.path import join
|
|
5
4
|
from typing import Optional, List
|
|
6
5
|
|
|
7
6
|
import requests
|
|
@@ -14,6 +13,7 @@ from devcycle_python_sdk.exceptions import (
|
|
|
14
13
|
APIClientUnauthorizedError,
|
|
15
14
|
)
|
|
16
15
|
from devcycle_python_sdk.models.event import UserEventsBatchRecord
|
|
16
|
+
from devcycle_python_sdk.util.strings import slash_join
|
|
17
17
|
|
|
18
18
|
logger = logging.getLogger(__name__)
|
|
19
19
|
|
|
@@ -29,7 +29,7 @@ class EventAPIClient:
|
|
|
29
29
|
}
|
|
30
30
|
self.session.max_redirects = 0
|
|
31
31
|
self.max_batch_retries = 0 # we don't retry events batches
|
|
32
|
-
self.batch_url =
|
|
32
|
+
self.batch_url = slash_join(self.options.events_api_uri, "v1/events/batch")
|
|
33
33
|
|
|
34
34
|
def publish_events(self, batch: List[UserEventsBatchRecord]) -> str:
|
|
35
35
|
"""
|
|
@@ -105,7 +105,7 @@ class LocalBucketing:
|
|
|
105
105
|
|
|
106
106
|
def __console_log_func(message_ptr) -> None:
|
|
107
107
|
message: str = self._read_assembly_script_string(message_ptr)
|
|
108
|
-
logger.warning(f"WASM console: {message!r}")
|
|
108
|
+
logger.warning(f"DevCycle: WASM console: {message!r}")
|
|
109
109
|
|
|
110
110
|
wasm_linker.define_func(
|
|
111
111
|
"env", "console.log", FuncType([ValType.i32()], []), __console_log_func
|
|
@@ -3,7 +3,7 @@ import platform
|
|
|
3
3
|
|
|
4
4
|
from typing import Any, Dict
|
|
5
5
|
|
|
6
|
-
from devcycle_python_sdk import DevCycleCloudOptions
|
|
6
|
+
from devcycle_python_sdk import DevCycleCloudOptions, AbstractDevCycleClient
|
|
7
7
|
from devcycle_python_sdk.api.bucketing_client import BucketingAPIClient
|
|
8
8
|
from devcycle_python_sdk.exceptions import (
|
|
9
9
|
NotFoundError,
|
|
@@ -14,11 +14,18 @@ from devcycle_python_sdk.models.event import DevCycleEvent
|
|
|
14
14
|
from devcycle_python_sdk.models.variable import Variable
|
|
15
15
|
from devcycle_python_sdk.models.feature import Feature
|
|
16
16
|
from devcycle_python_sdk.util.version import sdk_version
|
|
17
|
+
from devcycle_python_sdk.open_feature_provider.provider import DevCycleProvider
|
|
18
|
+
|
|
19
|
+
from openfeature.provider import AbstractProvider
|
|
17
20
|
|
|
18
21
|
logger = logging.getLogger(__name__)
|
|
19
22
|
|
|
20
23
|
|
|
21
|
-
class DevCycleCloudClient:
|
|
24
|
+
class DevCycleCloudClient(AbstractDevCycleClient):
|
|
25
|
+
"""
|
|
26
|
+
The DevCycle Python SDK that utilizes the DevCycle Bucketing API for feature and variable evaluation
|
|
27
|
+
"""
|
|
28
|
+
|
|
22
29
|
options: DevCycleCloudOptions
|
|
23
30
|
platform: str
|
|
24
31
|
platform_version: str
|
|
@@ -37,6 +44,13 @@ class DevCycleCloudClient:
|
|
|
37
44
|
self.sdk_version = sdk_version()
|
|
38
45
|
self.sdk_type = "server"
|
|
39
46
|
self.bucketing_api = BucketingAPIClient(sdk_key, self.options)
|
|
47
|
+
self._openfeature_provider = DevCycleProvider(self)
|
|
48
|
+
|
|
49
|
+
def get_sdk_platform(self) -> str:
|
|
50
|
+
return "Cloud"
|
|
51
|
+
|
|
52
|
+
def get_openfeature_provider(self) -> AbstractProvider:
|
|
53
|
+
return self._openfeature_provider
|
|
40
54
|
|
|
41
55
|
def _add_platform_data_to_user(self, user: DevCycleUser) -> DevCycleUser:
|
|
42
56
|
user.platform = self.platform
|
|
@@ -45,6 +59,9 @@ class DevCycleCloudClient:
|
|
|
45
59
|
user.sdkType = self.sdk_type
|
|
46
60
|
return user
|
|
47
61
|
|
|
62
|
+
def is_initialized(self) -> bool:
|
|
63
|
+
return True
|
|
64
|
+
|
|
48
65
|
def variable_value(self, user: DevCycleUser, key: str, default_value: Any) -> Any:
|
|
49
66
|
"""
|
|
50
67
|
Evaluates a variable for a user and returns the value. If the user is not bucketed into the variable, the default value will be returned
|
|
@@ -73,15 +90,15 @@ class DevCycleCloudClient:
|
|
|
73
90
|
try:
|
|
74
91
|
variable = self.bucketing_api.variable(key, user)
|
|
75
92
|
except CloudClientUnauthorizedError as e:
|
|
76
|
-
logger.warning("DevCycle SDK key is invalid, unable to make cloud request")
|
|
93
|
+
logger.warning("DevCycle: SDK key is invalid, unable to make cloud request")
|
|
77
94
|
raise e
|
|
78
95
|
except NotFoundError:
|
|
79
|
-
logger.warning(f"Variable not found: {key}")
|
|
96
|
+
logger.warning(f"DevCycle: Variable not found: {key}")
|
|
80
97
|
return Variable.create_default_variable(
|
|
81
98
|
key=key, default_value=default_value
|
|
82
99
|
)
|
|
83
100
|
except Exception as e:
|
|
84
|
-
logger.error(f"Error evaluating variable: {e}")
|
|
101
|
+
logger.error(f"DevCycle: Error evaluating variable: {e}")
|
|
85
102
|
return Variable.create_default_variable(
|
|
86
103
|
key=key, default_value=default_value
|
|
87
104
|
)
|
|
@@ -91,7 +108,7 @@ class DevCycleCloudClient:
|
|
|
91
108
|
# Allow default value to be a subclass of the same type as the variable
|
|
92
109
|
if not isinstance(default_value, type(variable.value)):
|
|
93
110
|
logger.warning(
|
|
94
|
-
f"Variable {key} is type {type(variable.value)}, but default value is type {type(default_value)}",
|
|
111
|
+
f"DevCycle: Variable {key} is type {type(variable.value)}, but default value is type {type(default_value)}",
|
|
95
112
|
)
|
|
96
113
|
return Variable.create_default_variable(
|
|
97
114
|
key=key, default_value=default_value
|
|
@@ -112,10 +129,10 @@ class DevCycleCloudClient:
|
|
|
112
129
|
try:
|
|
113
130
|
variable_map = self.bucketing_api.variables(user)
|
|
114
131
|
except CloudClientUnauthorizedError as e:
|
|
115
|
-
logger.warning("SDK key is invalid, unable to make cloud request")
|
|
132
|
+
logger.warning("DevCycle: SDK key is invalid, unable to make cloud request")
|
|
116
133
|
raise e
|
|
117
134
|
except Exception as e:
|
|
118
|
-
logger.error(f"Error retrieving all features for a user: {e}")
|
|
135
|
+
logger.error(f"DevCycle: Error retrieving all features for a user: {e}")
|
|
119
136
|
|
|
120
137
|
return variable_map
|
|
121
138
|
|
|
@@ -132,10 +149,10 @@ class DevCycleCloudClient:
|
|
|
132
149
|
try:
|
|
133
150
|
feature_map = self.bucketing_api.features(user)
|
|
134
151
|
except CloudClientUnauthorizedError as e:
|
|
135
|
-
logger.warning("SDK key is invalid, unable to make cloud request")
|
|
152
|
+
logger.warning("DevCycle: SDK key is invalid, unable to make cloud request")
|
|
136
153
|
raise e
|
|
137
154
|
except Exception as e:
|
|
138
|
-
logger.error(f"Error retrieving all features for a user: {e}")
|
|
155
|
+
logger.error(f"DevCycle: Error retrieving all features for a user: {e}")
|
|
139
156
|
|
|
140
157
|
return feature_map
|
|
141
158
|
|
|
@@ -160,10 +177,10 @@ class DevCycleCloudClient:
|
|
|
160
177
|
try:
|
|
161
178
|
self.bucketing_api.track(user, events)
|
|
162
179
|
except CloudClientUnauthorizedError as e:
|
|
163
|
-
logger.warning("SDK key is invalid, unable to make cloud request")
|
|
180
|
+
logger.warning("DevCycle: SDK key is invalid, unable to make cloud request")
|
|
164
181
|
raise e
|
|
165
182
|
except Exception as e:
|
|
166
|
-
logger.error(f"Error tracking event: {e}")
|
|
183
|
+
logger.error(f"DevCycle: Error tracking event: {e}")
|
|
167
184
|
|
|
168
185
|
|
|
169
186
|
def _validate_sdk_key(sdk_key: str) -> None:
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
from abc import abstractmethod
|
|
3
|
+
|
|
4
|
+
from devcycle_python_sdk.models.user import DevCycleUser
|
|
5
|
+
from devcycle_python_sdk.models.variable import Variable
|
|
6
|
+
|
|
7
|
+
from openfeature.provider import AbstractProvider
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AbstractDevCycleClient:
|
|
11
|
+
"""
|
|
12
|
+
A common interface for all DevCycle Clients
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def is_initialized(self) -> bool:
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def variable(
|
|
21
|
+
self, user: DevCycleUser, key: str, default_value: typing.Any
|
|
22
|
+
) -> Variable:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def variable_value(
|
|
27
|
+
self, user: DevCycleUser, key: str, default_value: typing.Any
|
|
28
|
+
) -> typing.Any:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def get_openfeature_provider(self) -> AbstractProvider:
|
|
33
|
+
"""
|
|
34
|
+
Returns the OpenFeature provider for this client
|
|
35
|
+
"""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def get_sdk_platform(self) -> str:
|
|
40
|
+
pass
|
|
@@ -3,7 +3,7 @@ import logging
|
|
|
3
3
|
from numbers import Real
|
|
4
4
|
from typing import Any, Dict, Union
|
|
5
5
|
|
|
6
|
-
from devcycle_python_sdk import DevCycleLocalOptions
|
|
6
|
+
from devcycle_python_sdk import DevCycleLocalOptions, AbstractDevCycleClient
|
|
7
7
|
from devcycle_python_sdk.api.local_bucketing import LocalBucketing
|
|
8
8
|
from devcycle_python_sdk.exceptions import VariableTypeMismatchError
|
|
9
9
|
from devcycle_python_sdk.managers.config_manager import EnvironmentConfigManager
|
|
@@ -14,11 +14,17 @@ from devcycle_python_sdk.models.feature import Feature
|
|
|
14
14
|
from devcycle_python_sdk.models.platform_data import default_platform_data
|
|
15
15
|
from devcycle_python_sdk.models.user import DevCycleUser
|
|
16
16
|
from devcycle_python_sdk.models.variable import Variable
|
|
17
|
+
from devcycle_python_sdk.open_feature_provider.provider import DevCycleProvider
|
|
18
|
+
from openfeature.provider import AbstractProvider
|
|
17
19
|
|
|
18
20
|
logger = logging.getLogger(__name__)
|
|
19
21
|
|
|
20
22
|
|
|
21
|
-
class DevCycleLocalClient:
|
|
23
|
+
class DevCycleLocalClient(AbstractDevCycleClient):
|
|
24
|
+
"""
|
|
25
|
+
The DevCycle Python SDK that utilizes the local bucketing library for feature and variable evaluation
|
|
26
|
+
"""
|
|
27
|
+
|
|
22
28
|
def __init__(self, sdk_key: str, options: DevCycleLocalOptions):
|
|
23
29
|
_validate_sdk_key(sdk_key)
|
|
24
30
|
self._sdk_key = sdk_key
|
|
@@ -42,6 +48,14 @@ class DevCycleLocalClient:
|
|
|
42
48
|
sdk_key, self.options, self.local_bucketing
|
|
43
49
|
)
|
|
44
50
|
|
|
51
|
+
self._openfeature_provider = DevCycleProvider(self)
|
|
52
|
+
|
|
53
|
+
def get_sdk_platform(self) -> str:
|
|
54
|
+
return "Local"
|
|
55
|
+
|
|
56
|
+
def get_openfeature_provider(self) -> AbstractProvider:
|
|
57
|
+
return self._openfeature_provider
|
|
58
|
+
|
|
45
59
|
def is_initialized(self) -> bool:
|
|
46
60
|
return self.config_manager.is_initialized()
|
|
47
61
|
|
|
@@ -57,7 +71,9 @@ class DevCycleLocalClient:
|
|
|
57
71
|
are not permitted
|
|
58
72
|
"""
|
|
59
73
|
if not self.is_initialized():
|
|
60
|
-
logger.debug(
|
|
74
|
+
logger.debug(
|
|
75
|
+
"DevCycle: set_client_custom_data called before client has initialized"
|
|
76
|
+
)
|
|
61
77
|
return
|
|
62
78
|
|
|
63
79
|
if custom_data:
|
|
@@ -65,7 +81,7 @@ class DevCycleLocalClient:
|
|
|
65
81
|
custom_data_json = json.dumps(custom_data)
|
|
66
82
|
self.local_bucketing.set_client_custom_data(custom_data_json)
|
|
67
83
|
except Exception as e:
|
|
68
|
-
logger.error("Error setting custom data: " + str(e))
|
|
84
|
+
logger.error("DevCycle: Error setting custom data: " + str(e))
|
|
69
85
|
|
|
70
86
|
def variable_value(self, user: DevCycleUser, key: str, default_value: Any) -> Any:
|
|
71
87
|
"""
|
|
@@ -92,7 +108,7 @@ class DevCycleLocalClient:
|
|
|
92
108
|
raise ValueError("Missing parameter: defaultValue")
|
|
93
109
|
|
|
94
110
|
if not self.is_initialized():
|
|
95
|
-
logger.debug("variable called before client has initialized")
|
|
111
|
+
logger.debug("DevCycle: variable called before client has initialized")
|
|
96
112
|
try:
|
|
97
113
|
self.event_queue_manager.queue_aggregate_event(
|
|
98
114
|
event=DevCycleEvent(
|
|
@@ -102,7 +118,7 @@ class DevCycleLocalClient:
|
|
|
102
118
|
)
|
|
103
119
|
except Exception as e:
|
|
104
120
|
logger.warning(
|
|
105
|
-
f"Unable to track AggVariableDefaulted event for Variable {key}: {e}"
|
|
121
|
+
f"DevCycle: Unable to track AggVariableDefaulted event for Variable {key}: {e}"
|
|
106
122
|
)
|
|
107
123
|
return Variable.create_default_variable(key, default_value)
|
|
108
124
|
|
|
@@ -113,9 +129,9 @@ class DevCycleLocalClient:
|
|
|
113
129
|
if variable:
|
|
114
130
|
return variable
|
|
115
131
|
except VariableTypeMismatchError:
|
|
116
|
-
logger.debug("Variable type mismatch, returning default value")
|
|
132
|
+
logger.debug("DevCycle: Variable type mismatch, returning default value")
|
|
117
133
|
except Exception as e:
|
|
118
|
-
logger.warning(f"Error retrieving variable for user: {e}")
|
|
134
|
+
logger.warning(f"DevCycle: Error retrieving variable for user: {e}")
|
|
119
135
|
|
|
120
136
|
return Variable.create_default_variable(key, default_value)
|
|
121
137
|
|
|
@@ -137,7 +153,9 @@ class DevCycleLocalClient:
|
|
|
137
153
|
_validate_user(user)
|
|
138
154
|
|
|
139
155
|
if not self.is_initialized():
|
|
140
|
-
logger.warning(
|
|
156
|
+
logger.warning(
|
|
157
|
+
"DevCycle: all_variables called before client has initialized"
|
|
158
|
+
)
|
|
141
159
|
return {}
|
|
142
160
|
|
|
143
161
|
variable_map: Dict[str, Variable] = {}
|
|
@@ -145,7 +163,9 @@ class DevCycleLocalClient:
|
|
|
145
163
|
try:
|
|
146
164
|
return self.local_bucketing.generate_bucketed_config(user).variables
|
|
147
165
|
except Exception as e:
|
|
148
|
-
logger.exception(
|
|
166
|
+
logger.exception(
|
|
167
|
+
f"DevCycle: Error retrieving all variables for a user: {e}"
|
|
168
|
+
)
|
|
149
169
|
return {}
|
|
150
170
|
|
|
151
171
|
return variable_map
|
|
@@ -159,14 +179,16 @@ class DevCycleLocalClient:
|
|
|
159
179
|
_validate_user(user)
|
|
160
180
|
|
|
161
181
|
if not self.is_initialized():
|
|
162
|
-
logger.warning(
|
|
182
|
+
logger.warning(
|
|
183
|
+
"DevCycle: all_features called before client has initialized"
|
|
184
|
+
)
|
|
163
185
|
return {}
|
|
164
186
|
|
|
165
187
|
feature_map: Dict[str, Feature] = {}
|
|
166
188
|
try:
|
|
167
189
|
return self.local_bucketing.generate_bucketed_config(user).features
|
|
168
190
|
except Exception as e:
|
|
169
|
-
logger.exception(f"Error retrieving all features for a user: {e}")
|
|
191
|
+
logger.exception(f"DevCycle: Error retrieving all features for a user: {e}")
|
|
170
192
|
|
|
171
193
|
return feature_map
|
|
172
194
|
|
|
@@ -186,13 +208,13 @@ class DevCycleLocalClient:
|
|
|
186
208
|
raise ValueError("Missing parameter: type")
|
|
187
209
|
|
|
188
210
|
if not self.is_initialized():
|
|
189
|
-
logger.debug("track called before client has initialized")
|
|
211
|
+
logger.debug("DevCycle: track called before client has initialized")
|
|
190
212
|
return
|
|
191
213
|
|
|
192
214
|
try:
|
|
193
215
|
self.event_queue_manager.queue_event(user, user_event)
|
|
194
216
|
except Exception as e:
|
|
195
|
-
logger.error(f"Error tracking event: {e}")
|
|
217
|
+
logger.error(f"DevCycle: Error tracking event: {e}")
|
|
196
218
|
|
|
197
219
|
def close(self) -> None:
|
|
198
220
|
"""
|
|
@@ -30,6 +30,7 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
30
30
|
|
|
31
31
|
self._config: Optional[dict] = None
|
|
32
32
|
self._config_etag: Optional[str] = None
|
|
33
|
+
self._config_lastmodified: Optional[str] = None
|
|
33
34
|
|
|
34
35
|
self._config_api_client = ConfigAPIClient(self._sdk_key, self._options)
|
|
35
36
|
|
|
@@ -42,17 +43,20 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
42
43
|
|
|
43
44
|
def _get_config(self):
|
|
44
45
|
try:
|
|
45
|
-
new_config, new_etag = self._config_api_client.get_config(
|
|
46
|
-
config_etag=self._config_etag
|
|
46
|
+
new_config, new_etag, new_lastmodified = self._config_api_client.get_config(
|
|
47
|
+
config_etag=self._config_etag, last_modified=self._config_lastmodified
|
|
47
48
|
)
|
|
48
49
|
|
|
50
|
+
# Abort early if the last modified is before the sent one.
|
|
51
|
+
if new_config is None and new_etag is None and new_lastmodified is None:
|
|
52
|
+
return
|
|
49
53
|
if new_config is None and new_etag == self._config_etag:
|
|
50
54
|
# api not returning data and the etag is the same
|
|
51
55
|
# no change to the config since last request
|
|
52
56
|
return
|
|
53
57
|
elif new_config is None:
|
|
54
58
|
logger.warning(
|
|
55
|
-
"Config CDN fetch returned no data with a different etag"
|
|
59
|
+
"DevCycle: Config CDN fetch returned no data with a different etag"
|
|
56
60
|
)
|
|
57
61
|
return
|
|
58
62
|
|
|
@@ -60,6 +64,7 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
60
64
|
|
|
61
65
|
self._config = new_config
|
|
62
66
|
self._config_etag = new_etag
|
|
67
|
+
self._config_lastmodified = new_lastmodified
|
|
63
68
|
|
|
64
69
|
json_config = json.dumps(self._config)
|
|
65
70
|
self._local_bucketing.store_config(json_config)
|
|
@@ -73,12 +78,14 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
73
78
|
except Exception as e:
|
|
74
79
|
# consume any error
|
|
75
80
|
logger.warning(
|
|
76
|
-
f"Error received from on_client_initialized callback: {str(e)}"
|
|
81
|
+
f"DevCycle: Error received from on_client_initialized callback: {str(e)}"
|
|
77
82
|
)
|
|
78
83
|
except CloudClientError as e:
|
|
79
|
-
logger.warning(f"Config fetch failed. Status: {str(e)}")
|
|
84
|
+
logger.warning(f"DevCycle: Config fetch failed. Status: {str(e)}")
|
|
80
85
|
except CloudClientUnauthorizedError:
|
|
81
|
-
logger.error(
|
|
86
|
+
logger.error(
|
|
87
|
+
"DevCycle: Unauthorized to get config. Aborting config polling."
|
|
88
|
+
)
|
|
82
89
|
self._polling_enabled = False
|
|
83
90
|
|
|
84
91
|
def run(self):
|
|
@@ -88,7 +95,9 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
88
95
|
except Exception as e:
|
|
89
96
|
if self._polling_enabled:
|
|
90
97
|
# Only log a warning if we're still polling
|
|
91
|
-
logger.warning(
|
|
98
|
+
logger.warning(
|
|
99
|
+
f"DevCycle: Error polling for config changes: {str(e)}"
|
|
100
|
+
)
|
|
92
101
|
time.sleep(self._options.config_polling_interval_ms / 1000.0)
|
|
93
102
|
|
|
94
103
|
def close(self):
|