dycw-utilities 0.121.1__py3-none-any.whl → 0.122.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dycw-utilities
3
- Version: 0.121.1
3
+ Version: 0.122.1
4
4
  Author-email: Derek Wan <d.wan@icloud.com>
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -1,7 +1,7 @@
1
- utilities/__init__.py,sha256=Ses6BHZNi0jOdocz8doinXp-udEMKae9OJkWZ9Ptqww,60
1
+ utilities/__init__.py,sha256=HQ1CqsUZ8X8-X0sFT3wvjjj0NMYqAnzvjRBDmvWB2xU,60
2
2
  utilities/altair.py,sha256=Gpja-flOo-Db0PIPJLJsgzAlXWoKUjPU1qY-DQ829ek,9156
3
3
  utilities/astor.py,sha256=xuDUkjq0-b6fhtwjhbnebzbqQZAjMSHR1IIS5uOodVg,777
4
- utilities/asyncio.py,sha256=gn8dxBsNzFfFzrNpImF731LAmgLwEQM3uiwKuUPlg08,18822
4
+ utilities/asyncio.py,sha256=cjXpSezv95ZG3t-D9sXLOn6BGBj7w-Wf4udvE5gU4vU,23508
5
5
  utilities/atomicwrites.py,sha256=geFjn9Pwn-tTrtoGjDDxWli9NqbYfy3gGL6ZBctiqSo,5393
6
6
  utilities/atools.py,sha256=IYMuFSFGSKyuQmqD6v5IUtDlz8PPw0Sr87Cub_gRU3M,1168
7
7
  utilities/cachetools.py,sha256=C1zqOg7BYz0IfQFK8e3qaDDgEZxDpo47F15RTfJM37Q,2910
@@ -16,7 +16,7 @@ utilities/datetime.py,sha256=uYoaOi_C1YtNXGfTN9xlTrW62Re2b1_4Skuv14_MeYQ,38985
16
16
  utilities/enum.py,sha256=HoRwVCWzsnH0vpO9ZEcAAIZLMv0Sn2vJxxA4sYMQgDs,5793
17
17
  utilities/errors.py,sha256=gxsaa7eq7jbYl41Of40-ivjXqJB5gt4QAcJ0smZZMJE,829
18
18
  utilities/eventkit.py,sha256=6M5Xu1SzN-juk9PqBHwy5dS-ta7T0qA6SMpDsakOJ0E,13039
19
- utilities/fastapi.py,sha256=eiisloI6kQVCkPfDpBzlLrDZDi8yJ0VmrSPlJ2k84Mo,2334
19
+ utilities/fastapi.py,sha256=LG1-Q8RDi7wsyVN6v74qptPYX8WGXPkFOQFniMvtzjc,2439
20
20
  utilities/fpdf2.py,sha256=y1NGXR5chWqLXWpewGV3hlRGMr_5yV1lVRkPBhPEgJI,1843
21
21
  utilities/functions.py,sha256=jgt592voaHNtX56qX0SRvFveVCRmSIxCZmqvpLZCnY8,27305
22
22
  utilities/functools.py,sha256=WrpHt7NLNWSUn9A1Q_ZIWlNaYZOEI4IFKyBG9HO3BC4,1643
@@ -59,7 +59,7 @@ utilities/pytest_regressions.py,sha256=-SVT9647Dg6-JcdsiaDKXe3NdOmmrvGevLKWwGjxq
59
59
  utilities/python_dotenv.py,sha256=iWcnpXbH7S6RoXHiLlGgyuH6udCupAcPd_gQ0eAenQ0,3190
60
60
  utilities/random.py,sha256=lYdjgxB7GCfU_fwFVl5U-BIM_HV3q6_urL9byjrwDM8,4157
61
61
  utilities/re.py,sha256=5J4d8VwIPFVrX2Eb8zfoxImDv7IwiN_U7mJ07wR2Wvs,3958
62
- utilities/redis.py,sha256=i5G3k-EIi2JSf9arLIu0YW6viGsiYtxAoynbb4KvJek,26628
62
+ utilities/redis.py,sha256=8ELnXiISVHY1m9elJWhekhLXg6NQSaNIIPvZ_EaGw3s,26624
63
63
  utilities/reprlib.py,sha256=Re9bk3n-kC__9DxQmRlevqFA86pE6TtVfWjUgpbVOv0,1849
64
64
  utilities/rich.py,sha256=t50MwwVBsoOLxzmeVFSVpjno4OW6Ufum32skXbV8-Bs,1911
65
65
  utilities/scipy.py,sha256=X6ROnHwiUhAmPhM0jkfEh0-Fd9iRvwiqtCQMOLmOQF8,945
@@ -67,7 +67,7 @@ utilities/sentinel.py,sha256=3jIwgpMekWgDAxPDA_hXMP2St43cPhciKN3LWiZ7kv0,1248
67
67
  utilities/shelve.py,sha256=HZsMwK4tcIfg3sh0gApx4-yjQnrY4o3V3ZRimvRhoW0,738
68
68
  utilities/slack_sdk.py,sha256=A2f7-DYOngRoUP6ZdLIaUQ6Lfzgru5Xp3U3k5JfEkQE,3301
69
69
  utilities/socket.py,sha256=K77vfREvzoVTrpYKo6MZakol0EYu2q1sWJnnZqL0So0,118
70
- utilities/sqlalchemy.py,sha256=iwNJyR0Dx47l81aHEq5P6eMzgduVxxGH531OVUp2k_A,35451
70
+ utilities/sqlalchemy.py,sha256=yCUCDhg0wFOCdEh6wwBD7Ma979OksLpz4arck1s653s,35447
71
71
  utilities/sqlalchemy_polars.py,sha256=wjJpoUo-yO9E2ujpG_06vV5r2OdvBiQ4yvV6wKCa2Tk,15605
72
72
  utilities/statsmodels.py,sha256=koyiBHvpMcSiBfh99wFUfSggLNx7cuAw3rwyfAhoKpQ,3410
73
73
  utilities/streamlit.py,sha256=U9PJBaKP1IdSykKhPZhIzSPTZsmLsnwbEPZWzNhJPKk,2955
@@ -88,7 +88,7 @@ utilities/warnings.py,sha256=un1LvHv70PU-LLv8RxPVmugTzDJkkGXRMZTE2-fTQHw,1771
88
88
  utilities/whenever.py,sha256=jS31ZAY5OMxFxLja_Yo5Fidi87Pd-GoVZ7Vi_teqVDA,16743
89
89
  utilities/zipfile.py,sha256=24lQc9ATcJxHXBPc_tBDiJk48pWyRrlxO2fIsFxU0A8,699
90
90
  utilities/zoneinfo.py,sha256=-5j7IQ9nb7gR43rdgA7ms05im-XuqhAk9EJnQBXxCoQ,1874
91
- dycw_utilities-0.121.1.dist-info/METADATA,sha256=54NwpwmQD2-MOcByhCWt9vZDiig3pdJSvdA5VmjhNbY,12943
92
- dycw_utilities-0.121.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
93
- dycw_utilities-0.121.1.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
94
- dycw_utilities-0.121.1.dist-info/RECORD,,
91
+ dycw_utilities-0.122.1.dist-info/METADATA,sha256=9QN9Ulm7O-TsU1Zg2xrl5ylNadZMkHMp9ktRVFxpFwQ,12943
92
+ dycw_utilities-0.122.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
93
+ dycw_utilities-0.122.1.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
94
+ dycw_utilities-0.122.1.dist-info/RECORD,,
utilities/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.121.1"
3
+ __version__ = "0.122.1"
utilities/asyncio.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import datetime as dt
4
4
  from abc import ABC, abstractmethod
5
5
  from asyncio import (
6
+ CancelledError,
6
7
  Event,
7
8
  PriorityQueue,
8
9
  Queue,
@@ -12,11 +13,18 @@ from asyncio import (
12
13
  Task,
13
14
  TaskGroup,
14
15
  create_subprocess_shell,
16
+ create_task,
15
17
  sleep,
16
18
  timeout,
17
19
  )
18
20
  from collections.abc import Callable, Hashable, Iterable, Iterator, Mapping
19
- from contextlib import _AsyncGeneratorContextManager, asynccontextmanager
21
+ from contextlib import (
22
+ AbstractAsyncContextManager,
23
+ AsyncExitStack,
24
+ _AsyncGeneratorContextManager,
25
+ asynccontextmanager,
26
+ suppress,
27
+ )
20
28
  from dataclasses import dataclass, field
21
29
  from io import StringIO
22
30
  from logging import getLogger
@@ -27,6 +35,7 @@ from typing import (
27
35
  Any,
28
36
  Generic,
29
37
  NoReturn,
38
+ Self,
30
39
  TextIO,
31
40
  TypeVar,
32
41
  assert_never,
@@ -42,7 +51,7 @@ from utilities.datetime import (
42
51
  get_now,
43
52
  round_datetime,
44
53
  )
45
- from utilities.errors import repr_error
54
+ from utilities.errors import ImpossibleCaseError, repr_error
46
55
  from utilities.functions import ensure_int, ensure_not_none, get_class_name
47
56
  from utilities.reprlib import get_repr
48
57
  from utilities.sentinel import Sentinel, sentinel
@@ -60,6 +69,7 @@ if TYPE_CHECKING:
60
69
  from asyncio.subprocess import Process
61
70
  from collections.abc import AsyncIterator, Sequence
62
71
  from contextvars import Context
72
+ from types import TracebackType
63
73
 
64
74
  from utilities.types import Duration
65
75
 
@@ -76,6 +86,8 @@ class EnhancedTaskGroup(TaskGroup):
76
86
  _semaphore: Semaphore | None
77
87
  _timeout: Duration | None
78
88
  _error: type[Exception]
89
+ _stack: AsyncExitStack
90
+ _stack_entered: bool
79
91
  _timeout_cm: _AsyncGeneratorContextManager[None] | None
80
92
 
81
93
  @override
@@ -90,8 +102,25 @@ class EnhancedTaskGroup(TaskGroup):
90
102
  self._semaphore = None if max_tasks is None else Semaphore(max_tasks)
91
103
  self._timeout = timeout
92
104
  self._error = error
105
+ self._stack = AsyncExitStack()
106
+ self._stack_entered = False # TOOD: no need
93
107
  self._timeout_cm = None
94
108
 
109
+ @override
110
+ async def __aenter__(self) -> Self:
111
+ _ = await self._stack.__aenter__()
112
+ return await super().__aenter__()
113
+
114
+ @override
115
+ async def __aexit__(
116
+ self,
117
+ et: type[BaseException] | None,
118
+ exc: BaseException | None,
119
+ tb: TracebackType | None,
120
+ ) -> None:
121
+ _ = await self._stack.__aexit__(et, exc, tb)
122
+ _ = await super().__aexit__(et, exc, tb)
123
+
95
124
  @override
96
125
  def create_task(
97
126
  self,
@@ -107,6 +136,9 @@ class EnhancedTaskGroup(TaskGroup):
107
136
  coroutine = self._wrap_with_timeout(coroutine)
108
137
  return super().create_task(coroutine, name=name, context=context)
109
138
 
139
+ async def enter_async_context(self, cm: AbstractAsyncContextManager[_T], /) -> _T:
140
+ return await self._stack.enter_async_context(cm)
141
+
110
142
  async def _wrap_with_semaphore(
111
143
  self, semaphore: Semaphore, coroutine: _CoroutineLike[_T], /
112
144
  ) -> _T:
@@ -125,26 +157,94 @@ class EnhancedTaskGroup(TaskGroup):
125
157
  class InfiniteLooper(ABC, Generic[THashable]):
126
158
  """An infinite loop which can throw exceptions by setting events."""
127
159
 
128
- sleep_core: DurationOrEveryDuration = SECOND
129
- sleep_restart: DurationOrEveryDuration = MINUTE
130
- logger: str | None = None
160
+ sleep_core: DurationOrEveryDuration = field(default=SECOND, repr=False)
161
+ sleep_restart: DurationOrEveryDuration = field(default=MINUTE, repr=False)
162
+ duration: Duration | None = field(default=None, repr=False)
163
+ logger: str | None = field(default=None, repr=False)
164
+ _await_upon_aenter: bool = field(default=True, init=False, repr=False)
165
+ _depth: int = field(default=0, init=False, repr=False)
131
166
  _events: Mapping[THashable | None, Event] = field(
132
167
  default_factory=dict, init=False, repr=False, hash=False
133
168
  )
169
+ _stack: AsyncExitStack = field(
170
+ default_factory=AsyncExitStack, init=False, repr=False
171
+ )
172
+ _task: Task[None] | None = field(default=None, init=False, repr=False)
134
173
 
135
174
  def __post_init__(self) -> None:
136
175
  self._events = {
137
176
  event: Event() for event, _ in self._yield_events_and_exceptions()
138
177
  }
139
178
 
140
- async def __call__(self) -> None:
141
- """Create a coroutine to run the looper."""
142
- coroutines = list(self._yield_coroutines())
143
- if len(coroutines) == 0:
144
- return await self._run_looper()
145
- return await self._run_looper_with_coroutines(*coroutines)
179
+ async def __aenter__(self) -> Self:
180
+ """Context manager entry."""
181
+ if (self._task is None) and (self._depth == 0):
182
+ _ = await self._stack.__aenter__()
183
+ self._task = create_task(self._run_looper())
184
+ if self._await_upon_aenter:
185
+ with suppress(CancelledError):
186
+ await self._task
187
+ elif (self._task is not None) and (self._depth >= 1):
188
+ ...
189
+ else:
190
+ raise ImpossibleCaseError( # pragma: no cover
191
+ case=[f"{self._task=}", f"{self._depth=}"]
192
+ )
193
+ self._depth += 1
194
+ return self
195
+
196
+ async def __aexit__(
197
+ self,
198
+ exc_type: type[BaseException] | None = None,
199
+ exc_value: BaseException | None = None,
200
+ traceback: TracebackType | None = None,
201
+ ) -> None:
202
+ """Context manager exit."""
203
+ _ = (exc_type, exc_value, traceback)
204
+ if (self._task is None) or (self._depth == 0):
205
+ raise ImpossibleCaseError( # pragma: no cover
206
+ case=[f"{self._task=}", f"{self._depth=}"]
207
+ )
208
+ self._depth -= 1
209
+ if self._depth == 0:
210
+ _ = await self._stack.__aexit__(exc_type, exc_value, traceback)
211
+ with suppress(CancelledError):
212
+ await self._task
213
+ self._task = None
214
+ with suppress(Exception):
215
+ await self._teardown()
216
+
217
+ async def stop(self) -> None:
218
+ """Stop the service."""
219
+ if self._task is None:
220
+ raise ImpossibleCaseError(case=[f"{self._task=}"]) # pragma: no cover
221
+ with suppress(CancelledError):
222
+ _ = self._task.cancel()
146
223
 
147
224
  async def _run_looper(self) -> None:
225
+ """Run the looper."""
226
+ match self.duration:
227
+ case None:
228
+ await self._run_looper_without_timeout()
229
+ case int() | float() | dt.timedelta() as duration:
230
+ try:
231
+ async with timeout_dur(duration=duration):
232
+ return await self._run_looper_without_timeout()
233
+ except TimeoutError:
234
+ await self.stop()
235
+ case _ as never:
236
+ assert_never(never)
237
+ return None
238
+
239
+ async def _run_looper_without_timeout(self) -> None:
240
+ """Run the looper without a timeout."""
241
+ coroutines = list(self._yield_coroutines())
242
+ loopers = list(self._yield_loopers())
243
+ if (len(coroutines) == 0) and (len(loopers) == 0):
244
+ return await self._run_looper_by_itself()
245
+ return await self._run_looper_with_others(coroutines, loopers)
246
+
247
+ async def _run_looper_by_itself(self) -> None:
148
248
  """Run the looper by itself."""
149
249
  while True:
150
250
  try:
@@ -171,20 +271,31 @@ class InfiniteLooper(ABC, Generic[THashable]):
171
271
  raise
172
272
  except Exception as error: # noqa: BLE001
173
273
  self._error_upon_core(error)
174
- await self._run_sleep(self.sleep_restart)
274
+ try:
275
+ await self._teardown()
276
+ except Exception as error: # noqa: BLE001
277
+ self._error_upon_teardown(error)
278
+ finally:
279
+ await self._run_sleep(self.sleep_restart)
175
280
 
176
- async def _run_looper_with_coroutines(
177
- self, *coroutines: Callable[[], Coroutine1[None]]
281
+ async def _run_looper_with_others(
282
+ self,
283
+ coroutines: Iterable[Callable[[], Coroutine1[None]]],
284
+ loopers: Iterable[InfiniteLooper[Any]],
285
+ /,
178
286
  ) -> None:
179
287
  """Run multiple loopers."""
180
288
  while True:
181
289
  self._reset_events()
182
290
  try:
183
- async with TaskGroup() as tg:
184
- _ = tg.create_task(self._run_looper())
291
+ async with TaskGroup() as tg, AsyncExitStack() as stack:
292
+ _ = tg.create_task(self._run_looper_by_itself())
185
293
  _ = [tg.create_task(c()) for c in coroutines]
294
+ _ = [
295
+ tg.create_task(stack.enter_async_context(lo)) for lo in loopers
296
+ ]
186
297
  except ExceptionGroup as error:
187
- self._error_group_upon_coroutines(error)
298
+ self._error_group_upon_others(error)
188
299
  await self._run_sleep(self.sleep_restart)
189
300
 
190
301
  async def _initialize(self) -> None:
@@ -193,6 +304,9 @@ class InfiniteLooper(ABC, Generic[THashable]):
193
304
  async def _core(self) -> None:
194
305
  """Run the core part of the loop."""
195
306
 
307
+ async def _teardown(self) -> None:
308
+ """Tear down the loop."""
309
+
196
310
  def _error_upon_initialize(self, error: Exception, /) -> None:
197
311
  """Handle any errors upon initializing the looper."""
198
312
  if self.logger is not None:
@@ -213,7 +327,17 @@ class InfiniteLooper(ABC, Generic[THashable]):
213
327
  self._sleep_restart_desc,
214
328
  )
215
329
 
216
- def _error_group_upon_coroutines(self, group: ExceptionGroup, /) -> None:
330
+ def _error_upon_teardown(self, error: Exception, /) -> None:
331
+ """Handle any errors upon tearing down the looper."""
332
+ if self.logger is not None:
333
+ getLogger(name=self.logger).error(
334
+ "%r encountered %r whilst tearing down; sleeping %s...",
335
+ get_class_name(self),
336
+ repr_error(error),
337
+ self._sleep_restart_desc,
338
+ )
339
+
340
+ def _error_group_upon_others(self, group: ExceptionGroup, /) -> None:
217
341
  """Handle any errors upon running the core function."""
218
342
  if self.logger is not None:
219
343
  errors = group.exceptions
@@ -269,15 +393,19 @@ class InfiniteLooper(ABC, Generic[THashable]):
269
393
  raise _InfiniteLooperNoSuchEventError(looper=self, event=event) from None
270
394
  event_obj.set()
271
395
 
396
+ def _yield_events_and_exceptions(
397
+ self,
398
+ ) -> Iterator[tuple[THashable | None, MaybeType[Exception]]]:
399
+ """Yield the events & exceptions."""
400
+ yield (None, _InfiniteLooperDefaultEventError(looper=self))
401
+
272
402
  def _yield_coroutines(self) -> Iterator[Callable[[], Coroutine1[None]]]:
273
403
  """Yield any other coroutines which must also be run."""
274
404
  yield from []
275
405
 
276
- def _yield_events_and_exceptions(
277
- self,
278
- ) -> Iterator[tuple[THashable | None, MaybeType[BaseException]]]:
279
- """Yield the events & exceptions."""
280
- yield (None, _InfiniteLooperDefaultEventError)
406
+ def _yield_loopers(self) -> Iterator[InfiniteLooper[Any]]:
407
+ """Yield any other loopers which must also be run."""
408
+ yield from []
281
409
 
282
410
 
283
411
  @dataclass(kw_only=True, slots=True)
@@ -309,6 +437,7 @@ class InfiniteQueueLooper(InfiniteLooper[THashable], Generic[THashable, _T]):
309
437
  """An infinite loop which processes a queue."""
310
438
 
311
439
  queue_type: type[Queue[_T]] = field(default=Queue, repr=False)
440
+ _await_upon_aenter: bool = field(default=False, init=False, repr=False)
312
441
  _queue: Queue[_T] = field(init=False)
313
442
 
314
443
  @override
@@ -346,6 +475,7 @@ class InfiniteQueueLooper(InfiniteLooper[THashable], Generic[THashable, _T]):
346
475
  """Run until the queue is empty."""
347
476
  while not self.empty():
348
477
  await self._process_items(*get_items_nowait(self._queue))
478
+ await self.stop()
349
479
 
350
480
  @override
351
481
  def _error_upon_core(self, error: Exception, /) -> None:
@@ -353,12 +483,12 @@ class InfiniteQueueLooper(InfiniteLooper[THashable], Generic[THashable, _T]):
353
483
  if self.logger is not None:
354
484
  if isinstance(error, InfiniteQueueLooperError):
355
485
  getLogger(name=self.logger).error(
356
- "%r encountered %s whilst processing %d item(s) %s; sleeping for %s...",
486
+ "%r encountered %s whilst processing %d item(s) %s; sleeping %s...",
357
487
  get_class_name(self),
358
488
  repr_error(error.error),
359
489
  len(error.items),
360
490
  get_repr(error.items),
361
- self.sleep_restart,
491
+ self._sleep_restart_desc,
362
492
  )
363
493
  else:
364
494
  super()._error_upon_core(error) # pragma: no cover
@@ -370,10 +500,6 @@ class InfiniteQueueLooperError(Exception, Generic[_T]):
370
500
  items: Sequence[_T]
371
501
  error: Exception
372
502
 
373
- @override
374
- def __str__(self) -> str:
375
- return f"{get_class_name(self.looper)!r} encountered {repr_error(self.error)} whilst processing {len(self.items)} item(s): {get_repr(self.items)}"
376
-
377
503
 
378
504
  ##
379
505
 
utilities/fastapi.py CHANGED
@@ -70,5 +70,9 @@ class PingReceiver(InfiniteLooper):
70
70
  async def _initialize(self) -> None:
71
71
  await self._server.serve() # skipif-ci
72
72
 
73
+ @override
74
+ async def _teardown(self) -> None:
75
+ await self._server.shutdown() # skipif-ci
76
+
73
77
 
74
78
  __all__ = ["PingReceiver"]
utilities/redis.py CHANGED
@@ -611,7 +611,7 @@ class Publisher(InfiniteQueueLooper[None, tuple[str, EncodableT]]):
611
611
  @override
612
612
  def _yield_events_and_exceptions(
613
613
  self,
614
- ) -> Iterator[tuple[None, MaybeType[BaseException]]]:
614
+ ) -> Iterator[tuple[None, MaybeType[Exception]]]:
615
615
  yield (None, PublisherError) # skipif-ci-and-not-linux
616
616
 
617
617
 
utilities/sqlalchemy.py CHANGED
@@ -640,7 +640,7 @@ class Upserter(InfiniteQueueLooper[None, _InsertItem]):
640
640
  @override
641
641
  def _yield_events_and_exceptions(
642
642
  self,
643
- ) -> Iterator[tuple[None, MaybeType[BaseException]]]:
643
+ ) -> Iterator[tuple[None, MaybeType[Exception]]]:
644
644
  yield (None, UpserterError)
645
645
 
646
646