launchpy 0.5.1__tar.gz → 0.5.2__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.
- {launchpy-0.5.1/launchpy.egg-info → launchpy-0.5.2}/PKG-INFO +31 -1
- launchpy-0.5.2/README.md +67 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/docs/releases.md +15 -0
- launchpy-0.5.2/launchpy/__version__.py +1 -0
- launchpy-0.5.2/launchpy/connector.py +193 -0
- {launchpy-0.5.1 → launchpy-0.5.2/launchpy.egg-info}/PKG-INFO +31 -1
- {launchpy-0.5.1 → launchpy-0.5.2}/launchpy.egg-info/SOURCES.txt +2 -1
- launchpy-0.5.2/tests/test_connector.py +253 -0
- launchpy-0.5.1/README.md +0 -37
- launchpy-0.5.1/launchpy/__version__.py +0 -1
- launchpy-0.5.1/launchpy/connector.py +0 -237
- {launchpy-0.5.1 → launchpy-0.5.2}/LICENSE +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/docs/admin.md +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/docs/cli.md +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/docs/getstarted.md +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/docs/library.md +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/docs/main.md +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/docs/property.md +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/docs/synchronizer.md +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/docs/translator.md +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/__init__.py +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/admin.py +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/cli/__init__.py +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/cli/__main__.py +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/config.py +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/configs.py +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/launchpy.py +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/library.py +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/property.py +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/synchronizer.py +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/launchpy.egg-info/dependency_links.txt +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/launchpy.egg-info/entry_points.txt +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/launchpy.egg-info/requires.txt +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/launchpy.egg-info/top_level.txt +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/pyproject.toml +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/requirements.txt +0 -0
- {launchpy-0.5.1 → launchpy-0.5.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: launchpy
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Adobe Launch API wrapper
|
|
5
5
|
Author-email: Julien Piccini <piccini.julien@gmail.com>
|
|
6
6
|
License: Apache License 2.0
|
|
@@ -34,6 +34,10 @@ Dynamic: license-file
|
|
|
34
34
|
Python wrapper for the Adobe Launch API.\
|
|
35
35
|
This module is named launchpy.
|
|
36
36
|
|
|
37
|
+
Current version: **0.5.2**. This version fixes GET retries and adds automatic
|
|
38
|
+
HTTP 429 retries across all five connector methods. See the
|
|
39
|
+
[0.5.2 release notes](./docs/releases.md#052) for details.
|
|
40
|
+
|
|
37
41
|
## Installation
|
|
38
42
|
|
|
39
43
|
You can install the module by realising the following pip command:\
|
|
@@ -66,3 +70,29 @@ Most of the documentation has been imported from the datanalyst website [here](.
|
|
|
66
70
|
## Release information
|
|
67
71
|
|
|
68
72
|
You can find release information [here](./docs/releases.md).
|
|
73
|
+
|
|
74
|
+
## HTTP retries
|
|
75
|
+
|
|
76
|
+
The `AdobeRequest` connector always retries HTTP 429 responses for GET, POST,
|
|
77
|
+
PUT, PATCH, and DELETE, even when their body is not JSON or `retry=0`.
|
|
78
|
+
Rate-limit retries have no attempt limit and do not consume the configured
|
|
79
|
+
`retry` budget. A request can therefore keep waiting while the API remains
|
|
80
|
+
rate limited; it can be interrupted normally.
|
|
81
|
+
|
|
82
|
+
The connector
|
|
83
|
+
honors `Retry-After` in seconds or HTTP-date format; absent or invalid values
|
|
84
|
+
use exponential backoff starting at 45 seconds and capped at 300 seconds.
|
|
85
|
+
The JSON-returning methods also always retry Adobe error code `429050`.
|
|
86
|
+
|
|
87
|
+
The `retry` setting is reserved for other retryable errors: currently, GET
|
|
88
|
+
responses that cannot be decoded as JSON. It specifies the number of
|
|
89
|
+
**additional attempts**, with a default of `0`. Set
|
|
90
|
+
`property.connector.retry = 3` or pass `retry=3` to a connector method to
|
|
91
|
+
override this budget; neither setting limits rate-limit retries.
|
|
92
|
+
|
|
93
|
+
Successful return values are unchanged: DELETE returns the HTTP status code,
|
|
94
|
+
while the other methods return decoded JSON. GET also retries invalid JSON
|
|
95
|
+
responses with a 30-second delay. Invalid JSON from write operations and
|
|
96
|
+
network exceptions do not trigger retries, to avoid replaying writes that
|
|
97
|
+
may already have succeeded. This policy applies to the five connector methods,
|
|
98
|
+
not the separate OAuth token request or async property-extraction requests.
|
launchpy-0.5.2/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Launchpy
|
|
2
|
+
|
|
3
|
+
Python wrapper for the Adobe Launch API.\
|
|
4
|
+
This module is named launchpy.
|
|
5
|
+
|
|
6
|
+
Current version: **0.5.2**. This version fixes GET retries and adds automatic
|
|
7
|
+
HTTP 429 retries across all five connector methods. See the
|
|
8
|
+
[0.5.2 release notes](./docs/releases.md#052) for details.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
You can install the module by realising the following pip command:\
|
|
13
|
+
`pip install launchpy`
|
|
14
|
+
|
|
15
|
+
You can access the version of the module that you run via the following attribute:\
|
|
16
|
+
`launchpy.__version__`
|
|
17
|
+
|
|
18
|
+
## Content of the module
|
|
19
|
+
|
|
20
|
+
This module is built around 7 main parts:
|
|
21
|
+
|
|
22
|
+
- Helping functions [Core Components](./docs/main.md)
|
|
23
|
+
- Admin instantiating functions [Admin Class](./docs/admin.md)
|
|
24
|
+
- Managing properties [Property Class](./docs/property.md)
|
|
25
|
+
- Managing Publishing Cycle [Library Class](./docs/library.md)
|
|
26
|
+
- Translator functionationality [Translator Class](./docs/translator.md)
|
|
27
|
+
- Synchronizer [Synchronizer Class](./docs/synchronizer.md)
|
|
28
|
+
- Command Line Interface [CLI](./docs/cli.md)
|
|
29
|
+
|
|
30
|
+
## Get Started
|
|
31
|
+
|
|
32
|
+
A [get started guide](./docs/getstarted.md) has been created to explain the different functionality.
|
|
33
|
+
You can find a more detail description functionalities at [datanalyst.info](https://datanalyst.info).
|
|
34
|
+
|
|
35
|
+
## Main documentation
|
|
36
|
+
|
|
37
|
+
Most of the documentation has been imported from the datanalyst website [here](./docs/main.md).
|
|
38
|
+
|
|
39
|
+
## Release information
|
|
40
|
+
|
|
41
|
+
You can find release information [here](./docs/releases.md).
|
|
42
|
+
|
|
43
|
+
## HTTP retries
|
|
44
|
+
|
|
45
|
+
The `AdobeRequest` connector always retries HTTP 429 responses for GET, POST,
|
|
46
|
+
PUT, PATCH, and DELETE, even when their body is not JSON or `retry=0`.
|
|
47
|
+
Rate-limit retries have no attempt limit and do not consume the configured
|
|
48
|
+
`retry` budget. A request can therefore keep waiting while the API remains
|
|
49
|
+
rate limited; it can be interrupted normally.
|
|
50
|
+
|
|
51
|
+
The connector
|
|
52
|
+
honors `Retry-After` in seconds or HTTP-date format; absent or invalid values
|
|
53
|
+
use exponential backoff starting at 45 seconds and capped at 300 seconds.
|
|
54
|
+
The JSON-returning methods also always retry Adobe error code `429050`.
|
|
55
|
+
|
|
56
|
+
The `retry` setting is reserved for other retryable errors: currently, GET
|
|
57
|
+
responses that cannot be decoded as JSON. It specifies the number of
|
|
58
|
+
**additional attempts**, with a default of `0`. Set
|
|
59
|
+
`property.connector.retry = 3` or pass `retry=3` to a connector method to
|
|
60
|
+
override this budget; neither setting limits rate-limit retries.
|
|
61
|
+
|
|
62
|
+
Successful return values are unchanged: DELETE returns the HTTP status code,
|
|
63
|
+
while the other methods return decoded JSON. GET also retries invalid JSON
|
|
64
|
+
responses with a 30-second delay. Invalid JSON from write operations and
|
|
65
|
+
network exceptions do not trigger retries, to avoid replaying writes that
|
|
66
|
+
may already have succeeded. This policy applies to the five connector methods,
|
|
67
|
+
not the separate OAuth token request or async property-extraction requests.
|
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
This page gathered the changes made between version of the launchpy module.\
|
|
4
4
|
This has been started after the 0.3.0 release.\
|
|
5
5
|
|
|
6
|
+
## 0.5.2
|
|
7
|
+
* Fixing recursive GET retries and always retrying HTTP 429 responses for GET, POST, PUT, PATCH, and DELETE.
|
|
8
|
+
* Respecting `Retry-After` seconds and HTTP dates, with exponential backoff when the header is absent or invalid.
|
|
9
|
+
* Keeping rate-limit retries independent of the connector's `retry` budget and per-call overrides, including when `retry=0`. Other retryable errors remain bounded by that budget.
|
|
10
|
+
* Preserving DELETE request bodies when sending and retrying requests.
|
|
11
|
+
|
|
12
|
+
## 0.5.1
|
|
13
|
+
* Raising the minimum supported Python version from 3.10 to 3.12.
|
|
14
|
+
* `syncComponent` now returns a dictionary of per-target status messages. `syncComponents`, `syncRules`, `syncDataElements`, `syncExtensions`, and `syncFromLibrary` now return results grouped by component name or ID, including error messages when library synchronization fails.
|
|
15
|
+
* Updating the CLI synchronization commands to report missing components instead of always reporting success. Successful per-target operations are displayed when verbose mode is enabled.
|
|
16
|
+
* Adding the `-v` / `--verbose` flag to the `sync_rules`, `sync_data_elements`, and `upgrade_extension` CLI commands.
|
|
17
|
+
* `checkExtensionUpdate` now raises a descriptive error when the requested extension is not installed, including the installed extension names.
|
|
18
|
+
* `upgradeTargetExtension` now respects the `platform` parameter and returns per-target upgrade, already-up-to-date, or failure messages. The CLI displays upgrade failures instead of reporting success.
|
|
19
|
+
* Fixing library-linked component preparation when no library component was retrieved, avoiding an uninitialized-variable error.
|
|
20
|
+
|
|
6
21
|
## 0.5.0
|
|
7
22
|
* Fixing the `create_property` method in the CLI
|
|
8
23
|
* Adding support for multi-org config files.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.2"
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from email.utils import parsedate_to_datetime
|
|
7
|
+
from typing import Any, Dict, Union
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
# Non standard libraries
|
|
10
|
+
import requests
|
|
11
|
+
from launchpy import config, configs
|
|
12
|
+
|
|
13
|
+
class AdobeRequest:
|
|
14
|
+
"""
|
|
15
|
+
Handle request to Audience Manager and taking care that the request have a valid token set each time.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self,
|
|
19
|
+
config_objects: dict = config.config_objects,
|
|
20
|
+
headers: dict = config.headers,
|
|
21
|
+
org_name: str = None,
|
|
22
|
+
verbose: bool = False,
|
|
23
|
+
retry: int = 0
|
|
24
|
+
) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Set the connector to be used for handling request to AAM
|
|
27
|
+
Arguments:
|
|
28
|
+
config_objects : OPTIONAL : Require the importConfig file to have been used.
|
|
29
|
+
headers : OPTIONAL : header of the config modules
|
|
30
|
+
verbose : OPTIONAL : display comment on the request.
|
|
31
|
+
retry : OPTIONAL : Number of additional attempts for GET responses
|
|
32
|
+
that cannot be decoded as JSON. Default: 0.
|
|
33
|
+
Rate-limited requests always retry and do not consume this budget.
|
|
34
|
+
"""
|
|
35
|
+
if org_name is None:
|
|
36
|
+
if len(config_objects) >= 1:
|
|
37
|
+
self.config_key = list(config_objects.keys())[0]
|
|
38
|
+
config_object = config_objects[self.config_key]
|
|
39
|
+
else:
|
|
40
|
+
if org_name in config_objects.keys():
|
|
41
|
+
self.config_key = org_name
|
|
42
|
+
config_object = config_objects[self.config_key]
|
|
43
|
+
else:
|
|
44
|
+
raise ValueError(f"Configuration for org_name '{org_name}' not found.")
|
|
45
|
+
if config_object['org_id'] == '':
|
|
46
|
+
raise Exception(
|
|
47
|
+
'You have to upload the configuration file with importConfigFile method.')
|
|
48
|
+
self.config = {self.config_key: deepcopy(config_object)}
|
|
49
|
+
header = headers[self.config_key]
|
|
50
|
+
self.header = deepcopy(header)
|
|
51
|
+
self.retry = retry
|
|
52
|
+
if self.config[self.config_key].get('token', '') == '' or time.time() > self.config[self.config_key].get('date_limit', 0):
|
|
53
|
+
if 'scopes' in self.config[self.config_key].keys() and self.config[self.config_key].get('scopes',None) is not None:
|
|
54
|
+
self.connectionType = 'oauthV2'
|
|
55
|
+
token_and_expiry = self.get_oauth_token_and_expiry_for_config(config=self.config[self.config_key], verbose=verbose)
|
|
56
|
+
else:
|
|
57
|
+
raise ValueError("Invalid configuration: missing 'scopes' for OAuth V2 authentication.")
|
|
58
|
+
token = token_and_expiry['token']
|
|
59
|
+
expiry = token_and_expiry['expiry']
|
|
60
|
+
self.token = deepcopy(token)
|
|
61
|
+
self.config[self.config_key]['token'] = deepcopy(token)
|
|
62
|
+
self.config[self.config_key]['date_limit'] = deepcopy(time.time() + expiry - 500)
|
|
63
|
+
self.header.update({'Authorization': f'Bearer {token}'})
|
|
64
|
+
|
|
65
|
+
def get_oauth_token_and_expiry_for_config(self,config:dict,verbose:bool=False,save:bool=False)->Dict[str,str]:
|
|
66
|
+
"""
|
|
67
|
+
Retrieve the access token by using the OAuth information provided by the user
|
|
68
|
+
during the import importConfigFile function.
|
|
69
|
+
Arguments :
|
|
70
|
+
config : REQUIRED : Configuration object.
|
|
71
|
+
verbose : OPTIONAL : Default False. If set to True, print information.
|
|
72
|
+
save : OPTIONAL : Default False. If set to True, save the token in the 'token.txt' file.
|
|
73
|
+
"""
|
|
74
|
+
if config is None:
|
|
75
|
+
raise ValueError("config dictionary is required")
|
|
76
|
+
oauth_payload = {
|
|
77
|
+
"grant_type": "client_credentials",
|
|
78
|
+
"client_id": config["client_id"],
|
|
79
|
+
"client_secret": config["secret"],
|
|
80
|
+
"scope": config["scopes"]
|
|
81
|
+
}
|
|
82
|
+
response = requests.post(
|
|
83
|
+
config["oauthTokenEndpointV2"], data=oauth_payload)
|
|
84
|
+
json_response = response.json()
|
|
85
|
+
if 'access_token' in json_response.keys():
|
|
86
|
+
token = json_response['access_token']
|
|
87
|
+
expiry = json_response["expires_in"]
|
|
88
|
+
else:
|
|
89
|
+
return json.dumps(json_response,indent=2)
|
|
90
|
+
if save:
|
|
91
|
+
with open('token.txt', 'w') as f:
|
|
92
|
+
f.write(token)
|
|
93
|
+
if verbose:
|
|
94
|
+
print('token valid till : ' + time.ctime(time.time() + expiry))
|
|
95
|
+
return {'token': token, 'expiry': expiry}
|
|
96
|
+
|
|
97
|
+
def _checkingDate(self) -> None:
|
|
98
|
+
"""
|
|
99
|
+
Checking if the token is still valid
|
|
100
|
+
"""
|
|
101
|
+
now = time.time()
|
|
102
|
+
if now > self.config[self.config_key]['date_limit']:
|
|
103
|
+
if self.connectionType =='oauthV2':
|
|
104
|
+
token_and_expiry = self.get_oauth_token_and_expiry_for_config(config=self.config[self.config_key], verbose=False)
|
|
105
|
+
token = token_and_expiry['token']
|
|
106
|
+
self.config[self.config_key]['token'] = deepcopy(token)
|
|
107
|
+
self.config[self.config_key]['date_limit'] = deepcopy(time.time() + token_and_expiry['expiry'] - 500)
|
|
108
|
+
self.header.update({'Authorization': f'Bearer {token}'})
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _retry_delay(response: requests.Response, attempt: int) -> float:
|
|
112
|
+
retry_after = response.headers.get("Retry-After")
|
|
113
|
+
if retry_after is not None:
|
|
114
|
+
try:
|
|
115
|
+
seconds = int(retry_after)
|
|
116
|
+
if seconds >= 0:
|
|
117
|
+
return float(seconds)
|
|
118
|
+
except ValueError:
|
|
119
|
+
try:
|
|
120
|
+
retry_at = parsedate_to_datetime(retry_after)
|
|
121
|
+
return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())
|
|
122
|
+
except (TypeError, ValueError, OverflowError):
|
|
123
|
+
pass
|
|
124
|
+
return min(45 * 2 ** min(attempt, 3), 300)
|
|
125
|
+
|
|
126
|
+
def _request(self, method: str, endpoint: str, params: dict | None = None,
|
|
127
|
+
data: Any = None, headers: dict | None = None, *,
|
|
128
|
+
retry: int | None = None, verbose: bool = False) -> Any:
|
|
129
|
+
retries = self.retry if retry is None else retry
|
|
130
|
+
if isinstance(retries, bool) or not isinstance(retries, int) or retries < 0:
|
|
131
|
+
raise ValueError("retry must be a non-negative integer")
|
|
132
|
+
request_kwargs: dict[str, Any] = {}
|
|
133
|
+
if params is not None:
|
|
134
|
+
request_kwargs["params"] = params
|
|
135
|
+
if data is not None:
|
|
136
|
+
request_kwargs["data"] = data if method == "get" else json.dumps(data)
|
|
137
|
+
attempt = 0
|
|
138
|
+
rate_limit_attempt = 0
|
|
139
|
+
while True:
|
|
140
|
+
self._checkingDate()
|
|
141
|
+
response = getattr(requests, method)(
|
|
142
|
+
endpoint, headers=self.header if headers is None else headers, **request_kwargs)
|
|
143
|
+
if verbose:
|
|
144
|
+
print(f"request URL : {response.request.url}")
|
|
145
|
+
print(f"status_code : {response.status_code}")
|
|
146
|
+
rate_limited = response.status_code == 429
|
|
147
|
+
if not rate_limited:
|
|
148
|
+
if method == "delete":
|
|
149
|
+
return response.status_code
|
|
150
|
+
try:
|
|
151
|
+
result = response.json()
|
|
152
|
+
except ValueError:
|
|
153
|
+
if method == "get" and attempt < retries:
|
|
154
|
+
time.sleep(30)
|
|
155
|
+
attempt += 1
|
|
156
|
+
continue
|
|
157
|
+
logging.getLogger(__name__).warning(
|
|
158
|
+
"%s request returned invalid JSON (HTTP %s)",
|
|
159
|
+
method.upper(), response.status_code)
|
|
160
|
+
return {"error": "Request Error"}
|
|
161
|
+
rate_limited = isinstance(result, dict) and result.get("error_code") == "429050"
|
|
162
|
+
if not rate_limited:
|
|
163
|
+
return result
|
|
164
|
+
delay = self._retry_delay(response, rate_limit_attempt)
|
|
165
|
+
if verbose:
|
|
166
|
+
print(f"Rate limited; retrying in {delay:g} seconds")
|
|
167
|
+
time.sleep(delay)
|
|
168
|
+
rate_limit_attempt += 1
|
|
169
|
+
|
|
170
|
+
def getData(self, endpoint: str, params: dict = None, data: dict = None, headers: dict = None, *args, **kwargs):
|
|
171
|
+
"""Get JSON data, always retrying rate limits independently of retry."""
|
|
172
|
+
return self._request("get", endpoint, params, data, headers,
|
|
173
|
+
retry=kwargs.get("retry"), verbose=kwargs.get("verbose", False))
|
|
174
|
+
|
|
175
|
+
def postData(self, endpoint: str, params: dict = None, data: dict = None, headers: dict = None, *args, **kwargs):
|
|
176
|
+
"""Post JSON data, always retrying rate limits."""
|
|
177
|
+
return self._request("post", endpoint, params, data, headers,
|
|
178
|
+
retry=kwargs.get("retry"), verbose=kwargs.get("verbose", False))
|
|
179
|
+
|
|
180
|
+
def patchData(self, endpoint: str, params: dict = None, data=None, headers: dict = None, *args, **kwargs):
|
|
181
|
+
"""Patch JSON data, always retrying rate limits."""
|
|
182
|
+
return self._request("patch", endpoint, params, data, headers,
|
|
183
|
+
retry=kwargs.get("retry"), verbose=kwargs.get("verbose", False))
|
|
184
|
+
|
|
185
|
+
def putData(self, endpoint: str, params: dict = None, data=None, headers: dict = None, *args, **kwargs):
|
|
186
|
+
"""Put JSON data, always retrying rate limits."""
|
|
187
|
+
return self._request("put", endpoint, params, data, headers,
|
|
188
|
+
retry=kwargs.get("retry"), verbose=kwargs.get("verbose", False))
|
|
189
|
+
|
|
190
|
+
def deleteData(self, endpoint: str, params: dict = None, data=None, headers: dict = None, *args, **kwargs):
|
|
191
|
+
"""Delete data and return the HTTP status, always retrying rate limits."""
|
|
192
|
+
return self._request("delete", endpoint, params, data, headers,
|
|
193
|
+
retry=kwargs.get("retry"), verbose=kwargs.get("verbose", False))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: launchpy
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Adobe Launch API wrapper
|
|
5
5
|
Author-email: Julien Piccini <piccini.julien@gmail.com>
|
|
6
6
|
License: Apache License 2.0
|
|
@@ -34,6 +34,10 @@ Dynamic: license-file
|
|
|
34
34
|
Python wrapper for the Adobe Launch API.\
|
|
35
35
|
This module is named launchpy.
|
|
36
36
|
|
|
37
|
+
Current version: **0.5.2**. This version fixes GET retries and adds automatic
|
|
38
|
+
HTTP 429 retries across all five connector methods. See the
|
|
39
|
+
[0.5.2 release notes](./docs/releases.md#052) for details.
|
|
40
|
+
|
|
37
41
|
## Installation
|
|
38
42
|
|
|
39
43
|
You can install the module by realising the following pip command:\
|
|
@@ -66,3 +70,29 @@ Most of the documentation has been imported from the datanalyst website [here](.
|
|
|
66
70
|
## Release information
|
|
67
71
|
|
|
68
72
|
You can find release information [here](./docs/releases.md).
|
|
73
|
+
|
|
74
|
+
## HTTP retries
|
|
75
|
+
|
|
76
|
+
The `AdobeRequest` connector always retries HTTP 429 responses for GET, POST,
|
|
77
|
+
PUT, PATCH, and DELETE, even when their body is not JSON or `retry=0`.
|
|
78
|
+
Rate-limit retries have no attempt limit and do not consume the configured
|
|
79
|
+
`retry` budget. A request can therefore keep waiting while the API remains
|
|
80
|
+
rate limited; it can be interrupted normally.
|
|
81
|
+
|
|
82
|
+
The connector
|
|
83
|
+
honors `Retry-After` in seconds or HTTP-date format; absent or invalid values
|
|
84
|
+
use exponential backoff starting at 45 seconds and capped at 300 seconds.
|
|
85
|
+
The JSON-returning methods also always retry Adobe error code `429050`.
|
|
86
|
+
|
|
87
|
+
The `retry` setting is reserved for other retryable errors: currently, GET
|
|
88
|
+
responses that cannot be decoded as JSON. It specifies the number of
|
|
89
|
+
**additional attempts**, with a default of `0`. Set
|
|
90
|
+
`property.connector.retry = 3` or pass `retry=3` to a connector method to
|
|
91
|
+
override this budget; neither setting limits rate-limit retries.
|
|
92
|
+
|
|
93
|
+
Successful return values are unchanged: DELETE returns the HTTP status code,
|
|
94
|
+
while the other methods return decoded JSON. GET also retries invalid JSON
|
|
95
|
+
responses with a 30-second delay. Invalid JSON from write operations and
|
|
96
|
+
network exceptions do not trigger retries, to avoid replaying writes that
|
|
97
|
+
may already have succeeded. This policy applies to the five connector methods,
|
|
98
|
+
not the separate OAuth token request or async property-extraction requests.
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import unittest
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from email.utils import format_datetime
|
|
5
|
+
from unittest.mock import Mock, patch
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from launchpy.connector import AdobeRequest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConnectorRetryTests(unittest.TestCase):
|
|
13
|
+
methods = ("get", "post", "put", "patch", "delete")
|
|
14
|
+
endpoint = "https://example.invalid/components"
|
|
15
|
+
|
|
16
|
+
def setUp(self):
|
|
17
|
+
self.connector = AdobeRequest.__new__(AdobeRequest)
|
|
18
|
+
self.connector.header = {"Authorization": "test"}
|
|
19
|
+
self.connector.retry = 2
|
|
20
|
+
self.connector._checkingDate = Mock()
|
|
21
|
+
sleep_patch = patch("launchpy.connector.time.sleep")
|
|
22
|
+
self.sleep = sleep_patch.start()
|
|
23
|
+
self.addCleanup(sleep_patch.stop)
|
|
24
|
+
|
|
25
|
+
def response(self, status=200, payload=None, headers=None, body=None):
|
|
26
|
+
response = requests.Response()
|
|
27
|
+
response.status_code = status
|
|
28
|
+
response.headers.update(headers or {})
|
|
29
|
+
response._content = (
|
|
30
|
+
body.encode() if body is not None else json.dumps(payload).encode()
|
|
31
|
+
)
|
|
32
|
+
response.request = requests.Request("GET", self.endpoint).prepare()
|
|
33
|
+
return response
|
|
34
|
+
|
|
35
|
+
def call(self, method, **kwargs):
|
|
36
|
+
return getattr(self.connector, method + "Data")(self.endpoint, **kwargs)
|
|
37
|
+
|
|
38
|
+
def test_consecutive_429s_retry_all_methods_and_preserve_request(self):
|
|
39
|
+
for method in self.methods:
|
|
40
|
+
with self.subTest(method=method):
|
|
41
|
+
self.sleep.reset_mock()
|
|
42
|
+
self.connector._checkingDate.reset_mock()
|
|
43
|
+
limited = self.response(429, headers={"Retry-After": "2"}, body="not JSON")
|
|
44
|
+
success = self.response(204 if method == "delete" else 200, {"data": []})
|
|
45
|
+
with patch(f"launchpy.connector.requests.{method}",
|
|
46
|
+
side_effect=[limited, limited, success]) as request:
|
|
47
|
+
result = self.call(method, params={"page": 1}, data={"value": 1},
|
|
48
|
+
headers={"X-Test": "custom"})
|
|
49
|
+
self.assertEqual(result, 204 if method == "delete" else {"data": []})
|
|
50
|
+
self.assertEqual(request.call_count, 3)
|
|
51
|
+
self.assertEqual(self.connector._checkingDate.call_count, 3)
|
|
52
|
+
expected_data = {"value": 1} if method == "get" else '{"value": 1}'
|
|
53
|
+
for call in request.call_args_list:
|
|
54
|
+
self.assertEqual(call.args, (self.endpoint,))
|
|
55
|
+
self.assertEqual(call.kwargs, {
|
|
56
|
+
"headers": {"X-Test": "custom"},
|
|
57
|
+
"params": {"page": 1},
|
|
58
|
+
"data": expected_data,
|
|
59
|
+
})
|
|
60
|
+
self.assertEqual([call.args for call in self.sleep.call_args_list],
|
|
61
|
+
[(2.0,), (2.0,)])
|
|
62
|
+
|
|
63
|
+
def test_rate_limits_retry_beyond_budget_with_capped_backoff(self):
|
|
64
|
+
for method in self.methods:
|
|
65
|
+
with self.subTest(method=method):
|
|
66
|
+
self.sleep.reset_mock()
|
|
67
|
+
limited = self.response(429)
|
|
68
|
+
success = self.response(204 if method == "delete" else 200, {"data": []})
|
|
69
|
+
with patch(f"launchpy.connector.requests.{method}",
|
|
70
|
+
side_effect=[limited] * 5 + [success]) as request:
|
|
71
|
+
self.assertEqual(self.call(method),
|
|
72
|
+
204 if method == "delete" else {"data": []})
|
|
73
|
+
self.assertEqual(request.call_count, 6)
|
|
74
|
+
self.assertEqual([call.args for call in self.sleep.call_args_list],
|
|
75
|
+
[(45,), (90,), (180,), (300,), (300,)])
|
|
76
|
+
|
|
77
|
+
def test_rate_limits_ignore_per_call_retry_override(self):
|
|
78
|
+
for method in self.methods:
|
|
79
|
+
for retries in (0, 1, 3):
|
|
80
|
+
with self.subTest(method=method, retry=retries):
|
|
81
|
+
self.sleep.reset_mock()
|
|
82
|
+
success = self.response(204 if method == "delete" else 200, {"data": []})
|
|
83
|
+
with patch(f"launchpy.connector.requests.{method}",
|
|
84
|
+
side_effect=[self.response(429)] * 4 + [success]) as request:
|
|
85
|
+
self.assertEqual(self.call(method, retry=retries),
|
|
86
|
+
204 if method == "delete" else {"data": []})
|
|
87
|
+
self.assertEqual(request.call_count, 5)
|
|
88
|
+
self.assertEqual(self.sleep.call_count, 4)
|
|
89
|
+
self.assertEqual(self.connector.retry, 2)
|
|
90
|
+
|
|
91
|
+
def test_rate_limits_retry_with_zero_budget(self):
|
|
92
|
+
self.connector.retry = 0
|
|
93
|
+
for method in self.methods:
|
|
94
|
+
with self.subTest(method=method):
|
|
95
|
+
self.sleep.reset_mock()
|
|
96
|
+
success = self.response(204 if method == "delete" else 200, {"data": []})
|
|
97
|
+
with patch(f"launchpy.connector.requests.{method}",
|
|
98
|
+
side_effect=[self.response(429)] * 3 + [success]) as request:
|
|
99
|
+
self.assertEqual(self.call(method),
|
|
100
|
+
204 if method == "delete" else {"data": []})
|
|
101
|
+
self.assertEqual(request.call_count, 4)
|
|
102
|
+
self.assertEqual(self.sleep.call_count, 3)
|
|
103
|
+
|
|
104
|
+
def test_adobe_rate_limit_code(self):
|
|
105
|
+
self.connector.retry = 0
|
|
106
|
+
for method in ("get", "post", "put", "patch"):
|
|
107
|
+
with self.subTest(method=method):
|
|
108
|
+
self.sleep.reset_mock()
|
|
109
|
+
with patch(f"launchpy.connector.requests.{method}", side_effect=[
|
|
110
|
+
self.response(400, {"error_code": "429050"}),
|
|
111
|
+
self.response(200, {"data": []}),
|
|
112
|
+
]) as request:
|
|
113
|
+
self.assertEqual(self.call(method), {"data": []})
|
|
114
|
+
self.assertEqual(request.call_count, 2)
|
|
115
|
+
self.sleep.assert_called_once_with(45)
|
|
116
|
+
|
|
117
|
+
def test_retry_after_http_date(self):
|
|
118
|
+
now = datetime(2026, 10, 5, 10, tzinfo=timezone.utc)
|
|
119
|
+
for offset, expected in ((20, 20.0), (-20, 0.0)):
|
|
120
|
+
with self.subTest(offset=offset):
|
|
121
|
+
response = self.response(429, headers={
|
|
122
|
+
"Retry-After": format_datetime(now + timedelta(seconds=offset), usegmt=True),
|
|
123
|
+
})
|
|
124
|
+
with patch("launchpy.connector.datetime") as clock:
|
|
125
|
+
clock.now.return_value = now
|
|
126
|
+
self.assertEqual(self.connector._retry_delay(response, 0), expected)
|
|
127
|
+
|
|
128
|
+
def test_retry_after_zero_invalid_and_missing(self):
|
|
129
|
+
for value, expected in (("0", 0.0), ("invalid", 45), ("-1", 45), (None, 45)):
|
|
130
|
+
with self.subTest(value=value):
|
|
131
|
+
headers = {} if value is None else {"Retry-After": value}
|
|
132
|
+
self.assertEqual(self.connector._retry_delay(self.response(429, headers=headers), 0),
|
|
133
|
+
expected)
|
|
134
|
+
self.assertEqual(self.connector._retry_delay(self.response(429), 100), 300)
|
|
135
|
+
|
|
136
|
+
def test_successful_return_types_and_empty_requests(self):
|
|
137
|
+
for method in self.methods:
|
|
138
|
+
with self.subTest(method=method):
|
|
139
|
+
response = self.response(204, body="") if method == "delete" else self.response(
|
|
140
|
+
200, ["one", "two"])
|
|
141
|
+
with patch(f"launchpy.connector.requests.{method}", return_value=response) as request:
|
|
142
|
+
self.assertEqual(self.call(method), 204 if method == "delete" else ["one", "two"])
|
|
143
|
+
request.assert_called_once_with(self.endpoint, headers=self.connector.header)
|
|
144
|
+
self.sleep.assert_not_called()
|
|
145
|
+
|
|
146
|
+
def test_get_json_decode_failure_retries(self):
|
|
147
|
+
with patch("launchpy.connector.requests.get", side_effect=[
|
|
148
|
+
self.response(body="invalid JSON"), self.response(payload={"data": []}),
|
|
149
|
+
]) as request:
|
|
150
|
+
self.assertEqual(self.call("get"), {"data": []})
|
|
151
|
+
self.assertEqual(request.call_count, 2)
|
|
152
|
+
self.sleep.assert_called_once_with(30)
|
|
153
|
+
|
|
154
|
+
def test_rate_limits_neither_consume_nor_reset_other_error_budget(self):
|
|
155
|
+
with patch("launchpy.connector.requests.get", side_effect=[
|
|
156
|
+
self.response(body="invalid JSON"),
|
|
157
|
+
self.response(429),
|
|
158
|
+
self.response(429),
|
|
159
|
+
self.response(body="invalid JSON"),
|
|
160
|
+
self.response(429),
|
|
161
|
+
self.response(body="invalid JSON"),
|
|
162
|
+
]) as request:
|
|
163
|
+
with self.assertLogs("launchpy.connector", level="WARNING"):
|
|
164
|
+
self.assertEqual(self.call("get"), {"error": "Request Error"})
|
|
165
|
+
self.assertEqual(request.call_count, 6)
|
|
166
|
+
self.assertEqual([call.args for call in self.sleep.call_args_list],
|
|
167
|
+
[(30,), (45,), (90,), (30,), (180,)])
|
|
168
|
+
|
|
169
|
+
def test_per_call_budget_still_limits_other_errors(self):
|
|
170
|
+
for retries in (0, 1, 3):
|
|
171
|
+
with self.subTest(retry=retries):
|
|
172
|
+
self.sleep.reset_mock()
|
|
173
|
+
with patch("launchpy.connector.requests.get",
|
|
174
|
+
return_value=self.response(body="invalid JSON")) as request:
|
|
175
|
+
with self.assertLogs("launchpy.connector", level="WARNING"):
|
|
176
|
+
self.assertEqual(self.call("get", retry=retries),
|
|
177
|
+
{"error": "Request Error"})
|
|
178
|
+
self.assertEqual(request.call_count, retries + 1)
|
|
179
|
+
self.assertEqual(self.sleep.call_count, retries)
|
|
180
|
+
self.assertEqual(self.connector.retry, 2)
|
|
181
|
+
|
|
182
|
+
def test_rate_limit_wait_can_be_interrupted(self):
|
|
183
|
+
self.sleep.side_effect = KeyboardInterrupt
|
|
184
|
+
with patch("launchpy.connector.requests.get",
|
|
185
|
+
return_value=self.response(429)) as request:
|
|
186
|
+
with self.assertRaises(KeyboardInterrupt):
|
|
187
|
+
self.call("get")
|
|
188
|
+
request.assert_called_once()
|
|
189
|
+
|
|
190
|
+
def test_retries_use_refreshed_default_headers(self):
|
|
191
|
+
def refresh_headers():
|
|
192
|
+
self.connector.header = {"Authorization": str(
|
|
193
|
+
self.connector._checkingDate.call_count)}
|
|
194
|
+
|
|
195
|
+
self.connector._checkingDate.side_effect = refresh_headers
|
|
196
|
+
with patch("launchpy.connector.requests.post", side_effect=[
|
|
197
|
+
self.response(429), self.response(payload={"data": []}),
|
|
198
|
+
]) as request:
|
|
199
|
+
self.assertEqual(self.call("post"), {"data": []})
|
|
200
|
+
self.assertEqual(request.call_args_list[0].kwargs["headers"],
|
|
201
|
+
{"Authorization": "1"})
|
|
202
|
+
self.assertEqual(request.call_args_list[1].kwargs["headers"],
|
|
203
|
+
{"Authorization": "2"})
|
|
204
|
+
|
|
205
|
+
def test_invalid_json_write_is_not_replayed(self):
|
|
206
|
+
for method in ("post", "put", "patch"):
|
|
207
|
+
with self.subTest(method=method):
|
|
208
|
+
with patch(f"launchpy.connector.requests.{method}",
|
|
209
|
+
return_value=self.response(body="invalid JSON")) as request:
|
|
210
|
+
with self.assertLogs("launchpy.connector", level="WARNING"):
|
|
211
|
+
self.assertEqual(self.call(method), {"error": "Request Error"})
|
|
212
|
+
request.assert_called_once()
|
|
213
|
+
self.sleep.assert_not_called()
|
|
214
|
+
|
|
215
|
+
def test_get_json_decode_exhaustion_is_logged(self):
|
|
216
|
+
with patch("launchpy.connector.requests.get",
|
|
217
|
+
return_value=self.response(body="invalid JSON")) as request:
|
|
218
|
+
with self.assertLogs("launchpy.connector", level="WARNING"):
|
|
219
|
+
self.assertEqual(self.call("get"), {"error": "Request Error"})
|
|
220
|
+
self.assertEqual(request.call_count, 3)
|
|
221
|
+
self.assertEqual(self.sleep.call_count, 2)
|
|
222
|
+
|
|
223
|
+
def test_other_http_errors_are_not_retried(self):
|
|
224
|
+
for method in self.methods:
|
|
225
|
+
with self.subTest(method=method):
|
|
226
|
+
payload = {"errors": ["bad request"]}
|
|
227
|
+
with patch(f"launchpy.connector.requests.{method}",
|
|
228
|
+
return_value=self.response(400, payload)) as request:
|
|
229
|
+
self.assertEqual(self.call(method), 400 if method == "delete" else payload)
|
|
230
|
+
request.assert_called_once()
|
|
231
|
+
self.sleep.assert_not_called()
|
|
232
|
+
|
|
233
|
+
def test_network_errors_propagate_without_replaying_writes(self):
|
|
234
|
+
for method in self.methods:
|
|
235
|
+
with self.subTest(method=method):
|
|
236
|
+
with patch(f"launchpy.connector.requests.{method}",
|
|
237
|
+
side_effect=requests.ConnectionError("connection failed")) as request:
|
|
238
|
+
with self.assertRaises(requests.ConnectionError):
|
|
239
|
+
self.call(method)
|
|
240
|
+
request.assert_called_once()
|
|
241
|
+
self.sleep.assert_not_called()
|
|
242
|
+
|
|
243
|
+
def test_invalid_retry_budget_is_rejected(self):
|
|
244
|
+
for value in (-1, 1.5, "2", True):
|
|
245
|
+
with self.subTest(value=value):
|
|
246
|
+
with patch("launchpy.connector.requests.get") as request:
|
|
247
|
+
with self.assertRaises(ValueError):
|
|
248
|
+
self.call("get", retry=value)
|
|
249
|
+
request.assert_not_called()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
unittest.main()
|
launchpy-0.5.1/README.md
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
# Launchpy
|
|
2
|
-
|
|
3
|
-
Python wrapper for the Adobe Launch API.\
|
|
4
|
-
This module is named launchpy.
|
|
5
|
-
|
|
6
|
-
## Installation
|
|
7
|
-
|
|
8
|
-
You can install the module by realising the following pip command:\
|
|
9
|
-
`pip install launchpy`
|
|
10
|
-
|
|
11
|
-
You can access the version of the module that you run via the following attribute:\
|
|
12
|
-
`launchpy.__version__`
|
|
13
|
-
|
|
14
|
-
## Content of the module
|
|
15
|
-
|
|
16
|
-
This module is built around 7 main parts:
|
|
17
|
-
|
|
18
|
-
- Helping functions [Core Components](./docs/main.md)
|
|
19
|
-
- Admin instantiating functions [Admin Class](./docs/admin.md)
|
|
20
|
-
- Managing properties [Property Class](./docs/property.md)
|
|
21
|
-
- Managing Publishing Cycle [Library Class](./docs/library.md)
|
|
22
|
-
- Translator functionationality [Translator Class](./docs/translator.md)
|
|
23
|
-
- Synchronizer [Synchronizer Class](./docs/synchronizer.md)
|
|
24
|
-
- Command Line Interface [CLI](./docs/cli.md)
|
|
25
|
-
|
|
26
|
-
## Get Started
|
|
27
|
-
|
|
28
|
-
A [get started guide](./docs/getstarted.md) has been created to explain the different functionality.
|
|
29
|
-
You can find a more detail description functionalities at [datanalyst.info](https://datanalyst.info).
|
|
30
|
-
|
|
31
|
-
## Main documentation
|
|
32
|
-
|
|
33
|
-
Most of the documentation has been imported from the datanalyst website [here](./docs/main.md).
|
|
34
|
-
|
|
35
|
-
## Release information
|
|
36
|
-
|
|
37
|
-
You can find release information [here](./docs/releases.md).
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.5.1"
|
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import json
|
|
3
|
-
import time
|
|
4
|
-
from typing import Dict, Union
|
|
5
|
-
from copy import deepcopy
|
|
6
|
-
# Non standard libraries
|
|
7
|
-
import requests
|
|
8
|
-
from launchpy import config, configs
|
|
9
|
-
|
|
10
|
-
class AdobeRequest:
|
|
11
|
-
"""
|
|
12
|
-
Handle request to Audience Manager and taking care that the request have a valid token set each time.
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
def __init__(self,
|
|
16
|
-
config_objects: dict = config.config_objects,
|
|
17
|
-
headers: dict = config.headers,
|
|
18
|
-
org_name: str = None,
|
|
19
|
-
verbose: bool = False,
|
|
20
|
-
retry: int = 0
|
|
21
|
-
) -> None:
|
|
22
|
-
"""
|
|
23
|
-
Set the connector to be used for handling request to AAM
|
|
24
|
-
Arguments:
|
|
25
|
-
config_objects : OPTIONAL : Require the importConfig file to have been used.
|
|
26
|
-
headers : OPTIONAL : header of the config modules
|
|
27
|
-
verbose : OPTIONAL : display comment on the request.
|
|
28
|
-
retry : OPTIONAL : If you wish to retry failed GET requests
|
|
29
|
-
"""
|
|
30
|
-
if org_name is None:
|
|
31
|
-
if len(config_objects) >= 1:
|
|
32
|
-
self.config_key = list(config_objects.keys())[0]
|
|
33
|
-
config_object = config_objects[self.config_key]
|
|
34
|
-
else:
|
|
35
|
-
if org_name in config_objects.keys():
|
|
36
|
-
self.config_key = org_name
|
|
37
|
-
config_object = config_objects[self.config_key]
|
|
38
|
-
else:
|
|
39
|
-
raise ValueError(f"Configuration for org_name '{org_name}' not found.")
|
|
40
|
-
if config_object['org_id'] == '':
|
|
41
|
-
raise Exception(
|
|
42
|
-
'You have to upload the configuration file with importConfigFile method.')
|
|
43
|
-
self.config = {self.config_key: deepcopy(config_object)}
|
|
44
|
-
header = headers[self.config_key]
|
|
45
|
-
self.header = deepcopy(header)
|
|
46
|
-
self.retry = retry
|
|
47
|
-
if self.config[self.config_key].get('token', '') == '' or time.time() > self.config[self.config_key].get('date_limit', 0):
|
|
48
|
-
if 'scopes' in self.config[self.config_key].keys() and self.config[self.config_key].get('scopes',None) is not None:
|
|
49
|
-
self.connectionType = 'oauthV2'
|
|
50
|
-
token_and_expiry = self.get_oauth_token_and_expiry_for_config(config=self.config[self.config_key], verbose=verbose)
|
|
51
|
-
else:
|
|
52
|
-
raise ValueError("Invalid configuration: missing 'scopes' for OAuth V2 authentication.")
|
|
53
|
-
token = token_and_expiry['token']
|
|
54
|
-
expiry = token_and_expiry['expiry']
|
|
55
|
-
self.token = deepcopy(token)
|
|
56
|
-
self.config[self.config_key]['token'] = deepcopy(token)
|
|
57
|
-
self.config[self.config_key]['date_limit'] = deepcopy(time.time() + expiry - 500)
|
|
58
|
-
self.header.update({'Authorization': f'Bearer {token}'})
|
|
59
|
-
|
|
60
|
-
def get_oauth_token_and_expiry_for_config(self,config:dict,verbose:bool=False,save:bool=False)->Dict[str,str]:
|
|
61
|
-
"""
|
|
62
|
-
Retrieve the access token by using the OAuth information provided by the user
|
|
63
|
-
during the import importConfigFile function.
|
|
64
|
-
Arguments :
|
|
65
|
-
config : REQUIRED : Configuration object.
|
|
66
|
-
verbose : OPTIONAL : Default False. If set to True, print information.
|
|
67
|
-
save : OPTIONAL : Default False. If set to True, save the token in the 'token.txt' file.
|
|
68
|
-
"""
|
|
69
|
-
if config is None:
|
|
70
|
-
raise ValueError("config dictionary is required")
|
|
71
|
-
oauth_payload = {
|
|
72
|
-
"grant_type": "client_credentials",
|
|
73
|
-
"client_id": config["client_id"],
|
|
74
|
-
"client_secret": config["secret"],
|
|
75
|
-
"scope": config["scopes"]
|
|
76
|
-
}
|
|
77
|
-
response = requests.post(
|
|
78
|
-
config["oauthTokenEndpointV2"], data=oauth_payload)
|
|
79
|
-
json_response = response.json()
|
|
80
|
-
if 'access_token' in json_response.keys():
|
|
81
|
-
token = json_response['access_token']
|
|
82
|
-
expiry = json_response["expires_in"]
|
|
83
|
-
else:
|
|
84
|
-
return json.dumps(json_response,indent=2)
|
|
85
|
-
if save:
|
|
86
|
-
with open('token.txt', 'w') as f:
|
|
87
|
-
f.write(token)
|
|
88
|
-
if verbose:
|
|
89
|
-
print('token valid till : ' + time.ctime(time.time() + expiry))
|
|
90
|
-
return {'token': token, 'expiry': expiry}
|
|
91
|
-
|
|
92
|
-
def _checkingDate(self) -> None:
|
|
93
|
-
"""
|
|
94
|
-
Checking if the token is still valid
|
|
95
|
-
"""
|
|
96
|
-
now = time.time()
|
|
97
|
-
if now > self.config[self.config_key]['date_limit']:
|
|
98
|
-
if self.connectionType =='oauthV2':
|
|
99
|
-
token_and_expiry = self.get_oauth_token_and_expiry_for_config(config=self.config[self.config_key], verbose=False)
|
|
100
|
-
token = token_and_expiry['token']
|
|
101
|
-
self.config[self.config_key]['token'] = deepcopy(token)
|
|
102
|
-
self.config[self.config_key]['date_limit'] = deepcopy(time.time() + token_and_expiry['expiry'] - 500)
|
|
103
|
-
self.header.update({'Authorization': f'Bearer {token}'})
|
|
104
|
-
|
|
105
|
-
def getData(self, endpoint: str, params: dict = None, data: dict = None, headers: dict = None, *args, **kwargs):
|
|
106
|
-
"""
|
|
107
|
-
Abstraction for getting data
|
|
108
|
-
"""
|
|
109
|
-
internRetry = self.retry - kwargs.get("retry", 0)
|
|
110
|
-
self._checkingDate()
|
|
111
|
-
if headers is None:
|
|
112
|
-
headers = self.header
|
|
113
|
-
if params is None and data is None:
|
|
114
|
-
res = requests.get(
|
|
115
|
-
endpoint, headers=headers)
|
|
116
|
-
elif params is not None and data is None:
|
|
117
|
-
res = requests.get(
|
|
118
|
-
endpoint, headers=headers, params=params)
|
|
119
|
-
elif params is None and data is not None:
|
|
120
|
-
res = requests.get(
|
|
121
|
-
endpoint, headers=headers, data=data)
|
|
122
|
-
elif params is not None and data is not None:
|
|
123
|
-
res = requests.get(endpoint, headers=headers, params=params, data=data)
|
|
124
|
-
if kwargs.get("verbose", False):
|
|
125
|
-
print(f"request URL : {res.request.url}")
|
|
126
|
-
print(f"statut_code : {res.status_code}")
|
|
127
|
-
try:
|
|
128
|
-
if res.status_code == 429:
|
|
129
|
-
if kwargs.get("verbose", False):
|
|
130
|
-
print(f'Too many requests')
|
|
131
|
-
time.sleep(45)
|
|
132
|
-
res_json = self.getData(endpoint, params=params, data=data, headers=headers, retry=internRetry, **kwargs)
|
|
133
|
-
return res_json
|
|
134
|
-
res_json = res.json()
|
|
135
|
-
except:
|
|
136
|
-
res_json = {'error': 'Request Error'}
|
|
137
|
-
while internRetry > 0:
|
|
138
|
-
internRetry -= 1
|
|
139
|
-
if kwargs.get("verbose", False):
|
|
140
|
-
print('Retry parameter activated')
|
|
141
|
-
print(f'{internRetry} retry left')
|
|
142
|
-
if 'error' in res_json.keys():
|
|
143
|
-
time.sleep(30)
|
|
144
|
-
res_json = self.getData(endpoint, params=params, data=data, headers=headers, retry=internRetry, **kwargs)
|
|
145
|
-
return res_json
|
|
146
|
-
return res_json
|
|
147
|
-
|
|
148
|
-
def postData(self, endpoint: str, params: dict = None, data: dict = None, headers: dict = None, *args, **kwargs):
|
|
149
|
-
"""
|
|
150
|
-
Abstraction for posting data
|
|
151
|
-
"""
|
|
152
|
-
self._checkingDate()
|
|
153
|
-
if headers is None:
|
|
154
|
-
headers = self.header
|
|
155
|
-
if params is None and data is None:
|
|
156
|
-
res = requests.post(endpoint, headers=headers)
|
|
157
|
-
elif params is not None and data is None:
|
|
158
|
-
res = requests.post(endpoint, headers=headers, params=params)
|
|
159
|
-
elif params is None and data is not None:
|
|
160
|
-
res = requests.post(endpoint, headers=headers, data=json.dumps(data))
|
|
161
|
-
elif params is not None and data is not None:
|
|
162
|
-
res = requests.post(endpoint, headers=headers, params=params, data=json.dumps(data))
|
|
163
|
-
try:
|
|
164
|
-
res_json = res.json()
|
|
165
|
-
if res.status_code == 429 or res_json.get('error_code', None) == "429050":
|
|
166
|
-
time.sleep(45)
|
|
167
|
-
res_json = self.postData(endpoint, params=params, data=data, headers=headers, **kwargs)
|
|
168
|
-
return res_json
|
|
169
|
-
except:
|
|
170
|
-
if kwargs.get("verbose", False):
|
|
171
|
-
print("status_code: {res.status_code}")
|
|
172
|
-
print(res.text)
|
|
173
|
-
res_json = {'error': 'Request Error'}
|
|
174
|
-
return res_json
|
|
175
|
-
|
|
176
|
-
def patchData(self, endpoint: str, params: dict = None, data=None, headers: dict = None, *args, **kwargs):
|
|
177
|
-
"""
|
|
178
|
-
Abstraction for deleting data
|
|
179
|
-
"""
|
|
180
|
-
self._checkingDate()
|
|
181
|
-
if headers is None:
|
|
182
|
-
headers = self.header
|
|
183
|
-
if params is not None and data is None:
|
|
184
|
-
res = requests.patch(endpoint, headers=headers, params=params)
|
|
185
|
-
elif params is None and data is not None:
|
|
186
|
-
res = requests.patch(endpoint, headers=headers, data=json.dumps(data))
|
|
187
|
-
elif params is not None and data is not None:
|
|
188
|
-
res = requests.patch(endpoint, headers=headers, params=params, data=json.dumps(data))
|
|
189
|
-
try:
|
|
190
|
-
status_code = res.json()
|
|
191
|
-
except:
|
|
192
|
-
if kwargs.get("verbose", False):
|
|
193
|
-
print(res.text)
|
|
194
|
-
status_code = {'error': 'Request Error'}
|
|
195
|
-
return status_code
|
|
196
|
-
|
|
197
|
-
def putData(self, endpoint: str, params: dict = None, data=None, headers: dict = None, *args, **kwargs):
|
|
198
|
-
"""
|
|
199
|
-
Abstraction for deleting data
|
|
200
|
-
"""
|
|
201
|
-
self._checkingDate()
|
|
202
|
-
if headers is None:
|
|
203
|
-
headers = self.header
|
|
204
|
-
if params is not None and data is None:
|
|
205
|
-
res = requests.put(endpoint, headers=headers, params=params)
|
|
206
|
-
elif params is None and data is not None:
|
|
207
|
-
res = requests.put(endpoint, headers=headers, data=json.dumps(data))
|
|
208
|
-
elif params is not None and data is not None:
|
|
209
|
-
res = requests.put(endpoint, headers=headers, params=params, data=json.dumps(data))
|
|
210
|
-
try:
|
|
211
|
-
status_code = res.json()
|
|
212
|
-
except:
|
|
213
|
-
if kwargs.get("verbose", False):
|
|
214
|
-
print(res.text)
|
|
215
|
-
status_code = {'error': 'Request Error'}
|
|
216
|
-
return status_code
|
|
217
|
-
|
|
218
|
-
def deleteData(self, endpoint: str, params: dict = None, data=None, headers: dict = None, *args, **kwargs):
|
|
219
|
-
"""
|
|
220
|
-
Abstraction for deleting data
|
|
221
|
-
"""
|
|
222
|
-
self._checkingDate()
|
|
223
|
-
if headers is None:
|
|
224
|
-
headers = self.header
|
|
225
|
-
if params is None:
|
|
226
|
-
res = requests.delete(endpoint, headers=headers)
|
|
227
|
-
elif params is not None:
|
|
228
|
-
res = requests.delete(endpoint, headers=headers, params=params)
|
|
229
|
-
elif params is None and data is not None:
|
|
230
|
-
res = requests.delete(endpoint, headers=headers, data=json.dumps(data))
|
|
231
|
-
elif params is not None and data is not None:
|
|
232
|
-
res = requests.delete(endpoint, headers=headers, params=params, data=json.dumps(data))
|
|
233
|
-
try:
|
|
234
|
-
status_code = res.status_code
|
|
235
|
-
except:
|
|
236
|
-
status_code = {'error': 'Request Error'}
|
|
237
|
-
return status_code
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|