baqueue 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.
baqueue/cli.py ADDED
@@ -0,0 +1,459 @@
1
+ """CLI commands for BaQueue."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import click
13
+
14
+ from baqueue.config import BaQueueConfig, DriverConfig, SupervisorConfig
15
+ from baqueue.queue import Queue
16
+
17
+
18
+ def _load_config(config_path: str | None) -> BaQueueConfig:
19
+ if config_path and Path(config_path).exists():
20
+ raw = json.loads(Path(config_path).read_text())
21
+ return BaQueueConfig.from_dict(raw)
22
+ return BaQueueConfig()
23
+
24
+
25
+ def _setup_logging(verbose: bool) -> None:
26
+ level = logging.DEBUG if verbose else logging.INFO
27
+ logging.basicConfig(
28
+ level=level,
29
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
30
+ datefmt="%H:%M:%S",
31
+ )
32
+
33
+
34
+ VALID_DRIVERS = ("sqlite", "memory", "redis", "postgres")
35
+
36
+
37
+ def _validate_driver(name: str) -> str:
38
+ if name not in VALID_DRIVERS:
39
+ raise click.BadParameter(
40
+ f"Unknown driver '{name}'. Choose from: {', '.join(VALID_DRIVERS)}",
41
+ param_hint="'--driver / -d'",
42
+ )
43
+ return name
44
+
45
+
46
+ def _run_async(coro_fn, *args: Any, **kwargs: Any) -> Any:
47
+ """Run an async function with clean error reporting."""
48
+ try:
49
+ return asyncio.run(coro_fn(*args, **kwargs))
50
+ except KeyboardInterrupt:
51
+ click.echo("\nShutting down...")
52
+ except ImportError as e:
53
+ click.echo(f"Error: {e}", err=True)
54
+ sys.exit(1)
55
+ except ConnectionError as e:
56
+ click.echo(f"Error: Connection failed - {e}", err=True)
57
+ sys.exit(1)
58
+ except OSError as e:
59
+ if "10048" in str(e) or "address already in use" in str(e).lower():
60
+ click.echo("Error: Port is already in use. Kill the existing process or use a different port.", err=True)
61
+ else:
62
+ click.echo(f"Error: {e}", err=True)
63
+ sys.exit(1)
64
+ except Exception as e:
65
+ click.echo(f"Error: {e}", err=True)
66
+ sys.exit(1)
67
+
68
+
69
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
70
+ @click.option("--config", "-c", default=None, help="Path to baqueue config JSON file.")
71
+ @click.option("--verbose", "-v", is_flag=True, help="Enable debug logging.")
72
+ @click.pass_context
73
+ def cli(ctx: click.Context, config: str | None, verbose: bool) -> None:
74
+ """BaQueue - Python Queue Management."""
75
+ _setup_logging(verbose)
76
+ ctx.ensure_object(dict)
77
+ ctx.obj["config"] = _load_config(config)
78
+
79
+
80
+ @cli.command()
81
+ @click.option("--queue", "-q", multiple=True, default=["default"], help="Queues to process.")
82
+ @click.option("--workers", "-w", default=3, help="Number of workers.")
83
+ @click.option("--balance", "-b", default="auto", help="Balancing strategy: auto, simple, false.")
84
+ @click.option("--sleep", "-s", default=1.0, help="Sleep interval when idle (seconds).")
85
+ @click.option("--timeout", "-t", default=60, help="Max job execution time (seconds).")
86
+ @click.option("--max-jobs", default=0, help="Max jobs per worker (0 = unlimited).")
87
+ @click.option("--driver", "-d", default="sqlite", help="Driver name (sqlite, memory, redis, postgres).")
88
+ @click.option("--driver-url", default=None, help="Driver connection URL.")
89
+ @click.option("--no-auto-prune", is_flag=True, help="Disable the background auto-pruner.")
90
+ @click.option(
91
+ "--prune-completed-seconds", type=int, default=None,
92
+ help="Override: delete completed jobs older than N seconds (default 5).",
93
+ )
94
+ @click.option(
95
+ "--prune-other-seconds", type=int, default=None,
96
+ help="Override: delete failed/cancelled jobs older than N seconds (default 86400).",
97
+ )
98
+ @click.option(
99
+ "--prune-interval-seconds", type=int, default=None,
100
+ help="Override: how often the auto-pruner runs, in seconds (default 5).",
101
+ )
102
+ @click.option(
103
+ "--no-disk-full-cleanup", is_flag=True,
104
+ help="Disable automatic emergency cleanup when the driver returns a disk-full error.",
105
+ )
106
+ @click.pass_context
107
+ def work(
108
+ ctx: click.Context,
109
+ queue: tuple[str, ...],
110
+ workers: int,
111
+ balance: str,
112
+ sleep: float,
113
+ timeout: int,
114
+ max_jobs: int,
115
+ driver: str,
116
+ driver_url: str | None,
117
+ no_auto_prune: bool,
118
+ prune_completed_seconds: int | None,
119
+ prune_other_seconds: int | None,
120
+ prune_interval_seconds: int | None,
121
+ no_disk_full_cleanup: bool,
122
+ ) -> None:
123
+ """Start processing jobs."""
124
+ config: BaQueueConfig = ctx.obj["config"]
125
+ config.driver = DriverConfig(name=driver, url=driver_url or "")
126
+
127
+ if no_auto_prune:
128
+ config.auto_prune = False
129
+ if prune_completed_seconds is not None:
130
+ config.prune_completed_seconds = prune_completed_seconds
131
+ if prune_other_seconds is not None:
132
+ config.prune_other_seconds = prune_other_seconds
133
+ if prune_interval_seconds is not None:
134
+ config.prune_interval_seconds = prune_interval_seconds
135
+ if no_disk_full_cleanup:
136
+ config.auto_cleanup_on_disk_full = False
137
+
138
+ supervisor_config = SupervisorConfig(
139
+ queues=list(queue),
140
+ min_workers=workers,
141
+ max_workers=workers * 2,
142
+ balance=balance,
143
+ sleep=sleep,
144
+ timeout=timeout,
145
+ max_jobs_per_worker=max_jobs,
146
+ )
147
+
148
+ _validate_driver(driver)
149
+
150
+ click.echo("BaQueue worker starting...")
151
+ click.echo(f" Driver: {config.driver.name}")
152
+ click.echo(f" Queues: {', '.join(queue)}")
153
+ click.echo(f" Workers: {workers}")
154
+ click.echo(f" Balance: {balance}")
155
+ if config.auto_prune:
156
+ click.echo(
157
+ f" Auto-prune: completed>{config.prune_completed_seconds}s, "
158
+ f"other>{config.prune_other_seconds}s, every {config.prune_interval_seconds}s"
159
+ )
160
+ else:
161
+ click.echo(" Auto-prune: disabled")
162
+ click.echo(
163
+ f" Disk-full cleanup: {'enabled' if config.auto_cleanup_on_disk_full else 'disabled'}"
164
+ )
165
+ click.echo()
166
+
167
+ _run_async(_run_worker, config, supervisor_config)
168
+
169
+
170
+ async def _run_worker(config: BaQueueConfig, supervisor_config: SupervisorConfig) -> None:
171
+ from baqueue.balancer import create_balancer
172
+ from baqueue.pruner import Pruner
173
+ from baqueue.supervisor import Supervisor
174
+
175
+ Queue.configure(config)
176
+ await Queue.connect()
177
+
178
+ balancer = create_balancer(
179
+ supervisor_config.balance,
180
+ min_workers=supervisor_config.min_workers,
181
+ max_workers=supervisor_config.max_workers,
182
+ )
183
+ pruner = Pruner(driver=Queue.get_driver(), config=config) if config.auto_prune else None
184
+ supervisor = Supervisor(
185
+ driver=Queue.get_driver(),
186
+ config=supervisor_config,
187
+ balancer=balancer,
188
+ pruner=pruner,
189
+ )
190
+
191
+ try:
192
+ await supervisor.start()
193
+ finally:
194
+ await supervisor.stop()
195
+ await Queue.disconnect()
196
+
197
+
198
+ @cli.command()
199
+ @click.option("--driver", "-d", default="sqlite", help="Driver name (sqlite, memory, redis, postgres).")
200
+ @click.option("--driver-url", default=None, help="Driver connection URL.")
201
+ @click.pass_context
202
+ def schedule(ctx: click.Context, driver: str, driver_url: str | None) -> None:
203
+ """Start the job scheduler."""
204
+ _validate_driver(driver)
205
+ config: BaQueueConfig = ctx.obj["config"]
206
+ config.driver = DriverConfig(name=driver, url=driver_url or "")
207
+
208
+ if not config.schedules:
209
+ click.echo("No schedules configured. Add entries to your config file.")
210
+ return
211
+
212
+ click.echo(f"Scheduler starting with {len(config.schedules)} entries...")
213
+ _run_async(_run_scheduler, config)
214
+
215
+
216
+ async def _run_scheduler(config: BaQueueConfig) -> None:
217
+ from baqueue.scheduler import Scheduler
218
+
219
+ Queue.configure(config)
220
+ await Queue.connect()
221
+
222
+ scheduler = Scheduler(driver=Queue.get_driver(), entries=config.schedules)
223
+ try:
224
+ await scheduler.start()
225
+ finally:
226
+ scheduler.stop()
227
+ await Queue.disconnect()
228
+
229
+
230
+ @cli.command()
231
+ @click.option("--host", "-H", default="0.0.0.0", help="Dashboard host.")
232
+ @click.option("--port", "-p", default=9100, help="Dashboard port.")
233
+ @click.option("--driver", "-d", default="sqlite", help="Driver name (sqlite, memory, redis, postgres).")
234
+ @click.option("--driver-url", default=None, help="Driver connection URL.")
235
+ @click.pass_context
236
+ def dashboard(ctx: click.Context, host: str, port: int, driver: str, driver_url: str | None) -> None:
237
+ """Launch the monitoring dashboard."""
238
+ _validate_driver(driver)
239
+ config: BaQueueConfig = ctx.obj["config"]
240
+ config.driver = DriverConfig(name=driver, url=driver_url or "")
241
+ config.dashboard_host = host
242
+ config.dashboard_port = port
243
+
244
+ if config.driver.name == "memory":
245
+ click.echo(
246
+ "WARNING: Memory driver only works within a single process.\n"
247
+ " The dashboard will NOT see jobs from other processes.\n"
248
+ " Use 'sqlite' driver instead, or run 'python examples/dashboard_demo.py'.\n"
249
+ )
250
+ click.echo(f"BaQueue Dashboard: http://{host}:{port}")
251
+ click.echo(f" Driver: {config.driver.name}")
252
+ _run_async(_run_dashboard, config)
253
+
254
+
255
+ async def _run_dashboard(config: BaQueueConfig) -> None:
256
+ from baqueue.dashboard.server import create_app
257
+ import uvicorn
258
+
259
+ Queue.configure(config)
260
+ await Queue.connect()
261
+
262
+ app = create_app(Queue.get_driver(), config)
263
+ server_config = uvicorn.Config(app, host=config.dashboard_host, port=config.dashboard_port, log_level="info")
264
+ server = uvicorn.Server(server_config)
265
+ await server.serve()
266
+
267
+
268
+ @cli.command()
269
+ @click.option("--status", "-s", default=None, help="Prune jobs by status (completed, failed, cancelled).")
270
+ @click.option("--tag", "-t", default=None, help="Prune jobs by tag.")
271
+ @click.option("--hours", default=None, type=float, help="Prune jobs older than N hours.")
272
+ @click.option("--queue", "-q", default=None, help="Limit to a specific queue.")
273
+ @click.option("--driver", "-d", default="sqlite", help="Driver name (sqlite, memory, redis, postgres).")
274
+ @click.option("--driver-url", default=None, help="Driver connection URL.")
275
+ @click.pass_context
276
+ def prune(
277
+ ctx: click.Context,
278
+ status: str | None,
279
+ tag: str | None,
280
+ hours: float | None,
281
+ queue: str | None,
282
+ driver: str,
283
+ driver_url: str | None,
284
+ ) -> None:
285
+ """Prune old jobs from the queue."""
286
+ if not status and not tag:
287
+ click.echo("Please specify --status or --tag to prune.")
288
+ return
289
+
290
+ _validate_driver(driver)
291
+ config: BaQueueConfig = ctx.obj["config"]
292
+ config.driver = DriverConfig(name=driver, url=driver_url or "")
293
+
294
+ count = _run_async(_run_prune, config, status, tag, hours, queue)
295
+ click.echo(f"Pruned {count or 0} jobs.")
296
+
297
+
298
+ async def _run_prune(
299
+ config: BaQueueConfig,
300
+ status: str | None,
301
+ tag: str | None,
302
+ hours: float | None,
303
+ queue: str | None,
304
+ ) -> int:
305
+ Queue.configure(config)
306
+ await Queue.connect()
307
+ try:
308
+ return await Queue.prune(status=status, tag=tag, hours=hours, queue=queue)
309
+ finally:
310
+ await Queue.disconnect()
311
+
312
+
313
+ @cli.command(name="retry-failed")
314
+ @click.option("--queue", "-q", default=None, help="Limit to a specific queue.")
315
+ @click.option("--tag", "-t", default=None, help="Limit to a specific tag.")
316
+ @click.option("--hours", default=None, type=float, help="Only retry jobs created within the last N hours.")
317
+ @click.option("--driver", "-d", default="sqlite", help="Driver name (sqlite, memory, redis, postgres).")
318
+ @click.option("--driver-url", default=None, help="Driver connection URL.")
319
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
320
+ @click.pass_context
321
+ def retry_failed(
322
+ ctx: click.Context,
323
+ queue: str | None,
324
+ tag: str | None,
325
+ hours: float | None,
326
+ driver: str,
327
+ driver_url: str | None,
328
+ yes: bool,
329
+ ) -> None:
330
+ """Retry all failed jobs (optionally filtered by queue, tag, or age)."""
331
+ _validate_driver(driver)
332
+ config: BaQueueConfig = ctx.obj["config"]
333
+ config.driver = DriverConfig(name=driver, url=driver_url or "")
334
+
335
+ scope_parts = []
336
+ if queue:
337
+ scope_parts.append(f"queue={queue}")
338
+ if tag:
339
+ scope_parts.append(f"tag={tag}")
340
+ if hours:
341
+ scope_parts.append(f"created within last {hours}h")
342
+ scope_str = f" ({', '.join(scope_parts)})" if scope_parts else ""
343
+
344
+ if not yes:
345
+ click.confirm(f"Retry all failed jobs{scope_str}?", abort=True)
346
+
347
+ count = _run_async(_run_retry_failed, config, queue, tag, hours)
348
+ click.echo(f"Retried {count or 0} failed job(s).")
349
+
350
+
351
+ async def _run_retry_failed(
352
+ config: BaQueueConfig,
353
+ queue: str | None,
354
+ tag: str | None,
355
+ hours: float | None,
356
+ ) -> int:
357
+ from baqueue.serializer import _now_ts
358
+
359
+ Queue.configure(config)
360
+ await Queue.connect()
361
+ try:
362
+ created_from = (_now_ts() - hours * 3600) if hours else None
363
+ return await Queue.retry_failed(queue=queue, tag=tag, created_from=created_from)
364
+ finally:
365
+ await Queue.disconnect()
366
+
367
+
368
+ @cli.command()
369
+ @click.option("--driver", "-d", default="sqlite", help="Driver name (sqlite, memory, redis, postgres).")
370
+ @click.option("--driver-url", default=None, help="Driver connection URL.")
371
+ @click.pass_context
372
+ def status(ctx: click.Context, driver: str, driver_url: str | None) -> None:
373
+ """Show queue status and metrics."""
374
+ _validate_driver(driver)
375
+ config: BaQueueConfig = ctx.obj["config"]
376
+ config.driver = DriverConfig(name=driver, url=driver_url or "")
377
+
378
+ _run_async(_show_status, config)
379
+
380
+
381
+ async def _show_status(config: BaQueueConfig) -> None:
382
+ Queue.configure(config)
383
+ await Queue.connect()
384
+
385
+ try:
386
+ queues = await Queue.queues()
387
+ if not queues:
388
+ click.echo("No queues found.")
389
+ return
390
+
391
+ metrics = await Queue.metrics()
392
+ click.echo(f"{'Queue':<20} {'Pending':<10} {'Processing':<12} {'Completed':<12} {'Failed':<10}")
393
+ click.echo("-" * 64)
394
+ for q in queues:
395
+ m = metrics.get(q, {})
396
+ click.echo(
397
+ f"{q:<20} {m.get('pending', 0):<10} {m.get('processing', 0):<12} "
398
+ f"{m.get('completed', 0):<12} {m.get('failed', 0):<10}"
399
+ )
400
+ finally:
401
+ await Queue.disconnect()
402
+
403
+
404
+ @cli.command()
405
+ @click.option("--match", "-k", default=None, help="Run only tests whose name matches this expression.")
406
+ @click.option("--marker", "-m", default=None, help="Run only tests with the given pytest marker (e.g. 'not slow').")
407
+ @click.option("--path", "-p", default="tests", help="Test path or file (defaults to the tests/ folder).")
408
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose pytest output.")
409
+ @click.option("--quiet", "-q", is_flag=True, help="Quieter pytest output.")
410
+ @click.option("--stop-on-first-failure", "-x", is_flag=True, help="Stop at the first failure.")
411
+ @click.option("--last-failed", is_flag=True, help="Re-run only tests that failed in the previous run.")
412
+ @click.option(
413
+ "--no-header", is_flag=True, default=False,
414
+ help="Suppress pytest's session header banner.",
415
+ )
416
+ def test(
417
+ match: str | None,
418
+ marker: str | None,
419
+ path: str,
420
+ verbose: bool,
421
+ quiet: bool,
422
+ stop_on_first_failure: bool,
423
+ last_failed: bool,
424
+ no_header: bool,
425
+ ) -> None:
426
+ """Run the BaQueue test suite (pytest under the hood)."""
427
+ try:
428
+ import pytest as _pytest
429
+ except ImportError:
430
+ click.echo(
431
+ "Error: pytest is not installed.\n"
432
+ " Install it with: pip install baqueue[dev]\n"
433
+ " Or directly: pip install pytest pytest-asyncio",
434
+ err=True,
435
+ )
436
+ sys.exit(1)
437
+
438
+ args: list[str] = [path]
439
+ if verbose:
440
+ args.append("-v")
441
+ if quiet:
442
+ args.append("-q")
443
+ if stop_on_first_failure:
444
+ args.append("-x")
445
+ if last_failed:
446
+ args.append("--lf")
447
+ if match:
448
+ args.extend(["-k", match])
449
+ if marker:
450
+ args.extend(["-m", marker])
451
+ if no_header:
452
+ args.append("--no-header")
453
+
454
+ exit_code = _pytest.main(args)
455
+ sys.exit(int(exit_code))
456
+
457
+
458
+ if __name__ == "__main__":
459
+ cli()
baqueue/config.py ADDED
@@ -0,0 +1,79 @@
1
+ """BaQueue configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class DriverConfig(BaseModel):
11
+ """Configuration for a specific driver."""
12
+
13
+ name: str = "memory"
14
+ url: str = ""
15
+ options: dict[str, Any] = Field(default_factory=dict)
16
+
17
+
18
+ class SupervisorConfig(BaseModel):
19
+ """Configuration for a supervisor process."""
20
+
21
+ name: str = "default"
22
+ queues: list[str] = Field(default_factory=lambda: ["default"])
23
+ balance: str = "auto" # auto | simple | false
24
+ min_workers: int = 1
25
+ max_workers: int = 10
26
+ max_jobs_per_worker: int = 0 # 0 = unlimited
27
+ max_time: int = 0 # seconds, 0 = unlimited
28
+ sleep: float = 1.0 # seconds to sleep when queue is empty
29
+ timeout: int = 60 # max job execution time in seconds
30
+ memory_limit: int = 128 # MB
31
+
32
+
33
+ class ScheduleEntry(BaseModel):
34
+ """A scheduled job entry."""
35
+
36
+ job_class: str
37
+ cron: str = ""
38
+ every: int = 0 # seconds
39
+ queue: str = "default"
40
+ payload: dict[str, Any] = Field(default_factory=dict)
41
+
42
+
43
+ class BaQueueConfig(BaseModel):
44
+ """Main configuration for BaQueue."""
45
+
46
+ prefix: str = "baqueue"
47
+ driver: DriverConfig = Field(default_factory=DriverConfig)
48
+ supervisors: list[SupervisorConfig] = Field(
49
+ default_factory=lambda: [SupervisorConfig()]
50
+ )
51
+ schedules: list[ScheduleEntry] = Field(default_factory=list)
52
+ dashboard_port: int = 9100
53
+ dashboard_host: str = "0.0.0.0"
54
+ metrics_trim_minutes: int = 1440 # 24 hours
55
+
56
+ # ── Auto-prune (primary, seconds-based) ─────────────────────
57
+ auto_prune: bool = True
58
+ prune_interval_seconds: int = 5
59
+ prune_completed_seconds: int = 5 # delete completed jobs ~5s after completion
60
+ prune_other_seconds: int = 86400 # 1 day — applies to failed + cancelled
61
+ prune_metrics_seconds: int = 604800 # 7 days
62
+
63
+ # ── Legacy hour-based overrides (kept for back-compat) ──────
64
+ # When > 0, these take precedence over the seconds fields above for the
65
+ # corresponding status. Default 0 means "use the *_seconds value".
66
+ prune_completed_hours: float = 0
67
+ prune_failed_hours: float = 0
68
+ prune_cancelled_hours: float = 0
69
+ prune_metrics_hours: float = 0
70
+
71
+ # ── Disk-full auto-cleanup ─────────────────────────────────
72
+ # When a driver write hits "disk full" / "out of memory" / "out of space",
73
+ # the driver runs an emergency cleanup (purges all completed/failed/cancelled
74
+ # jobs + old metrics) and retries the operation once before re-raising.
75
+ auto_cleanup_on_disk_full: bool = True
76
+
77
+ @classmethod
78
+ def from_dict(cls, data: dict[str, Any]) -> BaQueueConfig:
79
+ return cls(**data)
@@ -0,0 +1 @@
1
+ """BaQueue monitoring dashboard."""