dycw-utilities 0.125.26__py3-none-any.whl → 0.126.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.125.26
3
+ Version: 0.126.0
4
4
  Author-email: Derek Wan <d.wan@icloud.com>
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -1,4 +1,4 @@
1
- utilities/__init__.py,sha256=Q_deKUGgT21jjkI5E7MKTAtXWcqWJotIcZmKdq_5394,61
1
+ utilities/__init__.py,sha256=aFDfK6NakMMklkGA0bazES2h83YpO5ps55Ep7UY5lgw,60
2
2
  utilities/altair.py,sha256=Gpja-flOo-Db0PIPJLJsgzAlXWoKUjPU1qY-DQ829ek,9156
3
3
  utilities/asyncio.py,sha256=K5Kj7rsM0nA17-b7d7mrNgPR1U_NbkfQmTruq5LBLRA,51778
4
4
  utilities/atomicwrites.py,sha256=geFjn9Pwn-tTrtoGjDDxWli9NqbYfy3gGL6ZBctiqSo,5393
@@ -60,7 +60,7 @@ utilities/pytest_regressions.py,sha256=-SVT9647Dg6-JcdsiaDKXe3NdOmmrvGevLKWwGjxq
60
60
  utilities/python_dotenv.py,sha256=iWcnpXbH7S6RoXHiLlGgyuH6udCupAcPd_gQ0eAenQ0,3190
61
61
  utilities/random.py,sha256=lYdjgxB7GCfU_fwFVl5U-BIM_HV3q6_urL9byjrwDM8,4157
62
62
  utilities/re.py,sha256=5J4d8VwIPFVrX2Eb8zfoxImDv7IwiN_U7mJ07wR2Wvs,3958
63
- utilities/redis.py,sha256=fMVKsVCjCv63m_JgoOKqj3wHVzzHR0s4suvZ-mBy1gk,26615
63
+ utilities/redis.py,sha256=BTsRqr6VL-BkGVdpj8PPOmJ04ijCrPn5LK5MaMOopfg,32916
64
64
  utilities/reprlib.py,sha256=Re9bk3n-kC__9DxQmRlevqFA86pE6TtVfWjUgpbVOv0,1849
65
65
  utilities/rich.py,sha256=t50MwwVBsoOLxzmeVFSVpjno4OW6Ufum32skXbV8-Bs,1911
66
66
  utilities/scipy.py,sha256=X6ROnHwiUhAmPhM0jkfEh0-Fd9iRvwiqtCQMOLmOQF8,945
@@ -90,7 +90,7 @@ utilities/warnings.py,sha256=un1LvHv70PU-LLv8RxPVmugTzDJkkGXRMZTE2-fTQHw,1771
90
90
  utilities/whenever.py,sha256=jS31ZAY5OMxFxLja_Yo5Fidi87Pd-GoVZ7Vi_teqVDA,16743
91
91
  utilities/zipfile.py,sha256=24lQc9ATcJxHXBPc_tBDiJk48pWyRrlxO2fIsFxU0A8,699
92
92
  utilities/zoneinfo.py,sha256=-5j7IQ9nb7gR43rdgA7ms05im-XuqhAk9EJnQBXxCoQ,1874
93
- dycw_utilities-0.125.26.dist-info/METADATA,sha256=KvzlW9sc8xXbF0-RtEA4gzn8H_mkvGM1pgby4iC-u4A,12852
94
- dycw_utilities-0.125.26.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
95
- dycw_utilities-0.125.26.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
96
- dycw_utilities-0.125.26.dist-info/RECORD,,
93
+ dycw_utilities-0.126.0.dist-info/METADATA,sha256=9_nkcMMjhyueByXHhkoR268_woBxqk5jmOXg4mOPHz8,12851
94
+ dycw_utilities-0.126.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
95
+ dycw_utilities-0.126.0.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
96
+ dycw_utilities-0.126.0.dist-info/RECORD,,
utilities/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.125.26"
3
+ __version__ = "0.126.0"
utilities/redis.py CHANGED
@@ -1,15 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
- from collections.abc import Callable
5
- from contextlib import asynccontextmanager
4
+ from asyncio import CancelledError, Event, Queue, Task, create_task
5
+ from collections.abc import AsyncIterator, Callable, Mapping
6
+ from contextlib import asynccontextmanager, suppress
6
7
  from dataclasses import dataclass, field
8
+ from functools import partial
9
+ from operator import itemgetter
7
10
  from typing import (
8
11
  TYPE_CHECKING,
9
12
  Any,
10
13
  Generic,
11
14
  Literal,
15
+ Self,
12
16
  TypedDict,
17
+ TypeGuard,
13
18
  TypeVar,
14
19
  assert_never,
15
20
  cast,
@@ -19,10 +24,9 @@ from typing import (
19
24
  from uuid import UUID, uuid4
20
25
 
21
26
  from redis.asyncio import Redis
22
- from redis.asyncio.client import PubSub
23
27
  from redis.typing import EncodableT
24
28
 
25
- from utilities.asyncio import InfiniteQueueLooper, timeout_dur
29
+ from utilities.asyncio import EnhancedQueue, InfiniteQueueLooper, Looper, timeout_dur
26
30
  from utilities.datetime import (
27
31
  MILLISECOND,
28
32
  SECOND,
@@ -31,7 +35,7 @@ from utilities.datetime import (
31
35
  get_now,
32
36
  )
33
37
  from utilities.errors import ImpossibleCaseError
34
- from utilities.functions import ensure_int, get_class_name
38
+ from utilities.functions import ensure_int, get_class_name, identity
35
39
  from utilities.iterables import always_iterable, one
36
40
 
37
41
  if TYPE_CHECKING:
@@ -39,14 +43,15 @@ if TYPE_CHECKING:
39
43
  from collections.abc import (
40
44
  AsyncIterator,
41
45
  Awaitable,
42
- Callable,
46
+ Collection,
43
47
  Iterable,
44
48
  Iterator,
45
- Mapping,
46
49
  Sequence,
47
50
  )
51
+ from types import TracebackType
48
52
 
49
53
  from redis.asyncio import ConnectionPool
54
+ from redis.asyncio.client import PubSub
50
55
  from redis.typing import ResponseT
51
56
 
52
57
  from utilities.iterables import MaybeIterable
@@ -555,36 +560,63 @@ async def publish(
555
560
  data: _T,
556
561
  /,
557
562
  *,
558
- serializer: Callable[[_T], EncodableT],
563
+ serializer: Callable[[bytes | str | _T], EncodableT],
559
564
  timeout: Duration = _PUBLISH_TIMEOUT,
560
565
  ) -> ResponseT: ...
561
566
  @overload
562
567
  async def publish(
563
568
  redis: Redis,
564
569
  channel: str,
565
- data: EncodableT,
570
+ data: bytes | str,
566
571
  /,
567
572
  *,
568
- serializer: Callable[[EncodableT], EncodableT] | None = None,
573
+ serializer: None = None,
569
574
  timeout: Duration = _PUBLISH_TIMEOUT,
570
575
  ) -> ResponseT: ...
576
+ @overload
571
577
  async def publish(
572
578
  redis: Redis,
573
579
  channel: str,
574
- data: Any,
580
+ data: bytes | str | _T,
575
581
  /,
576
582
  *,
577
- serializer: Callable[[Any], EncodableT] | None = None,
583
+ serializer: Callable[[bytes | str | _T], EncodableT] | None = None,
584
+ timeout: Duration = _PUBLISH_TIMEOUT,
585
+ ) -> ResponseT: ...
586
+ async def publish(
587
+ redis: Redis,
588
+ channel: str,
589
+ data: bytes | str | _T,
590
+ /,
591
+ *,
592
+ serializer: Callable[[bytes | str | _T], EncodableT] | None = None,
578
593
  timeout: Duration = _PUBLISH_TIMEOUT,
579
594
  ) -> ResponseT:
580
595
  """Publish an object to a channel."""
581
- data_use = ( # skipif-ci-and-not-linux
582
- cast("EncodableT", data) if serializer is None else serializer(data)
583
- )
596
+ if isinstance(data, bytes | str) and ( # skipif-ci-and-not-linux
597
+ serializer is None
598
+ ):
599
+ data_use = data
600
+ elif serializer is not None: # skipif-ci-and-not-linux
601
+ data_use = serializer(data)
602
+ else: # skipif-ci-and-not-linux
603
+ raise PublishError(data=data, serializer=serializer)
584
604
  async with timeout_dur(duration=timeout): # skipif-ci-and-not-linux
585
605
  return await redis.publish(channel, data_use) # skipif-ci-and-not-linux
586
606
 
587
607
 
608
+ @dataclass(kw_only=True, slots=True)
609
+ class PublishError(Exception):
610
+ data: Any
611
+ serializer: Callable[[Any], EncodableT] | None = None
612
+
613
+ @override
614
+ def __str__(self) -> str:
615
+ return (
616
+ f"Unable to publish data {self.data!r} with serializer {self.serializer!r}"
617
+ )
618
+
619
+
588
620
  ##
589
621
 
590
622
 
@@ -624,6 +656,33 @@ class PublisherError(Exception):
624
656
  return f"Error running {get_class_name(self.publisher)!r}" # skipif-ci-and-not-linux
625
657
 
626
658
 
659
+ @dataclass(kw_only=True)
660
+ class PublishService(Looper[tuple[str, _T]]):
661
+ """Service to publish items to Redis."""
662
+
663
+ # base
664
+ freq: Duration = field(default=MILLISECOND, repr=False)
665
+ backoff: Duration = field(default=SECOND, repr=False)
666
+ empty_upon_exit: bool = field(default=True, repr=False)
667
+ # self
668
+ redis: Redis
669
+ serializer: Callable[[Any], EncodableT] | None = None
670
+ publish_timeout: Duration = SECOND
671
+
672
+ @override
673
+ async def core(self) -> None:
674
+ await super().core() # skipif-ci-and-not-linux
675
+ while not self.empty(): # skipif-ci-and-not-linux
676
+ channel, data = self.get_left_nowait()
677
+ _ = await publish(
678
+ self.redis,
679
+ channel,
680
+ cast("Any", data),
681
+ serializer=self.serializer,
682
+ timeout=self.publish_timeout,
683
+ )
684
+
685
+
627
686
  ##
628
687
 
629
688
 
@@ -632,90 +691,141 @@ _SUBSCRIBE_SLEEP: Duration = MILLISECOND
632
691
 
633
692
 
634
693
  @overload
694
+ @asynccontextmanager
635
695
  def subscribe(
636
- redis_or_pubsub: Redis | PubSub,
696
+ redis: Redis,
637
697
  channels: MaybeIterable[str],
698
+ queue: Queue[_RedisMessageSubscribe],
638
699
  /,
639
700
  *,
640
- deserializer: Callable[[bytes], _T],
641
701
  timeout: Duration | None = _SUBSCRIBE_TIMEOUT,
642
702
  sleep: Duration = _SUBSCRIBE_SLEEP,
643
- ) -> AsyncIterator[_T]: ...
703
+ output: Literal["raw"],
704
+ ) -> AsyncIterator[Task[None]]: ...
644
705
  @overload
706
+ @asynccontextmanager
645
707
  def subscribe(
646
- redis_or_pubsub: Redis | PubSub,
708
+ redis: Redis,
647
709
  channels: MaybeIterable[str],
710
+ queue: Queue[bytes],
648
711
  /,
649
712
  *,
650
- deserializer: None = None,
651
713
  timeout: Duration | None = _SUBSCRIBE_TIMEOUT,
652
714
  sleep: Duration = _SUBSCRIBE_SLEEP,
653
- ) -> AsyncIterator[bytes]: ...
654
- async def subscribe( # pyright: ignore[reportInconsistentOverload]
655
- redis_or_pubsub: Redis | PubSub,
715
+ output: Literal["bytes"],
716
+ ) -> AsyncIterator[Task[None]]: ...
717
+ @overload
718
+ @asynccontextmanager
719
+ def subscribe(
720
+ redis: Redis,
656
721
  channels: MaybeIterable[str],
722
+ queue: Queue[str],
657
723
  /,
658
724
  *,
659
- deserializer: Callable[[bytes], _T] | None = None,
660
725
  timeout: Duration | None = _SUBSCRIBE_TIMEOUT,
661
726
  sleep: Duration = _SUBSCRIBE_SLEEP,
662
- ) -> AsyncIterator[_T] | AsyncIterator[bytes]:
727
+ output: Literal["text"] = "text",
728
+ ) -> AsyncIterator[Task[None]]: ...
729
+ @overload
730
+ @asynccontextmanager
731
+ def subscribe(
732
+ redis: Redis,
733
+ channels: MaybeIterable[str],
734
+ queue: Queue[_T],
735
+ /,
736
+ *,
737
+ timeout: Duration | None = _SUBSCRIBE_TIMEOUT,
738
+ sleep: Duration = _SUBSCRIBE_SLEEP,
739
+ output: Callable[[bytes], _T],
740
+ ) -> AsyncIterator[Task[None]]: ...
741
+ @asynccontextmanager
742
+ async def subscribe(
743
+ redis: Redis,
744
+ channels: MaybeIterable[str],
745
+ queue: Queue[_RedisMessageSubscribe] | Queue[bytes] | Queue[_T],
746
+ /,
747
+ *,
748
+ timeout: Duration | None = _SUBSCRIBE_TIMEOUT,
749
+ sleep: Duration = _SUBSCRIBE_SLEEP,
750
+ output: Literal["raw", "bytes", "text"] | Callable[[bytes], _T] = "text",
751
+ ) -> AsyncIterator[Task[None]]:
663
752
  """Subscribe to the data of a given channel(s)."""
664
753
  channels = list(always_iterable(channels)) # skipif-ci-and-not-linux
665
- messages = subscribe_messages( # skipif-ci-and-not-linux
666
- redis_or_pubsub, channels, timeout=timeout, sleep=sleep
754
+ match output: # skipif-ci-and-not-linux
755
+ case "raw":
756
+ transform = cast("Any", identity)
757
+ case "bytes":
758
+ transform = cast("Any", itemgetter("data"))
759
+ case "text":
760
+
761
+ def transform(message: _RedisMessageSubscribe, /) -> str: # pyright: ignore[reportRedeclaration]
762
+ return message["data"].decode()
763
+
764
+ case Callable() as deserialize:
765
+
766
+ def transform(message: _RedisMessageSubscribe, /) -> _T:
767
+ return deserialize(message["data"])
768
+
769
+ case _ as never:
770
+ assert_never(never)
771
+
772
+ task = create_task( # skipif-ci-and-not-linux
773
+ _subscribe_core(redis, channels, transform, queue, timeout=timeout, sleep=sleep)
667
774
  )
668
- if deserializer is None: # skipif-ci-and-not-linux
669
- async for message in messages:
670
- yield message["data"]
671
- else: # skipif-ci-and-not-linux
672
- async for message in messages:
673
- yield deserializer(message["data"])
775
+ try: # skipif-ci-and-not-linux
776
+ yield task
777
+ finally: # skipif-ci-and-not-linux
778
+ _ = task.cancel()
779
+ with suppress(CancelledError):
780
+ await task
674
781
 
675
782
 
676
- async def subscribe_messages(
677
- redis_or_pubsub: Redis | PubSub,
783
+ async def _subscribe_core(
784
+ redis: Redis,
678
785
  channels: MaybeIterable[str],
786
+ transform: Callable[[_RedisMessageSubscribe], Any],
787
+ queue: Queue[Any],
679
788
  /,
680
789
  *,
681
790
  timeout: Duration | None = _SUBSCRIBE_TIMEOUT,
682
791
  sleep: Duration = _SUBSCRIBE_SLEEP,
683
- ) -> AsyncIterator[_RedisMessageSubscribe]:
684
- """Subscribe to the messages of a given channel(s)."""
685
- match redis_or_pubsub: # skipif-ci-and-not-linux
686
- case Redis() as redis:
687
- async for msg in subscribe_messages(
688
- redis.pubsub(), channels, timeout=timeout, sleep=sleep
689
- ):
690
- yield msg
691
- case PubSub() as pubsub:
692
- channels = list(always_iterable(channels))
693
- for channel in channels:
694
- await pubsub.subscribe(channel)
695
- channels_bytes = [c.encode() for c in channels]
696
- timeout_use = (
697
- None if timeout is None else datetime_duration_to_float(timeout)
792
+ ) -> None:
793
+ timeout_use = ( # skipif-ci-and-not-linux
794
+ None if timeout is None else datetime_duration_to_float(timeout)
795
+ )
796
+ sleep_use = datetime_duration_to_float(sleep) # skipif-ci-and-not-linux
797
+ is_subscribe_message = partial( # skipif-ci-and-not-linux
798
+ _is_subscribe_message, channels={c.encode() for c in channels}
799
+ )
800
+ async with yield_pubsub(redis, channels) as pubsub: # skipif-ci-and-not-linux
801
+ while True:
802
+ message = cast(
803
+ "_RedisMessageSubscribe | _RedisMessageUnsubscribe | None",
804
+ await pubsub.get_message(timeout=timeout_use),
698
805
  )
699
- sleep_use = datetime_duration_to_float(sleep)
700
- while True:
701
- message = cast(
702
- "_RedisMessageSubscribe | _RedisMessageUnsubscribe | None",
703
- await pubsub.get_message(timeout=timeout_use),
704
- )
705
- if (
706
- (message is not None)
707
- and (
708
- message["type"]
709
- in {"subscribe", "psubscribe", "message", "pmessage"}
710
- )
711
- and (message["channel"] in channels_bytes)
712
- and isinstance(message["data"], bytes)
713
- ):
714
- yield cast("_RedisMessageSubscribe", message)
806
+ if is_subscribe_message(message):
807
+ if isinstance(queue, EnhancedQueue):
808
+ queue.put_right_nowait(transform(message))
715
809
  else:
716
- await asyncio.sleep(sleep_use)
717
- case _ as never:
718
- assert_never(never)
810
+ queue.put_nowait(transform(message))
811
+ else:
812
+ await asyncio.sleep(sleep_use)
813
+
814
+
815
+ def _is_subscribe_message(
816
+ message: Any, /, *, channels: Collection[bytes]
817
+ ) -> TypeGuard[_RedisMessageSubscribe]:
818
+ return (
819
+ isinstance(message, Mapping)
820
+ and ("type" in message)
821
+ and (message["type"] in {"subscribe", "psubscribe", "message", "pmessage"})
822
+ and ("pattern" in message)
823
+ and ((message["pattern"] is None) or isinstance(message["pattern"], str))
824
+ and ("channel" in message)
825
+ and (message["channel"] in channels)
826
+ and ("data" in message)
827
+ and isinstance(message["data"], bytes)
828
+ )
719
829
 
720
830
 
721
831
  class _RedisMessageSubscribe(TypedDict):
@@ -735,6 +845,95 @@ class _RedisMessageUnsubscribe(TypedDict):
735
845
  ##
736
846
 
737
847
 
848
+ @dataclass(kw_only=True)
849
+ class SubscribeService(Looper[_T]):
850
+ """Service to subscribe to Redis."""
851
+
852
+ # base
853
+ freq: Duration = field(default=MILLISECOND, repr=False)
854
+ backoff: Duration = field(default=SECOND, repr=False)
855
+ logger: str | None = field(default=__name__, repr=False)
856
+ # self
857
+ redis: Redis
858
+ channel: str
859
+ deserializer: Callable[[bytes], _T] | None = None
860
+ subscribe_sleep: Duration = _SUBSCRIBE_SLEEP
861
+ subscribe_timeout: Duration | None = _SUBSCRIBE_TIMEOUT
862
+ _is_subscribed: Event = field(default_factory=Event, init=False, repr=False)
863
+
864
+ @override
865
+ async def __aenter__(self) -> Self:
866
+ _ = await super().__aenter__() # skipif-ci-and-not-linux
867
+ match self._is_subscribed.is_set(): # skipif-ci-and-not-linux
868
+ case True:
869
+ _ = self._debug and self._logger.debug("%s: already subscribing", self)
870
+ case False:
871
+ _ = self._debug and self._logger.debug(
872
+ "%s: starting subscription...", self
873
+ )
874
+ self._is_subscribed.set()
875
+ _ = await self._stack.enter_async_context(
876
+ subscribe(
877
+ self.redis,
878
+ self.channel,
879
+ self._queue,
880
+ sleep=self.subscribe_sleep,
881
+ timeout=self.subscribe_timeout,
882
+ output=cast(
883
+ "Any",
884
+ "text" if self.deserializer is None else self.deserializer,
885
+ ),
886
+ )
887
+ )
888
+ case _ as never:
889
+ assert_never(never)
890
+ return self # skipif-ci-and-not-linux
891
+
892
+ @override
893
+ async def __aexit__(
894
+ self,
895
+ exc_type: type[BaseException] | None = None,
896
+ exc_value: BaseException | None = None,
897
+ traceback: TracebackType | None = None,
898
+ ) -> None:
899
+ await super().__aexit__( # skipif-ci-and-not-linux
900
+ exc_type=exc_type, exc_value=exc_value, traceback=traceback
901
+ )
902
+ match self._is_subscribed.is_set(): # skipif-ci-and-not-linux
903
+ case True:
904
+ _ = self._debug and self._logger.debug(
905
+ "%s: stopping subscription...", self
906
+ )
907
+ self._is_subscribed.clear()
908
+ case False:
909
+ _ = self._debug and self._logger.debug(
910
+ "%s: already stopped subscription", self
911
+ )
912
+ case _ as never:
913
+ assert_never(never)
914
+
915
+
916
+ ##
917
+
918
+
919
+ @asynccontextmanager
920
+ async def yield_pubsub(
921
+ redis: Redis, channels: MaybeIterable[str], /
922
+ ) -> AsyncIterator[PubSub]:
923
+ """Yield a PubSub instance subscribed to some channels."""
924
+ pubsub = redis.pubsub() # skipif-ci-and-not-linux
925
+ channels = list(always_iterable(channels)) # skipif-ci-and-not-linux
926
+ await pubsub.subscribe(*channels) # skipif-ci-and-not-linux
927
+ try: # skipif-ci-and-not-linux
928
+ yield pubsub
929
+ finally: # skipif-ci-and-not-linux
930
+ await pubsub.unsubscribe(*channels)
931
+ await pubsub.aclose()
932
+
933
+
934
+ ##
935
+
936
+
738
937
  _HOST = "localhost"
739
938
  _PORT = 6379
740
939
 
@@ -812,14 +1011,16 @@ _ = _TestRedis
812
1011
 
813
1012
 
814
1013
  __all__ = [
1014
+ "PublishService",
815
1015
  "Publisher",
816
1016
  "PublisherError",
817
1017
  "RedisHashMapKey",
818
1018
  "RedisKey",
1019
+ "SubscribeService",
819
1020
  "publish",
820
1021
  "redis_hash_map_key",
821
1022
  "redis_key",
822
1023
  "subscribe",
823
- "subscribe_messages",
1024
+ "yield_pubsub",
824
1025
  "yield_redis",
825
1026
  ]