localqueue 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (27) hide show
  1. {localqueue-0.1.0 → localqueue-0.2.0}/CHANGELOG.md +15 -0
  2. {localqueue-0.1.0 → localqueue-0.2.0}/PKG-INFO +17 -1
  3. {localqueue-0.1.0 → localqueue-0.2.0}/README.md +12 -0
  4. {localqueue-0.1.0 → localqueue-0.2.0}/docs/api.md +4 -0
  5. {localqueue-0.1.0 → localqueue-0.2.0}/docs/index.md +23 -0
  6. {localqueue-0.1.0 → localqueue-0.2.0}/docs/queues.md +39 -3
  7. {localqueue-0.1.0 → localqueue-0.2.0}/docs/release.md +1 -1
  8. localqueue-0.2.0/examples/process_webhook.sh +10 -0
  9. {localqueue-0.1.0 → localqueue-0.2.0}/localqueue/cli.py +104 -14
  10. {localqueue-0.1.0 → localqueue-0.2.0}/pyproject.toml +7 -1
  11. {localqueue-0.1.0 → localqueue-0.2.0}/tests/test_cli.py +125 -0
  12. {localqueue-0.1.0 → localqueue-0.2.0}/uv.lock +1 -1
  13. {localqueue-0.1.0 → localqueue-0.2.0}/.gitignore +0 -0
  14. {localqueue-0.1.0 → localqueue-0.2.0}/LICENSE +0 -0
  15. {localqueue-0.1.0 → localqueue-0.2.0}/docs/operational-maturity.md +0 -0
  16. {localqueue-0.1.0 → localqueue-0.2.0}/docs/retries.md +0 -0
  17. {localqueue-0.1.0 → localqueue-0.2.0}/examples/email_worker.py +0 -0
  18. {localqueue-0.1.0 → localqueue-0.2.0}/examples/enqueue_email.py +0 -0
  19. {localqueue-0.1.0 → localqueue-0.2.0}/localqueue/__init__.py +0 -0
  20. {localqueue-0.1.0 → localqueue-0.2.0}/localqueue/queue.py +0 -0
  21. {localqueue-0.1.0 → localqueue-0.2.0}/localqueue/retry/__init__.py +0 -0
  22. {localqueue-0.1.0 → localqueue-0.2.0}/localqueue/retry/store.py +0 -0
  23. {localqueue-0.1.0 → localqueue-0.2.0}/localqueue/retry/tenacity.py +0 -0
  24. {localqueue-0.1.0 → localqueue-0.2.0}/localqueue/store.py +0 -0
  25. {localqueue-0.1.0 → localqueue-0.2.0}/localqueue/worker.py +0 -0
  26. {localqueue-0.1.0 → localqueue-0.2.0}/tests/test_queue.py +0 -0
  27. {localqueue-0.1.0 → localqueue-0.2.0}/tests/test_retry.py +0 -0
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.2.0
6
+
7
+ - Add `queue stats --watch` for monitoring queue counts while workers run.
8
+ - Add `queue dead --watch` for repeatedly listing dead letters.
9
+ - Add `queue requeue-dead --all` for bulk recovery after a fix.
10
+ - Add structured `command not found` handling for `queue exec`.
11
+ - Add `examples/process_webhook.sh` as a shell/curl worker example.
12
+
13
+ ## 0.1.1
14
+
15
+ - Add project URLs for PyPI metadata.
16
+ - Document `queue exec` command-failure fields stored in `last_error`.
17
+
3
18
  ## 0.1.0
4
19
 
5
20
  - Add persistent retry wrappers for sync and async Tenacity retryers.
@@ -1,7 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: localqueue
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Durable local queues for Python, with persistent retry state powered by Tenacity.
5
+ Project-URL: Homepage, https://brunoportis.github.io/localqueue/
6
+ Project-URL: Documentation, https://brunoportis.github.io/localqueue/
7
+ Project-URL: Repository, https://github.com/brunoportis/localqueue
8
+ Project-URL: Issues, https://github.com/brunoportis/localqueue/issues
5
9
  Author: Bruno Portis
6
10
  License-Expression: MIT
7
11
  License-File: LICENSE
@@ -121,9 +125,12 @@ localqueue queue add emails --value '{"to":"user@example.com"}'
121
125
  echo '{"to":"user@example.com"}' | localqueue queue add emails
122
126
  localqueue queue size emails
123
127
  localqueue queue stats emails
128
+ localqueue queue stats emails --watch --interval 1
124
129
  localqueue queue inspect emails <message-id>
125
130
  localqueue queue dead emails
131
+ localqueue queue dead emails --watch --interval 1
126
132
  localqueue queue requeue-dead emails <message-id>
133
+ localqueue queue requeue-dead emails --all
127
134
  localqueue queue pop emails --worker-id worker-1
128
135
  localqueue queue ack emails <message-id>
129
136
  localqueue queue exec emails -- python scripts/send_email.py
@@ -157,8 +164,13 @@ retry, release, and dead-letter rules as `queue process`.
157
164
  localqueue queue exec emails -- python scripts/send_email.py
158
165
  localqueue queue exec webhooks -- curl -X POST https://example.com/hook -d @-
159
166
  localqueue queue exec emails -- sh -c 'jq -r .to | xargs -I{} curl https://example.com/{}'
167
+ localqueue queue exec webhooks -- sh examples/process_webhook.sh
160
168
  ```
161
169
 
170
+ Command output is captured so the CLI can keep printing its own JSON status.
171
+ When a command fails, `last_error` includes the command, exit code, stdout, and
172
+ stderr, truncated for inspection.
173
+
162
174
  Use `--forever` for a long-running worker. When interrupted with `SIGINT` or
163
175
  `SIGTERM`, the CLI finishes the current message before stopping.
164
176
 
@@ -276,6 +288,10 @@ Use `queue stats` when you need ready, delayed, inflight, dead-letter, and total
276
288
  counts instead of only ready messages. Use `queue inspect` to inspect one
277
289
  message by id, `queue dead` to list dead-letter messages, and
278
290
  `queue requeue-dead` to retry one after the failure cause is fixed.
291
+ Use `queue stats --watch` to print those counts repeatedly while local workers
292
+ are running.
293
+ Use `queue dead --watch` to keep an eye on newly failed jobs, and
294
+ `queue requeue-dead --all` once you have fixed the underlying issue.
279
295
 
280
296
  ## Operational boundaries
281
297
 
@@ -90,9 +90,12 @@ localqueue queue add emails --value '{"to":"user@example.com"}'
90
90
  echo '{"to":"user@example.com"}' | localqueue queue add emails
91
91
  localqueue queue size emails
92
92
  localqueue queue stats emails
93
+ localqueue queue stats emails --watch --interval 1
93
94
  localqueue queue inspect emails <message-id>
94
95
  localqueue queue dead emails
96
+ localqueue queue dead emails --watch --interval 1
95
97
  localqueue queue requeue-dead emails <message-id>
98
+ localqueue queue requeue-dead emails --all
96
99
  localqueue queue pop emails --worker-id worker-1
97
100
  localqueue queue ack emails <message-id>
98
101
  localqueue queue exec emails -- python scripts/send_email.py
@@ -126,8 +129,13 @@ retry, release, and dead-letter rules as `queue process`.
126
129
  localqueue queue exec emails -- python scripts/send_email.py
127
130
  localqueue queue exec webhooks -- curl -X POST https://example.com/hook -d @-
128
131
  localqueue queue exec emails -- sh -c 'jq -r .to | xargs -I{} curl https://example.com/{}'
132
+ localqueue queue exec webhooks -- sh examples/process_webhook.sh
129
133
  ```
130
134
 
135
+ Command output is captured so the CLI can keep printing its own JSON status.
136
+ When a command fails, `last_error` includes the command, exit code, stdout, and
137
+ stderr, truncated for inspection.
138
+
131
139
  Use `--forever` for a long-running worker. When interrupted with `SIGINT` or
132
140
  `SIGTERM`, the CLI finishes the current message before stopping.
133
141
 
@@ -245,6 +253,10 @@ Use `queue stats` when you need ready, delayed, inflight, dead-letter, and total
245
253
  counts instead of only ready messages. Use `queue inspect` to inspect one
246
254
  message by id, `queue dead` to list dead-letter messages, and
247
255
  `queue requeue-dead` to retry one after the failure cause is fixed.
256
+ Use `queue stats --watch` to print those counts repeatedly while local workers
257
+ are running.
258
+ Use `queue dead --watch` to keep an eye on newly failed jobs, and
259
+ `queue requeue-dead --all` once you have fixed the underlying issue.
248
260
 
249
261
  ## Operational boundaries
250
262
 
@@ -94,6 +94,10 @@ Dataclass returned by `put()` and `get_message()`.
94
94
  | `last_error` | structured error from the most recent failed processing attempt, if recorded |
95
95
  | `failed_at` | timestamp for `last_error`, if recorded |
96
96
 
97
+ For `localqueue queue exec` failures, `last_error` also includes `command`,
98
+ `exit_code`, `stdout`, and `stderr` fields so command workers can be inspected
99
+ from `queue inspect` and `queue dead`.
100
+
97
101
  #### `QueueStats`
98
102
 
99
103
  Dataclass returned by `stats()`.
@@ -50,6 +50,8 @@ localqueue queue exec emails -- python scripts/send_email.py
50
50
  `queue exec` writes the message value to the command's stdin as JSON. Exit code
51
51
  `0` acknowledges the message. Any other exit code is treated as a failed handler
52
52
  attempt and follows the configured retry, release, and dead-letter policy.
53
+ Command failures are recorded in `last_error` with the command, exit code,
54
+ stdout, and stderr.
53
55
 
54
56
  Run a continuous local worker with `--forever`. `SIGINT` and `SIGTERM` request a
55
57
  graceful stop after the current message finishes.
@@ -62,6 +64,27 @@ localqueue queue process emails myapp.workers:send_email \
62
64
  --max-tries 5
63
65
  ```
64
66
 
67
+ Watch queue counts while workers run:
68
+
69
+ ```bash
70
+ localqueue queue stats emails --watch --interval 1
71
+ ```
72
+
73
+ For shell workers, `examples/process_webhook.sh` shows the same pattern with
74
+ `jq` and `curl`.
75
+
76
+ Inspect dead letters while workers are running:
77
+
78
+ ```bash
79
+ localqueue queue dead emails --watch --interval 2
80
+ ```
81
+
82
+ After fixing a problem, requeue all dead letters with:
83
+
84
+ ```bash
85
+ localqueue queue requeue-dead emails --all
86
+ ```
87
+
65
88
  For a local smoke test, enqueue one job and process it with the bundled example
66
89
  handler:
67
90
 
@@ -91,16 +91,29 @@ external command instead of an importable Python function.
91
91
  localqueue queue exec jobs -- python scripts/process_job.py
92
92
  localqueue queue exec webhooks -- curl -X POST https://example.com/hook -d @-
93
93
  localqueue queue exec jobs -- sh -c 'jq -r .id | xargs -I{} ./process-job {}'
94
+ localqueue queue exec webhooks -- sh examples/process_webhook.sh
94
95
  ```
95
96
 
96
97
  The command receives `message.value` on stdin as JSON. `localqueue` captures the
97
98
  command output so the CLI can keep printing its own JSON status. Exit code `0`
98
- acks the message. Any other exit code raises a command failure, records the exit
99
- code and stderr in `last_error`, and applies the same retry, release, or
100
- dead-letter behavior as `queue process`.
99
+ acks the message. Any other exit code raises a command failure and applies the
100
+ same retry, release, or dead-letter behavior as `queue process`.
101
+
102
+ Command failures are stored in `last_error` with structured fields:
103
+
104
+ | Field | Meaning |
105
+ | --- | --- |
106
+ | `type` | `_CommandExecutionError` |
107
+ | `message` | human-readable command failure |
108
+ | `command` | argv list that was executed |
109
+ | `exit_code` | command exit code |
110
+ | `stdout` | captured stdout, truncated for inspection |
111
+ | `stderr` | captured stderr, truncated for inspection |
101
112
 
102
113
  Commands are executed without an implicit shell. Use `sh -c` explicitly when you
103
114
  want pipes, redirects, shell expansion, or multiple commands.
115
+ See `examples/process_webhook.sh` for a shell worker that reads JSON, extracts
116
+ fields with `jq`, and posts them with `curl`.
104
117
 
105
118
  ## Queue-style usage
106
119
 
@@ -305,3 +318,26 @@ by id without changing state. `dead_letters()` lists dead-letter messages for
305
318
  inspection. `requeue_dead()` moves a dead-letter message back to the ready queue.
306
319
  `purge()` removes all records for that queue, including ready, inflight, and
307
320
  dead-letter records.
321
+
322
+ From the CLI, use `queue stats --watch` to monitor those counts while workers
323
+ are running:
324
+
325
+ ```bash
326
+ localqueue queue stats jobs --watch --interval 1
327
+ ```
328
+
329
+ Each sample is printed as JSON with `ready`, `delayed`, `inflight`, `dead`, and
330
+ `total` counts. Stop the watch with `Ctrl-C`.
331
+
332
+ Use `queue dead --watch` to keep an eye on recent failures:
333
+
334
+ ```bash
335
+ localqueue queue dead jobs --watch --interval 2
336
+ ```
337
+
338
+ When the underlying problem is fixed, `queue requeue-dead --all` moves every
339
+ dead letter back to ready delivery:
340
+
341
+ ```bash
342
+ localqueue queue requeue-dead jobs --all
343
+ ```
@@ -104,4 +104,4 @@ Before `1.0.0`, use this convention:
104
104
 
105
105
  ## Current Release
106
106
 
107
- The current planned release is `0.1.0`.
107
+ The current planned release is `0.2.0`.
@@ -0,0 +1,10 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ payload="$(cat)"
5
+ url="$(printf '%s' "$payload" | jq -r '.url')"
6
+ body="$(printf '%s' "$payload" | jq -c '.payload')"
7
+
8
+ curl -fsS -X POST "$url" \
9
+ -H 'content-type: application/json' \
10
+ -d "$body"
@@ -67,6 +67,17 @@ class _CommandExecutionError(RuntimeError):
67
67
  )
68
68
 
69
69
 
70
+ class _CommandNotFoundError(_CommandExecutionError):
71
+ def __init__(self, command: list[str]) -> None:
72
+ super().__init__(
73
+ command,
74
+ exit_code=127,
75
+ stdout="",
76
+ stderr="command not found",
77
+ )
78
+ self.args = (f"command not found: {_format_command(command)}",)
79
+
80
+
70
81
  def main(args: list[str] | None = None) -> None:
71
82
  try:
72
83
  from rich.console import Console
@@ -251,29 +262,65 @@ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
251
262
  def queue_stats(
252
263
  queue: str,
253
264
  store_path: str | None = typer.Option(None, "--store-path"),
265
+ watch: bool = typer.Option(False, "--watch"),
266
+ interval: float = typer.Option(1.0, "--interval", min=0.001),
254
267
  ) -> None:
255
- stats = _queue(queue, _resolve_store_path(store_path, config)).stats()
256
- _print_json(console, stats.as_dict())
268
+ persistent_queue = _queue(queue, _resolve_store_path(store_path, config))
269
+ with _shutdown_state() as shutdown:
270
+ _print_queue_stats(
271
+ persistent_queue,
272
+ console=console,
273
+ watch=watch,
274
+ interval=interval,
275
+ shutdown=shutdown,
276
+ )
257
277
 
258
278
  @queue_app.command("dead")
259
279
  def queue_dead(
260
280
  queue: str,
261
281
  store_path: str | None = typer.Option(None, "--store-path"),
262
282
  limit: int | None = typer.Option(None, "--limit", min=0),
283
+ watch: bool = typer.Option(False, "--watch"),
284
+ interval: float = typer.Option(1.0, "--interval", min=0.001),
263
285
  ) -> None:
264
- messages = _queue(queue, _resolve_store_path(store_path, config)).dead_letters(
265
- limit=limit
266
- )
267
- _print_json(console, [_message_payload(message) for message in messages])
286
+ persistent_queue = _queue(queue, _resolve_store_path(store_path, config))
287
+ with _shutdown_state() as shutdown:
288
+ _print_dead_letters(
289
+ persistent_queue,
290
+ console=console,
291
+ limit=limit,
292
+ watch=watch,
293
+ interval=interval,
294
+ shutdown=shutdown,
295
+ )
268
296
 
269
297
  @queue_app.command("requeue-dead")
270
298
  def queue_requeue_dead(
271
299
  queue: str,
272
- message_id: str,
300
+ message_id: str | None = typer.Argument(None),
273
301
  store_path: str | None = typer.Option(None, "--store-path"),
274
302
  delay: float = typer.Option(0.0, "--delay", min=0.0),
303
+ all_: bool = typer.Option(False, "--all"),
275
304
  ) -> None:
276
305
  persistent_queue = _queue(queue, _resolve_store_path(store_path, config))
306
+ if all_:
307
+ if message_id is not None:
308
+ err_console.print(
309
+ "[red]pass either a message id or --all, not both[/red]"
310
+ )
311
+ raise typer.Exit(1)
312
+ messages = persistent_queue.dead_letters()
313
+ requeued = 0
314
+ for message in messages:
315
+ if persistent_queue.requeue_dead(message, delay=delay):
316
+ requeued += 1
317
+ _print_json(console, {"requeued": requeued})
318
+ return
319
+ if message_id is None:
320
+ err_console.print(
321
+ "[red]pass a message id or use --all to requeue dead letters[/red]"
322
+ )
323
+ raise typer.Exit(1)
277
324
  message = QueueMessage(id=message_id, value=None, queue=queue)
278
325
  if not persistent_queue.requeue_dead(message, delay=delay):
279
326
  err_console.print(f"[red]dead-letter message not found:[/red] {message_id}")
@@ -550,6 +597,46 @@ def _process_queue_messages(
550
597
  return 0
551
598
 
552
599
 
600
+ def _print_dead_letters(
601
+ queue: PersistentQueue,
602
+ *,
603
+ console: Any,
604
+ limit: int | None,
605
+ watch: bool,
606
+ interval: float,
607
+ shutdown: _ShutdownState,
608
+ ) -> None:
609
+ while True:
610
+ messages = queue.dead_letters(limit=limit)
611
+ _print_json(console, [_message_payload(message) for message in messages])
612
+ if not watch:
613
+ return
614
+ if shutdown.requested:
615
+ return
616
+ time.sleep(interval)
617
+ if shutdown.requested:
618
+ return
619
+
620
+
621
+ def _print_queue_stats(
622
+ queue: PersistentQueue,
623
+ *,
624
+ console: Any,
625
+ watch: bool,
626
+ interval: float,
627
+ shutdown: _ShutdownState,
628
+ ) -> None:
629
+ while True:
630
+ _print_json(console, queue.stats().as_dict())
631
+ if not watch:
632
+ return
633
+ if shutdown.requested:
634
+ return
635
+ time.sleep(interval)
636
+ if shutdown.requested:
637
+ return
638
+
639
+
553
640
  def _poll_timeout(forever: bool, block: bool, timeout: float | None) -> float | None:
554
641
  if forever and block and timeout is None:
555
642
  return 0.5
@@ -692,13 +779,16 @@ def _command_handler(command: list[str]) -> Callable[[Any], None]:
692
779
  raise ValueError("command cannot be empty")
693
780
 
694
781
  def run_command(value: Any) -> None:
695
- completed = subprocess.run(
696
- command,
697
- input=_json_dumps(value) + "\n",
698
- capture_output=True,
699
- text=True,
700
- check=False,
701
- )
782
+ try:
783
+ completed = subprocess.run(
784
+ command,
785
+ input=_json_dumps(value) + "\n",
786
+ capture_output=True,
787
+ text=True,
788
+ check=False,
789
+ )
790
+ except FileNotFoundError as exc:
791
+ raise _CommandNotFoundError(command) from exc
702
792
  if completed.returncode != 0:
703
793
  raise _CommandExecutionError(
704
794
  command,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "localqueue"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Durable local queues for Python, with persistent retry state powered by Tenacity."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -43,6 +43,12 @@ all = [
43
43
  [project.scripts]
44
44
  localqueue = "localqueue.cli:main"
45
45
 
46
+ [project.urls]
47
+ Homepage = "https://brunoportis.github.io/localqueue/"
48
+ Documentation = "https://brunoportis.github.io/localqueue/"
49
+ Repository = "https://github.com/brunoportis/localqueue"
50
+ Issues = "https://github.com/brunoportis/localqueue/issues"
51
+
46
52
  [build-system]
47
53
  requires = ["hatchling>=1.27.0"]
48
54
  build-backend = "hatchling.build"
@@ -28,6 +28,8 @@ from localqueue.cli import (
28
28
  _load_callable,
29
29
  _load_config,
30
30
  _parse_json,
31
+ _print_dead_letters,
32
+ _print_queue_stats,
31
33
  _process_message,
32
34
  _process_queue_messages,
33
35
  _read_value,
@@ -380,6 +382,30 @@ class CliTests(unittest.TestCase):
380
382
  self.assertEqual(missing_requeue.exit_code, 1)
381
383
  self.assertIn("dead-letter message not found", missing_requeue.output)
382
384
 
385
+ def test_print_dead_letters_watch_stops_on_shutdown(self) -> None:
386
+ queue = PersistentQueue("emails", store=MemoryQueueStore())
387
+ _ = queue.put({"to": "user@example.com"})
388
+ message = queue.get_message()
389
+ self.assertTrue(queue.dead_letter(message, error=RuntimeError("bad")))
390
+ console = _JsonConsole()
391
+ shutdown = _ShutdownState()
392
+
393
+ def request_shutdown(_interval: float) -> None:
394
+ shutdown.requested = True
395
+
396
+ with mock.patch("time.sleep", side_effect=request_shutdown):
397
+ _print_dead_letters(
398
+ queue,
399
+ console=console,
400
+ limit=None,
401
+ watch=True,
402
+ interval=0.001,
403
+ shutdown=shutdown,
404
+ )
405
+
406
+ self.assertEqual(len(console.values), 1)
407
+ self.assertEqual(console.values[0][0]["id"], message.id)
408
+
383
409
  def test_queue_process_command_acks_successful_handler(self) -> None:
384
410
  with tempfile.TemporaryDirectory() as tmpdir:
385
411
  tmp_path = Path(tmpdir)
@@ -496,6 +522,40 @@ class CliTests(unittest.TestCase):
496
522
  self.assertEqual(result.exit_code, 1)
497
523
  self.assertIn("module:function", result.output)
498
524
 
525
+ def test_queue_exec_reports_missing_command(self) -> None:
526
+ with tempfile.TemporaryDirectory() as tmpdir:
527
+ store_path = str(Path(tmpdir) / "queues")
528
+ retry_store_path = str(Path(tmpdir) / "retries.sqlite3")
529
+ queue = PersistentQueue("emails", store_path=store_path)
530
+ message = queue.put({"to": "user@example.com"})
531
+
532
+ result = self._invoke(
533
+ [
534
+ "queue",
535
+ "exec",
536
+ "emails",
537
+ "--store-path",
538
+ store_path,
539
+ "--retry-store-path",
540
+ retry_store_path,
541
+ "--max-tries",
542
+ "1",
543
+ "--",
544
+ "definitely-not-a-real-command-123",
545
+ ]
546
+ )
547
+
548
+ self.assertEqual(result.exit_code, 1)
549
+ payload = json.loads(result.stdout)
550
+ self.assertEqual(payload["id"], message.id)
551
+ self.assertEqual(payload["state"], "failed")
552
+ self.assertEqual(payload["last_error"]["type"], "_CommandNotFoundError")
553
+ self.assertEqual(payload["last_error"]["exit_code"], 127)
554
+ self.assertEqual(
555
+ payload["last_error"]["command"], ["definitely-not-a-real-command-123"]
556
+ )
557
+ self.assertIn("command not found", payload["last_error"]["message"])
558
+
499
559
  def test_command_handler_passes_message_value_as_json_stdin(self) -> None:
500
560
  with tempfile.TemporaryDirectory() as tmpdir:
501
561
  tmp_path = Path(tmpdir)
@@ -610,6 +670,48 @@ class CliTests(unittest.TestCase):
610
670
  self.assertEqual(dead_letters[0].last_error["exit_code"], 7)
611
671
  self.assertEqual(dead_letters[0].last_error["stderr"], "cannot deliver")
612
672
 
673
+ def test_queue_requeue_dead_all_moves_every_dead_message(self) -> None:
674
+ with tempfile.TemporaryDirectory() as tmpdir:
675
+ store_path = str(Path(tmpdir) / "queues")
676
+ persistent_queue = PersistentQueue("emails", store_path=store_path)
677
+ first = persistent_queue.put({"to": "one@example.com"})
678
+ second = persistent_queue.put({"to": "two@example.com"})
679
+ self.assertTrue(
680
+ persistent_queue.dead_letter(
681
+ persistent_queue.get_message(), error=RuntimeError("bad 1")
682
+ )
683
+ )
684
+ self.assertTrue(
685
+ persistent_queue.dead_letter(
686
+ persistent_queue.get_message(), error=RuntimeError("bad 2")
687
+ )
688
+ )
689
+
690
+ result = self._invoke(
691
+ [
692
+ "queue",
693
+ "requeue-dead",
694
+ "emails",
695
+ "--all",
696
+ "--store-path",
697
+ store_path,
698
+ ]
699
+ )
700
+
701
+ self.assertEqual(result.exit_code, 0, result.output)
702
+ self.assertEqual(json.loads(result.stdout), {"requeued": 2})
703
+ fresh_queue = PersistentQueue("emails", store_path=store_path)
704
+ self.assertEqual(fresh_queue.dead_letters(), [])
705
+ self.assertEqual(fresh_queue.qsize(), 2)
706
+ first_message = fresh_queue.inspect(first.id)
707
+ second_message = fresh_queue.inspect(second.id)
708
+ self.assertIsNotNone(first_message)
709
+ self.assertIsNotNone(second_message)
710
+ assert first_message is not None
711
+ assert second_message is not None
712
+ self.assertEqual(first_message.state, "ready")
713
+ self.assertEqual(second_message.state, "ready")
714
+
613
715
  def test_process_message_acks_successful_handler(self) -> None:
614
716
  queue = PersistentQueue("emails", store=MemoryQueueStore())
615
717
  _ = queue.put({"to": "user@example.com"})
@@ -724,6 +826,29 @@ class CliTests(unittest.TestCase):
724
826
  self.assertEqual(console.values, [])
725
827
  self.assertEqual(err_console.messages, ["[yellow]queue is empty[/yellow]"])
726
828
 
829
+ def test_print_queue_stats_watch_stops_on_shutdown(self) -> None:
830
+ queue = PersistentQueue("emails", store=MemoryQueueStore())
831
+ _ = queue.put({"to": "user@example.com"})
832
+ console = _JsonConsole()
833
+ shutdown = _ShutdownState()
834
+
835
+ def request_shutdown(_interval: float) -> None:
836
+ shutdown.requested = True
837
+
838
+ with mock.patch("time.sleep", side_effect=request_shutdown):
839
+ _print_queue_stats(
840
+ queue,
841
+ console=console,
842
+ watch=True,
843
+ interval=0.001,
844
+ shutdown=shutdown,
845
+ )
846
+
847
+ self.assertEqual(
848
+ console.values,
849
+ [{"ready": 1, "delayed": 0, "inflight": 0, "dead": 0, "total": 1}],
850
+ )
851
+
727
852
  def test_process_queue_messages_forever_stops_after_current_message(self) -> None:
728
853
  queue = PersistentQueue("emails", store=MemoryQueueStore())
729
854
  _ = queue.put({"to": "first@example.com"})
@@ -200,7 +200,7 @@ wheels = [
200
200
 
201
201
  [[package]]
202
202
  name = "localqueue"
203
- version = "0.1.0"
203
+ version = "0.2.0"
204
204
  source = { editable = "." }
205
205
  dependencies = [
206
206
  { name = "tenacity" },
File without changes
File without changes
File without changes