canonicalwebteam.store-api 6.6.0__tar.gz → 6.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of canonicalwebteam.store-api might be problematic. Click here for more details.
- {canonicalwebteam_store_api-6.6.0 → canonicalwebteam_store_api-6.8.0}/PKG-INFO +1 -1
- {canonicalwebteam_store_api-6.6.0 → canonicalwebteam_store_api-6.8.0}/canonicalwebteam/exceptions.py +40 -0
- canonicalwebteam_store_api-6.8.0/canonicalwebteam/retry_utils.py +170 -0
- {canonicalwebteam_store_api-6.6.0 → canonicalwebteam_store_api-6.8.0}/canonicalwebteam/store_api/base.py +24 -4
- {canonicalwebteam_store_api-6.6.0 → canonicalwebteam_store_api-6.8.0}/canonicalwebteam/store_api/publishergw.py +23 -3
- {canonicalwebteam_store_api-6.6.0 → canonicalwebteam_store_api-6.8.0}/pyproject.toml +1 -1
- {canonicalwebteam_store_api-6.6.0 → canonicalwebteam_store_api-6.8.0}/LICENSE +0 -0
- {canonicalwebteam_store_api-6.6.0 → canonicalwebteam_store_api-6.8.0}/README.md +0 -0
- {canonicalwebteam_store_api-6.6.0 → canonicalwebteam_store_api-6.8.0}/canonicalwebteam/__init__.py +0 -0
- {canonicalwebteam_store_api-6.6.0 → canonicalwebteam_store_api-6.8.0}/canonicalwebteam/store_api/__init__.py +0 -0
- {canonicalwebteam_store_api-6.6.0 → canonicalwebteam_store_api-6.8.0}/canonicalwebteam/store_api/dashboard.py +0 -0
- {canonicalwebteam_store_api-6.6.0 → canonicalwebteam_store_api-6.8.0}/canonicalwebteam/store_api/devicegw.py +0 -0
{canonicalwebteam_store_api-6.6.0 → canonicalwebteam_store_api-6.8.0}/canonicalwebteam/exceptions.py
RENAMED
|
@@ -14,6 +14,46 @@ class StoreApiConnectionError(StoreApiError):
|
|
|
14
14
|
pass
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
class StoreApiInternalError(StoreApiConnectionError):
|
|
18
|
+
"""
|
|
19
|
+
Store API internal error
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StoreApiNotImplementedError(StoreApiConnectionError):
|
|
26
|
+
"""
|
|
27
|
+
Store API doesn't implement this method
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class StoreApiBadGatewayError(StoreApiConnectionError):
|
|
34
|
+
"""
|
|
35
|
+
Got and invalid response from Store API
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class StoreApiServiceUnavailableError(StoreApiConnectionError):
|
|
42
|
+
"""
|
|
43
|
+
Store API is not available
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class StoreApiGatewayTimeoutError(StoreApiConnectionError):
|
|
50
|
+
"""
|
|
51
|
+
Request to Store API timed out
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
17
57
|
class StoreApiResourceNotFound(StoreApiError):
|
|
18
58
|
"""
|
|
19
59
|
The requested resource is not found
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from typing import Callable, Tuple, Type, TypeVar
|
|
2
|
+
from random import random
|
|
3
|
+
from sys import maxsize as MAX_INT
|
|
4
|
+
import functools
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
P = TypeVar("P")
|
|
8
|
+
R = TypeVar("R")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def retry(
|
|
12
|
+
func: Callable[P, R] = None,
|
|
13
|
+
*,
|
|
14
|
+
limit: int = MAX_INT,
|
|
15
|
+
delay_fn: Callable[[int], float] = (lambda x: 0.0),
|
|
16
|
+
sleep_fn: Callable[[float], None] = (lambda x: None),
|
|
17
|
+
callback_fn: Callable[[Exception], bool] = (lambda x: False),
|
|
18
|
+
logger_fn: Callable[[str], None] = (lambda x: None),
|
|
19
|
+
exceptions: Tuple[Type[Exception]] = (Exception),
|
|
20
|
+
) -> Callable[P, R]:
|
|
21
|
+
"""
|
|
22
|
+
Decorator that implements retry logic for `func` when any of the
|
|
23
|
+
Exceptions in `exceptions` happen.
|
|
24
|
+
|
|
25
|
+
Arguments:
|
|
26
|
+
func: function what will be retried
|
|
27
|
+
|
|
28
|
+
Keyword arguments:
|
|
29
|
+
limit: max number of retry attempts
|
|
30
|
+
exceptions: tuple containing the types of exceptions we can catch and
|
|
31
|
+
that trigger a retry
|
|
32
|
+
callback_fn: function that takes as argument an exception caught a during
|
|
33
|
+
the retry loop, it should return a bool indicating whether to abort
|
|
34
|
+
the loop or not; it will be called every time a member of `exceptions`
|
|
35
|
+
is caught
|
|
36
|
+
logger_fn: function that logs errors caught and not propagated during
|
|
37
|
+
the retry loop; it will be called every time a member of `exceptions`
|
|
38
|
+
is caught
|
|
39
|
+
delay_fn: function that takes the current attempt as an argument and
|
|
40
|
+
returns a float indicating the delay in seconds before calling `func`
|
|
41
|
+
again
|
|
42
|
+
sleep_fn: function that takes a float indicating a delay in seconds and
|
|
43
|
+
waits for this specified time before executing `func` again; it's
|
|
44
|
+
user's responsibility to make sure the sleep function is appropriate
|
|
45
|
+
for their usage (e.g. use an async sleep in and async environment)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
if func is None:
|
|
49
|
+
# if the decorator is applied using parameters (e.g. @retry(limit=3)),
|
|
50
|
+
# `func` will be None, so we must first do a partial application on
|
|
51
|
+
# the decorator itself to actually wrap `func` correctly
|
|
52
|
+
return functools.partial(
|
|
53
|
+
retry,
|
|
54
|
+
limit=limit,
|
|
55
|
+
delay_fn=delay_fn,
|
|
56
|
+
sleep_fn=sleep_fn,
|
|
57
|
+
callback_fn=callback_fn,
|
|
58
|
+
logger_fn=logger_fn,
|
|
59
|
+
exceptions=exceptions,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if limit <= 0:
|
|
63
|
+
raise ValueError("The limit must be at least 1")
|
|
64
|
+
|
|
65
|
+
@functools.wraps(func)
|
|
66
|
+
def _retry(*args, **kwargs):
|
|
67
|
+
retry_attempts = 0
|
|
68
|
+
last_exception = None
|
|
69
|
+
|
|
70
|
+
while retry_attempts < limit:
|
|
71
|
+
if retry_attempts > 0:
|
|
72
|
+
# only sleep if we've already tried once
|
|
73
|
+
sleep_fn(delay_fn(retry_attempts))
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
return func(*args, **kwargs)
|
|
77
|
+
except exceptions as e:
|
|
78
|
+
last_exception = e
|
|
79
|
+
retry_attempts += 1
|
|
80
|
+
|
|
81
|
+
if callback_fn(e):
|
|
82
|
+
# stop early if callback says so, raise `e` immediately
|
|
83
|
+
raise e
|
|
84
|
+
|
|
85
|
+
logger_fn(
|
|
86
|
+
f"@retry ({retry_attempts}/{limit}) `{func.__name__}`: {e}"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# if we made it here, it means we ran the loop and couldn't get a
|
|
90
|
+
# clean run, raise the last exception we caught and let the user
|
|
91
|
+
# deal with it
|
|
92
|
+
raise last_exception
|
|
93
|
+
|
|
94
|
+
return _retry
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def delay_constant(delay: float):
|
|
98
|
+
"""
|
|
99
|
+
Returns a function that always returns `delay`
|
|
100
|
+
"""
|
|
101
|
+
if delay < 0:
|
|
102
|
+
raise ValueError("The delay must be at least 0")
|
|
103
|
+
|
|
104
|
+
def _delay_constant(_: int):
|
|
105
|
+
return delay
|
|
106
|
+
|
|
107
|
+
return _delay_constant
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def delay_random(min: float, max: float):
|
|
111
|
+
"""
|
|
112
|
+
Returns a function that picks a random delay between `min` and `max`
|
|
113
|
+
"""
|
|
114
|
+
if min < 0:
|
|
115
|
+
raise ValueError("The minimum delay must be at least 0")
|
|
116
|
+
if max <= min:
|
|
117
|
+
raise ValueError("The maximum delay must be greater than the minimum")
|
|
118
|
+
|
|
119
|
+
def _delay_random(_: int):
|
|
120
|
+
return min + random() * (max - min)
|
|
121
|
+
|
|
122
|
+
return _delay_random
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def delay_exponential(
|
|
126
|
+
delay_mult: float, exp_base: float, max_delay: float = float("inf")
|
|
127
|
+
):
|
|
128
|
+
"""
|
|
129
|
+
Returns a function that implements an exponential backoff with an upper
|
|
130
|
+
limit based on the number of attempts made `n`, according to the following
|
|
131
|
+
formula:
|
|
132
|
+
min(`max_delay`, `delay_mult` * `exp_base`^`n`)
|
|
133
|
+
"""
|
|
134
|
+
if delay_mult <= 0:
|
|
135
|
+
raise ValueError("The delay multiplier must be greater than 0")
|
|
136
|
+
if exp_base <= 1:
|
|
137
|
+
raise ValueError("The exponential base must be greater than 1")
|
|
138
|
+
if max_delay <= 0:
|
|
139
|
+
raise ValueError("The maximum delay must be greater than 0")
|
|
140
|
+
|
|
141
|
+
def _delay_exponential(attempt: int):
|
|
142
|
+
return min(max_delay, delay_mult * (exp_base**attempt))
|
|
143
|
+
|
|
144
|
+
return _delay_exponential
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
"""
|
|
148
|
+
Example usage:
|
|
149
|
+
|
|
150
|
+
@retry(
|
|
151
|
+
limit=5,
|
|
152
|
+
logger_fn=print,
|
|
153
|
+
exceptions=(TypeError),
|
|
154
|
+
sleep_fn=sleep,
|
|
155
|
+
delay_fn=delay_exponential(1, 2)
|
|
156
|
+
)
|
|
157
|
+
def test_fn(val: int):
|
|
158
|
+
return None + val # this will raise TypeError
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
test_fn(2)
|
|
162
|
+
except TypeError as e:
|
|
163
|
+
print(e)
|
|
164
|
+
|
|
165
|
+
This will attempt to run `test_fn` 5 times, will catch TypeError 4 times and
|
|
166
|
+
then it will let the exception propagate after executing the function for the
|
|
167
|
+
last time. Each time, it will take an exponentially longer amount of time to
|
|
168
|
+
run, making the total execution time around 30s (plus some small random delay
|
|
169
|
+
caused by the OS).
|
|
170
|
+
"""
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
from canonicalwebteam.exceptions import (
|
|
2
|
+
PublisherAgreementNotSigned,
|
|
3
|
+
PublisherMacaroonRefreshRequired,
|
|
4
|
+
PublisherMissingUsername,
|
|
5
|
+
StoreApiBadGatewayError,
|
|
2
6
|
StoreApiConnectionError,
|
|
7
|
+
StoreApiGatewayTimeoutError,
|
|
8
|
+
StoreApiInternalError,
|
|
9
|
+
StoreApiNotImplementedError,
|
|
3
10
|
StoreApiResourceNotFound,
|
|
4
11
|
StoreApiResponseDecodeError,
|
|
5
12
|
StoreApiResponseError,
|
|
6
13
|
StoreApiResponseErrorList,
|
|
7
|
-
|
|
8
|
-
PublisherMacaroonRefreshRequired,
|
|
9
|
-
PublisherMissingUsername,
|
|
14
|
+
StoreApiServiceUnavailableError,
|
|
10
15
|
)
|
|
11
16
|
|
|
12
17
|
|
|
@@ -17,7 +22,22 @@ class Base:
|
|
|
17
22
|
def process_response(self, response):
|
|
18
23
|
# 5xx responses are not in JSON format
|
|
19
24
|
if response.status_code >= 500:
|
|
20
|
-
|
|
25
|
+
if response.status_code == 500:
|
|
26
|
+
raise StoreApiInternalError("Internal error upstream")
|
|
27
|
+
elif response.status_code == 501:
|
|
28
|
+
raise StoreApiNotImplementedError(
|
|
29
|
+
"Service doesn't implement this method"
|
|
30
|
+
)
|
|
31
|
+
elif response.status_code == 502:
|
|
32
|
+
raise StoreApiBadGatewayError("Invalid response from upstream")
|
|
33
|
+
elif response.status_code == 503:
|
|
34
|
+
raise StoreApiServiceUnavailableError("Service is unavailable")
|
|
35
|
+
elif response.status_code == 504:
|
|
36
|
+
raise StoreApiGatewayTimeoutError("Upstream request timed out")
|
|
37
|
+
else:
|
|
38
|
+
raise StoreApiConnectionError(
|
|
39
|
+
f"Service unavailable, code {response.status_code}"
|
|
40
|
+
)
|
|
21
41
|
|
|
22
42
|
try:
|
|
23
43
|
body = response.json()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from os import getenv
|
|
2
|
-
from typing import Optional
|
|
2
|
+
from typing import Optional, Union
|
|
3
3
|
from requests import Session
|
|
4
4
|
|
|
5
5
|
from canonicalwebteam.store_api.base import Base
|
|
@@ -312,7 +312,7 @@ class PublisherGW(Base):
|
|
|
312
312
|
return self.process_response(response)
|
|
313
313
|
|
|
314
314
|
def unregister_package_name(
|
|
315
|
-
self, publisher_auth: str, package_name: str
|
|
315
|
+
self, publisher_auth: Union[str, dict], package_name: str
|
|
316
316
|
) -> dict:
|
|
317
317
|
"""
|
|
318
318
|
Unregister a package name.
|
|
@@ -328,9 +328,29 @@ class PublisherGW(Base):
|
|
|
328
328
|
Otherwise, returns an error list
|
|
329
329
|
"""
|
|
330
330
|
url = self.get_endpoint_url(package_name, has_name_space=True)
|
|
331
|
+
if self.name_space == "snap":
|
|
332
|
+
# for Snap packages, `unregister_package` uses SCA's API under the
|
|
333
|
+
# hood: this means we must pass the authorization header as if
|
|
334
|
+
# we were calling SCA
|
|
335
|
+
if not isinstance(publisher_auth, dict):
|
|
336
|
+
raise TypeError(
|
|
337
|
+
"Name space 'snap' requires a 'dict' as 'publisher_auth'"
|
|
338
|
+
)
|
|
339
|
+
authorization_header = dashboard_authorization_header(
|
|
340
|
+
publisher_auth
|
|
341
|
+
)
|
|
342
|
+
else:
|
|
343
|
+
if not isinstance(publisher_auth, str):
|
|
344
|
+
raise TypeError(
|
|
345
|
+
f"Name space '{self.name_space}' requires a 'str' as "
|
|
346
|
+
"'publisher_auth'"
|
|
347
|
+
)
|
|
348
|
+
authorization_header = self._get_authorization_header(
|
|
349
|
+
publisher_auth
|
|
350
|
+
)
|
|
331
351
|
response = self.session.delete(
|
|
332
352
|
url=url,
|
|
333
|
-
headers=
|
|
353
|
+
headers=authorization_header,
|
|
334
354
|
)
|
|
335
355
|
return response
|
|
336
356
|
|
|
File without changes
|
|
File without changes
|
{canonicalwebteam_store_api-6.6.0 → canonicalwebteam_store_api-6.8.0}/canonicalwebteam/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|