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.
- devcycle_python_sdk/VERSION.txt +1 -1
- devcycle_python_sdk/__init__.py +3 -2
- devcycle_python_sdk/api/backoff.py +11 -0
- devcycle_python_sdk/api/bucketing_client.py +17 -25
- devcycle_python_sdk/api/config_client.py +116 -0
- devcycle_python_sdk/api/event_client.py +95 -0
- devcycle_python_sdk/api/local_bucketing.py +436 -0
- devcycle_python_sdk/{dvc_cloud_client.py → cloud_client.py} +69 -41
- devcycle_python_sdk/exceptions.py +25 -3
- devcycle_python_sdk/local_client.py +220 -0
- devcycle_python_sdk/managers/__init__.py +0 -0
- devcycle_python_sdk/managers/config_manager.py +95 -0
- devcycle_python_sdk/managers/event_queue_manager.py +223 -0
- devcycle_python_sdk/models/bucketed_config.py +150 -0
- devcycle_python_sdk/models/event.py +111 -8
- devcycle_python_sdk/models/feature.py +12 -0
- devcycle_python_sdk/models/platform_data.py +33 -0
- devcycle_python_sdk/models/user.py +52 -7
- devcycle_python_sdk/models/variable.py +17 -5
- devcycle_python_sdk/options.py +106 -0
- devcycle_python_sdk/protobuf/__init__.py +0 -0
- devcycle_python_sdk/protobuf/utils.py +129 -0
- devcycle_python_sdk/protobuf/variableForUserParams_pb2.py +46 -0
- devcycle_python_sdk/py.typed +0 -0
- devcycle_python_sdk/util/version.py +1 -1
- {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/METADATA +3 -2
- devcycle_python_server_sdk-3.0.0.dist-info/RECORD +55 -0
- example/{example.py → cloud_client_example.py} +24 -20
- example/local_bucketing_client_example.py +74 -0
- test/api/__init__.py +0 -0
- test/{test_bucketing_client.py → api/test_bucketing_client.py} +10 -5
- test/api/test_config_client.py +133 -0
- test/api/test_event_client.py +97 -0
- test/api/test_local_bucketing.py +392 -0
- test/fixture/__init__.py +0 -0
- test/fixture/data.py +57 -0
- test/managers/__init__.py +0 -0
- test/managers/test_config_manager.py +132 -0
- test/managers/test_event_queue_manager.py +271 -0
- test/models/__init__.py +0 -0
- test/models/test_bucketed_config.py +26 -0
- test/{test_dvc_cloud_client.py → test_cloud_client.py} +11 -17
- test/test_local_client.py +397 -0
- test/util/__init__.py +0 -0
- test/util/test_utils.py +183 -0
- test/{test_version.py → util/test_version.py} +2 -2
- devcycle_python_sdk/dvc_options.py +0 -18
- devcycle_python_server_sdk-2.0.0.dist-info/RECORD +0 -26
- {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/LICENSE +0 -0
- {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/WHEEL +0 -0
- {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/top_level.txt +0 -0
devcycle_python_sdk/VERSION.txt
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
3.0.0
|
devcycle_python_sdk/__init__.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# Simplify imports for the SDK entry point objects
|
|
2
2
|
|
|
3
|
-
from devcycle_python_sdk.
|
|
4
|
-
from devcycle_python_sdk.
|
|
3
|
+
from devcycle_python_sdk.options import DevCycleCloudOptions, DevCycleLocalOptions
|
|
4
|
+
from devcycle_python_sdk.cloud_client import DevCycleCloudClient
|
|
5
|
+
from devcycle_python_sdk.local_client import DevCycleLocalClient
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import random
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def exponential_backoff(attempt: int, base_delay: float) -> float:
|
|
6
|
+
"""
|
|
7
|
+
Exponential backoff starting with 200ms +- 0...40ms jitter
|
|
8
|
+
"""
|
|
9
|
+
delay = math.pow(2, attempt) * base_delay / 2.0
|
|
10
|
+
random_sum = delay * 0.1 * random.random()
|
|
11
|
+
return delay + random_sum
|
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
import math
|
|
3
|
-
from os.path import join
|
|
4
|
-
import random
|
|
5
2
|
import time
|
|
3
|
+
from os.path import join
|
|
6
4
|
from typing import Dict, List, Optional
|
|
7
5
|
|
|
8
6
|
import requests
|
|
9
7
|
|
|
8
|
+
from devcycle_python_sdk.api.backoff import exponential_backoff
|
|
9
|
+
from devcycle_python_sdk.options import DevCycleCloudOptions
|
|
10
10
|
from devcycle_python_sdk.exceptions import (
|
|
11
11
|
CloudClientError,
|
|
12
12
|
NotFoundError,
|
|
13
13
|
CloudClientUnauthorizedError,
|
|
14
14
|
)
|
|
15
|
-
from devcycle_python_sdk.
|
|
16
|
-
from devcycle_python_sdk.models.user import User
|
|
17
|
-
from devcycle_python_sdk.models.event import Event
|
|
18
|
-
from devcycle_python_sdk.models.variable import Variable
|
|
15
|
+
from devcycle_python_sdk.models.event import DevCycleEvent
|
|
19
16
|
from devcycle_python_sdk.models.feature import Feature
|
|
17
|
+
from devcycle_python_sdk.models.user import DevCycleUser
|
|
18
|
+
from devcycle_python_sdk.models.variable import Variable
|
|
20
19
|
|
|
21
20
|
logger = logging.getLogger(__name__)
|
|
22
21
|
|
|
@@ -34,7 +33,7 @@ class BucketingAPIClient:
|
|
|
34
33
|
self.session.max_redirects = 0
|
|
35
34
|
|
|
36
35
|
def _url(self, *path_args: str) -> str:
|
|
37
|
-
return join(self.options.
|
|
36
|
+
return join(self.options.bucketing_api_uri, "v1", *path_args)
|
|
38
37
|
|
|
39
38
|
def request(self, method: str, url: str, **kwargs) -> dict:
|
|
40
39
|
retries_remaining = self.options.request_retries + 1
|
|
@@ -72,7 +71,7 @@ class BucketingAPIClient:
|
|
|
72
71
|
if not request_error:
|
|
73
72
|
break
|
|
74
73
|
|
|
75
|
-
logger.
|
|
74
|
+
logger.debug(
|
|
76
75
|
f"DevCycle cloud bucketing request failed (attempt {attempts}): {request_error}"
|
|
77
76
|
)
|
|
78
77
|
retries_remaining -= 1
|
|
@@ -89,17 +88,17 @@ class BucketingAPIClient:
|
|
|
89
88
|
data: dict = res.json()
|
|
90
89
|
return data
|
|
91
90
|
|
|
92
|
-
def variable(self, key: str, user:
|
|
91
|
+
def variable(self, key: str, user: DevCycleUser) -> Variable:
|
|
93
92
|
data = self.request("POST", self._url("variables", key), json=user.to_json())
|
|
94
93
|
|
|
95
94
|
return Variable(
|
|
96
95
|
_id=data.get("_id"),
|
|
97
|
-
key=data.get("key"),
|
|
98
|
-
type=data.get("type"),
|
|
96
|
+
key=data.get("key", ""),
|
|
97
|
+
type=data.get("type", ""),
|
|
99
98
|
value=data.get("value"),
|
|
100
99
|
)
|
|
101
100
|
|
|
102
|
-
def variables(self, user:
|
|
101
|
+
def variables(self, user: DevCycleUser) -> Dict[str, Variable]:
|
|
103
102
|
data = self.request("POST", self._url("variables"), json=user.to_json())
|
|
104
103
|
|
|
105
104
|
result: Dict[str, Variable] = {}
|
|
@@ -114,7 +113,7 @@ class BucketingAPIClient:
|
|
|
114
113
|
|
|
115
114
|
return result
|
|
116
115
|
|
|
117
|
-
def features(self, user:
|
|
116
|
+
def features(self, user: DevCycleUser) -> Dict[str, Feature]:
|
|
118
117
|
data = self.request("POST", self._url("features"), json=user.to_json())
|
|
119
118
|
|
|
120
119
|
result: Dict[str, Feature] = {}
|
|
@@ -131,23 +130,16 @@ class BucketingAPIClient:
|
|
|
131
130
|
|
|
132
131
|
return result
|
|
133
132
|
|
|
134
|
-
def track(self, user:
|
|
133
|
+
def track(self, user: DevCycleUser, events: List[DevCycleEvent]) -> str:
|
|
135
134
|
data = self.request(
|
|
136
135
|
"POST",
|
|
137
136
|
self._url("track"),
|
|
138
137
|
json={
|
|
139
138
|
"user": user.to_json(),
|
|
140
|
-
"events": [
|
|
139
|
+
"events": [
|
|
140
|
+
event.to_json(use_bucketing_api_format=True) for event in events
|
|
141
|
+
],
|
|
141
142
|
},
|
|
142
143
|
)
|
|
143
144
|
message = data.get("message", "")
|
|
144
145
|
return message
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def exponential_backoff(attempt: int, base_delay: float) -> float:
|
|
148
|
-
"""
|
|
149
|
-
Exponential backoff starting with 200ms +- 0...40ms jitter
|
|
150
|
-
"""
|
|
151
|
-
delay = math.pow(2, attempt) * base_delay / 2.0
|
|
152
|
-
random_sum = delay * 0.1 * random.random()
|
|
153
|
-
return delay + random_sum
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from http import HTTPStatus
|
|
4
|
+
from os.path import join
|
|
5
|
+
from typing import Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from devcycle_python_sdk.api.backoff import exponential_backoff
|
|
10
|
+
from devcycle_python_sdk.options import DevCycleLocalOptions
|
|
11
|
+
from devcycle_python_sdk.exceptions import (
|
|
12
|
+
APIClientError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
APIClientUnauthorizedError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigAPIClient:
|
|
21
|
+
def __init__(self, sdk_key: str, options: DevCycleLocalOptions):
|
|
22
|
+
self.sdk_key = sdk_key
|
|
23
|
+
self.options = options
|
|
24
|
+
self.session = requests.Session()
|
|
25
|
+
self.session.headers = {
|
|
26
|
+
"Content-Type": "application/json",
|
|
27
|
+
"Accept": "application/json",
|
|
28
|
+
}
|
|
29
|
+
self.session.max_redirects = 0
|
|
30
|
+
self.max_config_retries = 2
|
|
31
|
+
self.config_file_url = (
|
|
32
|
+
join(self.options.config_cdn_uri, "config", "v1", "server", self.sdk_key)
|
|
33
|
+
+ ".json"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def get_config(
|
|
37
|
+
self, config_etag: Optional[str] = None
|
|
38
|
+
) -> Tuple[Optional[dict], Optional[str]]:
|
|
39
|
+
"""
|
|
40
|
+
Get the config from the server. If the config_etag is provided, the server will only return the config if it
|
|
41
|
+
has changed since the last request. If the config hasn't changed, the server will return a 304 Not Modified
|
|
42
|
+
response.
|
|
43
|
+
|
|
44
|
+
:param config_etag: The etag of the last config request
|
|
45
|
+
|
|
46
|
+
:return: A tuple containing the config and the etag of the config. If the config hasn't changed since the last
|
|
47
|
+
request, the config will be None and the etag will be the same as the last request.
|
|
48
|
+
"""
|
|
49
|
+
retries_remaining = self.max_config_retries
|
|
50
|
+
timeout = self.options.config_request_timeout_ms / 1000.0
|
|
51
|
+
|
|
52
|
+
url = self.config_file_url
|
|
53
|
+
|
|
54
|
+
headers = {}
|
|
55
|
+
if config_etag:
|
|
56
|
+
headers["If-None-Match"] = config_etag
|
|
57
|
+
|
|
58
|
+
attempts = 1
|
|
59
|
+
while retries_remaining > 0:
|
|
60
|
+
request_error: Optional[Exception] = None
|
|
61
|
+
try:
|
|
62
|
+
res: requests.Response = self.session.request(
|
|
63
|
+
"GET", url, params={}, timeout=timeout, headers=headers
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if (
|
|
67
|
+
res.status_code == HTTPStatus.UNAUTHORIZED
|
|
68
|
+
or res.status_code == HTTPStatus.FORBIDDEN
|
|
69
|
+
):
|
|
70
|
+
# Not a retryable error
|
|
71
|
+
raise APIClientUnauthorizedError("Invalid SDK Key")
|
|
72
|
+
elif res.status_code == HTTPStatus.NOT_MODIFIED:
|
|
73
|
+
# the config hasn't changed since the last request
|
|
74
|
+
# don't return anything
|
|
75
|
+
return None, config_etag
|
|
76
|
+
elif res.status_code == HTTPStatus.NOT_FOUND:
|
|
77
|
+
# Not a retryable error
|
|
78
|
+
raise NotFoundError(url)
|
|
79
|
+
elif (
|
|
80
|
+
HTTPStatus.BAD_REQUEST
|
|
81
|
+
<= res.status_code
|
|
82
|
+
< HTTPStatus.INTERNAL_SERVER_ERROR
|
|
83
|
+
):
|
|
84
|
+
# Not a retryable error
|
|
85
|
+
raise APIClientError(f"Bad request: HTTP {res.status_code}")
|
|
86
|
+
elif res.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR:
|
|
87
|
+
# Retryable error
|
|
88
|
+
request_error = APIClientError(
|
|
89
|
+
f"Server error: HTTP {res.status_code}"
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
pass
|
|
93
|
+
except requests.exceptions.RequestException as e:
|
|
94
|
+
request_error = e
|
|
95
|
+
|
|
96
|
+
if not request_error:
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
logger.debug(
|
|
100
|
+
f"DevCycle config CDN request failed (attempt {attempts}): {request_error}"
|
|
101
|
+
)
|
|
102
|
+
retries_remaining -= 1
|
|
103
|
+
if retries_remaining:
|
|
104
|
+
retry_delay = exponential_backoff(
|
|
105
|
+
attempts, self.options.config_retry_delay_ms / 1000.0
|
|
106
|
+
)
|
|
107
|
+
time.sleep(retry_delay)
|
|
108
|
+
attempts += 1
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
raise APIClientError(message="Retries exceeded", cause=request_error)
|
|
112
|
+
|
|
113
|
+
new_etag = res.headers.get("ETag", None)
|
|
114
|
+
|
|
115
|
+
data: dict = res.json()
|
|
116
|
+
return data, new_etag
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from os.path import join
|
|
5
|
+
from typing import Optional, List
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from devcycle_python_sdk.api.backoff import exponential_backoff
|
|
10
|
+
from devcycle_python_sdk.options import DevCycleLocalOptions
|
|
11
|
+
from devcycle_python_sdk.exceptions import (
|
|
12
|
+
APIClientError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
APIClientUnauthorizedError,
|
|
15
|
+
)
|
|
16
|
+
from devcycle_python_sdk.models.event import UserEventsBatchRecord
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EventAPIClient:
|
|
22
|
+
def __init__(self, sdk_key: str, options: DevCycleLocalOptions):
|
|
23
|
+
self.options = options
|
|
24
|
+
self.session = requests.Session()
|
|
25
|
+
self.session.headers = {
|
|
26
|
+
"Content-Type": "application/json",
|
|
27
|
+
"Accept": "application/json",
|
|
28
|
+
"Authorization": sdk_key,
|
|
29
|
+
}
|
|
30
|
+
self.session.max_redirects = 0
|
|
31
|
+
self.max_batch_retries = 0 # we don't retry events batches
|
|
32
|
+
self.batch_url = join(self.options.events_api_uri, "v1/events/batch")
|
|
33
|
+
|
|
34
|
+
def publish_events(self, batch: List[UserEventsBatchRecord]) -> str:
|
|
35
|
+
"""
|
|
36
|
+
Attempts to send a batch of events to the server
|
|
37
|
+
"""
|
|
38
|
+
retries_remaining = self.max_batch_retries + 1
|
|
39
|
+
timeout = self.options.event_request_timeout_ms
|
|
40
|
+
|
|
41
|
+
payload_json = json.dumps(
|
|
42
|
+
{
|
|
43
|
+
"batch": [record.to_json() for record in batch],
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
attempts = 1
|
|
48
|
+
while retries_remaining > 0:
|
|
49
|
+
request_error: Optional[Exception] = None
|
|
50
|
+
try:
|
|
51
|
+
res: requests.Response = self.session.request(
|
|
52
|
+
"POST",
|
|
53
|
+
self.batch_url,
|
|
54
|
+
params={},
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
data=payload_json,
|
|
57
|
+
)
|
|
58
|
+
if res.status_code == 401 or res.status_code == 403:
|
|
59
|
+
# Not a retryable error
|
|
60
|
+
raise APIClientUnauthorizedError("Invalid SDK Key")
|
|
61
|
+
elif res.status_code == 404:
|
|
62
|
+
# Not a retryable error - bad URL
|
|
63
|
+
raise NotFoundError(self.batch_url)
|
|
64
|
+
elif 400 <= res.status_code < 500:
|
|
65
|
+
# Not a retryable error
|
|
66
|
+
raise APIClientError(
|
|
67
|
+
f"Bad request: HTTP {res.status_code} - {res.text}"
|
|
68
|
+
)
|
|
69
|
+
elif res.status_code >= 500:
|
|
70
|
+
# Retryable error
|
|
71
|
+
request_error = APIClientError(
|
|
72
|
+
f"Server error: HTTP {res.status_code}"
|
|
73
|
+
)
|
|
74
|
+
except requests.exceptions.RequestException as e:
|
|
75
|
+
request_error = e
|
|
76
|
+
|
|
77
|
+
if not request_error:
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
logger.debug(
|
|
81
|
+
f"DevCycle event batch request failed (attempt {attempts}): {request_error}"
|
|
82
|
+
)
|
|
83
|
+
retries_remaining -= 1
|
|
84
|
+
if retries_remaining:
|
|
85
|
+
retry_delay = exponential_backoff(
|
|
86
|
+
attempts, self.options.event_retry_delay_ms / 1000.0
|
|
87
|
+
)
|
|
88
|
+
time.sleep(retry_delay)
|
|
89
|
+
attempts += 1
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
raise APIClientError(message="Retries exceeded", cause=request_error)
|
|
93
|
+
|
|
94
|
+
data: dict = res.json()
|
|
95
|
+
return data.get("message", None)
|