localqueue 0.1.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.
localqueue/cli.py ADDED
@@ -0,0 +1,744 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import json
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from collections.abc import Callable
11
+ from contextlib import contextmanager
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from queue import Empty
15
+ from types import FrameType
16
+ from typing import Any, Iterator
17
+
18
+ from tenacity import wait_none
19
+
20
+ from .queue import PersistentQueue
21
+ from .retry import (
22
+ PersistentRetryExhausted,
23
+ PersistentRetrying,
24
+ SQLiteAttemptStore,
25
+ )
26
+ from .store import QueueMessage
27
+
28
+ DEFAULT_STORE_PATH = "localqueue_queue.sqlite3"
29
+ DEFAULT_RETRY_STORE_PATH = "localqueue_retries.sqlite3"
30
+ CONFIG_FILENAME = "config.yaml"
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class _ProcessResult:
35
+ processed: bool
36
+ last_error: dict[str, Any] | None = None
37
+
38
+
39
+ @dataclass(slots=True)
40
+ class _ShutdownState:
41
+ requested: bool = False
42
+
43
+
44
+ class _CommandExecutionError(RuntimeError):
45
+ command: list[str]
46
+ exit_code: int
47
+ stdout: str
48
+ stderr: str
49
+
50
+ def __init__(
51
+ self,
52
+ command: list[str],
53
+ *,
54
+ exit_code: int,
55
+ stdout: str,
56
+ stderr: str,
57
+ ) -> None:
58
+ self.command = command
59
+ self.exit_code = exit_code
60
+ self.stdout = _truncate_output(stdout.strip())
61
+ self.stderr = _truncate_output(stderr.strip())
62
+ stderr_message = _truncate_output(stderr.strip())
63
+ detail = f": {stderr_message}" if stderr_message else ""
64
+ super().__init__(
65
+ f"command exited with status {exit_code}: {_format_command(command)}"
66
+ + detail
67
+ )
68
+
69
+
70
+ def main(args: list[str] | None = None) -> None:
71
+ try:
72
+ from rich.console import Console
73
+ import typer
74
+ import yaml
75
+ except ImportError as exc:
76
+ raise SystemExit(
77
+ "The localqueue CLI requires optional dependencies. "
78
+ "Install with: pip install 'localqueue[cli]'"
79
+ ) from exc
80
+
81
+ app = _build_app(typer, yaml, Console(), Console(stderr=True))
82
+ app(args=args)
83
+
84
+
85
+ def _build_app(typer: Any, yaml: Any, console: Any, err_console: Any) -> Any:
86
+ config = _load_config(yaml)
87
+ app = typer.Typer(no_args_is_help=True)
88
+ queue_app = typer.Typer(no_args_is_help=True)
89
+ config_app = typer.Typer(no_args_is_help=True)
90
+ app.add_typer(queue_app, name="queue")
91
+ app.add_typer(config_app, name="config")
92
+
93
+ @config_app.command("path")
94
+ def config_path() -> None:
95
+ console.print(str(_config_path()))
96
+
97
+ @config_app.command("show")
98
+ def config_show() -> None:
99
+ _print_json(console, config)
100
+
101
+ @config_app.command("init")
102
+ def config_init(
103
+ store_path: str = typer.Option(DEFAULT_STORE_PATH, "--store-path"),
104
+ retry_store_path: str | None = typer.Option(None, "--retry-store-path"),
105
+ force: bool = typer.Option(False, "--force"),
106
+ ) -> None:
107
+ path = _config_path()
108
+ if path.exists() and not force:
109
+ err_console.print(
110
+ f"[red]config already exists:[/red] {path}; pass --force to overwrite"
111
+ )
112
+ raise typer.Exit(1)
113
+
114
+ new_config: dict[str, Any] = {"store_path": store_path}
115
+ if retry_store_path is not None:
116
+ new_config["retry_store_path"] = retry_store_path
117
+ _write_config(yaml, new_config)
118
+ config.clear()
119
+ config.update(new_config)
120
+ console.print(f"[green]wrote config:[/green] {path}")
121
+
122
+ @config_app.command("set")
123
+ def config_set(
124
+ key: str,
125
+ value: str,
126
+ ) -> None:
127
+ if key not in {"store_path", "retry_store_path"}:
128
+ err_console.print(
129
+ "[red]unsupported config key:[/red] "
130
+ + f"{key}; use store_path or retry_store_path"
131
+ )
132
+ raise typer.Exit(1)
133
+
134
+ updated = dict(config)
135
+ updated[key] = value
136
+ _write_config(yaml, updated)
137
+ config.clear()
138
+ config.update(updated)
139
+ _print_json(console, config)
140
+
141
+ @queue_app.command("add")
142
+ def queue_add(
143
+ queue: str,
144
+ value: str | None = typer.Option(None, "--value"),
145
+ store_path: str | None = typer.Option(None, "--store-path"),
146
+ delay: float = typer.Option(0.0, "--delay", min=0.0),
147
+ raw: bool = typer.Option(False, "--raw"),
148
+ ) -> None:
149
+ try:
150
+ payload_value = _read_value(value)
151
+ payload = payload_value if raw else _parse_json(payload_value)
152
+ except ValueError as exc:
153
+ raise typer.BadParameter(str(exc)) from exc
154
+ message = _queue(queue, _resolve_store_path(store_path, config)).put(
155
+ payload, delay=delay
156
+ )
157
+ _print_json(console, _message_payload(message))
158
+
159
+ @queue_app.command("pop")
160
+ def queue_pop(
161
+ queue: str,
162
+ store_path: str | None = typer.Option(None, "--store-path"),
163
+ lease_timeout: float = typer.Option(30.0, "--lease-timeout", min=0.001),
164
+ worker_id: str | None = typer.Option(None, "--worker-id"),
165
+ block: bool = typer.Option(False, "--block"),
166
+ timeout: float | None = typer.Option(None, "--timeout", min=0.0),
167
+ ) -> None:
168
+ try:
169
+ message = _queue(
170
+ queue,
171
+ _resolve_store_path(store_path, config),
172
+ lease_timeout=lease_timeout,
173
+ ).get_message(block=block, timeout=timeout, leased_by=worker_id)
174
+ except Empty as exc:
175
+ err_console.print("[yellow]queue is empty[/yellow]")
176
+ raise typer.Exit(1) from exc
177
+ _print_json(console, _message_payload(message))
178
+
179
+ @queue_app.command("inspect")
180
+ def queue_inspect(
181
+ queue: str,
182
+ message_id: str,
183
+ store_path: str | None = typer.Option(None, "--store-path"),
184
+ ) -> None:
185
+ message = _queue(queue, _resolve_store_path(store_path, config)).inspect(
186
+ message_id
187
+ )
188
+ if message is None:
189
+ err_console.print(f"[red]message not found:[/red] {message_id}")
190
+ raise typer.Exit(1)
191
+ _print_json(console, _message_payload(message))
192
+
193
+ @queue_app.command("ack")
194
+ def queue_ack(
195
+ queue: str,
196
+ message_id: str,
197
+ store_path: str | None = typer.Option(None, "--store-path"),
198
+ ) -> None:
199
+ _complete_message(
200
+ typer,
201
+ console,
202
+ err_console,
203
+ queue,
204
+ _resolve_store_path(store_path, config),
205
+ message_id,
206
+ "ack",
207
+ )
208
+
209
+ @queue_app.command("release")
210
+ def queue_release(
211
+ queue: str,
212
+ message_id: str,
213
+ store_path: str | None = typer.Option(None, "--store-path"),
214
+ delay: float = typer.Option(0.0, "--delay", min=0.0),
215
+ ) -> None:
216
+ _complete_message(
217
+ typer,
218
+ console,
219
+ err_console,
220
+ queue,
221
+ _resolve_store_path(store_path, config),
222
+ message_id,
223
+ "release",
224
+ delay=delay,
225
+ )
226
+
227
+ @queue_app.command("dead-letter")
228
+ def queue_dead_letter(
229
+ queue: str,
230
+ message_id: str,
231
+ store_path: str | None = typer.Option(None, "--store-path"),
232
+ ) -> None:
233
+ _complete_message(
234
+ typer,
235
+ console,
236
+ err_console,
237
+ queue,
238
+ _resolve_store_path(store_path, config),
239
+ message_id,
240
+ "dead-letter",
241
+ )
242
+
243
+ @queue_app.command("size")
244
+ def queue_size(
245
+ queue: str,
246
+ store_path: str | None = typer.Option(None, "--store-path"),
247
+ ) -> None:
248
+ console.print(_queue(queue, _resolve_store_path(store_path, config)).qsize())
249
+
250
+ @queue_app.command("stats")
251
+ def queue_stats(
252
+ queue: str,
253
+ store_path: str | None = typer.Option(None, "--store-path"),
254
+ ) -> None:
255
+ stats = _queue(queue, _resolve_store_path(store_path, config)).stats()
256
+ _print_json(console, stats.as_dict())
257
+
258
+ @queue_app.command("dead")
259
+ def queue_dead(
260
+ queue: str,
261
+ store_path: str | None = typer.Option(None, "--store-path"),
262
+ limit: int | None = typer.Option(None, "--limit", min=0),
263
+ ) -> 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])
268
+
269
+ @queue_app.command("requeue-dead")
270
+ def queue_requeue_dead(
271
+ queue: str,
272
+ message_id: str,
273
+ store_path: str | None = typer.Option(None, "--store-path"),
274
+ delay: float = typer.Option(0.0, "--delay", min=0.0),
275
+ ) -> None:
276
+ persistent_queue = _queue(queue, _resolve_store_path(store_path, config))
277
+ message = QueueMessage(id=message_id, value=None, queue=queue)
278
+ if not persistent_queue.requeue_dead(message, delay=delay):
279
+ err_console.print(f"[red]dead-letter message not found:[/red] {message_id}")
280
+ raise typer.Exit(1)
281
+ _print_json(console, {"id": message_id, "state": "requeued"})
282
+
283
+ @queue_app.command("purge")
284
+ def queue_purge(
285
+ queue: str,
286
+ store_path: str | None = typer.Option(None, "--store-path"),
287
+ ) -> None:
288
+ console.print(_queue(queue, _resolve_store_path(store_path, config)).purge())
289
+
290
+ @queue_app.command("process")
291
+ def queue_process(
292
+ queue: str,
293
+ handler: str,
294
+ store_path: str | None = typer.Option(None, "--store-path"),
295
+ retry_store_path: str | None = typer.Option(None, "--retry-store-path"),
296
+ max_jobs: int = typer.Option(1, "--max-jobs", min=1),
297
+ forever: bool = typer.Option(False, "--forever"),
298
+ max_tries: int = typer.Option(3, "--max-tries", min=1),
299
+ lease_timeout: float = typer.Option(30.0, "--lease-timeout", min=0.001),
300
+ worker_id: str | None = typer.Option(None, "--worker-id"),
301
+ block: bool = typer.Option(False, "--block"),
302
+ timeout: float | None = typer.Option(None, "--timeout", min=0.0),
303
+ idle_sleep: float = typer.Option(1.0, "--idle-sleep", min=0.001),
304
+ release_delay: float = typer.Option(0.0, "--release-delay", min=0.0),
305
+ dead_letter_on_exhaustion: bool = typer.Option(
306
+ True,
307
+ "--dead-letter-on-exhaustion/--release-on-exhaustion",
308
+ ),
309
+ ) -> None:
310
+ try:
311
+ worker = _load_callable(handler)
312
+ except (AttributeError, ImportError, TypeError, ValueError) as exc:
313
+ err_console.print(f"[red]{exc}[/red]")
314
+ raise typer.Exit(1) from exc
315
+ persistent_queue = _queue(
316
+ queue,
317
+ _resolve_store_path(store_path, config),
318
+ lease_timeout=lease_timeout,
319
+ )
320
+ with _shutdown_state() as shutdown:
321
+ exit_code = _process_queue_messages(
322
+ persistent_queue,
323
+ worker,
324
+ console=console,
325
+ err_console=err_console,
326
+ shutdown=shutdown,
327
+ retry_store_path=_resolve_retry_store_path(retry_store_path, config),
328
+ max_jobs=max_jobs,
329
+ forever=forever,
330
+ max_tries=max_tries,
331
+ worker_id=worker_id,
332
+ block=block,
333
+ timeout=timeout,
334
+ idle_sleep=idle_sleep,
335
+ release_delay=release_delay,
336
+ dead_letter_on_exhaustion=dead_letter_on_exhaustion,
337
+ )
338
+ if exit_code:
339
+ raise typer.Exit(exit_code)
340
+
341
+ @queue_app.command("exec")
342
+ def queue_exec(
343
+ queue: str,
344
+ command: list[str] = typer.Argument(
345
+ ...,
346
+ help="Command to run. Use '--' before commands that contain options.",
347
+ ),
348
+ store_path: str | None = typer.Option(None, "--store-path"),
349
+ retry_store_path: str | None = typer.Option(None, "--retry-store-path"),
350
+ max_jobs: int = typer.Option(1, "--max-jobs", min=1),
351
+ forever: bool = typer.Option(False, "--forever"),
352
+ max_tries: int = typer.Option(3, "--max-tries", min=1),
353
+ lease_timeout: float = typer.Option(30.0, "--lease-timeout", min=0.001),
354
+ worker_id: str | None = typer.Option(None, "--worker-id"),
355
+ block: bool = typer.Option(False, "--block"),
356
+ timeout: float | None = typer.Option(None, "--timeout", min=0.0),
357
+ idle_sleep: float = typer.Option(1.0, "--idle-sleep", min=0.001),
358
+ release_delay: float = typer.Option(0.0, "--release-delay", min=0.0),
359
+ dead_letter_on_exhaustion: bool = typer.Option(
360
+ True,
361
+ "--dead-letter-on-exhaustion/--release-on-exhaustion",
362
+ ),
363
+ ) -> None:
364
+ persistent_queue = _queue(
365
+ queue,
366
+ _resolve_store_path(store_path, config),
367
+ lease_timeout=lease_timeout,
368
+ )
369
+ with _shutdown_state() as shutdown:
370
+ exit_code = _process_queue_messages(
371
+ persistent_queue,
372
+ _command_handler(command),
373
+ console=console,
374
+ err_console=err_console,
375
+ shutdown=shutdown,
376
+ retry_store_path=_resolve_retry_store_path(retry_store_path, config),
377
+ max_jobs=max_jobs,
378
+ forever=forever,
379
+ max_tries=max_tries,
380
+ worker_id=worker_id,
381
+ block=block,
382
+ timeout=timeout,
383
+ idle_sleep=idle_sleep,
384
+ release_delay=release_delay,
385
+ dead_letter_on_exhaustion=dead_letter_on_exhaustion,
386
+ )
387
+ if exit_code:
388
+ raise typer.Exit(exit_code)
389
+
390
+ return app
391
+
392
+
393
+ def _queue(
394
+ name: str, store_path: str, *, lease_timeout: float = 30.0
395
+ ) -> PersistentQueue:
396
+ return PersistentQueue(name, store_path=store_path, lease_timeout=lease_timeout)
397
+
398
+
399
+ def _complete_message(
400
+ typer: Any,
401
+ console: Any,
402
+ err_console: Any,
403
+ queue: str,
404
+ store_path: str,
405
+ message_id: str,
406
+ action: str,
407
+ *,
408
+ delay: float = 0.0,
409
+ ) -> None:
410
+ persistent_queue = _queue(queue, store_path)
411
+ message = QueueMessage(id=message_id, value=None, queue=queue)
412
+ if action == "ack":
413
+ completed = persistent_queue.ack(message)
414
+ elif action == "release":
415
+ completed = persistent_queue.release(message, delay=delay)
416
+ elif action == "dead-letter":
417
+ completed = persistent_queue.dead_letter(message)
418
+ else:
419
+ raise ValueError(f"unsupported queue action: {action}")
420
+
421
+ if not completed:
422
+ err_console.print(f"[red]message not found:[/red] {message_id}")
423
+ raise typer.Exit(1)
424
+ _print_json(console, {"id": message_id, "state": action})
425
+
426
+
427
+ def _process_message(
428
+ queue: PersistentQueue,
429
+ message: QueueMessage,
430
+ handler: Callable[[Any], Any],
431
+ *,
432
+ retry_store: Any | None = None,
433
+ retry_store_path: str | None,
434
+ max_tries: int,
435
+ release_delay: float,
436
+ dead_letter_on_exhaustion: bool,
437
+ ) -> _ProcessResult:
438
+ retry_kwargs: dict[str, Any] = {
439
+ "key": message.id,
440
+ "max_tries": max_tries,
441
+ "wait": wait_none(),
442
+ }
443
+ owned_retry_store: SQLiteAttemptStore | None = None
444
+ if retry_store is not None:
445
+ retry_kwargs["store"] = retry_store
446
+ elif retry_store_path is not None:
447
+ owned_retry_store = SQLiteAttemptStore(retry_store_path)
448
+ retry_kwargs["store"] = owned_retry_store
449
+
450
+ retryer = PersistentRetrying(**retry_kwargs)
451
+ try:
452
+ try:
453
+ _ = retryer(handler, message.value)
454
+ except PersistentRetryExhausted as exc:
455
+ last_error = _error_payload(exc)
456
+ _ = _finish_failed_message(
457
+ queue,
458
+ message,
459
+ release_delay=release_delay,
460
+ dead_letter_on_exhaustion=dead_letter_on_exhaustion,
461
+ error=exc,
462
+ )
463
+ return _ProcessResult(processed=False, last_error=last_error)
464
+ except Exception as exc:
465
+ last_error = _error_payload(exc)
466
+ record = retryer.get_record(message.id)
467
+ exhausted = record is not None and record.exhausted
468
+ if exhausted:
469
+ _ = _finish_failed_message(
470
+ queue,
471
+ message,
472
+ release_delay=release_delay,
473
+ dead_letter_on_exhaustion=dead_letter_on_exhaustion,
474
+ error=exc,
475
+ )
476
+ else:
477
+ _ = queue.release(message, delay=release_delay, error=exc)
478
+ return _ProcessResult(processed=False, last_error=last_error)
479
+
480
+ return _ProcessResult(processed=queue.ack(message))
481
+ finally:
482
+ if owned_retry_store is not None:
483
+ owned_retry_store.close()
484
+
485
+
486
+ def _process_queue_messages(
487
+ queue: PersistentQueue,
488
+ handler: Callable[[Any], Any],
489
+ *,
490
+ console: Any,
491
+ err_console: Any,
492
+ shutdown: _ShutdownState,
493
+ retry_store_path: str | None,
494
+ max_jobs: int,
495
+ forever: bool,
496
+ max_tries: int,
497
+ worker_id: str | None,
498
+ block: bool,
499
+ timeout: float | None,
500
+ idle_sleep: float,
501
+ release_delay: float,
502
+ dead_letter_on_exhaustion: bool,
503
+ ) -> int:
504
+ processed = 0
505
+
506
+ while forever or processed < max_jobs:
507
+ if shutdown.requested:
508
+ if forever:
509
+ _print_json(console, {"state": "stopped", "processed": processed})
510
+ return 0
511
+
512
+ try:
513
+ message = queue.get_message(
514
+ block=block,
515
+ timeout=_poll_timeout(forever, block, timeout),
516
+ leased_by=worker_id,
517
+ )
518
+ except Empty:
519
+ if forever:
520
+ if not block:
521
+ time.sleep(idle_sleep)
522
+ continue
523
+ if processed == 0:
524
+ err_console.print("[yellow]queue is empty[/yellow]")
525
+ return 1
526
+ break
527
+
528
+ result = _process_message(
529
+ queue,
530
+ message,
531
+ handler,
532
+ retry_store_path=retry_store_path,
533
+ max_tries=max_tries,
534
+ release_delay=release_delay,
535
+ dead_letter_on_exhaustion=dead_letter_on_exhaustion,
536
+ )
537
+ if result.processed:
538
+ processed += 1
539
+ _print_json(console, {"id": message.id, "state": "acked"})
540
+ continue
541
+
542
+ _print_json(
543
+ console,
544
+ {"id": message.id, "state": "failed", "last_error": result.last_error},
545
+ )
546
+ if forever:
547
+ continue
548
+ return 1
549
+
550
+ return 0
551
+
552
+
553
+ def _poll_timeout(forever: bool, block: bool, timeout: float | None) -> float | None:
554
+ if forever and block and timeout is None:
555
+ return 0.5
556
+ return timeout
557
+
558
+
559
+ @contextmanager
560
+ def _shutdown_state() -> Iterator[_ShutdownState]:
561
+ state = _ShutdownState()
562
+
563
+ def request_shutdown(_signum: int, _frame: FrameType | None) -> None:
564
+ state.requested = True
565
+
566
+ previous_sigint = signal.getsignal(signal.SIGINT)
567
+ previous_sigterm = signal.getsignal(signal.SIGTERM)
568
+ signal.signal(signal.SIGINT, request_shutdown)
569
+ signal.signal(signal.SIGTERM, request_shutdown)
570
+ try:
571
+ yield state
572
+ finally:
573
+ signal.signal(signal.SIGINT, previous_sigint)
574
+ signal.signal(signal.SIGTERM, previous_sigterm)
575
+
576
+
577
+ def _finish_failed_message(
578
+ queue: PersistentQueue,
579
+ message: QueueMessage,
580
+ *,
581
+ release_delay: float,
582
+ dead_letter_on_exhaustion: bool,
583
+ error: BaseException | str | None,
584
+ ) -> bool:
585
+ if dead_letter_on_exhaustion:
586
+ return queue.dead_letter(message, error=error)
587
+ return queue.release(message, delay=release_delay, error=error)
588
+
589
+
590
+ def _error_payload(error: BaseException | str | None) -> dict[str, Any] | None:
591
+ if error is None:
592
+ return None
593
+ if isinstance(error, _CommandExecutionError):
594
+ return {
595
+ "type": type(error).__name__,
596
+ "module": type(error).__module__,
597
+ "message": str(error),
598
+ "command": error.command,
599
+ "exit_code": error.exit_code,
600
+ "stdout": _truncate_output(error.stdout.strip()),
601
+ "stderr": _truncate_output(error.stderr.strip()),
602
+ }
603
+ if isinstance(error, BaseException):
604
+ error_type = type(error)
605
+ return {
606
+ "type": error_type.__name__,
607
+ "module": error_type.__module__,
608
+ "message": str(error),
609
+ }
610
+ return {"type": None, "module": None, "message": str(error)}
611
+
612
+
613
+ def _parse_json(value: str) -> Any:
614
+ try:
615
+ return json.loads(value)
616
+ except json.JSONDecodeError as exc:
617
+ raise ValueError(
618
+ f"value must be valid JSON unless --raw is passed: {exc}"
619
+ ) from exc
620
+
621
+
622
+ def _read_value(value: str | None) -> str:
623
+ if value is not None:
624
+ return value
625
+ if sys.stdin.isatty():
626
+ raise ValueError("missing value; pass an argument or pipe data on stdin")
627
+ stdin_value = sys.stdin.read()
628
+ if stdin_value == "":
629
+ raise ValueError("stdin did not contain a value")
630
+ return stdin_value
631
+
632
+
633
+ def _config_path() -> Path:
634
+ config_home = os.environ.get("XDG_CONFIG_HOME")
635
+ if config_home:
636
+ return Path(config_home) / "localqueue" / CONFIG_FILENAME
637
+ return Path.home() / ".config" / "localqueue" / CONFIG_FILENAME
638
+
639
+
640
+ def _load_config(yaml: Any, path: Path | None = None) -> dict[str, Any]:
641
+ config_path = path if path is not None else _config_path()
642
+ if not config_path.exists():
643
+ return {}
644
+
645
+ with config_path.open("r", encoding="utf-8") as file:
646
+ loaded = yaml.safe_load(file)
647
+
648
+ if loaded is None:
649
+ return {}
650
+ if not isinstance(loaded, dict):
651
+ raise ValueError(f"config must be a YAML mapping: {config_path}")
652
+ return dict(loaded)
653
+
654
+
655
+ def _write_config(yaml: Any, config: dict[str, Any], path: Path | None = None) -> None:
656
+ config_path = path if path is not None else _config_path()
657
+ config_path.parent.mkdir(parents=True, exist_ok=True)
658
+ with config_path.open("w", encoding="utf-8") as file:
659
+ yaml.safe_dump(config, file, sort_keys=False)
660
+
661
+
662
+ def _resolve_store_path(explicit: str | None, config: dict[str, Any]) -> str:
663
+ return str(explicit or config.get("store_path") or DEFAULT_STORE_PATH)
664
+
665
+
666
+ def _resolve_retry_store_path(explicit: str | None, config: dict[str, Any]) -> str:
667
+ value = explicit if explicit is not None else config.get("retry_store_path")
668
+ if value is None:
669
+ return DEFAULT_RETRY_STORE_PATH
670
+ return str(value)
671
+
672
+
673
+ def _load_callable(spec: str) -> Callable[[Any], Any]:
674
+ module_name, separator, attribute = spec.partition(":")
675
+ if not separator or not module_name or not attribute:
676
+ raise ValueError("handler must use 'module:function' format")
677
+ cwd = str(Path.cwd())
678
+ if cwd not in sys.path:
679
+ sys.path.insert(0, cwd)
680
+ importlib.invalidate_caches()
681
+ module = importlib.import_module(module_name)
682
+ target: Any = module
683
+ for part in attribute.split("."):
684
+ target = getattr(target, part)
685
+ if not callable(target):
686
+ raise TypeError(f"handler is not callable: {spec}")
687
+ return target
688
+
689
+
690
+ def _command_handler(command: list[str]) -> Callable[[Any], None]:
691
+ if not command:
692
+ raise ValueError("command cannot be empty")
693
+
694
+ 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
+ )
702
+ if completed.returncode != 0:
703
+ raise _CommandExecutionError(
704
+ command,
705
+ exit_code=completed.returncode,
706
+ stdout=completed.stdout,
707
+ stderr=completed.stderr,
708
+ )
709
+
710
+ return run_command
711
+
712
+
713
+ def _message_payload(message: QueueMessage) -> dict[str, Any]:
714
+ return {
715
+ "id": message.id,
716
+ "queue": message.queue,
717
+ "state": message.state,
718
+ "value": message.value,
719
+ "attempts": message.attempts,
720
+ "created_at": message.created_at,
721
+ "available_at": message.available_at,
722
+ "leased_until": message.leased_until,
723
+ "leased_by": message.leased_by,
724
+ "last_error": message.last_error,
725
+ "failed_at": message.failed_at,
726
+ }
727
+
728
+
729
+ def _json_dumps(value: Any) -> str:
730
+ return json.dumps(value, ensure_ascii=False, sort_keys=True)
731
+
732
+
733
+ def _format_command(command: list[str]) -> str:
734
+ return " ".join(command)
735
+
736
+
737
+ def _truncate_output(value: str, *, limit: int = 500) -> str:
738
+ if len(value) <= limit:
739
+ return value
740
+ return value[: limit - 3] + "..."
741
+
742
+
743
+ def _print_json(console: Any, value: Any) -> None:
744
+ console.print_json(_json_dumps(value))