localqueue 0.3.1__py3-none-any.whl → 0.3.2__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.
localqueue/cli.py CHANGED
@@ -8,13 +8,11 @@ import subprocess
8
8
  import sys
9
9
  import time
10
10
  from collections import Counter
11
- from collections.abc import Callable
12
11
  from contextlib import contextmanager
13
12
  from dataclasses import dataclass
14
13
  from pathlib import Path
15
14
  from queue import Empty
16
- from types import FrameType
17
- from typing import Any, Iterator
15
+ from typing import Any, Iterator, TYPE_CHECKING
18
16
 
19
17
  from tenacity import wait_none
20
18
 
@@ -35,6 +33,10 @@ from .worker import (
35
33
  _sleep_for_policy,
36
34
  )
37
35
 
36
+ if TYPE_CHECKING:
37
+ from types import FrameType
38
+ from collections.abc import Callable
39
+
38
40
  DEFAULT_STORE_PATH = str(default_queue_store_path())
39
41
  DEFAULT_RETRY_STORE_PATH = str(default_retry_store_path())
40
42
  CONFIG_FILENAME = "config.yaml"
@@ -114,7 +116,20 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
114
116
  app.add_typer(queue_app, name="queue")
115
117
  app.add_typer(retry_app, name="retry")
116
118
  app.add_typer(config_app, name="config")
119
+ _register_config_commands(config_app, typer, yaml, console, err_console, config)
120
+ _register_queue_commands(queue_app, typer, console, err_console, config)
121
+ _register_retry_commands(retry_app, typer, console, err_console, config)
122
+ return app
117
123
 
124
+
125
+ def _register_config_commands(
126
+ config_app: Any,
127
+ typer: Any,
128
+ yaml: Any,
129
+ console: Any,
130
+ err_console: Any,
131
+ config: dict[str, Any],
132
+ ) -> None:
118
133
  @config_app.command("path")
119
134
  def config_path() -> None:
120
135
  console.print(str(_config_path()))
@@ -181,6 +196,26 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
181
196
  config.update(updated)
182
197
  _print_json(console, config)
183
198
 
199
+
200
+ def _register_queue_commands(
201
+ queue_app: Any,
202
+ typer: Any,
203
+ console: Any,
204
+ err_console: Any,
205
+ config: dict[str, Any],
206
+ ) -> None:
207
+ _register_queue_basic_commands(queue_app, typer, console, err_console, config)
208
+ _register_queue_admin_commands(queue_app, typer, console, err_console, config)
209
+ _register_queue_worker_commands(queue_app, typer, console, err_console, config)
210
+
211
+
212
+ def _register_queue_basic_commands(
213
+ queue_app: Any,
214
+ typer: Any,
215
+ console: Any,
216
+ err_console: Any,
217
+ config: dict[str, Any],
218
+ ) -> None:
184
219
  @queue_app.command("add")
185
220
  def queue_add(
186
221
  queue: str,
@@ -210,6 +245,17 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
210
245
  )
211
246
  _print_json(console, _message_payload(message))
212
247
 
248
+ _register_queue_message_commands(queue_app, typer, console, err_console, config)
249
+ _register_queue_maintenance_commands(queue_app, typer, console, err_console, config)
250
+
251
+
252
+ def _register_queue_message_commands(
253
+ queue_app: Any,
254
+ typer: Any,
255
+ console: Any,
256
+ err_console: Any,
257
+ config: dict[str, Any],
258
+ ) -> None:
213
259
  @queue_app.command("pop")
214
260
  def queue_pop(
215
261
  queue: str,
@@ -319,6 +365,27 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
319
365
  ) -> None:
320
366
  console.print(_queue(queue, _resolve_store_path(store_path, config)).qsize())
321
367
 
368
+
369
+ def _register_queue_maintenance_commands(
370
+ queue_app: Any,
371
+ typer: Any,
372
+ console: Any,
373
+ err_console: Any,
374
+ config: dict[str, Any],
375
+ ) -> None:
376
+ _register_queue_stats_commands(queue_app, typer, console, err_console, config)
377
+ _register_queue_dead_commands(queue_app, typer, console, err_console, config)
378
+ _register_queue_requeue_commands(queue_app, typer, console, err_console, config)
379
+ _register_queue_purge_commands(queue_app, typer, console, err_console, config)
380
+
381
+
382
+ def _register_queue_stats_commands(
383
+ queue_app: Any,
384
+ typer: Any,
385
+ console: Any,
386
+ err_console: Any,
387
+ config: dict[str, Any],
388
+ ) -> None:
322
389
  @queue_app.command("stats")
323
390
  def queue_stats(
324
391
  queue: str,
@@ -362,6 +429,14 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
362
429
  },
363
430
  )
364
431
 
432
+
433
+ def _register_queue_dead_commands(
434
+ queue_app: Any,
435
+ typer: Any,
436
+ console: Any,
437
+ err_console: Any,
438
+ config: dict[str, Any],
439
+ ) -> None:
365
440
  @queue_app.command("dead")
366
441
  def queue_dead(
367
442
  queue: str,
@@ -434,39 +509,14 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
434
509
  failed_within=failed_within,
435
510
  )
436
511
 
437
- @retry_app.command("prune")
438
- def retry_prune(
439
- older_than: float | None = typer.Option(None, "--older-than", min=0.0),
440
- retry_store_path: str | None = typer.Option(None, "--retry-store-path"),
441
- dry_run: bool = typer.Option(False, "--dry-run"),
442
- ) -> None:
443
- store = SQLiteAttemptStore(_resolve_retry_store_path(retry_store_path, config))
444
- try:
445
- resolved_older_than = _resolve_retry_record_ttl(older_than, config)
446
- if resolved_older_than is None:
447
- err_console.print(
448
- "[red]pass --older-than or configure retry_record_ttl_seconds[/red]"
449
- )
450
- raise typer.Exit(1)
451
- now = time.time()
452
- deleted = (
453
- store.count_exhausted_older_than(
454
- older_than=resolved_older_than, now=now
455
- )
456
- if dry_run
457
- else store.prune_exhausted(older_than=resolved_older_than, now=now)
458
- )
459
- finally:
460
- store.close()
461
- _print_json(
462
- console,
463
- {
464
- "dry_run": dry_run,
465
- "older_than": resolved_older_than,
466
- "would_delete" if dry_run else "deleted": deleted,
467
- },
468
- )
469
512
 
513
+ def _register_queue_requeue_commands(
514
+ queue_app: Any,
515
+ typer: Any,
516
+ console: Any,
517
+ err_console: Any,
518
+ config: dict[str, Any],
519
+ ) -> None:
470
520
  @queue_app.command("requeue-dead")
471
521
  def queue_requeue_dead(
472
522
  queue: str,
@@ -518,6 +568,14 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
518
568
  )
519
569
  _print_json(console, {"id": message_id, "state": "requeued"})
520
570
 
571
+
572
+ def _register_queue_purge_commands(
573
+ queue_app: Any,
574
+ typer: Any,
575
+ console: Any,
576
+ err_console: Any,
577
+ config: dict[str, Any],
578
+ ) -> None:
521
579
  @queue_app.command("purge")
522
580
  def queue_purge(
523
581
  queue: str,
@@ -529,6 +587,55 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
529
587
  _emit_event(err_console, "queue.purge", queue=queue, deleted=deleted)
530
588
  console.print(deleted)
531
589
 
590
+
591
+ def _register_retry_commands(
592
+ retry_app: Any,
593
+ typer: Any,
594
+ console: Any,
595
+ err_console: Any,
596
+ config: dict[str, Any],
597
+ ) -> None:
598
+ @retry_app.command("prune")
599
+ def retry_prune(
600
+ older_than: float | None = typer.Option(None, "--older-than", min=0.0),
601
+ retry_store_path: str | None = typer.Option(None, "--retry-store-path"),
602
+ dry_run: bool = typer.Option(False, "--dry-run"),
603
+ ) -> None:
604
+ store = SQLiteAttemptStore(_resolve_retry_store_path(retry_store_path, config))
605
+ try:
606
+ resolved_older_than = _resolve_retry_record_ttl(older_than, config)
607
+ if resolved_older_than is None:
608
+ err_console.print(
609
+ "[red]pass --older-than or configure retry_record_ttl_seconds[/red]"
610
+ )
611
+ raise typer.Exit(1)
612
+ now = time.time()
613
+ deleted = (
614
+ store.count_exhausted_older_than(
615
+ older_than=resolved_older_than, now=now
616
+ )
617
+ if dry_run
618
+ else store.prune_exhausted(older_than=resolved_older_than, now=now)
619
+ )
620
+ finally:
621
+ store.close()
622
+ _print_json(
623
+ console,
624
+ {
625
+ "dry_run": dry_run,
626
+ "older_than": resolved_older_than,
627
+ "would_delete" if dry_run else "deleted": deleted,
628
+ },
629
+ )
630
+
631
+
632
+ def _register_queue_admin_commands(
633
+ queue_app: Any,
634
+ typer: Any,
635
+ console: Any,
636
+ err_console: Any,
637
+ config: dict[str, Any],
638
+ ) -> None:
532
639
  @queue_app.command("process")
533
640
  def queue_process(
534
641
  queue: str,
@@ -606,6 +713,14 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
606
713
  if exit_code:
607
714
  raise typer.Exit(exit_code)
608
715
 
716
+
717
+ def _register_queue_worker_commands(
718
+ queue_app: Any,
719
+ typer: Any,
720
+ console: Any,
721
+ err_console: Any,
722
+ config: dict[str, Any],
723
+ ) -> None:
609
724
  @queue_app.command("exec")
610
725
  def queue_exec(
611
726
  queue: str,
@@ -681,8 +796,6 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
681
796
  if exit_code:
682
797
  raise typer.Exit(exit_code)
683
798
 
684
- return app
685
-
686
799
 
687
800
  def _queue(
688
801
  name: str, store_path: str, *, lease_timeout: float = 30.0
@@ -869,86 +982,53 @@ def _process_queue_messages(
869
982
  _print_json(console, {"state": "stopped", "processed": processed})
870
983
  return 0
871
984
 
872
- if worker_id is not None:
873
- queue.record_worker_heartbeat(worker_id)
874
- _sleep_for_policy(policy_state, resolved_policy)
875
-
876
- try:
877
- message = queue.get_message(
878
- block=block,
879
- timeout=_poll_timeout(forever, block, timeout),
880
- leased_by=worker_id,
881
- )
882
- except Empty:
883
- if forever:
884
- if not block:
885
- time.sleep(idle_sleep)
886
- continue
887
- if processed == 0:
888
- if err_console is not None:
889
- err_console.print("[yellow]queue is empty[/yellow]")
890
- return 1
891
- break
892
-
893
- if log_events and err_console is not None:
894
- _emit_event(
895
- err_console,
896
- f"{mode}.lease",
897
- queue=message.queue,
898
- message_id=message.id,
899
- leased_by=message.leased_by,
900
- attempts=message.attempts,
901
- leased_until=message.leased_until,
902
- )
903
-
904
- if retry_store_path is not None and owned_retry_store is None:
905
- owned_retry_store = SQLiteAttemptStore(retry_store_path)
906
-
907
- result = _process_message(
985
+ iteration, owned_retry_store, empty_queue = _process_queue_iteration(
908
986
  queue,
909
- message,
910
987
  handler,
911
- retry_store=owned_retry_store,
912
- retry_store_path=None
913
- if owned_retry_store is not None
914
- else retry_store_path,
988
+ console=console,
989
+ err_console=err_console,
990
+ policy_state=policy_state,
991
+ resolved_policy=resolved_policy,
992
+ retry_store_path=retry_store_path,
993
+ owned_retry_store=owned_retry_store,
915
994
  max_tries=max_tries,
995
+ worker_id=worker_id,
996
+ block=block,
997
+ timeout=timeout,
998
+ idle_sleep=idle_sleep,
916
999
  release_delay=release_delay,
917
1000
  dead_letter_on_exhaustion=dead_letter_on_exhaustion,
918
1001
  log_events=log_events,
919
1002
  mode=mode,
920
- err_console=err_console,
1003
+ forever=forever,
921
1004
  )
922
- if result.processed:
1005
+ if empty_queue:
1006
+ if processed == 0 and err_console is not None:
1007
+ err_console.print("[yellow]queue is empty[/yellow]")
1008
+ if processed == 0:
1009
+ return 1
1010
+ break
1011
+ assert iteration is not None
1012
+ if _handle_processed_queue_result(
1013
+ queue,
1014
+ iteration.message,
1015
+ iteration.process_result,
1016
+ console=console,
1017
+ worker_id=worker_id,
1018
+ policy_state=policy_state,
1019
+ ):
923
1020
  processed += 1
924
- _record_success(policy_state)
925
- if worker_id is not None:
926
- queue.record_worker_heartbeat(worker_id)
927
- _print_json(console, {"id": message.id, "state": "acked"})
928
1021
  continue
929
-
930
- _record_failure(
931
- policy_state,
932
- resolved_policy,
933
- permanent=result.permanent_failure,
934
- )
935
-
936
- payload = (
937
- _message_payload(current_message)
938
- if (current_message := queue.inspect(message.id)) is not None
939
- else {"id": message.id, "queue": message.queue}
940
- )
941
- _print_json(
942
- console,
943
- {
944
- **payload,
945
- "state": "failed",
946
- "last_error": result.last_error,
947
- },
948
- )
949
- if worker_id is not None:
950
- queue.record_worker_heartbeat(worker_id)
951
- if forever:
1022
+ if _handle_failed_queue_result(
1023
+ queue,
1024
+ iteration.message,
1025
+ iteration.process_result,
1026
+ console=console,
1027
+ worker_id=worker_id,
1028
+ policy_state=policy_state,
1029
+ resolved_policy=resolved_policy,
1030
+ forever=forever,
1031
+ ):
952
1032
  continue
953
1033
  return 1
954
1034
 
@@ -958,6 +1038,137 @@ def _process_queue_messages(
958
1038
  owned_retry_store.close()
959
1039
 
960
1040
 
1041
+ @dataclass(slots=True)
1042
+ class _QueueIterationResult:
1043
+ message: QueueMessage
1044
+ process_result: _ProcessResult
1045
+
1046
+
1047
+ def _process_queue_iteration(
1048
+ queue: PersistentQueue,
1049
+ handler: Callable[[Any], Any],
1050
+ *,
1051
+ console: Any,
1052
+ err_console: Any,
1053
+ policy_state: WorkerPolicyState,
1054
+ resolved_policy: PersistentWorkerConfig,
1055
+ retry_store_path: str | None,
1056
+ owned_retry_store: SQLiteAttemptStore | None,
1057
+ max_tries: int,
1058
+ worker_id: str | None,
1059
+ block: bool,
1060
+ timeout: float | None,
1061
+ idle_sleep: float,
1062
+ release_delay: float,
1063
+ dead_letter_on_exhaustion: bool,
1064
+ log_events: bool,
1065
+ mode: str,
1066
+ forever: bool,
1067
+ ) -> tuple[_QueueIterationResult | None, SQLiteAttemptStore | None, bool]:
1068
+ if worker_id is not None:
1069
+ queue.record_worker_heartbeat(worker_id)
1070
+ _sleep_for_policy(policy_state, resolved_policy)
1071
+
1072
+ try:
1073
+ message = queue.get_message(
1074
+ block=block,
1075
+ timeout=_poll_timeout(forever, block, timeout),
1076
+ leased_by=worker_id,
1077
+ )
1078
+ except Empty:
1079
+ if forever:
1080
+ if not block:
1081
+ time.sleep(idle_sleep)
1082
+ return None, owned_retry_store, True
1083
+ return None, owned_retry_store, True
1084
+
1085
+ if log_events and err_console is not None:
1086
+ _emit_event(
1087
+ err_console,
1088
+ f"{mode}.lease",
1089
+ queue=message.queue,
1090
+ message_id=message.id,
1091
+ leased_by=message.leased_by,
1092
+ attempts=message.attempts,
1093
+ leased_until=message.leased_until,
1094
+ )
1095
+
1096
+ if retry_store_path is not None and owned_retry_store is None:
1097
+ owned_retry_store = SQLiteAttemptStore(retry_store_path)
1098
+
1099
+ result = _process_message(
1100
+ queue,
1101
+ message,
1102
+ handler,
1103
+ retry_store=owned_retry_store,
1104
+ retry_store_path=None if owned_retry_store is not None else retry_store_path,
1105
+ max_tries=max_tries,
1106
+ release_delay=release_delay,
1107
+ dead_letter_on_exhaustion=dead_letter_on_exhaustion,
1108
+ log_events=log_events,
1109
+ mode=mode,
1110
+ err_console=err_console,
1111
+ )
1112
+ return (
1113
+ _QueueIterationResult(message=message, process_result=result),
1114
+ owned_retry_store,
1115
+ False,
1116
+ )
1117
+
1118
+
1119
+ def _handle_processed_queue_result(
1120
+ queue: PersistentQueue,
1121
+ message: QueueMessage,
1122
+ result: _ProcessResult,
1123
+ *,
1124
+ console: Any,
1125
+ worker_id: str | None,
1126
+ policy_state: WorkerPolicyState,
1127
+ ) -> bool:
1128
+ if not result.processed:
1129
+ return False
1130
+ _record_success(policy_state)
1131
+ if worker_id is not None:
1132
+ queue.record_worker_heartbeat(worker_id)
1133
+ _print_json(console, {"id": message.id, "state": "acked"})
1134
+ return True
1135
+
1136
+
1137
+ def _handle_failed_queue_result(
1138
+ queue: PersistentQueue,
1139
+ message: QueueMessage,
1140
+ result: _ProcessResult,
1141
+ *,
1142
+ console: Any,
1143
+ worker_id: str | None,
1144
+ policy_state: WorkerPolicyState,
1145
+ resolved_policy: PersistentWorkerConfig,
1146
+ forever: bool,
1147
+ ) -> bool:
1148
+ _record_failure(
1149
+ policy_state,
1150
+ resolved_policy,
1151
+ permanent=result.permanent_failure,
1152
+ )
1153
+
1154
+ payload = (
1155
+ _message_payload(current_message)
1156
+ if (current_message := queue.inspect(message.id)) is not None
1157
+ else {"id": message.id, "queue": message.queue}
1158
+ )
1159
+ _print_json(
1160
+ console,
1161
+ {
1162
+ **payload,
1163
+ "state": "failed",
1164
+ "last_error": result.last_error,
1165
+ },
1166
+ )
1167
+ if worker_id is not None:
1168
+ queue.record_worker_heartbeat(worker_id)
1169
+ return forever
1170
+
1171
+
961
1172
  def _print_dead_letters(
962
1173
  queue: PersistentQueue,
963
1174
  *,
localqueue/failure.py CHANGED
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any
3
+ from typing import Protocol
4
4
 
5
5
  _PERMANENT_FAILURE_TYPES = (
6
6
  ImportError,
@@ -9,7 +9,11 @@ _PERMANENT_FAILURE_TYPES = (
9
9
  )
10
10
 
11
11
 
12
- def is_permanent_failure(error: BaseException | Any) -> bool:
12
+ class _ExitCodeCarrier(Protocol):
13
+ exit_code: int
14
+
15
+
16
+ def is_permanent_failure(error: BaseException | _ExitCodeCarrier) -> bool:
13
17
  if getattr(error, "exit_code", None) == 127:
14
18
  return True
15
19
  return isinstance(error, _PERMANENT_FAILURE_TYPES)
localqueue/queue.py CHANGED
@@ -5,13 +5,15 @@ from pathlib import Path
5
5
  from collections.abc import Mapping
6
6
  from queue import Empty, Full
7
7
  from threading import Condition
8
- from typing import Any
8
+ from typing import Any, Generic, TypeVar, cast
9
9
 
10
10
  from .paths import default_queue_store_path
11
11
  from .store import QueueMessage, QueueStats, QueueStore, SQLiteQueueStore
12
12
 
13
+ T = TypeVar("T")
13
14
 
14
- class PersistentQueue:
15
+
16
+ class PersistentQueue(Generic[T]):
15
17
  name: str
16
18
  lease_timeout: float
17
19
  maxsize: int
@@ -52,7 +54,7 @@ class PersistentQueue:
52
54
 
53
55
  def put(
54
56
  self,
55
- item: Any,
57
+ item: T,
56
58
  block: bool = True,
57
59
  timeout: float | None = None,
58
60
  *,
@@ -81,7 +83,7 @@ class PersistentQueue:
81
83
  _ = self._condition.notify_all()
82
84
  return message
83
85
 
84
- def put_nowait(self, item: Any) -> QueueMessage:
86
+ def put_nowait(self, item: T) -> QueueMessage:
85
87
  return self.put(item, block=False)
86
88
 
87
89
  def get(
@@ -90,13 +92,13 @@ class PersistentQueue:
90
92
  timeout: float | None = None,
91
93
  *,
92
94
  leased_by: str | None = None,
93
- ) -> Any:
95
+ ) -> T:
94
96
  message = self.get_message(block=block, timeout=timeout, leased_by=leased_by)
95
97
  with self._condition:
96
98
  self._unfinished[message.id] = message
97
- return message.value
99
+ return cast("T", message.value)
98
100
 
99
- def get_nowait(self) -> Any:
101
+ def get_nowait(self) -> T:
100
102
  return self.get(block=False)
101
103
 
102
104
  def get_message(
localqueue/retry/store.py CHANGED
@@ -10,6 +10,7 @@ from pathlib import Path
10
10
  from typing import TYPE_CHECKING, Any, Protocol
11
11
 
12
12
  if TYPE_CHECKING:
13
+ from types import ModuleType
13
14
  import lmdb
14
15
 
15
16
  _ENVS: dict[tuple[str, int], Any] = {}
@@ -17,7 +18,7 @@ _ENVS_LOCK = threading.Lock()
17
18
  _SQLITE_RETRY_SCHEMA_VERSION = 1
18
19
 
19
20
 
20
- def _import_lmdb() -> Any:
21
+ def _import_lmdb() -> ModuleType:
21
22
  try:
22
23
  import lmdb
23
24
  except ModuleNotFoundError as exc:
@@ -9,6 +9,7 @@ import weakref
9
9
  from dataclasses import dataclass
10
10
  from pathlib import Path
11
11
  from typing import Any, Callable, TypeVar, cast
12
+ import asyncio
12
13
 
13
14
  from tenacity import DoAttempt, DoSleep
14
15
  from tenacity import AsyncRetrying, RetryCallState, Retrying
@@ -100,7 +101,7 @@ def _get_default_store() -> AttemptStore:
100
101
  _default_owned_stores.add(store)
101
102
  except TypeError:
102
103
  pass
103
- return cast(AttemptStore, store)
104
+ return cast("AttemptStore", store)
104
105
 
105
106
 
106
107
  def configure_default_store(store: AttemptStore | None = None) -> None:
@@ -321,7 +322,7 @@ class _PersistentMixin:
321
322
  context = getattr(self._local, "persistent_context", None)
322
323
  if context is None:
323
324
  raise RuntimeError("persistent retry context not initialized")
324
- return cast(PersistentCallContext, context)
325
+ return cast("PersistentCallContext", context)
325
326
 
326
327
  def _translate_state(self, retry_state: RetryCallState) -> PersistentRetryState:
327
328
  context = self._current_context()
@@ -411,7 +412,7 @@ class _PersistentMixin:
411
412
 
412
413
  @property
413
414
  def statistics(self) -> dict[str, Any]:
414
- return cast(dict[str, Any], self._retrying.statistics)
415
+ return cast("dict[str, Any]", self._retrying.statistics)
415
416
 
416
417
  def begin(self) -> None:
417
418
  self._retrying.begin()
@@ -426,7 +427,7 @@ class _PersistentMixin:
426
427
  def wrapped_f(*args: Any, **kwargs: Any) -> Any:
427
428
  copy = self.copy()
428
429
  wrapped_f.statistics = copy.statistics # type: ignore[attr-defined]
429
- return cast(Any, copy)(f, *args, **kwargs)
430
+ return cast("Any", copy)(f, *args, **kwargs)
430
431
 
431
432
  def retry_with(*args: Any, **kwargs: Any) -> WrappedFn:
432
433
  return self.copy(*args, **kwargs).wraps(f)
@@ -434,7 +435,7 @@ class _PersistentMixin:
434
435
  wrapped_f.retry = self # type: ignore[attr-defined]
435
436
  wrapped_f.retry_with = retry_with # type: ignore[attr-defined]
436
437
  wrapped_f.statistics = {} # type: ignore[attr-defined]
437
- return cast(WrappedFn, wrapped_f)
438
+ return cast("WrappedFn", wrapped_f)
438
439
 
439
440
  def copy(self, **kwargs: Any) -> "_PersistentMixin":
440
441
  store = kwargs.pop("store", self._store)
@@ -449,17 +450,16 @@ class _PersistentMixin:
449
450
  _ = tenacity_kwargs.pop("stop", None)
450
451
 
451
452
  return self.__class__(
452
- store=cast(AttemptStore | None, store),
453
+ store=cast("AttemptStore | None", store),
453
454
  store_path=self._store_path
454
455
  if store_path is _UNSET
455
- else cast(str | Path | None, store_path),
456
- key=cast(str | None, key),
456
+ else cast("str | Path | None", store_path),
457
+ key=cast("str | None", key),
457
458
  key_fn=cast(
458
- Callable[[Callable[..., Any], tuple[Any, ...], dict[str, Any]], str]
459
- | None,
459
+ "Callable[[Callable[..., Any], tuple[Any, ...], dict[str, Any]], str] | None",
460
460
  key_fn,
461
461
  ),
462
- clear_on_success=cast(bool, clear_on_success),
462
+ clear_on_success=cast("bool", clear_on_success),
463
463
  **({} if max_tries is _UNSET else {"max_tries": max_tries}),
464
464
  **tenacity_kwargs,
465
465
  )
@@ -506,7 +506,10 @@ class PersistentRetrying(_PersistentMixin):
506
506
  self._local.persistent_context = context
507
507
  try:
508
508
  retry_state = RetryCallState(
509
- retry_object=cast(Any, self._retrying), fn=fn, args=args, kwargs=kwargs
509
+ retry_object=cast("Any", self._retrying),
510
+ fn=fn,
511
+ args=args,
512
+ kwargs=kwargs,
510
513
  )
511
514
  while True:
512
515
  do = self.iter(retry_state=retry_state)
@@ -527,7 +530,7 @@ class PersistentRetrying(_PersistentMixin):
527
530
  retry_state.set_result(result)
528
531
  elif isinstance(do, DoSleep):
529
532
  retry_state.prepare_for_next_attempt()
530
- cast(Any, self._retrying).sleep(do)
533
+ cast("Any", self._retrying).sleep(do)
531
534
  else:
532
535
  self._clear_if_success(retry_state)
533
536
  return do
@@ -605,10 +608,13 @@ class PersistentAsyncRetrying(_PersistentMixin):
605
608
  if _utils.is_coroutine_callable(self._user_stop):
606
609
 
607
610
  async def async_wrapped_stop(retry_state: RetryCallState) -> bool:
611
+ context = self._current_context()
608
612
  should_stop = bool(
609
613
  await self._user_stop(self._translate_state(retry_state))
610
614
  )
611
- self._persist_attempt(retry_state, exhausted=should_stop)
615
+ await self._persist_attempt_async(
616
+ context, retry_state, exhausted=should_stop
617
+ )
612
618
  return should_stop
613
619
 
614
620
  return async_wrapped_stop
@@ -645,7 +651,8 @@ class PersistentAsyncRetrying(_PersistentMixin):
645
651
  async def async_wrapped_retry_error_callback(
646
652
  retry_state: RetryCallState,
647
653
  ) -> Any:
648
- self._persist_attempt(retry_state, exhausted=True)
654
+ context = self._current_context()
655
+ await self._persist_attempt_async(context, retry_state, exhausted=True)
649
656
  return await self._user_retry_error_callback(
650
657
  self._translate_state(retry_state)
651
658
  )
@@ -658,19 +665,66 @@ class PersistentAsyncRetrying(_PersistentMixin):
658
665
 
659
666
  return wrapped_retry_error_callback
660
667
 
668
+ async def _load_context_async(
669
+ self, fn: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any]
670
+ ) -> PersistentCallContext:
671
+ return await asyncio.to_thread(self._load_context, fn, args, kwargs)
672
+
673
+ async def _persist_attempt_async(
674
+ self,
675
+ context: PersistentCallContext,
676
+ retry_state: RetryCallState,
677
+ *,
678
+ exhausted: bool,
679
+ ) -> None:
680
+ await asyncio.to_thread(
681
+ self._persist_attempt_for_context,
682
+ context,
683
+ retry_state,
684
+ exhausted=exhausted,
685
+ )
686
+
687
+ def _persist_attempt_for_context(
688
+ self,
689
+ context: PersistentCallContext,
690
+ retry_state: RetryCallState,
691
+ *,
692
+ exhausted: bool,
693
+ ) -> None:
694
+ context.record.attempts = context.starting_attempts + retry_state.attempt_number
695
+ context.record.exhausted = exhausted
696
+ self._get_store().save(context.key, context.record)
697
+
698
+ async def _clear_if_success_async(
699
+ self, context: PersistentCallContext, retry_state: RetryCallState
700
+ ) -> None:
701
+ await asyncio.to_thread(
702
+ self._clear_if_success_for_context, context, retry_state
703
+ )
704
+
705
+ def _clear_if_success_for_context(
706
+ self, context: PersistentCallContext, retry_state: RetryCallState
707
+ ) -> None:
708
+ outcome = retry_state.outcome
709
+ if outcome is not None and not outcome.failed:
710
+ self._get_store().delete(context.key)
711
+
661
712
  async def __call__(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
662
713
  self.begin()
663
- context = self._load_context(fn, args, kwargs)
714
+ context = await self._load_context_async(fn, args, kwargs)
664
715
  if context.record.exhausted:
665
716
  raise PersistentRetryExhausted(context.key, context.record.attempts)
666
717
 
667
718
  self._local.persistent_context = context
668
719
  try:
669
720
  retry_state = RetryCallState(
670
- retry_object=cast(Any, self._retrying), fn=fn, args=args, kwargs=kwargs
721
+ retry_object=cast("Any", self._retrying),
722
+ fn=fn,
723
+ args=args,
724
+ kwargs=kwargs,
671
725
  )
672
726
  while True:
673
- do = await cast(AsyncRetrying, self._retrying).iter(
727
+ do = await cast("AsyncRetrying", self._retrying).iter(
674
728
  retry_state=retry_state
675
729
  )
676
730
  if isinstance(do, DoAttempt):
@@ -692,9 +746,9 @@ class PersistentAsyncRetrying(_PersistentMixin):
692
746
  retry_state.set_result(result)
693
747
  elif isinstance(do, DoSleep):
694
748
  retry_state.prepare_for_next_attempt()
695
- await cast(Any, self._retrying).sleep(do)
749
+ await cast("Any", self._retrying).sleep(do)
696
750
  else:
697
- self._clear_if_success(retry_state)
751
+ await self._clear_if_success_async(context, retry_state)
698
752
  return do
699
753
  finally:
700
754
  if hasattr(self._local, "persistent_context"):
@@ -702,10 +756,10 @@ class PersistentAsyncRetrying(_PersistentMixin):
702
756
 
703
757
 
704
758
  def persistent_retry(**kwargs: Any) -> Callable[[WrappedFn], WrappedFn]:
705
- return cast(Callable[[WrappedFn], WrappedFn], PersistentRetrying(**kwargs).wraps)
759
+ return cast("Callable[[WrappedFn], WrappedFn]", PersistentRetrying(**kwargs).wraps)
706
760
 
707
761
 
708
762
  def persistent_async_retry(**kwargs: Any) -> Callable[[WrappedFn], WrappedFn]:
709
763
  return cast(
710
- Callable[[WrappedFn], WrappedFn], PersistentAsyncRetrying(**kwargs).wraps
764
+ "Callable[[WrappedFn], WrappedFn]", PersistentAsyncRetrying(**kwargs).wraps
711
765
  )
localqueue/store.py CHANGED
@@ -9,10 +9,11 @@ from collections import Counter
9
9
  from contextlib import contextmanager, suppress
10
10
  from dataclasses import dataclass, field, replace
11
11
  from pathlib import Path
12
- from collections.abc import Iterable
13
12
  from typing import TYPE_CHECKING, Any, Iterator, Protocol
14
13
 
15
14
  if TYPE_CHECKING:
15
+ from collections.abc import Iterable
16
+ from types import ModuleType
16
17
  import lmdb
17
18
 
18
19
  _ENVS: dict[tuple[str, int], Any] = {}
@@ -24,7 +25,7 @@ _QUEUE_RECORD_VERSION = 4
24
25
  _SQLITE_SCHEMA_VERSION = 2
25
26
 
26
27
 
27
- def _import_lmdb() -> Any:
28
+ def _import_lmdb() -> ModuleType:
28
29
  try:
29
30
  import lmdb
30
31
  except ModuleNotFoundError as exc:
localqueue/worker.py CHANGED
@@ -5,13 +5,15 @@ import time
5
5
  from dataclasses import dataclass
6
6
  from collections.abc import Callable
7
7
  from queue import Empty
8
- from typing import Any, TypeVar, cast
8
+ from typing import Any, TypeVar, cast, TYPE_CHECKING
9
9
 
10
10
  from .retry import PersistentAsyncRetrying, PersistentRetrying
11
11
 
12
12
  from .failure import is_permanent_failure
13
- from .queue import PersistentQueue
14
- from .store import QueueMessage
13
+
14
+ if TYPE_CHECKING:
15
+ from .queue import PersistentQueue
16
+ from .store import QueueMessage
15
17
 
16
18
  WrappedFn = TypeVar("WrappedFn", bound=Callable[..., Any])
17
19
  _UNSET = object()
@@ -32,9 +34,9 @@ def _resolve_dead_letter_on_failure(
32
34
  "dead_letter_on_exhaustion=, not conflicting values"
33
35
  )
34
36
  if dead_letter_on_failure is not _UNSET:
35
- return cast(bool, dead_letter_on_failure)
37
+ return cast("bool", dead_letter_on_failure)
36
38
  if dead_letter_on_exhaustion is not _UNSET:
37
- return cast(bool, dead_letter_on_exhaustion)
39
+ return cast("bool", dead_letter_on_exhaustion)
38
40
  return True
39
41
 
40
42
 
@@ -130,9 +132,9 @@ class PersistentWorkerConfig:
130
132
  dead_letter_on_exhaustion=dead_letter_on_exhaustion,
131
133
  )
132
134
  if release_delay is not _UNSET:
133
- _validate_release_delay(cast(float, release_delay))
135
+ _validate_release_delay(cast("float", release_delay))
134
136
  if min_interval is not _UNSET:
135
- _validate_min_interval(cast(float, min_interval))
137
+ _validate_min_interval(cast("float", min_interval))
136
138
  if (
137
139
  circuit_breaker_failures is not _UNSET
138
140
  or circuit_breaker_cooldown is not _UNSET
@@ -140,12 +142,12 @@ class PersistentWorkerConfig:
140
142
  resolved_breaker_failures = (
141
143
  self.circuit_breaker_failures
142
144
  if circuit_breaker_failures is _UNSET
143
- else cast(int, circuit_breaker_failures)
145
+ else cast("int", circuit_breaker_failures)
144
146
  )
145
147
  resolved_breaker_cooldown = (
146
148
  self.circuit_breaker_cooldown
147
149
  if circuit_breaker_cooldown is _UNSET
148
- else cast(float, circuit_breaker_cooldown)
150
+ else cast("float", circuit_breaker_cooldown)
149
151
  )
150
152
  _validate_circuit_breaker(
151
153
  resolved_breaker_failures, resolved_breaker_cooldown
@@ -160,22 +162,22 @@ class PersistentWorkerConfig:
160
162
  release_delay=(
161
163
  self.release_delay
162
164
  if release_delay is _UNSET
163
- else cast(float, release_delay)
165
+ else cast("float", release_delay)
164
166
  ),
165
167
  min_interval=(
166
168
  self.min_interval
167
169
  if min_interval is _UNSET
168
- else cast(float, min_interval)
170
+ else cast("float", min_interval)
169
171
  ),
170
172
  circuit_breaker_failures=(
171
173
  self.circuit_breaker_failures
172
174
  if circuit_breaker_failures is _UNSET
173
- else cast(int, circuit_breaker_failures)
175
+ else cast("int", circuit_breaker_failures)
174
176
  ),
175
177
  circuit_breaker_cooldown=(
176
178
  self.circuit_breaker_cooldown
177
179
  if circuit_breaker_cooldown is _UNSET
178
- else cast(float, circuit_breaker_cooldown)
180
+ else cast("float", circuit_breaker_cooldown)
179
181
  ),
180
182
  **merged_retry_kwargs,
181
183
  )
@@ -313,7 +315,10 @@ def persistent_worker(
313
315
  if worker_id is not None:
314
316
  queue.record_worker_heartbeat(worker_id)
315
317
  _record_success(policy_state)
316
- queue.ack(message)
318
+ try:
319
+ queue.ack(message)
320
+ except Exception:
321
+ raise
317
322
  return result
318
323
 
319
324
  return wrapped
@@ -372,7 +377,10 @@ def persistent_async_worker(
372
377
  if worker_id is not None:
373
378
  queue.record_worker_heartbeat(worker_id)
374
379
  _record_success(policy_state)
375
- queue.ack(message)
380
+ try:
381
+ queue.ack(message)
382
+ except Exception:
383
+ raise
376
384
  return result
377
385
 
378
386
  return wrapped
@@ -383,6 +391,6 @@ def persistent_async_worker(
383
391
  async def _get_message_async(queue: PersistentQueue) -> QueueMessage:
384
392
  while True:
385
393
  try:
386
- return queue.get_message(block=False)
394
+ return await asyncio.to_thread(queue.get_message, False)
387
395
  except Empty:
388
396
  await asyncio.sleep(0.05)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: localqueue
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Durable local queues for Python, with persistent retry state powered by Tenacity.
5
5
  Project-URL: Homepage, https://brunoportis.github.io/localqueue/
6
6
  Project-URL: Documentation, https://brunoportis.github.io/localqueue/
@@ -37,7 +37,8 @@ Description-Content-Type: text/markdown
37
37
 
38
38
  [![Tests](https://github.com/brunoportis/localqueue/actions/workflows/tests.yml/badge.svg)](https://github.com/brunoportis/localqueue/actions/workflows/tests.yml)
39
39
  [![CodeQL](https://github.com/brunoportis/localqueue/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/brunoportis/localqueue/actions/workflows/github-code-scanning/codeql)
40
- ![Coverage](https://img.shields.io/badge/coverage-%E2%89%A595%25-brightgreen)
40
+ [![PyPI version](https://img.shields.io/pypi/v/localqueue.svg)](https://pypi.org/project/localqueue/)
41
+ ![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)
41
42
 
42
43
  `localqueue` is a small durable queue for one machine. It stores work on the local filesystem by default and keeps retry state with Tenacity.
43
44
 
@@ -47,6 +48,12 @@ Use it for scripts, CLI tools, cron jobs, and small Python workers that share on
47
48
  localqueue queue exec emails -- python scripts/send_email.py
48
49
  ```
49
50
 
51
+ To run the CLI in a clean container:
52
+
53
+ ```bash
54
+ docker run --rm ghcr.io/brunoportis/localqueue:latest --help
55
+ ```
56
+
50
57
  ```python
51
58
  from localqueue import PersistentQueue, persistent_worker
52
59
 
@@ -0,0 +1,15 @@
1
+ localqueue/__init__.py,sha256=76PTXzQOzD_VWK8sXiPxoyidN3TlxYtg3oJhIDqGBSU,1667
2
+ localqueue/cli.py,sha256=jKNjl45VwjZEzH0r0AI9OvV5SOBLjNhN4x8choZBMSQ,52762
3
+ localqueue/failure.py,sha256=8pBorAaEGoTlij-HDFOTJmNRROsPKY7evMr2rJVeI6w,409
4
+ localqueue/paths.py,sha256=0IRbSMhu35nB5N-HGYltJ0NFYj3I4iagSzUvR15LgWs,555
5
+ localqueue/queue.py,sha256=8M2LnQixnwtD07Oetgugprrn_7q9nVXqhCOAWu6K6JM,10660
6
+ localqueue/store.py,sha256=vxujfXad86gU6SaRSBEvjWXiFJ0KVHESRqUoRgV85ik,72759
7
+ localqueue/worker.py,sha256=AxpVHJciX2bh569kdwqA7oSb_xNQLwg_P-wkQKt4IK8,14010
8
+ localqueue/retry/__init__.py,sha256=kxDYtiksSd-JPlTzmd1esJLBUSrvm2fJD-TN9sMsh0Q,956
9
+ localqueue/retry/store.py,sha256=B9OsmcST_HhGyGYLKgt94_sofBMhAx976sPoX_aykmM,11128
10
+ localqueue/retry/tenacity.py,sha256=s0BmSWUuTZe8Sy5hw_3e46Us94NO8lvuOnrFX-_KJa8,27763
11
+ localqueue-0.3.2.dist-info/METADATA,sha256=i4h_5xrdS9VLLrJrBmyNdPZc_vI8h4kSSrZPOzHp_6Q,3313
12
+ localqueue-0.3.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ localqueue-0.3.2.dist-info/entry_points.txt,sha256=UtW4aZEN4Xi_yb3sFeiAd6k8jQ--1A5acmPs6FAs2JQ,51
14
+ localqueue-0.3.2.dist-info/licenses/LICENSE,sha256=WaGG8H1cJIusbf6T862P7oDXMchv9Gy32I--7WwIdsI,1069
15
+ localqueue-0.3.2.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- localqueue/__init__.py,sha256=76PTXzQOzD_VWK8sXiPxoyidN3TlxYtg3oJhIDqGBSU,1667
2
- localqueue/cli.py,sha256=we4znzvSI55pobSqhXs1TMm5Yd8k-Y-SRQ71sR03AsA,47464
3
- localqueue/failure.py,sha256=s9s9q3PqAtsuNn2Huxsd0cYqdery0kHiF0hg92BqEvc,336
4
- localqueue/paths.py,sha256=0IRbSMhu35nB5N-HGYltJ0NFYj3I4iagSzUvR15LgWs,555
5
- localqueue/queue.py,sha256=UiATgC8MXtcnQoKNVE9n90eZvWFw-B7vVQ5fBeQQG2o,10603
6
- localqueue/store.py,sha256=zusZwArzF4aH8N6MuiM-2mwLpvHgaVy8JIQVWZqYfyM,72715
7
- localqueue/worker.py,sha256=BRdb2d8BrRfKFuhI1ggBHeKOQFkuMvZYy5HuWNOYIXQ,13783
8
- localqueue/retry/__init__.py,sha256=kxDYtiksSd-JPlTzmd1esJLBUSrvm2fJD-TN9sMsh0Q,956
9
- localqueue/retry/store.py,sha256=USV1G8RGbThWLwuFpwnr9qntJyctZdEfNKMv21vjTTg,11088
10
- localqueue/retry/tenacity.py,sha256=cBAoXUesg8aFgRqQtDyL0_j-buKlzdavXSNQ-yFwAdg,25952
11
- localqueue-0.3.1.dist-info/METADATA,sha256=VRUmcdLZ1ZUnQfWKPSTtX-Eh_cXk9kjm0htD6aWI68s,3107
12
- localqueue-0.3.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
- localqueue-0.3.1.dist-info/entry_points.txt,sha256=UtW4aZEN4Xi_yb3sFeiAd6k8jQ--1A5acmPs6FAs2JQ,51
14
- localqueue-0.3.1.dist-info/licenses/LICENSE,sha256=WaGG8H1cJIusbf6T862P7oDXMchv9Gy32I--7WwIdsI,1069
15
- localqueue-0.3.1.dist-info/RECORD,,