py-app-runner 0.5.49.dev0__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.
Files changed (75) hide show
  1. py_app_runner/__init__.py +11 -0
  2. py_app_runner/audit/__init__.py +29 -0
  3. py_app_runner/audit/_service.py +91 -0
  4. py_app_runner/audit/_service_args.py +44 -0
  5. py_app_runner/audit/audit.py +319 -0
  6. py_app_runner/audit/commands.py +151 -0
  7. py_app_runner/audit/diff.py +202 -0
  8. py_app_runner/audit/errors.py +8 -0
  9. py_app_runner/audit/event.py +130 -0
  10. py_app_runner/audit/store.py +134 -0
  11. py_app_runner/bridge/__init__.py +0 -0
  12. py_app_runner/bridge/_service.py +265 -0
  13. py_app_runner/bridge/_service_args.py +24 -0
  14. py_app_runner/bridge/api.py +138 -0
  15. py_app_runner/bridge/encoders/__init__.py +5 -0
  16. py_app_runner/bridge/encoders/base.py +24 -0
  17. py_app_runner/bridge/encoders/json_encoder.py +26 -0
  18. py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
  19. py_app_runner/bridge/web_app.py +31 -0
  20. py_app_runner/bridge/websocket.py +313 -0
  21. py_app_runner/colors.py +73 -0
  22. py_app_runner/config.py +132 -0
  23. py_app_runner/crypto/__init__.py +14 -0
  24. py_app_runner/crypto/_service.py +75 -0
  25. py_app_runner/crypto/_service_args.py +54 -0
  26. py_app_runner/crypto/commands.py +164 -0
  27. py_app_runner/crypto/envelope.py +144 -0
  28. py_app_runner/crypto/errors.py +8 -0
  29. py_app_runner/crypto/fields.py +300 -0
  30. py_app_runner/crypto/passwords.py +66 -0
  31. py_app_runner/db_pools.py +20 -0
  32. py_app_runner/http_exception.py +31 -0
  33. py_app_runner/logger_handlers.py +167 -0
  34. py_app_runner/migrations/__init__.py +5 -0
  35. py_app_runner/migrations/_service.py +296 -0
  36. py_app_runner/migrations/_service_args.py +91 -0
  37. py_app_runner/migrations/commands.py +386 -0
  38. py_app_runner/migrations/discovery.py +108 -0
  39. py_app_runner/migrations/states.py +63 -0
  40. py_app_runner/migrations/tracker.py +141 -0
  41. py_app_runner/py.typed +0 -0
  42. py_app_runner/pybridge.py +64 -0
  43. py_app_runner/queue/__init__.py +25 -0
  44. py_app_runner/queue/_service.py +231 -0
  45. py_app_runner/queue/_service_args.py +67 -0
  46. py_app_runner/queue/commands.py +180 -0
  47. py_app_runner/queue/driver_pg.py +464 -0
  48. py_app_runner/queue/driver_redis.py +613 -0
  49. py_app_runner/queue/handler.py +90 -0
  50. py_app_runner/queue/interface.py +63 -0
  51. py_app_runner/queue/job.py +46 -0
  52. py_app_runner/queue/worker.py +221 -0
  53. py_app_runner/registry.py +54 -0
  54. py_app_runner/request_handler/__init__.py +0 -0
  55. py_app_runner/request_handler/auth_service.py +123 -0
  56. py_app_runner/request_handler/decorators.py +304 -0
  57. py_app_runner/request_handler/handlers.py +604 -0
  58. py_app_runner/request_handler/pagination.py +24 -0
  59. py_app_runner/return_model.py +78 -0
  60. py_app_runner/runner.py +182 -0
  61. py_app_runner/throttle/__init__.py +5 -0
  62. py_app_runner/throttle/throttle.py +217 -0
  63. py_app_runner/tick_service.py +308 -0
  64. py_app_runner/timer.py +289 -0
  65. py_app_runner/utils.py +346 -0
  66. py_app_runner/wbcm/__init__.py +0 -0
  67. py_app_runner/wbcm/device_connections.py +89 -0
  68. py_app_runner/wbcm/factory.py +113 -0
  69. py_app_runner/wbcm/wb_connection_manager.py +333 -0
  70. py_app_runner/wbcm/ws_interface.py +56 -0
  71. py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
  72. py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
  73. py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
  74. py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
  75. py_app_runner-0.5.49.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,64 @@
1
+ from importlib import import_module
2
+ from typing import Any, Literal
3
+
4
+ from py_app_runner.db_pools import DbPools
5
+
6
+
7
+ class PyBridge:
8
+ services_placeholder: dict[str, Any]
9
+ db_pools: DbPools
10
+
11
+ def __init__(self) -> None:
12
+ self.services_placeholder = {}
13
+
14
+ def _import_or_none(self, module_name: str) -> Any | None:
15
+ """Imports `module_name`, returning None only when that module - or one of its own
16
+ ancestor packages - is the thing that is missing. An ImportError naming anything
17
+ else is a broken import *inside* the module and must surface - swallowing it would
18
+ let a typo in a project service silently fall through to the built-in of the same
19
+ name."""
20
+
21
+ try:
22
+ return import_module(module_name)
23
+ except ModuleNotFoundError as e:
24
+ if e.name is not None and (e.name == module_name or module_name.startswith(f"{e.name}.")):
25
+ return None
26
+
27
+ raise
28
+
29
+ def _resolve(self, service_name: str, module: str) -> Any | Literal[False]:
30
+ """Project services win over built-ins, so a project can override `bridge` or
31
+ `migrations` by shipping its own `services.<name>.<module>`."""
32
+
33
+ for package in (f"services.{service_name}", f"py_app_runner.{service_name}"):
34
+ found = self._import_or_none(f"{package}.{module}")
35
+ if found is not None:
36
+ return found
37
+
38
+ return False
39
+
40
+ def load_service(self, service_name: str) -> Any:
41
+ service = self._resolve(service_name, "_service")
42
+ if service is not False:
43
+ return service
44
+
45
+ return import_module(f"services.{service_name}")
46
+
47
+ def load_service_args(self, service_name: str) -> Any | Literal[False]:
48
+ return self._resolve(service_name, "_service_args")
49
+
50
+ def load_service_runner(self, service_name: str) -> Any | Literal[False]:
51
+ return self._resolve(service_name, "_service")
52
+
53
+ def load_service_pybridge(self, service_name: str) -> Any | Literal[False]:
54
+ return self._resolve(service_name, "_service_pybridge")
55
+
56
+ def cache_service(self, service_name: str, service: Any) -> None:
57
+ self.services_placeholder[service_name] = service
58
+
59
+ def register_alias(self, name: str, handler: Any) -> None:
60
+ """Expose an already-loaded handler under an additional service name."""
61
+ self.services_placeholder[name] = handler
62
+
63
+ def get_service(self, service_name: str) -> Any | Literal[False]:
64
+ return self.services_placeholder.get(service_name, False)
@@ -0,0 +1,25 @@
1
+ """Jobs on PostgreSQL, or on Redis streams behind the same interface.
2
+
3
+ The database driver is the one to reach for first: a push there is an INSERT, so it joins
4
+ the transaction that caused it. Redis cannot do that, whatever it is set to.
5
+ """
6
+
7
+ from py_app_runner.queue.driver_pg import PgQueue
8
+ from py_app_runner.queue.driver_redis import RedisQueue
9
+ from py_app_runner.queue.handler import Handler, assert_resolvable, resolve
10
+ from py_app_runner.queue.interface import QueueDriver
11
+ from py_app_runner.queue.job import Job, QueueError
12
+ from py_app_runner.queue.worker import Worker, backoff
13
+
14
+ __all__ = [
15
+ "Handler",
16
+ "Job",
17
+ "PgQueue",
18
+ "QueueDriver",
19
+ "QueueError",
20
+ "RedisQueue",
21
+ "Worker",
22
+ "assert_resolvable",
23
+ "backoff",
24
+ "resolve",
25
+ ]
@@ -0,0 +1,231 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import pathlib
5
+ import signal
6
+ import socket
7
+ from argparse import Namespace
8
+ from datetime import UTC, datetime
9
+ from typing import Any, cast
10
+
11
+ import psycopg
12
+ from database_wrapper_redis import RedisDbWithPoolAsync
13
+
14
+ from py_app_runner.migrations._service import connect_kwargs, resolve_targets
15
+ from py_app_runner.pybridge import PyBridge
16
+ from py_app_runner.queue.commands import (
17
+ Out,
18
+ cmd_failed,
19
+ cmd_forget,
20
+ cmd_install,
21
+ cmd_retry,
22
+ cmd_status,
23
+ parse_queues,
24
+ resolve_handlers,
25
+ )
26
+ from py_app_runner.queue.driver_pg import PgQueue
27
+ from py_app_runner.queue.driver_redis import RedisQueue
28
+ from py_app_runner.queue.interface import QueueDriver
29
+ from py_app_runner.queue.job import QueueError
30
+ from py_app_runner.queue.worker import Worker
31
+ from py_app_runner.registry import AppRegistry
32
+
33
+ _DEFAULTS: dict[str, Any] = {
34
+ # "database" first because a push there joins the caller's transaction, which is the
35
+ # one thing redis cannot offer however fast it is.
36
+ "driver": "database",
37
+ "db": "main",
38
+ "table": "queue_jobs",
39
+ "failed_table": "queue_failed_jobs",
40
+ # Deliberately not the cache connection: a queue that shares a database with a cache is
41
+ # one FLUSHDB away from an empty backlog.
42
+ "redis": {},
43
+ "queue": "default",
44
+ "tries": 3,
45
+ "backoff": [10, 60, 300],
46
+ "timeout": 300,
47
+ "sleep": 1.0,
48
+ "handlers": {},
49
+ }
50
+
51
+
52
+ def _settings(config: dict[str, Any]) -> dict[str, Any]:
53
+ return {**_DEFAULTS, **(config.get("queue") or {})}
54
+
55
+
56
+ def _migrations_dir(args: Namespace, config: dict[str, Any]) -> pathlib.Path:
57
+ raw = getattr(args, "dir", None)
58
+ if raw:
59
+ directory = pathlib.Path(raw)
60
+ if directory.is_absolute():
61
+ return directory
62
+
63
+ return pathlib.Path(config.get("current_path") or ".") / directory
64
+
65
+ return next(iter(resolve_targets(config).values())).directory
66
+
67
+
68
+ def _install_shutdown_handlers(worker: Worker, logger: logging.Logger) -> None:
69
+ """SIGTERM and SIGINT ask the loop to stop; nothing interrupts the job in hand.
70
+
71
+ Installed on the running loop rather than through signal.signal, so the flag is set
72
+ between awaits rather than inside arbitrary C code. A second signal is left to the
73
+ default handler: if the first one is being ignored because a job is wedged, the
74
+ operator needs a way out that does not require finding the pid.
75
+ """
76
+
77
+ loop = asyncio.get_running_loop()
78
+ for name in ("SIGTERM", "SIGINT"):
79
+ try:
80
+ loop.add_signal_handler(getattr(signal, name), worker.stop)
81
+ except (NotImplementedError, AttributeError, RuntimeError):
82
+ # No signal support (Windows, or a non-main thread). The visibility timeout is
83
+ # still the backstop, so a killed worker's jobs come back on their own.
84
+ logger.debug("queue: could not install a %s handler; shutdown will not be graceful", name)
85
+
86
+
87
+ async def init_service(args: Namespace, _pybridge: PyBridge, logger: logging.Logger) -> None:
88
+ out: Out = print
89
+
90
+ # Same reasoning as migrations: runner.py catches Exception around init_service and
91
+ # returns normally, which exits 0. A worker that could not reach the database must not
92
+ # look like a worker that ran cleanly.
93
+ code = 1
94
+ try:
95
+ config = AppRegistry.config()
96
+ settings = _settings(config)
97
+
98
+ driver = settings["driver"]
99
+ if driver not in ("database", "redis"):
100
+ out(f'error: config["queue"]["driver"] is {driver!r}; it must be "database" or "redis".')
101
+ raise SystemExit(2)
102
+
103
+ if args.step == "install":
104
+ if driver == "redis":
105
+ # Nothing to create: the keys appear on first push. Exiting 0 rather than
106
+ # refusing, so a deploy playbook can run `queue install` unconditionally
107
+ # whichever driver a project happens to be on.
108
+ out("Nothing to install: this queue is not on a database.")
109
+ raise SystemExit(0)
110
+
111
+ raise SystemExit(
112
+ cmd_install(
113
+ _migrations_dir(args, config),
114
+ settings["table"],
115
+ settings["failed_table"],
116
+ datetime.now(UTC),
117
+ out,
118
+ )
119
+ )
120
+
121
+ if driver == "redis":
122
+ # No database connection is opened at all on this path, so a worker host running
123
+ # the redis driver needs no database credentials.
124
+ #
125
+ # `prefix` and `group` are this module's keys and are not part of the connector's
126
+ # config, so they are split out rather than handed to it.
127
+ redis_settings = dict(settings["redis"] or {})
128
+ prefix = redis_settings.pop("prefix", None) or "queue:"
129
+ group = redis_settings.pop("group", None) or "workers"
130
+
131
+ pool = RedisDbWithPoolAsync(cast(Any, redis_settings))
132
+ try:
133
+ async with pool as redis_con:
134
+ queue = RedisQueue(redis_con, prefix, group)
135
+ code = await _dispatch(args, settings, config, queue, logger, out)
136
+ finally:
137
+ await pool.close()
138
+
139
+ raise SystemExit(code)
140
+
141
+ db_name = settings["db"]
142
+ db_config = config.get("db") or {}
143
+ if db_name not in db_config:
144
+ out(
145
+ f'error: no database {db_name!r} in config["db"]; '
146
+ f"configured are: {', '.join(sorted(db_config)) or 'none'}."
147
+ )
148
+ raise SystemExit(2)
149
+
150
+ async with await psycopg.AsyncConnection.connect(**connect_kwargs(db_config[db_name])) as conn:
151
+ code = await _dispatch(
152
+ args, settings, config, PgQueue(conn, settings["table"], settings["failed_table"]), logger, out
153
+ )
154
+
155
+ except QueueError as e:
156
+ out(f"error: {e}")
157
+ raise SystemExit(1) from None
158
+ except (KeyboardInterrupt, asyncio.CancelledError):
159
+ # Above `except Exception` because both derive from BaseException. A worker
160
+ # interrupted mid-job leaves that job reserved until its deadline passes, which is
161
+ # correct - but it must not be reported as a clean run.
162
+ logger.error("queue: interrupted")
163
+ raise SystemExit(1) from None
164
+ except Exception:
165
+ logger.exception("queue: unhandled failure")
166
+ raise SystemExit(1) from None
167
+
168
+ raise SystemExit(code)
169
+
170
+
171
+ async def _dispatch(
172
+ args: Namespace,
173
+ settings: dict[str, Any],
174
+ config: dict[str, Any],
175
+ queue: QueueDriver,
176
+ logger: logging.Logger,
177
+ out: Out,
178
+ ) -> int:
179
+ """Every command below is driver-agnostic, which is the point of the interface: switching
180
+ `driver` in config changes where the jobs live and nothing else about the CLI."""
181
+
182
+ if args.step == "work":
183
+ return await _work(args, settings, config, queue, logger, out)
184
+
185
+ if args.step == "status":
186
+ return await cmd_status(queue, out)
187
+
188
+ if args.step == "failed":
189
+ return await cmd_failed(queue, args.limit, out)
190
+
191
+ if args.step == "retry":
192
+ return await cmd_retry(queue, args.id, args.all, settings["tries"], out)
193
+
194
+ if args.step == "forget":
195
+ return await cmd_forget(queue, args.id, args.all, args.before, out)
196
+
197
+ out(f"error: unknown queue command {args.step!r}")
198
+ return 1
199
+
200
+
201
+ async def _work(
202
+ args: Namespace,
203
+ settings: dict[str, Any],
204
+ config: dict[str, Any],
205
+ queue: QueueDriver,
206
+ logger: logging.Logger,
207
+ out: Out,
208
+ ) -> int:
209
+ worker = Worker(
210
+ queue=queue,
211
+ handlers=resolve_handlers(config),
212
+ backoff_steps=settings["backoff"],
213
+ out=out,
214
+ worker_id=f"{socket.gethostname()}:{os.getpid()}"[:64],
215
+ )
216
+
217
+ _install_shutdown_handlers(worker, logger)
218
+
219
+ # --once is not run_next(): it is a full run with the limits set, so it still installs
220
+ # signal handlers and still reports what it did.
221
+ max_jobs = 1 if args.once else args.max_jobs
222
+ stop_when_empty = args.stop_when_empty or args.once
223
+
224
+ return await worker.run(
225
+ queues=parse_queues(args.queue, settings["queue"]),
226
+ timeout=args.timeout if args.timeout is not None else settings["timeout"],
227
+ sleep=args.sleep if args.sleep is not None else settings["sleep"],
228
+ max_jobs=max_jobs,
229
+ max_time=args.max_time,
230
+ stop_when_empty=stop_when_empty,
231
+ )
@@ -0,0 +1,67 @@
1
+ """CLI subparsers for the built-in queue service.
2
+
3
+ python3 src/app.py queue install [--dir PATH]
4
+ python3 src/app.py queue work [--queue a,b] [--once] [--stop-when-empty]
5
+ [--max-jobs N] [--max-time N] [--timeout N] [--sleep N]
6
+ python3 src/app.py queue status
7
+ python3 src/app.py queue failed [--limit N]
8
+ python3 src/app.py queue retry (--id N | --all)
9
+ python3 src/app.py queue forget (--id N | --all | --before DATE)
10
+
11
+ There is no --driver flag by design: the driver comes from config, so a push and a worker
12
+ cannot disagree about where the jobs are.
13
+ """
14
+
15
+ import logging
16
+ from argparse import ArgumentParser, _SubParsersAction # type: ignore
17
+
18
+ from py_app_runner.pybridge import PyBridge
19
+
20
+
21
+ def reg_subparsers(
22
+ subparsers: "_SubParsersAction[ArgumentParser]",
23
+ _pybridge: PyBridge,
24
+ _base_logger: logging.Logger,
25
+ ) -> None:
26
+ """Command line subparsers"""
27
+
28
+ parser = subparsers.add_parser(
29
+ "queue",
30
+ description="Install the queue schema, run workers, and work through failed jobs",
31
+ help="Job queue",
32
+ )
33
+ group = parser.add_subparsers(title="command", dest="step", required=True)
34
+
35
+ install_parser = group.add_parser("install", help="Write the queue schema into the migrations directory")
36
+ install_parser.add_argument("--dir", default=None, help="Migrations directory to write into")
37
+
38
+ work_parser = group.add_parser("work", help="Reserve and run jobs until told to stop")
39
+ work_parser.add_argument("--queue", default=None, help="Comma separated, in precedence order")
40
+ work_parser.add_argument("--timeout", type=int, default=None, help="Visibility and per-job timeout")
41
+ work_parser.add_argument("--sleep", type=float, default=None, help="Idle poll interval in seconds")
42
+ work_parser.add_argument("--max-jobs", type=int, default=0, help="Stop after N jobs (0 = no limit)")
43
+ work_parser.add_argument("--max-time", type=int, default=0, help="Stop after N seconds (0 = no limit)")
44
+ work_parser.add_argument(
45
+ "--stop-when-empty",
46
+ action="store_true",
47
+ help="Exit once the queue has nothing due, for a cron-driven worker",
48
+ )
49
+ work_parser.add_argument(
50
+ "--once",
51
+ action="store_true",
52
+ help="Run at most one job and exit; implies --stop-when-empty",
53
+ )
54
+
55
+ group.add_parser("status", help="Show what is queued, delayed, held and failed")
56
+
57
+ failed_parser = group.add_parser("failed", help="List failed jobs")
58
+ failed_parser.add_argument("--limit", type=int, default=20, help="How many to show (default: 20)")
59
+
60
+ retry_parser = group.add_parser("retry", help="Put failed jobs back on the queue")
61
+ retry_parser.add_argument("--id", type=int, default=None, help="A single failed job id")
62
+ retry_parser.add_argument("--all", action="store_true", help="Every failed job")
63
+
64
+ forget_parser = group.add_parser("forget", help="Delete failed jobs without requeueing them")
65
+ forget_parser.add_argument("--id", type=int, default=None, help="A single failed job id")
66
+ forget_parser.add_argument("--all", action="store_true", help="Every failed job")
67
+ forget_parser.add_argument("--before", default=None, help="Delete rows failed before this date")
@@ -0,0 +1,180 @@
1
+ """Command implementations, importable directly for programmatic use."""
2
+
3
+ import datetime
4
+ import pathlib
5
+ import re
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ import psycopg
10
+
11
+ from py_app_runner.migrations.discovery import MigrationError, new_filename
12
+ from py_app_runner.queue.interface import QueueDriver
13
+ from py_app_runner.queue.job import QueueError
14
+
15
+ Out = Callable[[str], None]
16
+
17
+ _TEMPLATE = pathlib.Path(__file__).parent / "files" / "install.pgsql.sql"
18
+
19
+ _BEFORE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$")
20
+
21
+
22
+ def cmd_install(
23
+ migrations_dir: pathlib.Path,
24
+ table: str,
25
+ failed_table: str,
26
+ now: datetime.datetime,
27
+ out: Out,
28
+ ) -> int:
29
+ """Write the queue schema into the project's own migrations directory."""
30
+
31
+ if not migrations_dir.is_dir():
32
+ out(f"error: no migrations directory at {migrations_dir}")
33
+ return 2
34
+
35
+ try:
36
+ schema = _TEMPLATE.read_text(encoding="utf-8")
37
+ except OSError as e:
38
+ out(f"error: could not read {_TEMPLATE}: {e}")
39
+ return 1
40
+
41
+ # The failed table is substituted first. "queue_jobs" is a prefix of nothing, but
42
+ # "queue_failed_jobs" contains "queue_jobs" nowhere while its *index* names derive from
43
+ # it - do the longer name first and neither can be half-rewritten by the other.
44
+ if failed_table != "queue_failed_jobs":
45
+ schema = schema.replace("queue_failed_jobs", failed_table)
46
+ if table != "queue_jobs":
47
+ schema = schema.replace("queue_jobs", table)
48
+
49
+ try:
50
+ target = migrations_dir / new_filename("create queue tables", now)
51
+ except MigrationError as e:
52
+ out(f"error: {e}")
53
+ return 2
54
+
55
+ if target.exists():
56
+ out(f"error: {target} already exists")
57
+ return 1
58
+
59
+ try:
60
+ target.write_text(schema, encoding="utf-8")
61
+ except OSError as e:
62
+ out(f"error: could not write {target}: {e}")
63
+ return 1
64
+
65
+ out(f"Wrote {target}")
66
+ out("Review it, then: python3 src/app.py migrations apply")
67
+
68
+ return 0
69
+
70
+
71
+ async def cmd_status(queue: QueueDriver, out: Out) -> int:
72
+ try:
73
+ rows = await queue.stats()
74
+ failed = await queue.failed_count()
75
+ except (psycopg.Error, QueueError) as e:
76
+ out(f"error: cannot read the queue: {e}")
77
+ return 1
78
+
79
+ if not rows:
80
+ out("No jobs queued.")
81
+ else:
82
+ out(f"{'queue':<24} {'pending':>9} {'delayed':>9} {'held':>9} {'total':>9}")
83
+ for row in rows:
84
+ out(f"{row['queue']:<24} {row['pending']:>9} {row['delayed']:>9} {row['reserved']:>9} {row['total']:>9}")
85
+
86
+ out("")
87
+ out(f"failed: {failed}")
88
+ if failed > 0:
89
+ out("Read them with: python3 src/app.py queue failed")
90
+
91
+ return 0
92
+
93
+
94
+ async def cmd_failed(queue: QueueDriver, limit: int, out: Out) -> int:
95
+ if limit < 1:
96
+ out("error: --limit must be at least 1")
97
+ return 2
98
+
99
+ try:
100
+ rows = await queue.failed_rows(limit)
101
+ except (psycopg.Error, QueueError) as e:
102
+ out(f"error: cannot read the queue: {e}")
103
+ return 1
104
+
105
+ if not rows:
106
+ out("Nothing has failed.")
107
+ return 0
108
+
109
+ for row in rows:
110
+ out(
111
+ f"#{str(row['id']):<6} {str(row['failed_at']):<26} {row['queue']:<16} "
112
+ f"{row['name']} ({row['attempts']} attempts)"
113
+ )
114
+ # Only the first line: a traceback in a list view buries the next entry.
115
+ first_line = (row["error"] or "").split("\n", 1)[0]
116
+ if first_line:
117
+ out(f" {first_line}")
118
+
119
+ out("")
120
+ out("Requeue one with: python3 src/app.py queue retry --id N")
121
+
122
+ return 0
123
+
124
+
125
+ async def cmd_retry(queue: QueueDriver, job_id: int | None, all_jobs: bool, max_attempts: int, out: Out) -> int:
126
+ if job_id is None and not all_jobs:
127
+ out("error: retry needs --id N or --all")
128
+ return 2
129
+
130
+ try:
131
+ count = await queue.retry_failed(job_id, max_attempts)
132
+ except (psycopg.Error, QueueError) as e:
133
+ out(f"error: could not requeue: {e}")
134
+ return 1
135
+
136
+ if count == 0:
137
+ out("Nothing to requeue.")
138
+ # Naming a specific id that is not there is a failed instruction; --all finding
139
+ # nothing is a legitimately empty table.
140
+ return 0 if job_id is None else 1
141
+
142
+ out(f"Requeued {count} job(s).")
143
+ return 0
144
+
145
+
146
+ async def cmd_forget(
147
+ queue: QueueDriver,
148
+ job_id: int | None,
149
+ all_jobs: bool,
150
+ before: str | None,
151
+ out: Out,
152
+ ) -> int:
153
+ if job_id is None and not all_jobs and before is None:
154
+ out("error: forget needs --id N, --all or --before DATE")
155
+ return 2
156
+
157
+ if before is not None and _BEFORE_RE.match(before) is None:
158
+ out(f"error: --before={before!r} must be YYYY-MM-DD or 'YYYY-MM-DD HH:MM:SS'")
159
+ return 2
160
+
161
+ try:
162
+ count = await queue.forget_failed(job_id, before)
163
+ except (psycopg.Error, QueueError) as e:
164
+ out(f"error: could not forget: {e}")
165
+ return 1
166
+
167
+ out(f"Deleted {count} row(s).")
168
+ return 0
169
+
170
+
171
+ def parse_queues(raw: str | None, default: str = "default") -> list[str]:
172
+ """Comma separated, in precedence order. Blanks dropped so a trailing comma is not a
173
+ queue named "" that never has work."""
174
+
175
+ names = [part.strip() for part in (raw or default).split(",")]
176
+ return [name for name in names if name] or [default]
177
+
178
+
179
+ def resolve_handlers(config: dict[str, Any]) -> dict[str, Any]:
180
+ return (config.get("queue") or {}).get("handlers") or {}