dycw-utilities 0.121.1__py3-none-any.whl → 0.122.0__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.0
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=V19RNamTpOs4KxZ_wh7w8SsT-d-C24iIQHK07Pu3opA,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=sVCGCGl5vGo1MztLcd46DiuWBR6XQW1CsGDyDdaevYg,22766
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.0.dist-info/METADATA,sha256=VUsd4omUw1y-str8uasKFbLZUMA2Sr3HpSaq8f8coh0,12943
92
+ dycw_utilities-0.122.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
93
+ dycw_utilities-0.122.0.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
94
+ dycw_utilities-0.122.0.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.0"
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,17 @@ 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
+ AsyncExitStack,
23
+ _AsyncGeneratorContextManager,
24
+ asynccontextmanager,
25
+ suppress,
26
+ )
20
27
  from dataclasses import dataclass, field
21
28
  from io import StringIO
22
29
  from logging import getLogger
@@ -27,6 +34,7 @@ from typing import (
27
34
  Any,
28
35
  Generic,
29
36
  NoReturn,
37
+ Self,
30
38
  TextIO,
31
39
  TypeVar,
32
40
  assert_never,
@@ -42,7 +50,7 @@ from utilities.datetime import (
42
50
  get_now,
43
51
  round_datetime,
44
52
  )
45
- from utilities.errors import repr_error
53
+ from utilities.errors import ImpossibleCaseError, repr_error
46
54
  from utilities.functions import ensure_int, ensure_not_none, get_class_name
47
55
  from utilities.reprlib import get_repr
48
56
  from utilities.sentinel import Sentinel, sentinel
@@ -60,6 +68,7 @@ if TYPE_CHECKING:
60
68
  from asyncio.subprocess import Process
61
69
  from collections.abc import AsyncIterator, Sequence
62
70
  from contextvars import Context
71
+ from types import TracebackType
63
72
 
64
73
  from utilities.types import Duration
65
74
 
@@ -125,26 +134,94 @@ class EnhancedTaskGroup(TaskGroup):
125
134
  class InfiniteLooper(ABC, Generic[THashable]):
126
135
  """An infinite loop which can throw exceptions by setting events."""
127
136
 
128
- sleep_core: DurationOrEveryDuration = SECOND
129
- sleep_restart: DurationOrEveryDuration = MINUTE
130
- logger: str | None = None
137
+ sleep_core: DurationOrEveryDuration = field(default=SECOND, repr=False)
138
+ sleep_restart: DurationOrEveryDuration = field(default=MINUTE, repr=False)
139
+ duration: Duration | None = field(default=None, repr=False)
140
+ logger: str | None = field(default=None, repr=False)
141
+ _await_upon_aenter: bool = field(default=True, init=False, repr=False)
142
+ _depth: int = field(default=0, init=False, repr=False)
131
143
  _events: Mapping[THashable | None, Event] = field(
132
144
  default_factory=dict, init=False, repr=False, hash=False
133
145
  )
146
+ _stack: AsyncExitStack = field(
147
+ default_factory=AsyncExitStack, init=False, repr=False
148
+ )
149
+ _task: Task[None] | None = field(default=None, init=False, repr=False)
134
150
 
135
151
  def __post_init__(self) -> None:
136
152
  self._events = {
137
153
  event: Event() for event, _ in self._yield_events_and_exceptions()
138
154
  }
139
155
 
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)
156
+ async def __aenter__(self) -> Self:
157
+ """Context manager entry."""
158
+ if (self._task is None) and (self._depth == 0):
159
+ _ = await self._stack.__aenter__()
160
+ self._task = create_task(self._run_looper())
161
+ if self._await_upon_aenter:
162
+ with suppress(CancelledError):
163
+ await self._task
164
+ elif (self._task is not None) and (self._depth >= 1):
165
+ ...
166
+ else:
167
+ raise ImpossibleCaseError( # pragma: no cover
168
+ case=[f"{self._task=}", f"{self._depth=}"]
169
+ )
170
+ self._depth += 1
171
+ return self
172
+
173
+ async def __aexit__(
174
+ self,
175
+ exc_type: type[BaseException] | None = None,
176
+ exc_value: BaseException | None = None,
177
+ traceback: TracebackType | None = None,
178
+ ) -> None:
179
+ """Context manager exit."""
180
+ _ = (exc_type, exc_value, traceback)
181
+ if (self._task is None) or (self._depth == 0):
182
+ raise ImpossibleCaseError( # pragma: no cover
183
+ case=[f"{self._task=}", f"{self._depth=}"]
184
+ )
185
+ self._depth -= 1
186
+ if self._depth == 0:
187
+ _ = await self._stack.__aexit__(exc_type, exc_value, traceback)
188
+ with suppress(CancelledError):
189
+ await self._task
190
+ self._task = None
191
+ with suppress(Exception):
192
+ await self._teardown()
193
+
194
+ async def stop(self) -> None:
195
+ """Stop the service."""
196
+ if self._task is None:
197
+ raise ImpossibleCaseError(case=[f"{self._task=}"]) # pragma: no cover
198
+ with suppress(CancelledError):
199
+ _ = self._task.cancel()
146
200
 
147
201
  async def _run_looper(self) -> None:
202
+ """Run the looper."""
203
+ match self.duration:
204
+ case None:
205
+ await self._run_looper_without_timeout()
206
+ case int() | float() | dt.timedelta() as duration:
207
+ try:
208
+ async with timeout_dur(duration=duration):
209
+ return await self._run_looper_without_timeout()
210
+ except TimeoutError:
211
+ await self.stop()
212
+ case _ as never:
213
+ assert_never(never)
214
+ return None
215
+
216
+ async def _run_looper_without_timeout(self) -> None:
217
+ """Run the looper without a timeout."""
218
+ coroutines = list(self._yield_coroutines())
219
+ loopers = list(self._yield_loopers())
220
+ if (len(coroutines) == 0) and (len(loopers) == 0):
221
+ return await self._run_looper_by_itself()
222
+ return await self._run_looper_with_others(coroutines, loopers)
223
+
224
+ async def _run_looper_by_itself(self) -> None:
148
225
  """Run the looper by itself."""
149
226
  while True:
150
227
  try:
@@ -171,20 +248,31 @@ class InfiniteLooper(ABC, Generic[THashable]):
171
248
  raise
172
249
  except Exception as error: # noqa: BLE001
173
250
  self._error_upon_core(error)
174
- await self._run_sleep(self.sleep_restart)
251
+ try:
252
+ await self._teardown()
253
+ except Exception as error: # noqa: BLE001
254
+ self._error_upon_teardown(error)
255
+ finally:
256
+ await self._run_sleep(self.sleep_restart)
175
257
 
176
- async def _run_looper_with_coroutines(
177
- self, *coroutines: Callable[[], Coroutine1[None]]
258
+ async def _run_looper_with_others(
259
+ self,
260
+ coroutines: Iterable[Callable[[], Coroutine1[None]]],
261
+ loopers: Iterable[InfiniteLooper[Any]],
262
+ /,
178
263
  ) -> None:
179
264
  """Run multiple loopers."""
180
265
  while True:
181
266
  self._reset_events()
182
267
  try:
183
- async with TaskGroup() as tg:
184
- _ = tg.create_task(self._run_looper())
268
+ async with TaskGroup() as tg, AsyncExitStack() as stack:
269
+ _ = tg.create_task(self._run_looper_by_itself())
185
270
  _ = [tg.create_task(c()) for c in coroutines]
271
+ _ = [
272
+ tg.create_task(stack.enter_async_context(lo)) for lo in loopers
273
+ ]
186
274
  except ExceptionGroup as error:
187
- self._error_group_upon_coroutines(error)
275
+ self._error_group_upon_others(error)
188
276
  await self._run_sleep(self.sleep_restart)
189
277
 
190
278
  async def _initialize(self) -> None:
@@ -193,6 +281,9 @@ class InfiniteLooper(ABC, Generic[THashable]):
193
281
  async def _core(self) -> None:
194
282
  """Run the core part of the loop."""
195
283
 
284
+ async def _teardown(self) -> None:
285
+ """Tear down the loop."""
286
+
196
287
  def _error_upon_initialize(self, error: Exception, /) -> None:
197
288
  """Handle any errors upon initializing the looper."""
198
289
  if self.logger is not None:
@@ -213,7 +304,17 @@ class InfiniteLooper(ABC, Generic[THashable]):
213
304
  self._sleep_restart_desc,
214
305
  )
215
306
 
216
- def _error_group_upon_coroutines(self, group: ExceptionGroup, /) -> None:
307
+ def _error_upon_teardown(self, error: Exception, /) -> None:
308
+ """Handle any errors upon tearing down the looper."""
309
+ if self.logger is not None:
310
+ getLogger(name=self.logger).error(
311
+ "%r encountered %r whilst tearing down; sleeping %s...",
312
+ get_class_name(self),
313
+ repr_error(error),
314
+ self._sleep_restart_desc,
315
+ )
316
+
317
+ def _error_group_upon_others(self, group: ExceptionGroup, /) -> None:
217
318
  """Handle any errors upon running the core function."""
218
319
  if self.logger is not None:
219
320
  errors = group.exceptions
@@ -269,15 +370,19 @@ class InfiniteLooper(ABC, Generic[THashable]):
269
370
  raise _InfiniteLooperNoSuchEventError(looper=self, event=event) from None
270
371
  event_obj.set()
271
372
 
373
+ def _yield_events_and_exceptions(
374
+ self,
375
+ ) -> Iterator[tuple[THashable | None, MaybeType[Exception]]]:
376
+ """Yield the events & exceptions."""
377
+ yield (None, _InfiniteLooperDefaultEventError(looper=self))
378
+
272
379
  def _yield_coroutines(self) -> Iterator[Callable[[], Coroutine1[None]]]:
273
380
  """Yield any other coroutines which must also be run."""
274
381
  yield from []
275
382
 
276
- def _yield_events_and_exceptions(
277
- self,
278
- ) -> Iterator[tuple[THashable | None, MaybeType[BaseException]]]:
279
- """Yield the events & exceptions."""
280
- yield (None, _InfiniteLooperDefaultEventError)
383
+ def _yield_loopers(self) -> Iterator[InfiniteLooper[Any]]:
384
+ """Yield any other loopers which must also be run."""
385
+ yield from []
281
386
 
282
387
 
283
388
  @dataclass(kw_only=True, slots=True)
@@ -309,6 +414,7 @@ class InfiniteQueueLooper(InfiniteLooper[THashable], Generic[THashable, _T]):
309
414
  """An infinite loop which processes a queue."""
310
415
 
311
416
  queue_type: type[Queue[_T]] = field(default=Queue, repr=False)
417
+ _await_upon_aenter: bool = field(default=False, init=False, repr=False)
312
418
  _queue: Queue[_T] = field(init=False)
313
419
 
314
420
  @override
@@ -346,6 +452,7 @@ class InfiniteQueueLooper(InfiniteLooper[THashable], Generic[THashable, _T]):
346
452
  """Run until the queue is empty."""
347
453
  while not self.empty():
348
454
  await self._process_items(*get_items_nowait(self._queue))
455
+ await self.stop()
349
456
 
350
457
  @override
351
458
  def _error_upon_core(self, error: Exception, /) -> None:
@@ -353,12 +460,12 @@ class InfiniteQueueLooper(InfiniteLooper[THashable], Generic[THashable, _T]):
353
460
  if self.logger is not None:
354
461
  if isinstance(error, InfiniteQueueLooperError):
355
462
  getLogger(name=self.logger).error(
356
- "%r encountered %s whilst processing %d item(s) %s; sleeping for %s...",
463
+ "%r encountered %s whilst processing %d item(s) %s; sleeping %s...",
357
464
  get_class_name(self),
358
465
  repr_error(error.error),
359
466
  len(error.items),
360
467
  get_repr(error.items),
361
- self.sleep_restart,
468
+ self._sleep_restart_desc,
362
469
  )
363
470
  else:
364
471
  super()._error_upon_core(error) # pragma: no cover
@@ -370,10 +477,6 @@ class InfiniteQueueLooperError(Exception, Generic[_T]):
370
477
  items: Sequence[_T]
371
478
  error: Exception
372
479
 
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
480
 
378
481
  ##
379
482
 
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