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 CHANGED
@@ -22,6 +22,7 @@ from ._vendor import color_print, toml, yanny
22
22
  from .configuration import *
23
23
  from .logger import *
24
24
  from .metadata import *
25
+ from .retrier import *
25
26
  from .time import get_sjd
26
27
  from .utils import *
27
28
 
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 = asyncio.get_event_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.4
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=G8u94gRu4THFJvptEITykK0Q_3jHqhQY9p01j9QCQH4,744
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=TMzq3RgD0KqY6jmeeJASlCsw7CjDpLmF8QrENaxCHBU,7796
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.4.dist-info/METADATA,sha256=MXPK8lZT3NZeTdKjO10nznLWlh0ZPqPtlZG3J3wE9hU,15701
19
- sdsstools-1.9.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
20
- sdsstools-1.9.4.dist-info/entry_points.txt,sha256=H1UefQFMHyzmGcdNib_qxiOfFSx3351wr4c2ax0PJbQ,87
21
- sdsstools-1.9.4.dist-info/licenses/LICENSE.md,sha256=_7dAUQQ5Ph_x1hcFXhi9aHBcqq9H11zco12eO4B3Cyg,1504
22
- sdsstools-1.9.4.dist-info/RECORD,,
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,,