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