dycw-utilities 0.114.2__py3-none-any.whl → 0.114.4__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.114.2
3
+ Version: 0.114.4
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=oAmd7ttFz157b1dwFyTjWf9pMUSBunxAZPRwd4iDr68,60
1
+ utilities/__init__.py,sha256=tJolUL8rPFxB39Y9tDrGxZfStHDwKSYDM0b1ZF2U11Q,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=XVvmVNXKhP246JawvQCpFh4bMOl_NYm7QM60e-zrSFQ,20850
4
+ utilities/asyncio.py,sha256=d_z20q4pFhjMBruepHKjvbWVFF-89VpP6_o9ZFQIe9k,21508
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
@@ -58,15 +58,15 @@ utilities/pytest_regressions.py,sha256=-SVT9647Dg6-JcdsiaDKXe3NdOmmrvGevLKWwGjxq
58
58
  utilities/python_dotenv.py,sha256=iWcnpXbH7S6RoXHiLlGgyuH6udCupAcPd_gQ0eAenQ0,3190
59
59
  utilities/random.py,sha256=lYdjgxB7GCfU_fwFVl5U-BIM_HV3q6_urL9byjrwDM8,4157
60
60
  utilities/re.py,sha256=5J4d8VwIPFVrX2Eb8zfoxImDv7IwiN_U7mJ07wR2Wvs,3958
61
- utilities/redis.py,sha256=otrtsfGvU5FBzqDJ7IEMQOJwG5ccFaq4TRKEMteqYk8,25489
61
+ utilities/redis.py,sha256=O2EoSbavGRZsLT9Jfh7byZwBNBpZFPzlIcg4Qf_8CkE,26663
62
62
  utilities/reprlib.py,sha256=Re9bk3n-kC__9DxQmRlevqFA86pE6TtVfWjUgpbVOv0,1849
63
63
  utilities/rich.py,sha256=t50MwwVBsoOLxzmeVFSVpjno4OW6Ufum32skXbV8-Bs,1911
64
64
  utilities/scipy.py,sha256=X6ROnHwiUhAmPhM0jkfEh0-Fd9iRvwiqtCQMOLmOQF8,945
65
65
  utilities/sentinel.py,sha256=3jIwgpMekWgDAxPDA_hXMP2St43cPhciKN3LWiZ7kv0,1248
66
66
  utilities/shelve.py,sha256=HZsMwK4tcIfg3sh0gApx4-yjQnrY4o3V3ZRimvRhoW0,738
67
- utilities/slack_sdk.py,sha256=Gbla983KulSSXnNyzaXgYQLKoq84KvLH8SdhxU-jQ0Q,4126
67
+ utilities/slack_sdk.py,sha256=wPqn9F5AMXgmkp3zgIrBMllLt2SDCCnBNNyi-ag3yzw,5555
68
68
  utilities/socket.py,sha256=K77vfREvzoVTrpYKo6MZakol0EYu2q1sWJnnZqL0So0,118
69
- utilities/sqlalchemy.py,sha256=bs7rD1f8yB0uaFMYgmjo8wEoGow0x6aiELSYTPY_Img,35447
69
+ utilities/sqlalchemy.py,sha256=585hWuuXVTKTnyn0Pfd9JI6jp-hmKW6pLKGYMjXjytM,36959
70
70
  utilities/sqlalchemy_polars.py,sha256=wjJpoUo-yO9E2ujpG_06vV5r2OdvBiQ4yvV6wKCa2Tk,15605
71
71
  utilities/statsmodels.py,sha256=koyiBHvpMcSiBfh99wFUfSggLNx7cuAw3rwyfAhoKpQ,3410
72
72
  utilities/streamlit.py,sha256=U9PJBaKP1IdSykKhPZhIzSPTZsmLsnwbEPZWzNhJPKk,2955
@@ -87,7 +87,7 @@ utilities/warnings.py,sha256=un1LvHv70PU-LLv8RxPVmugTzDJkkGXRMZTE2-fTQHw,1771
87
87
  utilities/whenever.py,sha256=iLRP_-8CZtBpHKbGZGu-kjSMg1ZubJ-VSmgSy7Eudxw,17787
88
88
  utilities/zipfile.py,sha256=24lQc9ATcJxHXBPc_tBDiJk48pWyRrlxO2fIsFxU0A8,699
89
89
  utilities/zoneinfo.py,sha256=-Xm57PMMwDTYpxJdkiJG13wnbwK--I7XItBh5WVhD-o,1874
90
- dycw_utilities-0.114.2.dist-info/METADATA,sha256=mbXuybydflJ95yffJ4FaYeqq1tiATKR6DrtDd_5v02c,12943
91
- dycw_utilities-0.114.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
92
- dycw_utilities-0.114.2.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
93
- dycw_utilities-0.114.2.dist-info/RECORD,,
90
+ dycw_utilities-0.114.4.dist-info/METADATA,sha256=9qRUMytURQugNiOj679IgyjcF69EMzLAGsd_y0cwi_E,12943
91
+ dycw_utilities-0.114.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
92
+ dycw_utilities-0.114.4.dist-info/licenses/LICENSE,sha256=gppZp16M6nSVpBbUBrNL6JuYfvKwZiKgV7XoKKsHzqo,1066
93
+ dycw_utilities-0.114.4.dist-info/RECORD,,
utilities/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.114.2"
3
+ __version__ = "0.114.4"
utilities/asyncio.py CHANGED
@@ -25,6 +25,7 @@ from contextlib import (
25
25
  )
26
26
  from dataclasses import dataclass, field
27
27
  from io import StringIO
28
+ from logging import getLogger
28
29
  from subprocess import PIPE
29
30
  from sys import stderr, stdout
30
31
  from typing import (
@@ -41,7 +42,7 @@ from typing import (
41
42
 
42
43
  from utilities.datetime import MILLISECOND, MINUTE, SECOND, datetime_duration_to_float
43
44
  from utilities.errors import ImpossibleCaseError
44
- from utilities.functions import ensure_int, ensure_not_none
45
+ from utilities.functions import ensure_int, ensure_not_none, get_class_name
45
46
  from utilities.sentinel import Sentinel, sentinel
46
47
  from utilities.types import (
47
48
  MaybeCallableEvent,
@@ -59,6 +60,7 @@ if TYPE_CHECKING:
59
60
 
60
61
  from utilities.types import Duration
61
62
 
63
+
62
64
  _T = TypeVar("_T")
63
65
 
64
66
 
@@ -324,15 +326,16 @@ class ExceptionProcessor(QueueProcessor[Exception | type[Exception]]):
324
326
  ##
325
327
 
326
328
 
327
- @dataclass(kw_only=True)
329
+ @dataclass(kw_only=True, unsafe_hash=True)
328
330
  class InfiniteLooper(ABC, Generic[THashable]):
329
331
  """An infinite loop which can throw exceptions by setting events."""
330
332
 
331
- _events: Mapping[THashable, Event] = field(
332
- default_factory=dict, init=False, repr=False
333
- )
334
333
  sleep_core: Duration = SECOND
335
334
  sleep_restart: Duration = MINUTE
335
+ logger: str | None = None
336
+ _events: Mapping[THashable, Event] = field(
337
+ default_factory=dict, init=False, repr=False, hash=False
338
+ )
336
339
 
337
340
  def __post_init__(self) -> None:
338
341
  self._events = {
@@ -395,11 +398,23 @@ class InfiniteLooper(ABC, Generic[THashable]):
395
398
 
396
399
  def _error_upon_initialize(self, error: Exception, /) -> None:
397
400
  """Handle any errors upon initializing the looper."""
398
- _ = error
401
+ if self.logger is not None:
402
+ getLogger(name=self.logger).error(
403
+ "Error initializing %r due to %s; sleeping for %s...",
404
+ get_class_name(self),
405
+ error,
406
+ self.sleep_restart,
407
+ )
399
408
 
400
409
  def _error_upon_core(self, error: Exception, /) -> None:
401
410
  """Handle any errors upon running the core function."""
402
- _ = error
411
+ if self.logger is not None:
412
+ getLogger(name=self.logger).error(
413
+ "Error running core part of %r due to %s; sleeping for %s...",
414
+ get_class_name(self),
415
+ error,
416
+ self.sleep_restart,
417
+ )
403
418
 
404
419
  def _raise_error(self, event: THashable, /) -> NoReturn:
405
420
  """Raise the error corresponding to given event."""
@@ -684,7 +699,9 @@ __all__ = [
684
699
  "AsyncService",
685
700
  "EnhancedTaskGroup",
686
701
  "ExceptionProcessor",
702
+ "InfiniteLooper",
687
703
  "InfiniteLooperError",
704
+ "InfiniteQueueLooper",
688
705
  "QueueProcessor",
689
706
  "StreamCommandOutput",
690
707
  "UniquePriorityQueue",
utilities/redis.py CHANGED
@@ -21,7 +21,7 @@ from uuid import UUID, uuid4
21
21
  from redis.asyncio import Redis
22
22
  from redis.typing import EncodableT
23
23
 
24
- from utilities.asyncio import QueueProcessor, timeout_dur
24
+ from utilities.asyncio import InfiniteQueueLooper, QueueProcessor, timeout_dur
25
25
  from utilities.datetime import (
26
26
  MILLISECOND,
27
27
  SECOND,
@@ -30,7 +30,7 @@ from utilities.datetime import (
30
30
  get_now,
31
31
  )
32
32
  from utilities.errors import ImpossibleCaseError
33
- from utilities.functions import ensure_int
33
+ from utilities.functions import ensure_int, get_class_name
34
34
  from utilities.iterables import always_iterable, one
35
35
 
36
36
  if TYPE_CHECKING:
@@ -40,6 +40,7 @@ if TYPE_CHECKING:
40
40
  Awaitable,
41
41
  Callable,
42
42
  Iterable,
43
+ Iterator,
43
44
  Mapping,
44
45
  Sequence,
45
46
  )
@@ -49,7 +50,7 @@ if TYPE_CHECKING:
49
50
  from redis.typing import ResponseT
50
51
 
51
52
  from utilities.iterables import MaybeIterable
52
- from utilities.types import Duration, TypeLike
53
+ from utilities.types import Duration, MaybeType, TypeLike
53
54
 
54
55
 
55
56
  _K = TypeVar("_K")
@@ -603,6 +604,42 @@ class Publisher(QueueProcessor[tuple[str, EncodableT]]):
603
604
  )
604
605
 
605
606
 
607
+ @dataclass(kw_only=True)
608
+ class PublisherIQL(InfiniteQueueLooper[None, tuple[str, EncodableT]]):
609
+ """Publish a set of messages to Redis."""
610
+
611
+ redis: Redis
612
+ serializer: Callable[[Any], EncodableT] | None = None
613
+ timeout: Duration = _PUBLISH_TIMEOUT
614
+
615
+ @override
616
+ async def _process_items(self, *items: tuple[str, EncodableT]) -> None:
617
+ for item in items: # skipif-ci-and-not-linux
618
+ channel, data = item
619
+ _ = await publish(
620
+ self.redis,
621
+ channel,
622
+ data,
623
+ serializer=self.serializer,
624
+ timeout=self.timeout,
625
+ )
626
+
627
+ @override
628
+ def _yield_events_and_exceptions(
629
+ self,
630
+ ) -> Iterator[tuple[None, MaybeType[BaseException]]]:
631
+ yield (None, PublisherIQLError) # skipif-ci-and-not-linux
632
+
633
+
634
+ @dataclass(kw_only=True)
635
+ class PublisherIQLError(Exception):
636
+ publisher: PublisherIQL
637
+
638
+ @override
639
+ def __str__(self) -> str:
640
+ return f"Error running {get_class_name(self.publisher)!r}" # skipif-ci-and-not-linux
641
+
642
+
606
643
  ##
607
644
 
608
645
 
@@ -780,6 +817,8 @@ _ = _TestRedis
780
817
 
781
818
  __all__ = [
782
819
  "Publisher",
820
+ "PublisherIQL",
821
+ "PublisherIQLError",
783
822
  "RedisHashMapKey",
784
823
  "RedisKey",
785
824
  "publish",
utilities/slack_sdk.py CHANGED
@@ -9,7 +9,12 @@ from typing import TYPE_CHECKING, override
9
9
 
10
10
  from slack_sdk.webhook.async_client import AsyncWebhookClient
11
11
 
12
- from utilities.asyncio import QueueProcessor, sleep_dur, timeout_dur
12
+ from utilities.asyncio import (
13
+ InfiniteQueueLooper,
14
+ QueueProcessor,
15
+ sleep_dur,
16
+ timeout_dur,
17
+ )
13
18
  from utilities.datetime import MINUTE, SECOND, datetime_duration_to_float
14
19
  from utilities.functools import cache
15
20
  from utilities.math import safe_round
@@ -95,6 +100,49 @@ class SlackHandler(Handler, QueueProcessor[str]):
95
100
  await sleep_dur(duration=self.sleep)
96
101
 
97
102
 
103
+ @dataclass(init=False, unsafe_hash=True)
104
+ class SlackHandlerIQL(Handler, InfiniteQueueLooper[None, str]):
105
+ """Handler for sending messages to Slack."""
106
+
107
+ @override
108
+ def __init__(
109
+ self,
110
+ url: str,
111
+ /,
112
+ *,
113
+ level: int = NOTSET,
114
+ sleep_core: Duration = _SLEEP,
115
+ sleep_restart: Duration = _SLEEP,
116
+ queue_type: type[Queue[str]] = Queue,
117
+ sender: Callable[[str, str], Coroutine1[None]] = _send_adapter,
118
+ timeout: Duration = _TIMEOUT,
119
+ ) -> None:
120
+ InfiniteQueueLooper.__init__( # InfiniteQueueLooper first
121
+ self, queue_type=queue_type
122
+ )
123
+ InfiniteQueueLooper.__post_init__(self)
124
+ Handler.__init__(self, level=level)
125
+ self.url = url
126
+ self.sender = sender
127
+ self.timeout = timeout
128
+ self.sleep_core = sleep_core
129
+ self.sleep_restart = sleep_restart
130
+
131
+ @override
132
+ def emit(self, record: LogRecord) -> None:
133
+ try:
134
+ self.put_items_nowait(self.format(record))
135
+ except Exception: # noqa: BLE001 # pragma: no cover
136
+ self.handleError(record)
137
+
138
+ @override
139
+ async def _process_items(self, *items: str) -> None:
140
+ """Process the first item."""
141
+ text = "\n".join(items)
142
+ async with timeout_dur(duration=self.timeout):
143
+ await self.sender(self.url, text)
144
+
145
+
98
146
  ##
99
147
 
100
148
 
@@ -128,4 +176,4 @@ def _get_client(url: str, /, *, timeout: Duration = _TIMEOUT) -> AsyncWebhookCli
128
176
  return AsyncWebhookClient(url, timeout=timeout_use)
129
177
 
130
178
 
131
- __all__ = ["SendToSlackError", "SlackHandler", "send_to_slack"]
179
+ __all__ = ["SendToSlackError", "SlackHandler", "SlackHandlerIQL", "send_to_slack"]
utilities/sqlalchemy.py CHANGED
@@ -57,7 +57,7 @@ from sqlalchemy.orm import (
57
57
  from sqlalchemy.orm.exc import UnmappedClassError
58
58
  from sqlalchemy.pool import NullPool, Pool
59
59
 
60
- from utilities.asyncio import QueueProcessor, timeout_dur
60
+ from utilities.asyncio import InfiniteQueueLooper, QueueProcessor, timeout_dur
61
61
  from utilities.functions import (
62
62
  ensure_str,
63
63
  get_class_name,
@@ -80,7 +80,13 @@ from utilities.iterables import (
80
80
  )
81
81
  from utilities.reprlib import get_repr
82
82
  from utilities.text import snake_case
83
- from utilities.types import Duration, MaybeIterable, StrMapping, TupleOrStrMapping
83
+ from utilities.types import (
84
+ Duration,
85
+ MaybeIterable,
86
+ MaybeType,
87
+ StrMapping,
88
+ TupleOrStrMapping,
89
+ )
84
90
 
85
91
  _T = TypeVar("_T")
86
92
  type _EngineOrConnectionOrAsync = Engine | Connection | AsyncEngine | AsyncConnection
@@ -644,6 +650,51 @@ class Upserter(QueueProcessor[_InsertItem]):
644
650
  await self._post_upsert(items)
645
651
 
646
652
 
653
+ @dataclass(kw_only=True)
654
+ class UpserterIQL(InfiniteQueueLooper[None, _InsertItem]):
655
+ """Upsert a set of items to a database."""
656
+
657
+ engine: AsyncEngine
658
+ snake: bool = False
659
+ selected_or_all: _SelectedOrAll = "selected"
660
+ chunk_size_frac: float = CHUNK_SIZE_FRAC
661
+ assume_tables_exist: bool = False
662
+ timeout_create: Duration | None = None
663
+ error_create: type[Exception] = TimeoutError
664
+ timeout_insert: Duration | None = None
665
+ error_insert: type[Exception] = TimeoutError
666
+
667
+ @override
668
+ async def _process_items(self, *items: _InsertItem) -> None:
669
+ await upsert_items(
670
+ self.engine,
671
+ *items,
672
+ snake=self.snake,
673
+ selected_or_all=self.selected_or_all,
674
+ chunk_size_frac=self.chunk_size_frac,
675
+ assume_tables_exist=self.assume_tables_exist,
676
+ timeout_create=self.timeout_create,
677
+ error_create=self.error_create,
678
+ timeout_insert=self.timeout_insert,
679
+ error_insert=self.error_insert,
680
+ )
681
+
682
+ @override
683
+ def _yield_events_and_exceptions(
684
+ self,
685
+ ) -> Iterator[tuple[None, MaybeType[BaseException]]]:
686
+ yield (None, UpserterIQLError)
687
+
688
+
689
+ @dataclass(kw_only=True)
690
+ class UpserterIQLError(Exception):
691
+ upserter: UpserterIQL
692
+
693
+ @override
694
+ def __str__(self) -> str:
695
+ return f"Error running {get_class_name(self.upserter)!r}"
696
+
697
+
647
698
  ##
648
699
 
649
700
 
@@ -1099,6 +1150,9 @@ __all__ = [
1099
1150
  "InsertItemsError",
1100
1151
  "TablenameMixin",
1101
1152
  "UpsertItemsError",
1153
+ "Upserter",
1154
+ "UpserterIQL",
1155
+ "UpserterIQLError",
1102
1156
  "check_engine",
1103
1157
  "columnwise_max",
1104
1158
  "columnwise_min",