devcycle-python-server-sdk 1.2.1__tar.gz → 3.5.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.5.0/PKG-INFO +12 -0
- devcycle-python-server-sdk-3.5.0/README.md +119 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/__init__.py +6 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/api/backoff.py +11 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/api/bucketing_client.py +145 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/api/config_client.py +118 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/api/event_client.py +95 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/api/local_bucketing.py +436 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/cloud_client.py +201 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/devcycle_client.py +40 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/exceptions.py +43 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/local_client.py +242 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/managers/config_manager.py +99 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/managers/event_queue_manager.py +227 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/models/bucketed_config.py +152 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/models/error_response.py +17 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/models/event.py +123 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/models/feature.py +33 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/models/platform_data.py +33 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/models/user.py +159 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/models/variable.py +65 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/open_feature_provider/__init__.py +0 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/open_feature_provider/provider.py +123 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/options.py +106 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/protobuf/__init__.py +0 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/protobuf/utils.py +129 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/protobuf/variableForUserParams_pb2.py +46 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/util/__init__.py +1 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/util/strings.py +6 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/util/version.py +9 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_server_sdk.egg-info/PKG-INFO +12 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_server_sdk.egg-info/SOURCES.txt +68 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_server_sdk.egg-info/dependency_links.txt +1 -0
- devcycle-python-server-sdk-3.5.0/devcycle_python_server_sdk.egg-info/requires.txt +6 -0
- devcycle-python-server-sdk-3.5.0/example/__init__.py +0 -0
- devcycle-python-server-sdk-3.5.0/example/cloud_client_example.py +68 -0
- devcycle-python-server-sdk-3.5.0/example/local_bucketing_client_example.py +79 -0
- devcycle-python-server-sdk-3.5.0/example/openfeature_example.py +77 -0
- devcycle-python-server-sdk-3.5.0/pyproject.toml +56 -0
- devcycle-python-server-sdk-3.5.0/setup.py +36 -0
- devcycle-python-server-sdk-3.5.0/test/__init__.py +0 -0
- devcycle-python-server-sdk-3.5.0/test/api/__init__.py +0 -0
- devcycle-python-server-sdk-3.5.0/test/api/test_bucketing_client.py +237 -0
- devcycle-python-server-sdk-3.5.0/test/api/test_config_client.py +136 -0
- devcycle-python-server-sdk-3.5.0/test/api/test_event_client.py +100 -0
- devcycle-python-server-sdk-3.5.0/test/api/test_local_bucketing.py +395 -0
- devcycle-python-server-sdk-3.5.0/test/fixture/__init__.py +0 -0
- devcycle-python-server-sdk-3.5.0/test/fixture/data.py +57 -0
- devcycle-python-server-sdk-3.5.0/test/managers/__init__.py +0 -0
- devcycle-python-server-sdk-3.5.0/test/managers/test_config_manager.py +132 -0
- devcycle-python-server-sdk-3.5.0/test/managers/test_event_queue_manager.py +271 -0
- devcycle-python-server-sdk-3.5.0/test/models/__init__.py +0 -0
- devcycle-python-server-sdk-3.5.0/test/models/test_bucketed_config.py +26 -0
- devcycle-python-server-sdk-3.5.0/test/models/test_user.py +188 -0
- devcycle-python-server-sdk-3.5.0/test/openfeature_test/__init__.py +0 -0
- devcycle-python-server-sdk-3.5.0/test/openfeature_test/test_provider.py +219 -0
- devcycle-python-server-sdk-3.5.0/test/openfeature_test/test_provider_local_sdk.py +136 -0
- devcycle-python-server-sdk-3.5.0/test/test_cloud_client.py +286 -0
- devcycle-python-server-sdk-3.5.0/test/test_local_client.py +407 -0
- devcycle-python-server-sdk-3.5.0/test/util/__init__.py +0 -0
- devcycle-python-server-sdk-3.5.0/test/util/test_strings.py +25 -0
- devcycle-python-server-sdk-3.5.0/test/util/test_utils.py +183 -0
- devcycle-python-server-sdk-3.5.0/test/util/test_version.py +17 -0
- devcycle-python-server-sdk-1.2.1/PKG-INFO +0 -12
- devcycle-python-server-sdk-1.2.1/README.md +0 -53
- devcycle-python-server-sdk-1.2.1/devcycle_python_sdk/VERSION.txt +0 -1
- devcycle-python-server-sdk-1.2.1/devcycle_python_sdk/__init__.py +0 -30
- devcycle-python-server-sdk-1.2.1/devcycle_python_sdk/api/__init__.py +0 -6
- devcycle-python-server-sdk-1.2.1/devcycle_python_sdk/api/dvc_client.py +0 -482
- devcycle-python-server-sdk-1.2.1/devcycle_python_sdk/api_client.py +0 -628
- devcycle-python-server-sdk-1.2.1/devcycle_python_sdk/configuration.py +0 -251
- devcycle-python-server-sdk-1.2.1/devcycle_python_sdk/dvc_options.py +0 -3
- devcycle-python-server-sdk-1.2.1/devcycle_python_sdk/models/__init__.py +0 -23
- devcycle-python-server-sdk-1.2.1/devcycle_python_sdk/models/error_response.py +0 -141
- devcycle-python-server-sdk-1.2.1/devcycle_python_sdk/models/event.py +0 -225
- devcycle-python-server-sdk-1.2.1/devcycle_python_sdk/models/feature.py +0 -294
- devcycle-python-server-sdk-1.2.1/devcycle_python_sdk/models/inline_response201.py +0 -110
- devcycle-python-server-sdk-1.2.1/devcycle_python_sdk/models/user_data.py +0 -539
- devcycle-python-server-sdk-1.2.1/devcycle_python_sdk/models/user_data_and_events_body.py +0 -136
- devcycle-python-server-sdk-1.2.1/devcycle_python_sdk/models/variable.py +0 -230
- devcycle-python-server-sdk-1.2.1/devcycle_python_sdk/rest.py +0 -322
- devcycle-python-server-sdk-1.2.1/devcycle_python_server_sdk.egg-info/PKG-INFO +0 -12
- devcycle-python-server-sdk-1.2.1/devcycle_python_server_sdk.egg-info/SOURCES.txt +0 -35
- devcycle-python-server-sdk-1.2.1/devcycle_python_server_sdk.egg-info/requires.txt +0 -4
- devcycle-python-server-sdk-1.2.1/example/example.py +0 -78
- devcycle-python-server-sdk-1.2.1/setup.py +0 -43
- devcycle-python-server-sdk-1.2.1/test/test_devcycle_api.py +0 -61
- devcycle-python-server-sdk-1.2.1/test/test_error_response.py +0 -39
- devcycle-python-server-sdk-1.2.1/test/test_feature.py +0 -39
- devcycle-python-server-sdk-1.2.1/test/test_inline_response200.py +0 -39
- devcycle-python-server-sdk-1.2.1/test/test_user_data.py +0 -39
- devcycle-python-server-sdk-1.2.1/test/test_user_data_and_event_body.py +0 -39
- devcycle-python-server-sdk-1.2.1/test/test_variable.py +0 -39
- {devcycle-python-server-sdk-1.2.1 → devcycle-python-server-sdk-3.5.0}/LICENSE +0 -0
- /devcycle-python-server-sdk-1.2.1/devcycle_python_server_sdk.egg-info/dependency_links.txt → /devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/api/__init__.py +0 -0
- {devcycle-python-server-sdk-1.2.1/example → devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/managers}/__init__.py +0 -0
- {devcycle-python-server-sdk-1.2.1/test → devcycle-python-server-sdk-3.5.0/devcycle_python_sdk/models}/__init__.py +0 -0
- {devcycle-python-server-sdk-1.2.1 → devcycle-python-server-sdk-3.5.0}/devcycle_python_server_sdk.egg-info/top_level.txt +0 -0
- {devcycle-python-server-sdk-1.2.1 → devcycle-python-server-sdk-3.5.0}/setup.cfg +0 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 1.0
|
|
2
|
+
Name: devcycle-python-server-sdk
|
|
3
|
+
Version: 3.5.0
|
|
4
|
+
Summary: DevCycle Python SDK
|
|
5
|
+
Home-page: https://github.com/devcycleHQ/python-server-sdk
|
|
6
|
+
Author: UNKNOWN
|
|
7
|
+
Author-email: UNKNOWN
|
|
8
|
+
License: UNKNOWN
|
|
9
|
+
Description: The DevCycle Python SDK used for feature management.
|
|
10
|
+
|
|
11
|
+
Keywords: DevCycle
|
|
12
|
+
Platform: UNKNOWN
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# DevCycle Python Server SDK
|
|
2
|
+
|
|
3
|
+
The DevCycle Python SDK used for feature management.
|
|
4
|
+
|
|
5
|
+
This SDK allows your application to interface with the [DevCycle Bucketing API](https://docs.devcycle.com/bucketing-api/#tag/devcycle).
|
|
6
|
+
|
|
7
|
+
## Requirements.
|
|
8
|
+
|
|
9
|
+
* Python 3.8+
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pip install devcycle-python-server-sdk
|
|
15
|
+
```
|
|
16
|
+
(you may need to run `pip` with root permission: `sudo pip install devcycle-python-server-sdk`)
|
|
17
|
+
|
|
18
|
+
## Getting Started
|
|
19
|
+
|
|
20
|
+
The core DevCycle objects are in the `devcycle_python_sdk` package. To get started, import the `DevCycleLocalClient` class and the `DevCycleLocalOptions` class. The `DevCycleLocalClient` class is used to interact with the DevCycle API. The `DevCycleLocalOptions` class is used to configure the client.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from devcycle_python_sdk import DevCycleLocalClient, DevCycleLocalOptions
|
|
24
|
+
from devcycle_python_sdk.models.user import DevCycleUser
|
|
25
|
+
|
|
26
|
+
options = DevCycleLocalOptions()
|
|
27
|
+
|
|
28
|
+
# create an instance of the client class
|
|
29
|
+
client = DevCycleLocalClient('DEVCYCLE_SERVER_SDK_KEY', options)
|
|
30
|
+
|
|
31
|
+
user = DevCycleUser(
|
|
32
|
+
user_id='test',
|
|
33
|
+
email='example@example.ca',
|
|
34
|
+
country='CA'
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
value = client.variable_value(user, 'variable-key', 'default-value')
|
|
38
|
+
```
|
|
39
|
+
|
|
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
|
+
|
|
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
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
To find usage documentation, visit our [docs](https://docs.devcycle.com/docs/sdk/server-side-sdks/python#usage).
|
|
60
|
+
|
|
61
|
+
## Development
|
|
62
|
+
|
|
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.
|
|
64
|
+
|
|
65
|
+
### Dependencies
|
|
66
|
+
|
|
67
|
+
To set up dependencies for local development, run:
|
|
68
|
+
```
|
|
69
|
+
pip install -r requirements.test.txt
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
To run the example app against the local version of the API for testing and development, run:
|
|
73
|
+
```sh
|
|
74
|
+
pip install --editable .
|
|
75
|
+
```
|
|
76
|
+
from the top level of the repo (same level as setup.py). Then run the example app as normal.
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
### Linting & Formatting
|
|
80
|
+
|
|
81
|
+
Linting checks on PRs are run using [ruff](https://github.com/charliermarsh/ruff), and are configured using `.ruff.toml`. To run the linter locally, run this command from the top level of the repo:
|
|
82
|
+
```
|
|
83
|
+
ruff check .
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Ruff can automatically fix simple linting errors (the ones marked with `[*]`). To do so, run:
|
|
87
|
+
```
|
|
88
|
+
ruff check . --fix
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Formatting checks on PRs are done using [black](https://github.com/psf/black). To run the formatter locally, run this command from the top level of the repo:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
black .
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Unit Tests
|
|
98
|
+
|
|
99
|
+
To run the unit tests, run:
|
|
100
|
+
```bash
|
|
101
|
+
pytest
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Benchmarks
|
|
105
|
+
|
|
106
|
+
To run the benchmarks, run:
|
|
107
|
+
```bash
|
|
108
|
+
pytest --benchmark-only
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Protobuf Code Generation
|
|
112
|
+
|
|
113
|
+
To generate the protobuf source files run the following from the root of the project. Ensure you have `protoc` installed.
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
protoc --proto_path=./protobuf/ --python_out=devcycle_python_sdk/protobuf --pyi_out=devcycle_python_sdk/protobuf variableForUserParams.proto
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
This will rebuild the `variableForUserParams_pb2.py` file. DO NOT edit this file directly.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Simplify imports for the SDK entry point objects
|
|
2
|
+
|
|
3
|
+
from devcycle_python_sdk.options import DevCycleCloudOptions, DevCycleLocalOptions
|
|
4
|
+
from devcycle_python_sdk.devcycle_client import AbstractDevCycleClient
|
|
5
|
+
from devcycle_python_sdk.cloud_client import DevCycleCloudClient
|
|
6
|
+
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
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from typing import Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from devcycle_python_sdk.api.backoff import exponential_backoff
|
|
8
|
+
from devcycle_python_sdk.options import DevCycleCloudOptions
|
|
9
|
+
from devcycle_python_sdk.exceptions import (
|
|
10
|
+
CloudClientError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
CloudClientUnauthorizedError,
|
|
13
|
+
)
|
|
14
|
+
from devcycle_python_sdk.models.event import DevCycleEvent
|
|
15
|
+
from devcycle_python_sdk.models.feature import Feature
|
|
16
|
+
from devcycle_python_sdk.models.user import DevCycleUser
|
|
17
|
+
from devcycle_python_sdk.models.variable import Variable
|
|
18
|
+
from devcycle_python_sdk.util.strings import slash_join
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BucketingAPIClient:
|
|
24
|
+
def __init__(self, sdk_key: str, options: DevCycleCloudOptions):
|
|
25
|
+
self.sdk_key = sdk_key
|
|
26
|
+
self.options = options
|
|
27
|
+
self.session = requests.Session()
|
|
28
|
+
self.session.headers = {
|
|
29
|
+
"Authorization": sdk_key,
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
"Accept": "application/json",
|
|
32
|
+
}
|
|
33
|
+
self.session.max_redirects = 0
|
|
34
|
+
|
|
35
|
+
def _url(self, *path_args: str) -> str:
|
|
36
|
+
return slash_join(self.options.bucketing_api_uri, "v1", *path_args)
|
|
37
|
+
|
|
38
|
+
def request(self, method: str, url: str, **kwargs) -> dict:
|
|
39
|
+
retries_remaining = self.options.request_retries + 1
|
|
40
|
+
timeout = self.options.request_timeout
|
|
41
|
+
|
|
42
|
+
query_params = {}
|
|
43
|
+
if self.options.enable_edge_db:
|
|
44
|
+
query_params["enableEdgeDB"] = "true"
|
|
45
|
+
|
|
46
|
+
attempts = 1
|
|
47
|
+
while retries_remaining > 0:
|
|
48
|
+
request_error: Optional[Exception] = None
|
|
49
|
+
try:
|
|
50
|
+
res: requests.Response = self.session.request(
|
|
51
|
+
method, url, params=query_params, timeout=timeout, **kwargs
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if res.status_code == 401:
|
|
55
|
+
# Not a retryable error
|
|
56
|
+
raise CloudClientUnauthorizedError("Invalid SDK Key")
|
|
57
|
+
elif res.status_code == 404:
|
|
58
|
+
# Not a retryable error
|
|
59
|
+
raise NotFoundError(url)
|
|
60
|
+
elif 400 <= res.status_code < 500:
|
|
61
|
+
# Not a retryable error
|
|
62
|
+
raise CloudClientError(f"Bad request: HTTP {res.status_code}")
|
|
63
|
+
elif res.status_code >= 500:
|
|
64
|
+
# Retryable error
|
|
65
|
+
request_error = CloudClientError(
|
|
66
|
+
f"Server error: HTTP {res.status_code}"
|
|
67
|
+
)
|
|
68
|
+
except requests.exceptions.RequestException as e:
|
|
69
|
+
request_error = e
|
|
70
|
+
|
|
71
|
+
if not request_error:
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
logger.debug(
|
|
75
|
+
f"DevCycle cloud bucketing request failed (attempt {attempts}): {request_error}"
|
|
76
|
+
)
|
|
77
|
+
retries_remaining -= 1
|
|
78
|
+
if retries_remaining:
|
|
79
|
+
retry_delay = exponential_backoff(
|
|
80
|
+
attempts, self.options.retry_delay / 1000.0
|
|
81
|
+
)
|
|
82
|
+
time.sleep(retry_delay)
|
|
83
|
+
attempts += 1
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
raise CloudClientError(message="Retries exceeded", cause=request_error)
|
|
87
|
+
|
|
88
|
+
data: dict = res.json()
|
|
89
|
+
return data
|
|
90
|
+
|
|
91
|
+
def variable(self, key: str, user: DevCycleUser) -> Variable:
|
|
92
|
+
data = self.request("POST", self._url("variables", key), json=user.to_json())
|
|
93
|
+
|
|
94
|
+
return Variable(
|
|
95
|
+
_id=data.get("_id"),
|
|
96
|
+
key=data.get("key", ""),
|
|
97
|
+
type=data.get("type", ""),
|
|
98
|
+
value=data.get("value"),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def variables(self, user: DevCycleUser) -> Dict[str, Variable]:
|
|
102
|
+
data = self.request("POST", self._url("variables"), json=user.to_json())
|
|
103
|
+
|
|
104
|
+
result: Dict[str, Variable] = {}
|
|
105
|
+
for key, value in data.items():
|
|
106
|
+
result[key] = Variable(
|
|
107
|
+
_id=str(value.get("_id")),
|
|
108
|
+
key=str(value.get("key")),
|
|
109
|
+
type=str(value.get("type")),
|
|
110
|
+
value=value.get("value"),
|
|
111
|
+
isDefaulted=None,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
def features(self, user: DevCycleUser) -> Dict[str, Feature]:
|
|
117
|
+
data = self.request("POST", self._url("features"), json=user.to_json())
|
|
118
|
+
|
|
119
|
+
result: Dict[str, Feature] = {}
|
|
120
|
+
for key, value in data.items():
|
|
121
|
+
result[key] = Feature(
|
|
122
|
+
_id=value.get("_id"),
|
|
123
|
+
key=value.get("key"),
|
|
124
|
+
type=value.get("type"),
|
|
125
|
+
_variation=value.get("_variation"),
|
|
126
|
+
variationKey=value.get("variationKey"),
|
|
127
|
+
variationName=value.get("variationName"),
|
|
128
|
+
evalReason=value.get("evalReason"),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
def track(self, user: DevCycleUser, events: List[DevCycleEvent]) -> str:
|
|
134
|
+
data = self.request(
|
|
135
|
+
"POST",
|
|
136
|
+
self._url("track"),
|
|
137
|
+
json={
|
|
138
|
+
"user": user.to_json(),
|
|
139
|
+
"events": [
|
|
140
|
+
event.to_json(use_bucketing_api_format=True) for event in events
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
message = data.get("message", "")
|
|
145
|
+
return message
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from http import HTTPStatus
|
|
4
|
+
from typing import Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from devcycle_python_sdk.api.backoff import exponential_backoff
|
|
9
|
+
from devcycle_python_sdk.options import DevCycleLocalOptions
|
|
10
|
+
from devcycle_python_sdk.exceptions import (
|
|
11
|
+
APIClientError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
APIClientUnauthorizedError,
|
|
14
|
+
)
|
|
15
|
+
from devcycle_python_sdk.util.strings import slash_join
|
|
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
|
+
slash_join(
|
|
33
|
+
self.options.config_cdn_uri, "config", "v1", "server", self.sdk_key
|
|
34
|
+
)
|
|
35
|
+
+ ".json"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def get_config(
|
|
39
|
+
self, config_etag: Optional[str] = None
|
|
40
|
+
) -> Tuple[Optional[dict], Optional[str]]:
|
|
41
|
+
"""
|
|
42
|
+
Get the config from the server. If the config_etag is provided, the server will only return the config if it
|
|
43
|
+
has changed since the last request. If the config hasn't changed, the server will return a 304 Not Modified
|
|
44
|
+
response.
|
|
45
|
+
|
|
46
|
+
:param config_etag: The etag of the last config request
|
|
47
|
+
|
|
48
|
+
:return: A tuple containing the config and the etag of the config. If the config hasn't changed since the last
|
|
49
|
+
request, the config will be None and the etag will be the same as the last request.
|
|
50
|
+
"""
|
|
51
|
+
retries_remaining = self.max_config_retries
|
|
52
|
+
timeout = self.options.config_request_timeout_ms / 1000.0
|
|
53
|
+
|
|
54
|
+
url = self.config_file_url
|
|
55
|
+
|
|
56
|
+
headers = {}
|
|
57
|
+
if config_etag:
|
|
58
|
+
headers["If-None-Match"] = config_etag
|
|
59
|
+
|
|
60
|
+
attempts = 1
|
|
61
|
+
while retries_remaining > 0:
|
|
62
|
+
request_error: Optional[Exception] = None
|
|
63
|
+
try:
|
|
64
|
+
res: requests.Response = self.session.request(
|
|
65
|
+
"GET", url, params={}, timeout=timeout, headers=headers
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if (
|
|
69
|
+
res.status_code == HTTPStatus.UNAUTHORIZED
|
|
70
|
+
or res.status_code == HTTPStatus.FORBIDDEN
|
|
71
|
+
):
|
|
72
|
+
# Not a retryable error
|
|
73
|
+
raise APIClientUnauthorizedError("Invalid SDK Key")
|
|
74
|
+
elif res.status_code == HTTPStatus.NOT_MODIFIED:
|
|
75
|
+
# the config hasn't changed since the last request
|
|
76
|
+
# don't return anything
|
|
77
|
+
return None, config_etag
|
|
78
|
+
elif res.status_code == HTTPStatus.NOT_FOUND:
|
|
79
|
+
# Not a retryable error
|
|
80
|
+
raise NotFoundError(url)
|
|
81
|
+
elif (
|
|
82
|
+
HTTPStatus.BAD_REQUEST
|
|
83
|
+
<= res.status_code
|
|
84
|
+
< HTTPStatus.INTERNAL_SERVER_ERROR
|
|
85
|
+
):
|
|
86
|
+
# Not a retryable error
|
|
87
|
+
raise APIClientError(f"Bad request: HTTP {res.status_code}")
|
|
88
|
+
elif res.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR:
|
|
89
|
+
# Retryable error
|
|
90
|
+
request_error = APIClientError(
|
|
91
|
+
f"Server error: HTTP {res.status_code}"
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
pass
|
|
95
|
+
except requests.exceptions.RequestException as e:
|
|
96
|
+
request_error = e
|
|
97
|
+
|
|
98
|
+
if not request_error:
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
logger.debug(
|
|
102
|
+
f"DevCycle config CDN request failed (attempt {attempts}): {request_error}"
|
|
103
|
+
)
|
|
104
|
+
retries_remaining -= 1
|
|
105
|
+
if retries_remaining:
|
|
106
|
+
retry_delay = exponential_backoff(
|
|
107
|
+
attempts, self.options.config_retry_delay_ms / 1000.0
|
|
108
|
+
)
|
|
109
|
+
time.sleep(retry_delay)
|
|
110
|
+
attempts += 1
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
raise APIClientError(message="Retries exceeded", cause=request_error)
|
|
114
|
+
|
|
115
|
+
new_etag = res.headers.get("ETag", None)
|
|
116
|
+
|
|
117
|
+
data: dict = res.json()
|
|
118
|
+
return data, new_etag
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from typing import Optional, List
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from devcycle_python_sdk.api.backoff import exponential_backoff
|
|
9
|
+
from devcycle_python_sdk.options import DevCycleLocalOptions
|
|
10
|
+
from devcycle_python_sdk.exceptions import (
|
|
11
|
+
APIClientError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
APIClientUnauthorizedError,
|
|
14
|
+
)
|
|
15
|
+
from devcycle_python_sdk.models.event import UserEventsBatchRecord
|
|
16
|
+
from devcycle_python_sdk.util.strings import slash_join
|
|
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 = slash_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)
|