sdsstools 1.9.4__py3-none-any.whl → 1.9.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sdsstools/__init__.py +1 -0
- sdsstools/daemonizer.py +14 -2
- sdsstools/retrier.py +179 -0
- {sdsstools-1.9.4.dist-info → sdsstools-1.9.6.dist-info}/METADATA +27 -1
- {sdsstools-1.9.4.dist-info → sdsstools-1.9.6.dist-info}/RECORD +8 -7
- {sdsstools-1.9.4.dist-info → sdsstools-1.9.6.dist-info}/WHEEL +0 -0
- {sdsstools-1.9.4.dist-info → sdsstools-1.9.6.dist-info}/entry_points.txt +0 -0
- {sdsstools-1.9.4.dist-info → sdsstools-1.9.6.dist-info}/licenses/LICENSE.md +0 -0
sdsstools/__init__.py
CHANGED
sdsstools/daemonizer.py
CHANGED
|
@@ -23,7 +23,19 @@ from click.decorators import pass_context
|
|
|
23
23
|
from daemonocle import Daemon
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
__all__ = ["cli_coro", "DaemonGroup"]
|
|
26
|
+
__all__ = ["cli_coro", "DaemonGroup", "get_event_loop", "daemonize"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_event_loop() -> asyncio.AbstractEventLoop:
|
|
30
|
+
"""Gets the current event loop, or creates a new one if none exists."""
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
return asyncio.get_event_loop()
|
|
34
|
+
except RuntimeError:
|
|
35
|
+
# "There is no current event loop in thread %r"
|
|
36
|
+
loop = asyncio.new_event_loop()
|
|
37
|
+
asyncio.set_event_loop(loop)
|
|
38
|
+
return loop
|
|
27
39
|
|
|
28
40
|
|
|
29
41
|
def cli_coro(
|
|
@@ -35,7 +47,7 @@ def cli_coro(
|
|
|
35
47
|
def decorator_cli_coro(f):
|
|
36
48
|
@wraps(f)
|
|
37
49
|
def wrapper(*args, **kwargs):
|
|
38
|
-
loop =
|
|
50
|
+
loop = get_event_loop()
|
|
39
51
|
if shutdown_func:
|
|
40
52
|
for ss in signals:
|
|
41
53
|
loop.add_signal_handler(ss, shutdown_func, ss, loop)
|
sdsstools/retrier.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
#
|
|
4
|
+
# @Author: José Sánchez-Gallego (gallegoj@uw.edu)
|
|
5
|
+
# @Date: 2024-01-02
|
|
6
|
+
# @Filename: retrier.py
|
|
7
|
+
# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause)
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import inspect
|
|
13
|
+
import time
|
|
14
|
+
import warnings
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from functools import wraps
|
|
17
|
+
|
|
18
|
+
from typing import (
|
|
19
|
+
Any,
|
|
20
|
+
Awaitable,
|
|
21
|
+
Callable,
|
|
22
|
+
TypeVar,
|
|
23
|
+
overload,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from typing_extensions import ParamSpec, Self
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = ["Retrier"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T", bound=Any)
|
|
33
|
+
P = ParamSpec("P")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Retrier:
|
|
38
|
+
"""A class that implements a retry mechanism.
|
|
39
|
+
|
|
40
|
+
The object returned by this class can be used to wrap a function that
|
|
41
|
+
will be retried ``max_attempts`` times if it fails::
|
|
42
|
+
|
|
43
|
+
def test_function():
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
retrier = Retrier(max_attempts=5)
|
|
47
|
+
retrier(test_function)()
|
|
48
|
+
|
|
49
|
+
where the wrapped function can be a coroutine, in which case the wrapped function
|
|
50
|
+
will also be a coroutine.
|
|
51
|
+
|
|
52
|
+
Most frequently this class will be used as a decorator::
|
|
53
|
+
|
|
54
|
+
@Retrier(max_attempts=4, delay=0.1)
|
|
55
|
+
async def test_function(x, y):
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
await test_function(1, 2)
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
max_attempts
|
|
63
|
+
The maximum number of attempts before giving up.
|
|
64
|
+
delay
|
|
65
|
+
The delay between attempts, in seconds.
|
|
66
|
+
use_exponential_backoff
|
|
67
|
+
Whether to use exponential backoff for the delay between attempts. If
|
|
68
|
+
:obj:`True`, the delay will be
|
|
69
|
+
``delay * exponential_backoff_base ** (attempt - 1) + random_ms`` where
|
|
70
|
+
``random_ms`` is a random number between 0 and 100 ms used to avoid
|
|
71
|
+
synchronisation issues.
|
|
72
|
+
exponential_backoff_base
|
|
73
|
+
The base for the exponential backoff.
|
|
74
|
+
max_delay
|
|
75
|
+
The maximum delay between attempts when using exponential backoff.
|
|
76
|
+
on_retry
|
|
77
|
+
A function that will be called when a retry is attempted. The function
|
|
78
|
+
should accept an exception as its only argument.
|
|
79
|
+
raise_on_exception_class
|
|
80
|
+
A list of exception classes that will cause an exception to be raised
|
|
81
|
+
without retrying.
|
|
82
|
+
timeout
|
|
83
|
+
If defined, each attempt can take at most this amount of time. If the
|
|
84
|
+
attempt times out, an :obj:`asyncio.TimeoutError` will be raised.
|
|
85
|
+
This only works if the wrapped function is a coroutine.
|
|
86
|
+
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
max_attempts: int = 3
|
|
90
|
+
delay: float = 1
|
|
91
|
+
use_exponential_backoff: bool = True
|
|
92
|
+
exponential_backoff_base: float = 2
|
|
93
|
+
max_delay: float = 32.0
|
|
94
|
+
on_retry: Callable[[Exception], None] | None = None
|
|
95
|
+
raise_on_exception_class: list[type[Exception]] = field(default_factory=list)
|
|
96
|
+
timeout: float | None = None
|
|
97
|
+
|
|
98
|
+
def calculate_delay(self, attempt: int) -> float:
|
|
99
|
+
"""Calculates the delay for a given attempt."""
|
|
100
|
+
|
|
101
|
+
# Random number between 0 and 100 ms to avoid synchronisation issues.
|
|
102
|
+
random_ms = 0.1 * (time.time() % 1)
|
|
103
|
+
|
|
104
|
+
if self.use_exponential_backoff:
|
|
105
|
+
return min(
|
|
106
|
+
self.delay * self.exponential_backoff_base ** (attempt - 1) + random_ms,
|
|
107
|
+
self.max_delay,
|
|
108
|
+
)
|
|
109
|
+
else:
|
|
110
|
+
return self.delay
|
|
111
|
+
|
|
112
|
+
@overload
|
|
113
|
+
def __call__(
|
|
114
|
+
self: Self,
|
|
115
|
+
func: Callable[P, T],
|
|
116
|
+
) -> Callable[P, T]: ...
|
|
117
|
+
|
|
118
|
+
@overload
|
|
119
|
+
def __call__(
|
|
120
|
+
self: Self,
|
|
121
|
+
func: Callable[P, Awaitable[T]],
|
|
122
|
+
) -> Callable[P, Awaitable[T]]: ...
|
|
123
|
+
|
|
124
|
+
def __call__(
|
|
125
|
+
self,
|
|
126
|
+
func: Callable[P, T] | Callable[P, Awaitable[T]],
|
|
127
|
+
) -> Callable[P, T] | Callable[P, Awaitable[T]]:
|
|
128
|
+
"""Wraps a function to retry it if it fails."""
|
|
129
|
+
|
|
130
|
+
if inspect.iscoroutinefunction(func):
|
|
131
|
+
|
|
132
|
+
@wraps(func)
|
|
133
|
+
async def async_wrapper(*args: P.args, **kwargs: P.kwargs):
|
|
134
|
+
attempt = 0
|
|
135
|
+
while True:
|
|
136
|
+
try:
|
|
137
|
+
return await asyncio.wait_for(
|
|
138
|
+
func(*args, **kwargs),
|
|
139
|
+
timeout=self.timeout,
|
|
140
|
+
)
|
|
141
|
+
except Exception as ee:
|
|
142
|
+
attempt += 1
|
|
143
|
+
if attempt >= self.max_attempts:
|
|
144
|
+
raise ee
|
|
145
|
+
elif isinstance(ee, tuple(self.raise_on_exception_class)):
|
|
146
|
+
raise ee
|
|
147
|
+
else:
|
|
148
|
+
if self.on_retry:
|
|
149
|
+
self.on_retry(ee)
|
|
150
|
+
await asyncio.sleep(self.calculate_delay(attempt))
|
|
151
|
+
|
|
152
|
+
return async_wrapper
|
|
153
|
+
|
|
154
|
+
else:
|
|
155
|
+
|
|
156
|
+
@wraps(func)
|
|
157
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs):
|
|
158
|
+
attempt = 0
|
|
159
|
+
while True:
|
|
160
|
+
try:
|
|
161
|
+
if self.timeout is not None:
|
|
162
|
+
warnings.warn(
|
|
163
|
+
"The wrapped function is not a coroutine. "
|
|
164
|
+
"The timeout parameter will be ignored.",
|
|
165
|
+
RuntimeWarning,
|
|
166
|
+
)
|
|
167
|
+
return func(*args, **kwargs)
|
|
168
|
+
except Exception as ee:
|
|
169
|
+
attempt += 1
|
|
170
|
+
if attempt >= self.max_attempts:
|
|
171
|
+
raise ee
|
|
172
|
+
elif isinstance(ee, tuple(self.raise_on_exception_class)):
|
|
173
|
+
raise ee
|
|
174
|
+
else:
|
|
175
|
+
if self.on_retry:
|
|
176
|
+
self.on_retry(ee)
|
|
177
|
+
time.sleep(self.calculate_delay(attempt))
|
|
178
|
+
|
|
179
|
+
return wrapper
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sdsstools
|
|
3
|
-
Version: 1.9.
|
|
3
|
+
Version: 1.9.6
|
|
4
4
|
Summary: Small tools for SDSS products
|
|
5
5
|
Project-URL: Homepage, https://github.com/sdss/sdsstools
|
|
6
6
|
Project-URL: Repository, https://github.com/sdss/sdsstools
|
|
@@ -284,6 +284,32 @@ To stop the daemon do `daemonize stop NAME`. See `daemonize --help` for more opt
|
|
|
284
284
|
|
|
285
285
|
The function `sdsstools.time.get_sjd()` returns the integer with the SDSS-style Modified Julian Day. The function accepts an observatory (`'APO'` or `'LCO'`) but otherwise will try to determine the current location from environment variables or the fully qualified domain name.
|
|
286
286
|
|
|
287
|
+
## Retrier
|
|
288
|
+
|
|
289
|
+
The `Retrier` class provides a simple way to add retry logic to functions that may fail intermittently. It can be used to wrap both synchronous and asynchronous functions. `Retrier` produces a callable that can be used as a decorator or called directly. For example:
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
from sdsstools.retrier import Retrier
|
|
293
|
+
|
|
294
|
+
@Retrier(max_attempts=5, delay=2.0)
|
|
295
|
+
async def my_function(param1, param2):
|
|
296
|
+
...
|
|
297
|
+
|
|
298
|
+
await my_function(1, 2)
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
or
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
def my_function(param1, param2):
|
|
305
|
+
...
|
|
306
|
+
|
|
307
|
+
retrier = Retrier(max_attempts=5, delay=2.0)
|
|
308
|
+
retrier(my_function)(1, 2)
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
By default `Retrier` will try three times with a one second delay between attempts. An exponential backoff is used by default. If the function keeps failing after the maximum number of attempts, the last exception is raised. It is possible to stop the retry process early if a certain type of exception is raised by passing a list of such exception classes to `raise_on_exception_class`.
|
|
312
|
+
|
|
287
313
|
## Bundled packages
|
|
288
314
|
|
|
289
315
|
For convenience, `sdsstools` bundles the following products:
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
sdsstools/__init__.py,sha256=
|
|
1
|
+
sdsstools/__init__.py,sha256=K1wL8SmTcCdutb-Nd_P1dCesoRJPJfOpJI7bcjR8gL0,767
|
|
2
2
|
sdsstools/_tasks.py,sha256=U7fvxk9IExzE6voNjio2QqRTw-H9Upu1d0zKQlQFs5I,4263
|
|
3
3
|
sdsstools/cli.py,sha256=e4BS4b1cJThCj5tytajr_VMUvmVP1xYq_r90EtEscg8,1348
|
|
4
4
|
sdsstools/configuration.py,sha256=y_nmF_TN_o68ib2YIpdypHfwggvBpjz_dwKjNd0jzc8,14673
|
|
5
|
-
sdsstools/daemonizer.py,sha256=
|
|
5
|
+
sdsstools/daemonizer.py,sha256=Yd06BTwcAjQ9hIC3O-pQhqE5daHK-ECPZ17FzZPe3xM,8176
|
|
6
6
|
sdsstools/logger.py,sha256=nGAC_hDK0dWVMAS-a-vBsw0oSCxAwx-ZsJUE5GMzILs,18382
|
|
7
7
|
sdsstools/metadata.py,sha256=LZWO0KI-gVvzdJNIaVlIn4bBBHxbglU8BLW-MdlxOFA,3268
|
|
8
|
+
sdsstools/retrier.py,sha256=inKy3dqe4RWNTdGGUHi2PwJqtbx8E5OmwLqpOmNnm1Y,5690
|
|
8
9
|
sdsstools/time.py,sha256=cqLrch0ZUCt3ShagN1v-5GhgLyNjzPXgaQRem4j7vAk,4606
|
|
9
10
|
sdsstools/utils.py,sha256=kl3FtvJdw3uEZye7gcBI9Ksvd1rk0EwkriiDkuyvlrU,5077
|
|
10
11
|
sdsstools/_vendor/__init__.py,sha256=sy3LqhCDgkFjMQY7mLQolVOgvD7y9LM6DuWp2ylG0vc,282
|
|
@@ -15,8 +16,8 @@ sdsstools/_vendor/toml/decoder.py,sha256=hWUJ3UQ43MGVER35rSUUj7Hdh85Vat8Od_B4HeF
|
|
|
15
16
|
sdsstools/_vendor/toml/encoder.py,sha256=OBRwH2tRUhRI8nJgKBwKbkyQnBAuhcUf60r3wykjPco,9989
|
|
16
17
|
sdsstools/_vendor/toml/ordered.py,sha256=aW5woa5xOqR4BjIz9t10_lghxyhF54KQ7FqUNVv7WJ0,334
|
|
17
18
|
sdsstools/_vendor/toml/tz.py,sha256=8TAiXrTqU08sE0ruz2TXH_pFY2rlwNKE47MSE4rDo8Y,618
|
|
18
|
-
sdsstools-1.9.
|
|
19
|
-
sdsstools-1.9.
|
|
20
|
-
sdsstools-1.9.
|
|
21
|
-
sdsstools-1.9.
|
|
22
|
-
sdsstools-1.9.
|
|
19
|
+
sdsstools-1.9.6.dist-info/METADATA,sha256=9dtTD1rOuRKeJZiJ2niBhYgKa9WU5zbzxdbuGnRc8x0,16665
|
|
20
|
+
sdsstools-1.9.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
21
|
+
sdsstools-1.9.6.dist-info/entry_points.txt,sha256=H1UefQFMHyzmGcdNib_qxiOfFSx3351wr4c2ax0PJbQ,87
|
|
22
|
+
sdsstools-1.9.6.dist-info/licenses/LICENSE.md,sha256=_7dAUQQ5Ph_x1hcFXhi9aHBcqq9H11zco12eO4B3Cyg,1504
|
|
23
|
+
sdsstools-1.9.6.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|