dycw-utilities 0.125.11__py3-none-any.whl → 0.125.13__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.125.11
3
+ Version: 0.125.13
4
4
  Author-email: Derek Wan <d.wan@icloud.com>
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -25,7 +25,7 @@ Requires-Dist: altair<5.6,>=5.5.0; extra == 'zzz-test-altair'
25
25
  Requires-Dist: atomicwrites<1.5,>=1.4.1; extra == 'zzz-test-altair'
26
26
  Requires-Dist: img2pdf<0.7,>=0.6.0; extra == 'zzz-test-altair'
27
27
  Requires-Dist: polars-lts-cpu<1.31,>=1.30.0; extra == 'zzz-test-altair'
28
- Requires-Dist: vl-convert-python<1.8,>=1.7.0; extra == 'zzz-test-altair'
28
+ Requires-Dist: vl-convert-python<1.9,>=1.8.0; extra == 'zzz-test-altair'
29
29
  Requires-Dist: whenever<0.9,>=0.8.4; extra == 'zzz-test-altair'
30
30
  Provides-Extra: zzz-test-asyncio
31
31
  Provides-Extra: zzz-test-atomicwrites
@@ -82,7 +82,7 @@ Requires-Dist: hypothesis<6.132,>=6.131.30; extra == 'zzz-test-hypothesis'
82
82
  Requires-Dist: luigi<3.7,>=3.6.0; extra == 'zzz-test-hypothesis'
83
83
  Requires-Dist: numpy<2.3,>=2.2.6; extra == 'zzz-test-hypothesis'
84
84
  Requires-Dist: pathvalidate<3.3,>=3.2.3; extra == 'zzz-test-hypothesis'
85
- Requires-Dist: redis<6.2,>=6.1.0; extra == 'zzz-test-hypothesis'
85
+ Requires-Dist: redis<6.3,>=6.2.0; extra == 'zzz-test-hypothesis'
86
86
  Requires-Dist: sqlalchemy<2.1,>=2.0.41; extra == 'zzz-test-hypothesis'
87
87
  Requires-Dist: tenacity<9.0,>=8.5.0; extra == 'zzz-test-hypothesis'
88
88
  Requires-Dist: tzlocal<5.4,>=5.3.1; extra == 'zzz-test-hypothesis'
@@ -162,7 +162,7 @@ Provides-Extra: zzz-test-re
162
162
  Provides-Extra: zzz-test-redis
163
163
  Requires-Dist: orjson<3.11,>=3.10.15; extra == 'zzz-test-redis'
164
164
  Requires-Dist: polars-lts-cpu<1.31,>=1.30.0; extra == 'zzz-test-redis'
165
- Requires-Dist: redis<6.2,>=6.1.0; extra == 'zzz-test-redis'
165
+ Requires-Dist: redis<6.3,>=6.2.0; extra == 'zzz-test-redis'
166
166
  Requires-Dist: rich<14.1,>=14.0.0; extra == 'zzz-test-redis'
167
167
  Requires-Dist: tenacity<9.0,>=8.5.0; extra == 'zzz-test-redis'
168
168
  Requires-Dist: tzlocal<5.4,>=5.3.1; extra == 'zzz-test-redis'
@@ -174,7 +174,7 @@ Requires-Dist: scipy<1.16,>=1.15.3; extra == 'zzz-test-scipy'
174
174
  Provides-Extra: zzz-test-sentinel
175
175
  Provides-Extra: zzz-test-shelve
176
176
  Provides-Extra: zzz-test-slack-sdk
177
- Requires-Dist: aiohttp<3.12.3,>=3.12.2; extra == 'zzz-test-slack-sdk'
177
+ Requires-Dist: aiohttp<3.12.5,>=3.12.4; extra == 'zzz-test-slack-sdk'
178
178
  Requires-Dist: slack-sdk<3.36,>=3.35.0; extra == 'zzz-test-slack-sdk'
179
179
  Provides-Extra: zzz-test-socket
180
180
  Provides-Extra: zzz-test-sqlalchemy
@@ -1,6 +1,6 @@
1
- utilities/__init__.py,sha256=1KLLBbQUSgDlyi5mt8fAcIzoykpEOLEnPlQ-8KDJ2iA,61
1
+ utilities/__init__.py,sha256=7ZYwhNHFuejRkKzg0MeSvh2I14YxTE-mRj6ie69FmMU,61
2
2
  utilities/altair.py,sha256=Gpja-flOo-Db0PIPJLJsgzAlXWoKUjPU1qY-DQ829ek,9156
3
- utilities/asyncio.py,sha256=3OVbJKTYCucrIkM7WSMMkQA9jYJFCQMnGNM4H5rBNgA,29154
3
+ utilities/asyncio.py,sha256=uUVIpmBMP-tAYPnE2Yi7ZyTno7VYImjNp7uA85MQhys,47865
4
4
  utilities/atomicwrites.py,sha256=geFjn9Pwn-tTrtoGjDDxWli9NqbYfy3gGL6ZBctiqSo,5393
5
5
  utilities/atools.py,sha256=IYMuFSFGSKyuQmqD6v5IUtDlz8PPw0Sr87Cub_gRU3M,1168
6
6
  utilities/cachetools.py,sha256=C1zqOg7BYz0IfQFK8e3qaDDgEZxDpo47F15RTfJM37Q,2910
@@ -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.125.11.dist-info/METADATA,sha256=iPt3X8RJ44P_WrBe8mWByZVUftXSyQlmv7jj0VtUOyU,12852
92
- dycw_utilities-0.125.11.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
93
- dycw_utilities-0.125.11.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
94
- dycw_utilities-0.125.11.dist-info/RECORD,,
91
+ dycw_utilities-0.125.13.dist-info/METADATA,sha256=hQ21eoRHxkp3q3Y7wQ3F9fXew2_S1sTHV3hd68qX8e4,12852
92
+ dycw_utilities-0.125.13.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
93
+ dycw_utilities-0.125.13.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
94
+ dycw_utilities-0.125.13.dist-info/RECORD,,
utilities/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.125.11"
3
+ __version__ = "0.125.13"
utilities/asyncio.py CHANGED
@@ -5,6 +5,7 @@ from abc import ABC, abstractmethod
5
5
  from asyncio import (
6
6
  CancelledError,
7
7
  Event,
8
+ Lock,
8
9
  PriorityQueue,
9
10
  Queue,
10
11
  QueueEmpty,
@@ -29,7 +30,7 @@ from contextlib import (
29
30
  from dataclasses import dataclass, field
30
31
  from io import StringIO
31
32
  from itertools import chain
32
- from logging import getLogger
33
+ from logging import DEBUG, Logger, getLogger
33
34
  from subprocess import PIPE
34
35
  from sys import stderr, stdout
35
36
  from typing import (
@@ -47,6 +48,7 @@ from typing import (
47
48
 
48
49
  from typing_extensions import deprecated
49
50
 
51
+ from utilities.dataclasses import replace_non_sentinel
50
52
  from utilities.datetime import (
51
53
  MINUTE,
52
54
  SECOND,
@@ -610,12 +612,9 @@ class InfiniteQueueLooper(InfiniteLooper[THashable], Generic[THashable, _T]):
610
612
  """An infinite loop which processes a queue."""
611
613
 
612
614
  _await_upon_aenter: bool = field(default=False, init=False, repr=False)
613
- _queue: EnhancedQueue[_T] = field(init=False, repr=False)
614
-
615
- @override
616
- def __post_init__(self) -> None:
617
- super().__post_init__()
618
- self._queue = EnhancedQueue()
615
+ _queue: EnhancedQueue[_T] = field(
616
+ default_factory=EnhancedQueue, init=False, repr=False
617
+ )
619
618
 
620
619
  def __len__(self) -> int:
621
620
  return self._queue.qsize()
@@ -623,8 +622,8 @@ class InfiniteQueueLooper(InfiniteLooper[THashable], Generic[THashable, _T]):
623
622
  @override
624
623
  async def _core(self) -> None:
625
624
  """Run the core part of the loop."""
626
- first = await self._queue.get_left()
627
- self._queue.put_left_nowait(first)
625
+ if self.empty():
626
+ return
628
627
  await self._process_queue()
629
628
 
630
629
  @abstractmethod
@@ -643,6 +642,10 @@ class InfiniteQueueLooper(InfiniteLooper[THashable], Generic[THashable, _T]):
643
642
  """Put items into the queue at the end without blocking."""
644
643
  self._queue.put_right_nowait(*items) # pragma: no cover
645
644
 
645
+ def qsize(self) -> int:
646
+ """Get the number of items in the queue."""
647
+ return self._queue.qsize()
648
+
646
649
  async def run_until_empty(self, *, stop: bool = False) -> None:
647
650
  """Run until the queue is empty."""
648
651
  while not self.empty():
@@ -654,6 +657,464 @@ class InfiniteQueueLooper(InfiniteLooper[THashable], Generic[THashable, _T]):
654
657
  ##
655
658
 
656
659
 
660
+ @dataclass(kw_only=True, slots=True)
661
+ class LooperError(Exception): ...
662
+
663
+
664
+ @dataclass(kw_only=True, slots=True)
665
+ class LooperTimeoutError(LooperError):
666
+ duration: Duration | None = None
667
+
668
+ @override
669
+ def __str__(self) -> str:
670
+ return "Timeout" if self.duration is None else f"Timeout after {self.duration}"
671
+
672
+
673
+ @dataclass(kw_only=True, slots=True)
674
+ class _LooperNoTaskError(LooperError):
675
+ looper: Looper
676
+
677
+ @override
678
+ def __str__(self) -> str:
679
+ return f"{self.looper} has no running task"
680
+
681
+
682
+ @dataclass(kw_only=True, unsafe_hash=True)
683
+ class Looper(Generic[_T]):
684
+ """A looper of a core coroutine, handling errors."""
685
+
686
+ auto_start: bool = field(default=False, repr=False)
687
+ freq: Duration = field(default=SECOND, repr=False)
688
+ backoff: Duration = field(default=10 * SECOND, repr=False)
689
+ logger: str | None = field(default=None, repr=False)
690
+ timeout: Duration | None = field(default=None, repr=False)
691
+ timeout_error: type[Exception] = field(default=LooperTimeoutError, repr=False)
692
+ # settings
693
+ _backoff: float = field(init=False, repr=False)
694
+ _debug: bool = field(default=False, repr=False)
695
+ _freq: float = field(init=False, repr=False)
696
+ # counts
697
+ _entries: int = field(default=0, init=False, repr=False)
698
+ _core_attempts: int = field(default=0, init=False, repr=False)
699
+ _core_successes: int = field(default=0, init=False, repr=False)
700
+ _core_failures: int = field(default=0, init=False, repr=False)
701
+ _initialization_attempts: int = field(default=0, init=False, repr=False)
702
+ _initialization_successes: int = field(default=0, init=False, repr=False)
703
+ _initialization_failures: int = field(default=0, init=False, repr=False)
704
+ _tear_down_attempts: int = field(default=0, init=False, repr=False)
705
+ _tear_down_successes: int = field(default=0, init=False, repr=False)
706
+ _tear_down_failures: int = field(default=0, init=False, repr=False)
707
+ _restart_attempts: int = field(default=0, init=False, repr=False)
708
+ _restart_successes: int = field(default=0, init=False, repr=False)
709
+ _restart_failures: int = field(default=0, init=False, repr=False)
710
+ _stops: int = field(default=0, init=False, repr=False)
711
+ # flags
712
+ _is_entered: Event = field(default_factory=Event, init=False, repr=False)
713
+ _is_initialized: Event = field(default_factory=Event, init=False, repr=False)
714
+ _is_initializing: Event = field(default_factory=Event, init=False, repr=False)
715
+ _is_pending_restart: Event = field(default_factory=Event, init=False, repr=False)
716
+ _is_pending_stop: Event = field(default_factory=Event, init=False, repr=False)
717
+ _is_pending_stop_when_empty: Event = field(
718
+ default_factory=Event, init=False, repr=False
719
+ )
720
+ _is_stopped: Event = field(default_factory=Event, init=False, repr=False)
721
+ _is_tearing_down: Event = field(default_factory=Event, init=False, repr=False)
722
+ # internal objects
723
+ _lock: Lock = field(default_factory=Lock, init=False, repr=False, hash=False)
724
+ _logger: Logger = field(init=False, repr=False, hash=False)
725
+ _queue: EnhancedQueue[_T] = field(
726
+ default_factory=EnhancedQueue, init=False, repr=False, hash=False
727
+ )
728
+ _stack: AsyncExitStack = field(
729
+ default_factory=AsyncExitStack, init=False, repr=False, hash=False
730
+ )
731
+ _task: Task[None] | None = field(default=None, init=False, repr=False, hash=False)
732
+
733
+ def __post_init__(self) -> None:
734
+ self._backoff = datetime_duration_to_float(self.backoff)
735
+ self._freq = datetime_duration_to_float(self.freq)
736
+ self._logger = getLogger(name=self.logger)
737
+ self._logger.setLevel(DEBUG)
738
+
739
+ async def __aenter__(self) -> Self:
740
+ """Enter the context manager."""
741
+ match self._is_entered.is_set():
742
+ case True:
743
+ _ = self._debug and self._logger.debug("%s: already entered", self)
744
+ case False:
745
+ _ = self._debug and self._logger.debug("%s: entering context...", self)
746
+ self._is_entered.set()
747
+ async with self._lock:
748
+ self._entries += 1
749
+ self._task = create_task(self.run_looper())
750
+ for looper in self._yield_sub_loopers():
751
+ _ = self._debug and self._logger.debug(
752
+ "%s: adding sub-looper %s", self, looper
753
+ )
754
+ if not looper.auto_start:
755
+ self._logger.warning(
756
+ "%s: changing sub-looper %s to auto-start...", self, looper
757
+ )
758
+ async with self._lock:
759
+ looper.auto_start = True
760
+ _ = await self._stack.enter_async_context(looper)
761
+ if self.auto_start:
762
+ _ = self._debug and self._logger.debug("%s: auto-starting...", self)
763
+ with suppress(self.timeout_error):
764
+ await self._task
765
+ case _ as never:
766
+ assert_never(never)
767
+ return self
768
+
769
+ async def __aexit__(
770
+ self,
771
+ exc_type: type[BaseException] | None = None,
772
+ exc_value: BaseException | None = None,
773
+ traceback: TracebackType | None = None,
774
+ ) -> None:
775
+ """Exit the context manager."""
776
+ match self._is_entered.is_set():
777
+ case True:
778
+ _ = self._debug and self._logger.debug("%s: exiting context...", self)
779
+ self._is_entered.clear()
780
+ if (
781
+ (exc_type is not None)
782
+ and (exc_value is not None)
783
+ and (traceback is not None)
784
+ ):
785
+ _ = self._debug and self._logger.warning(
786
+ "%s: encountered %s whilst in context",
787
+ self,
788
+ repr_error(exc_value),
789
+ )
790
+ _ = await self._stack.__aexit__(exc_type, exc_value, traceback)
791
+ await self.stop()
792
+ case False:
793
+ _ = self._debug and self._logger.debug("%s: already exited", self)
794
+ case _ as never:
795
+ assert_never(never)
796
+
797
+ def __await__(self) -> Any:
798
+ match self._task:
799
+ case None:
800
+ raise _LooperNoTaskError(looper=self)
801
+ case Task() as task:
802
+ return task.__await__()
803
+ case _ as never:
804
+ assert_never(never)
805
+
806
+ def __len__(self) -> int:
807
+ return self._queue.qsize()
808
+
809
+ async def core(self) -> None:
810
+ """Core part of running the looper."""
811
+
812
+ def empty(self) -> bool:
813
+ """Check if the queue is empty."""
814
+ return self._queue.empty()
815
+
816
+ def get_all_nowait(self, *, reverse: bool = False) -> Sequence[_T]:
817
+ """Remove and return all items from the queue without blocking."""
818
+ return self._queue.get_all_nowait(reverse=reverse)
819
+
820
+ def get_left_nowait(self) -> _T:
821
+ """Remove and return an item from the start of the queue without blocking."""
822
+ return self._queue.get_left_nowait()
823
+
824
+ def get_right_nowait(self) -> _T:
825
+ """Remove and return an item from the end of the queue without blocking."""
826
+ return self._queue.get_right_nowait()
827
+
828
+ async def initialize(self) -> Exception | None:
829
+ """Initialize the looper."""
830
+ match self._is_initializing.is_set():
831
+ case True:
832
+ _ = self._debug and self._logger.debug("%s: already initializing", self)
833
+ return None
834
+ case False:
835
+ _ = self._debug and self._logger.debug("%s: initializing...", self)
836
+ self._is_initializing.set()
837
+ self._is_initialized.clear()
838
+ async with self._lock:
839
+ self._initialization_attempts += 1
840
+ try:
841
+ await self._initialize_core()
842
+ except Exception as error: # noqa: BLE001
843
+ _ = self._logger.warning(
844
+ "%s: encountered %s whilst initializing",
845
+ self,
846
+ repr_error(error),
847
+ )
848
+ async with self._lock:
849
+ self._initialization_failures += 1
850
+ ret = error
851
+ else:
852
+ _ = self._debug and self._logger.debug(
853
+ "%s: finished initializing", self
854
+ )
855
+ self._is_initialized.set()
856
+ async with self._lock:
857
+ self._initialization_successes += 1
858
+ ret = None
859
+ finally:
860
+ self._is_initializing.clear()
861
+ return ret
862
+ case _ as never:
863
+ assert_never(never)
864
+
865
+ async def _initialize_core(self) -> None:
866
+ """Core part of initializing the looper."""
867
+
868
+ def put_left_nowait(self, *items: _T) -> None:
869
+ """Put items into the queue at the start without blocking."""
870
+ self._queue.put_left_nowait(*items)
871
+
872
+ def put_right_nowait(self, *items: _T) -> None:
873
+ """Put items into the queue at the end without blocking."""
874
+ self._queue.put_right_nowait(*items)
875
+
876
+ def qsize(self) -> int:
877
+ """Get the number of items in the queue."""
878
+ return self._queue.qsize()
879
+
880
+ def replace(
881
+ self,
882
+ *,
883
+ auto_start: bool | Sentinel = sentinel,
884
+ freq: Duration | Sentinel = sentinel,
885
+ backoff: Duration | Sentinel = sentinel,
886
+ logger: str | None | Sentinel = sentinel,
887
+ timeout: Duration | None | Sentinel = sentinel,
888
+ ) -> Self:
889
+ """Replace elements of the looper."""
890
+ return replace_non_sentinel(
891
+ self,
892
+ auto_start=auto_start,
893
+ freq=freq,
894
+ backoff=backoff,
895
+ logger=logger,
896
+ timeout=timeout,
897
+ )
898
+
899
+ def request_restart(self) -> None:
900
+ """Request the looper to restart."""
901
+ match self._is_pending_restart.is_set():
902
+ case True:
903
+ _ = self._debug and self._logger.debug(
904
+ "%s: already requested restart", self
905
+ )
906
+ case False:
907
+ _ = self._debug and self._logger.debug(
908
+ "%s: requesting restart...", self
909
+ )
910
+ self._is_pending_restart.set()
911
+ case _ as never:
912
+ assert_never(never)
913
+
914
+ def request_stop(self) -> None:
915
+ """Request the looper to stop."""
916
+ match self._is_pending_stop.is_set():
917
+ case True:
918
+ _ = self._debug and self._logger.debug(
919
+ "%s: already requested stop", self
920
+ )
921
+ case False:
922
+ _ = self._debug and self._logger.debug("%s: requesting stop...", self)
923
+ self._is_pending_stop.set()
924
+ case _ as never:
925
+ assert_never(never)
926
+
927
+ def request_stop_when_empty(self) -> None:
928
+ """Request the looper to stop when the queue is empty."""
929
+ match self._is_pending_stop_when_empty.is_set():
930
+ case True:
931
+ _ = self._debug and self._logger.debug(
932
+ "%s: already requested stop when empty", self
933
+ )
934
+ case False:
935
+ _ = self._debug and self._logger.debug(
936
+ "%s: requesting stop when empty...", self
937
+ )
938
+ self._is_pending_stop_when_empty.set()
939
+ case _ as never:
940
+ assert_never(never)
941
+
942
+ async def restart(self) -> None:
943
+ """Restart the looper."""
944
+ _ = self._debug and self._logger.debug("%s: restarting...", self)
945
+ self._is_pending_restart.clear()
946
+ async with self._lock:
947
+ self._restart_attempts += 1
948
+ tear_down = await self.tear_down()
949
+ initialization = await self.initialize()
950
+ match tear_down, initialization:
951
+ case None, None:
952
+ _ = self._debug and self._logger.debug("%s: finished restarting", self)
953
+ async with self._lock:
954
+ self._restart_successes += 1
955
+ case Exception(), None:
956
+ _ = self._logger.warning(
957
+ "%s: encountered %s whilst restarting, during tear down",
958
+ self,
959
+ repr_error(tear_down),
960
+ )
961
+ async with self._lock:
962
+ self._restart_failures += 1
963
+ case None, Exception():
964
+ _ = self._logger.warning(
965
+ "%s: encountered %s whilst restarting, during initialization",
966
+ self,
967
+ repr_error(initialization),
968
+ )
969
+ async with self._lock:
970
+ self._restart_failures += 1
971
+ case Exception(), Exception():
972
+ _ = self._logger.warning(
973
+ "%s: encountered %s (tear down) and then %s (initialization) whilst restarting",
974
+ self,
975
+ repr_error(tear_down),
976
+ repr_error(initialization),
977
+ )
978
+ async with self._lock:
979
+ self._restart_failures += 1
980
+ case _ as never:
981
+ assert_never(never)
982
+
983
+ async def run_looper(self) -> None:
984
+ """Run the looper."""
985
+ async with timeout_dur(duration=self.timeout, error=self.timeout_error):
986
+ while True:
987
+ if self._is_stopped.is_set():
988
+ _ = self._debug and self._logger.debug("%s: stopped", self)
989
+ return
990
+ if (self._is_pending_stop.is_set()) or (
991
+ self._is_pending_stop_when_empty.is_set() and self.empty()
992
+ ):
993
+ await self.stop()
994
+ elif self._is_pending_restart.is_set():
995
+ await self.restart()
996
+ elif not self._is_initialized.is_set():
997
+ _ = await self.initialize()
998
+ else:
999
+ _ = self._debug and self._logger.debug("%s: running core...", self)
1000
+ async with self._lock:
1001
+ self._core_attempts += 1
1002
+ try:
1003
+ await self.core()
1004
+ except Exception as error: # noqa: BLE001
1005
+ _ = self._logger.warning(
1006
+ "%s: encountered %s whilst running core...",
1007
+ self,
1008
+ repr_error(error),
1009
+ )
1010
+ async with self._lock:
1011
+ self._core_failures += 1
1012
+ self.request_restart()
1013
+ await sleep(self._backoff)
1014
+ else:
1015
+ async with self._lock:
1016
+ self._core_successes += 1
1017
+ await sleep(self._freq)
1018
+
1019
+ @property
1020
+ def stats(self) -> _LooperStats:
1021
+ """Return the statistics."""
1022
+ return _LooperStats(
1023
+ entries=self._entries,
1024
+ core_attempts=self._core_attempts,
1025
+ core_successes=self._core_successes,
1026
+ core_failures=self._core_failures,
1027
+ initialization_attempts=self._initialization_attempts,
1028
+ initialization_successes=self._initialization_successes,
1029
+ initialization_failures=self._initialization_failures,
1030
+ tear_down_attempts=self._tear_down_attempts,
1031
+ tear_down_successes=self._tear_down_successes,
1032
+ tear_down_failures=self._tear_down_failures,
1033
+ restart_attempts=self._restart_attempts,
1034
+ restart_successes=self._restart_successes,
1035
+ restart_failures=self._restart_failures,
1036
+ stops=self._stops,
1037
+ )
1038
+
1039
+ async def stop(self) -> None:
1040
+ """Stop the looper."""
1041
+ match self._is_stopped.is_set():
1042
+ case True:
1043
+ _ = self._debug and self._logger.debug("%s: already stopped", self)
1044
+ case False:
1045
+ _ = self._debug and self._logger.debug("%s: stopping...", self)
1046
+ self._is_pending_stop.clear()
1047
+ self._is_stopped.set()
1048
+ async with self._lock:
1049
+ self._stops += 1
1050
+ _ = self._debug and self._logger.debug("%s: stopped", self)
1051
+ case _ as never:
1052
+ assert_never(never)
1053
+
1054
+ async def tear_down(self) -> Exception | None:
1055
+ """Tear down the looper."""
1056
+ match self._is_tearing_down.is_set():
1057
+ case True:
1058
+ _ = self._debug and self._logger.debug("%s: already tearing down", self)
1059
+ return None
1060
+ case False:
1061
+ _ = self._debug and self._logger.debug("%s: tearing down...", self)
1062
+ self._is_tearing_down.set()
1063
+ async with self._lock:
1064
+ self._tear_down_attempts += 1
1065
+ try:
1066
+ await self._tear_down_core()
1067
+ except Exception as error: # noqa: BLE001
1068
+ _ = self._logger.warning(
1069
+ "%s: encountered %s whilst tearing down",
1070
+ self,
1071
+ repr_error(error),
1072
+ )
1073
+ async with self._lock:
1074
+ self._tear_down_failures += 1
1075
+ ret = error
1076
+ else:
1077
+ _ = self._debug and self._logger.debug(
1078
+ "%s: finished tearing down", self
1079
+ )
1080
+ async with self._lock:
1081
+ self._tear_down_successes += 1
1082
+ ret = None
1083
+ finally:
1084
+ self._is_tearing_down.clear()
1085
+ return ret
1086
+ case _ as never:
1087
+ assert_never(never)
1088
+
1089
+ async def _tear_down_core(self) -> None:
1090
+ """Core part of tearing down the looper."""
1091
+
1092
+ def _yield_sub_loopers(self) -> Iterator[Looper]:
1093
+ """Yield all sub-loopers."""
1094
+ yield from []
1095
+
1096
+
1097
+ @dataclass(kw_only=True, slots=True)
1098
+ class _LooperStats:
1099
+ entries: int = 0
1100
+ core_attempts: int = 0
1101
+ core_successes: int = 0
1102
+ core_failures: int = 0
1103
+ initialization_attempts: int = 0
1104
+ initialization_successes: int = 0
1105
+ initialization_failures: int = 0
1106
+ tear_down_attempts: int = 0
1107
+ tear_down_successes: int = 0
1108
+ tear_down_failures: int = 0
1109
+ restart_attempts: int = 0
1110
+ restart_successes: int = 0
1111
+ restart_failures: int = 0
1112
+ stops: int = 0
1113
+
1114
+
1115
+ ##
1116
+
1117
+
657
1118
  class UniquePriorityQueue(PriorityQueue[tuple[TSupportsRichComparison, THashable]]):
658
1119
  """Priority queue with unique tasks."""
659
1120
 
@@ -878,6 +1339,9 @@ __all__ = [
878
1339
  "InfiniteLooper",
879
1340
  "InfiniteLooperError",
880
1341
  "InfiniteQueueLooper",
1342
+ "Looper",
1343
+ "LooperError",
1344
+ "LooperTimeoutError",
881
1345
  "StreamCommandOutput",
882
1346
  "UniquePriorityQueue",
883
1347
  "UniqueQueue",