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.
Files changed (37) hide show
  1. {launchpy-0.5.1/launchpy.egg-info → launchpy-0.5.2}/PKG-INFO +31 -1
  2. launchpy-0.5.2/README.md +67 -0
  3. {launchpy-0.5.1 → launchpy-0.5.2}/docs/releases.md +15 -0
  4. launchpy-0.5.2/launchpy/__version__.py +1 -0
  5. launchpy-0.5.2/launchpy/connector.py +193 -0
  6. {launchpy-0.5.1 → launchpy-0.5.2/launchpy.egg-info}/PKG-INFO +31 -1
  7. {launchpy-0.5.1 → launchpy-0.5.2}/launchpy.egg-info/SOURCES.txt +2 -1
  8. launchpy-0.5.2/tests/test_connector.py +253 -0
  9. launchpy-0.5.1/README.md +0 -37
  10. launchpy-0.5.1/launchpy/__version__.py +0 -1
  11. launchpy-0.5.1/launchpy/connector.py +0 -237
  12. {launchpy-0.5.1 → launchpy-0.5.2}/LICENSE +0 -0
  13. {launchpy-0.5.1 → launchpy-0.5.2}/docs/admin.md +0 -0
  14. {launchpy-0.5.1 → launchpy-0.5.2}/docs/cli.md +0 -0
  15. {launchpy-0.5.1 → launchpy-0.5.2}/docs/getstarted.md +0 -0
  16. {launchpy-0.5.1 → launchpy-0.5.2}/docs/library.md +0 -0
  17. {launchpy-0.5.1 → launchpy-0.5.2}/docs/main.md +0 -0
  18. {launchpy-0.5.1 → launchpy-0.5.2}/docs/property.md +0 -0
  19. {launchpy-0.5.1 → launchpy-0.5.2}/docs/synchronizer.md +0 -0
  20. {launchpy-0.5.1 → launchpy-0.5.2}/docs/translator.md +0 -0
  21. {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/__init__.py +0 -0
  22. {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/admin.py +0 -0
  23. {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/cli/__init__.py +0 -0
  24. {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/cli/__main__.py +0 -0
  25. {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/config.py +0 -0
  26. {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/configs.py +0 -0
  27. {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/launchpy.py +0 -0
  28. {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/library.py +0 -0
  29. {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/property.py +0 -0
  30. {launchpy-0.5.1 → launchpy-0.5.2}/launchpy/synchronizer.py +0 -0
  31. {launchpy-0.5.1 → launchpy-0.5.2}/launchpy.egg-info/dependency_links.txt +0 -0
  32. {launchpy-0.5.1 → launchpy-0.5.2}/launchpy.egg-info/entry_points.txt +0 -0
  33. {launchpy-0.5.1 → launchpy-0.5.2}/launchpy.egg-info/requires.txt +0 -0
  34. {launchpy-0.5.1 → launchpy-0.5.2}/launchpy.egg-info/top_level.txt +0 -0
  35. {launchpy-0.5.1 → launchpy-0.5.2}/pyproject.toml +0 -0
  36. {launchpy-0.5.1 → launchpy-0.5.2}/requirements.txt +0 -0
  37. {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.1
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,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.1
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.
@@ -28,4 +28,5 @@ launchpy.egg-info/entry_points.txt
28
28
  launchpy.egg-info/requires.txt
29
29
  launchpy.egg-info/top_level.txt
30
30
  launchpy/cli/__init__.py
31
- launchpy/cli/__main__.py
31
+ launchpy/cli/__main__.py
32
+ tests/test_connector.py
@@ -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