rrq 0.4.0__py3-none-any.whl → 0.7.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.
rrq/cli.py CHANGED
@@ -7,6 +7,9 @@ import os
7
7
  import signal
8
8
  import subprocess
9
9
  import sys
10
+ import time
11
+
12
+ # import multiprocessing # No longer needed directly, os.cpu_count() is sufficient
10
13
  from contextlib import suppress
11
14
 
12
15
  import click
@@ -30,6 +33,29 @@ logger = logging.getLogger(__name__)
30
33
 
31
34
 
32
35
  # Helper to load settings for commands
36
+ def _resolve_settings_source(
37
+ settings_object_path: str | None = None,
38
+ ) -> tuple[str | None, str]:
39
+ """Resolve the settings path and its source.
40
+
41
+ Returns:
42
+ A tuple of (settings_path, source_description)
43
+ """
44
+ if settings_object_path is not None:
45
+ return settings_object_path, "--settings parameter"
46
+
47
+ env_setting = os.getenv("RRQ_SETTINGS")
48
+ if env_setting is not None:
49
+ # Check if a .env file exists to give more specific info
50
+ if DOTENV_AVAILABLE and find_dotenv(usecwd=True):
51
+ # We can't definitively know if it came from .env or system env,
52
+ # but we can indicate both are possible
53
+ return env_setting, "RRQ_SETTINGS env var (system or .env)"
54
+ return env_setting, "RRQ_SETTINGS env var"
55
+
56
+ return None, "built-in defaults"
57
+
58
+
33
59
  def _load_app_settings(settings_object_path: str | None = None) -> RRQSettings:
34
60
  """Load the settings object from the given path.
35
61
  If not provided, the RRQ_SETTINGS environment variable will be used.
@@ -142,7 +168,12 @@ async def check_health_async_impl(settings_object_path: str | None = None) -> bo
142
168
  )
143
169
  return True
144
170
  except redis.exceptions.ConnectionError as e:
145
- logger.error(f"Redis connection failed during health check: {e}", exc_info=True)
171
+ click.echo(
172
+ click.style(
173
+ f"ERROR: Redis connection failed during health check: {e}", fg="red"
174
+ ),
175
+ err=True,
176
+ )
146
177
  click.echo(
147
178
  click.style(
148
179
  f"Worker Health Check: FAIL - Redis connection error: {e}", fg="red"
@@ -150,8 +181,12 @@ async def check_health_async_impl(settings_object_path: str | None = None) -> bo
150
181
  )
151
182
  return False
152
183
  except Exception as e:
153
- logger.error(
154
- f"An unexpected error occurred during health check: {e}", exc_info=True
184
+ click.echo(
185
+ click.style(
186
+ f"ERROR: An unexpected error occurred during health check: {e}",
187
+ fg="red",
188
+ ),
189
+ err=True,
155
190
  )
156
191
  click.echo(
157
192
  click.style(f"Worker Health Check: FAIL - Unexpected error: {e}", fg="red")
@@ -169,7 +204,7 @@ def start_rrq_worker_subprocess(
169
204
  queues: list[str] | None = None,
170
205
  ) -> subprocess.Popen | None:
171
206
  """Start an RRQ worker process, optionally for specific queues."""
172
- command = ["rrq", "worker", "run"]
207
+ command = ["rrq", "worker", "run", "--num-workers", "1"]
173
208
 
174
209
  if settings_object_path:
175
210
  command.extend(["--settings", settings_object_path])
@@ -219,15 +254,25 @@ def terminate_worker_process(
219
254
  f"Terminating worker process group for PID {process.pid} (PGID {pgid})..."
220
255
  )
221
256
  os.killpg(pgid, signal.SIGTERM)
222
- process.wait(timeout=5)
257
+ process.wait(timeout=10)
223
258
  except subprocess.TimeoutExpired:
224
- logger.warning(
225
- f"Worker process {process.pid} did not terminate gracefully (SIGTERM timeout), sending SIGKILL."
259
+ click.echo(
260
+ click.style(
261
+ f"WARNING: Worker process {process.pid} did not terminate gracefully (SIGTERM timeout), sending SIGKILL.",
262
+ fg="yellow",
263
+ ),
264
+ err=True,
226
265
  )
227
266
  with suppress(ProcessLookupError):
228
267
  os.killpg(os.getpgid(process.pid), signal.SIGKILL)
229
268
  except Exception as e:
230
- logger.error(f"Unexpected error checking worker process {process.pid}: {e}")
269
+ click.echo(
270
+ click.style(
271
+ f"ERROR: Unexpected error checking worker process {process.pid}: {e}",
272
+ fg="red",
273
+ ),
274
+ err=True,
275
+ )
231
276
 
232
277
 
233
278
  async def watch_rrq_worker_impl(
@@ -236,9 +281,19 @@ async def watch_rrq_worker_impl(
236
281
  queues: list[str] | None = None,
237
282
  ) -> None:
238
283
  abs_watch_path = os.path.abspath(watch_path)
239
- click.echo(
240
- f"Watching for file changes in {abs_watch_path} to restart RRQ worker (app settings: {settings_object_path})..."
241
- )
284
+ click.echo(f"Watching for file changes in {abs_watch_path}...")
285
+
286
+ # Load settings and display source
287
+ click.echo("Loading RRQ Settings... ", nl=False)
288
+
289
+ if settings_object_path:
290
+ click.echo(f"from --settings parameter ({settings_object_path}).")
291
+ elif os.getenv("RRQ_SETTINGS"):
292
+ click.echo(f"from RRQ_SETTINGS env var ({os.getenv('RRQ_SETTINGS')}).")
293
+ elif DOTENV_AVAILABLE and find_dotenv(usecwd=True):
294
+ click.echo("found in .env file.")
295
+ else:
296
+ click.echo("using defaults.")
242
297
  worker_process: subprocess.Popen | None = None
243
298
  loop = asyncio.get_event_loop()
244
299
  shutdown_event = asyncio.Event()
@@ -278,7 +333,9 @@ async def watch_rrq_worker_impl(
278
333
  queues=queues,
279
334
  )
280
335
  except Exception as e:
281
- logger.error(f"Error in watch_rrq_worker: {e}", exc_info=True)
336
+ click.echo(
337
+ click.style(f"ERROR: Error in watch_rrq_worker: {e}", fg="red"), err=True
338
+ )
282
339
  finally:
283
340
  logger.info("Exiting watch mode. Ensuring worker process is terminated.")
284
341
  if not shutdown_event.is_set():
@@ -306,6 +363,38 @@ def rrq():
306
363
  pass
307
364
 
308
365
 
366
+ # Register modular commands
367
+ try:
368
+ # Import new command classes
369
+ from .cli_commands.commands.queues import QueueCommands
370
+ from .cli_commands.commands.jobs import JobCommands
371
+ from .cli_commands.commands.monitor import MonitorCommands
372
+ from .cli_commands.commands.debug import DebugCommands
373
+ from .cli_commands.commands.dlq import DLQCommands
374
+
375
+ # Register new commands with existing CLI
376
+ command_classes = [
377
+ QueueCommands(),
378
+ JobCommands(),
379
+ MonitorCommands(),
380
+ DebugCommands(),
381
+ DLQCommands(),
382
+ ]
383
+
384
+ for command_instance in command_classes:
385
+ try:
386
+ command_instance.register(rrq)
387
+ except Exception as e:
388
+ click.echo(
389
+ f"Warning: Failed to register command {command_instance.__class__.__name__}: {e}",
390
+ err=True,
391
+ )
392
+
393
+ except ImportError as e:
394
+ # Fall back to original CLI if new modules aren't available
395
+ click.echo(f"Warning: Enhanced CLI features not available: {e}", err=True)
396
+
397
+
309
398
  @rrq.group("worker")
310
399
  def worker_cli():
311
400
  """Manage RRQ workers (run, watch)."""
@@ -338,28 +427,85 @@ def worker_cli():
338
427
  "The specified settings object must include a `job_registry: JobRegistry`."
339
428
  ),
340
429
  )
430
+ @click.option(
431
+ "--num-workers",
432
+ type=int,
433
+ default=None,
434
+ help="Number of parallel worker processes to start. Defaults to the number of CPU cores.",
435
+ )
341
436
  def worker_run_command(
342
437
  burst: bool,
343
438
  queues: tuple[str, ...],
344
439
  settings_object_path: str,
440
+ num_workers: int | None,
345
441
  ):
346
- """Run an RRQ worker process.
442
+ """Run RRQ worker processes.
347
443
  Requires an application-specific settings object.
348
444
  """
349
- rrq_settings = _load_app_settings(settings_object_path)
445
+ if num_workers is None:
446
+ num_workers = (
447
+ os.cpu_count() or 1
448
+ ) # Default to CPU cores, or 1 if cpu_count() is None
449
+ click.echo(
450
+ f"No --num-workers specified, defaulting to {num_workers} (CPU cores)."
451
+ )
452
+ elif num_workers <= 0:
453
+ click.echo(
454
+ click.style("ERROR: --num-workers must be a positive integer.", fg="red"),
455
+ err=True,
456
+ )
457
+ sys.exit(1)
350
458
 
351
- # Determine queues to poll
352
- queues_arg = list(queues) if queues else None
353
- # Run worker in foreground (burst or continuous mode)
459
+ # Restrict burst mode with multiple workers
460
+ if num_workers > 1 and burst:
461
+ click.echo(
462
+ click.style(
463
+ "ERROR: --burst mode is not supported with multiple workers (--num-workers > 1). "
464
+ "Burst mode cannot coordinate across multiple processes.",
465
+ fg="red",
466
+ ),
467
+ err=True,
468
+ )
469
+ sys.exit(1)
470
+
471
+ # Display settings source
472
+ click.echo("Loading RRQ Settings... ", nl=False)
473
+ if settings_object_path:
474
+ click.echo(f"from --settings parameter ({settings_object_path}).")
475
+ elif os.getenv("RRQ_SETTINGS"):
476
+ click.echo(f"from RRQ_SETTINGS env var ({os.getenv('RRQ_SETTINGS')}).")
477
+ elif DOTENV_AVAILABLE and find_dotenv(usecwd=True):
478
+ click.echo("found in .env file.")
479
+ else:
480
+ click.echo("using defaults.")
481
+
482
+ if num_workers == 1:
483
+ # Run a single worker in the current process
484
+ click.echo(f"Starting 1 RRQ worker process (Burst: {burst})")
485
+ _run_single_worker(
486
+ burst, list(queues) if queues else None, settings_object_path
487
+ )
488
+ else:
489
+ # Run multiple worker subprocesses
490
+ click.echo(f"Starting {num_workers} RRQ worker processes")
491
+ # Burst is guaranteed to be False here
492
+ _run_multiple_workers(
493
+ num_workers, list(queues) if queues else None, settings_object_path
494
+ )
354
495
 
355
- logger.info(
356
- f"Starting RRQ Worker (Burst: {burst}, App Settings: {settings_object_path})"
357
- )
496
+
497
+ def _run_single_worker(
498
+ burst: bool,
499
+ queues_arg: list[str] | None,
500
+ settings_object_path: str | None,
501
+ ):
502
+ """Helper function to run a single RRQ worker instance."""
503
+ rrq_settings = _load_app_settings(settings_object_path)
358
504
 
359
505
  if not rrq_settings.job_registry:
360
506
  click.echo(
361
507
  click.style(
362
- "ERROR: No 'job_registry_app'. You must provide a JobRegistry instance in settings.",
508
+ "ERROR: No 'job_registry'. You must provide a JobRegistry instance in settings.",
363
509
  fg="red",
364
510
  ),
365
511
  err=True,
@@ -378,22 +524,187 @@ def worker_run_command(
378
524
  burst=burst,
379
525
  )
380
526
 
381
- loop = asyncio.get_event_loop()
382
527
  try:
383
- logger.info("Starting worker run loop...")
384
- loop.run_until_complete(worker_instance.run())
528
+ logger.info("Starting worker run loop for single worker...")
529
+ asyncio.run(worker_instance.run())
385
530
  except KeyboardInterrupt:
386
531
  logger.info("RRQ Worker run interrupted by user (KeyboardInterrupt).")
387
532
  except Exception as e:
388
- logger.error(f"Exception during RRQ Worker run: {e}", exc_info=True)
533
+ click.echo(
534
+ click.style(f"ERROR: Exception during RRQ Worker run: {e}", fg="red"),
535
+ err=True,
536
+ )
537
+ # Consider re-raising or sys.exit(1) if the exception means failure
389
538
  finally:
390
- logger.info("RRQ Worker run finished or exited. Cleaning up event loop.")
391
- if loop.is_running():
392
- loop.run_until_complete(loop.shutdown_asyncgens())
393
- loop.close()
539
+ # asyncio.run handles loop cleanup.
540
+ logger.info("RRQ Worker run finished or exited.")
394
541
  logger.info("RRQ Worker has shut down.")
395
542
 
396
543
 
544
+ def _run_multiple_workers(
545
+ num_workers: int,
546
+ queues: list[str] | None,
547
+ settings_object_path: str | None,
548
+ ):
549
+ """Manages multiple worker subprocesses."""
550
+ processes: list[subprocess.Popen] = []
551
+ # loop = asyncio.get_event_loop() # Not needed here, this function is synchronous
552
+
553
+ original_sigint_handler = signal.getsignal(signal.SIGINT)
554
+ original_sigterm_handler = signal.getsignal(signal.SIGTERM)
555
+
556
+ def sig_handler(signum, frame):
557
+ click.echo(
558
+ f"\nSignal {signal.Signals(signum).name} received. Terminating child workers..."
559
+ )
560
+ # Send SIGTERM to all processes
561
+ for i, p in enumerate(processes):
562
+ if p.poll() is None: # Process is still running
563
+ try:
564
+ pgid = os.getpgid(p.pid)
565
+ click.echo(f"Sending SIGTERM to worker {i + 1} (PID {p.pid})...")
566
+ os.killpg(pgid, signal.SIGTERM)
567
+ except (ProcessLookupError, OSError):
568
+ pass # Process already dead
569
+ # Restore original handlers before exiting or re-raising
570
+ signal.signal(signal.SIGINT, original_sigint_handler)
571
+ signal.signal(signal.SIGTERM, original_sigterm_handler)
572
+ # Propagate signal to ensure parent exits if it was, e.g., a Ctrl+C
573
+ # This is a bit tricky; for now, just exit.
574
+ # A more robust way might involve re-raising the signal if not handled by click/asyncio.
575
+ sys.exit(0)
576
+
577
+ signal.signal(signal.SIGINT, sig_handler)
578
+ signal.signal(signal.SIGTERM, sig_handler)
579
+
580
+ try:
581
+ for i in range(num_workers):
582
+ # Construct the command for the subprocess.
583
+ # Each subprocess runs 'rrq worker run' for a single worker.
584
+ # We pass along relevant flags like --settings, --queue, and --burst.
585
+ # Crucially, we do *not* pass --num-workers to the child,
586
+ # or rather, we could conceptually pass --num-workers 1.
587
+ # Use the rrq executable from the same venv
588
+ venv_bin_dir = os.path.dirname(sys.executable)
589
+ rrq_executable = os.path.join(venv_bin_dir, "rrq")
590
+ cmd = [rrq_executable, "worker", "run", "--num-workers=1"]
591
+ if settings_object_path:
592
+ cmd.extend(["--settings", settings_object_path])
593
+ elif os.getenv("RRQ_SETTINGS"):
594
+ # Pass the RRQ_SETTINGS env var as explicit parameter to subprocess
595
+ cmd.extend(["--settings", os.getenv("RRQ_SETTINGS")])
596
+ else:
597
+ # Error: No settings provided for multi-worker mode
598
+ click.echo(
599
+ "Error: Multi-worker mode requires explicit settings. "
600
+ "Please provide either --settings option or set RRQ_SETTINGS environment variable.",
601
+ err=True,
602
+ )
603
+ sys.exit(1)
604
+ if queues:
605
+ for q_name in queues:
606
+ cmd.extend(["--queue", q_name])
607
+ click.echo(f"Starting worker subprocess {i + 1}/{num_workers}...")
608
+
609
+ # Set up environment - add current directory to PYTHONPATH
610
+ env = os.environ.copy()
611
+ current_pythonpath = env.get("PYTHONPATH", "")
612
+ current_dir = os.getcwd()
613
+ if current_pythonpath:
614
+ env["PYTHONPATH"] = f"{current_dir}:{current_pythonpath}"
615
+ else:
616
+ env["PYTHONPATH"] = current_dir
617
+
618
+ # Configure output redirection
619
+ is_testing = "PYTEST_CURRENT_TEST" in os.environ
620
+ stdout_dest = None if not is_testing else subprocess.DEVNULL
621
+ stderr_dest = None if not is_testing else subprocess.DEVNULL
622
+
623
+ process = subprocess.Popen(
624
+ cmd,
625
+ start_new_session=True,
626
+ stdout=stdout_dest,
627
+ stderr=stderr_dest,
628
+ cwd=os.getcwd(),
629
+ env=env,
630
+ )
631
+ processes.append(process)
632
+ click.echo(f"Worker subprocess {i + 1} started with PID {process.pid}")
633
+
634
+ # Wait for all processes to complete
635
+ click.echo(f"All {num_workers} workers started. Press Ctrl+C to stop.")
636
+ exit_codes = []
637
+
638
+ try:
639
+ for p in processes:
640
+ exit_code = p.wait()
641
+ exit_codes.append(exit_code)
642
+ except KeyboardInterrupt:
643
+ # Signal handler has already sent SIGTERM, now wait with timeout
644
+ max_wait = 10
645
+ check_interval = 0.1
646
+ elapsed = 0
647
+
648
+ while elapsed < max_wait:
649
+ time.sleep(check_interval)
650
+ elapsed += check_interval
651
+
652
+ # Check if all processes have terminated
653
+ all_terminated = all(p.poll() is not None for p in processes)
654
+ if all_terminated:
655
+ click.echo("All workers terminated gracefully.")
656
+ break
657
+ else:
658
+ # Timeout reached, force kill remaining processes
659
+ for i, p in enumerate(processes):
660
+ if p.poll() is None:
661
+ try:
662
+ click.echo(
663
+ click.style(
664
+ f"WARNING: Worker {i + 1} did not terminate gracefully, sending SIGKILL.",
665
+ fg="yellow",
666
+ ),
667
+ err=True,
668
+ )
669
+ os.killpg(os.getpgid(p.pid), signal.SIGKILL)
670
+ except (ProcessLookupError, OSError):
671
+ pass
672
+
673
+ # Collect exit codes
674
+ for p in processes:
675
+ exit_codes.append(p.wait())
676
+
677
+ # Report results
678
+ for i, exit_code in enumerate(exit_codes):
679
+ click.echo(f"Worker subprocess {i + 1} exited with code {exit_code}")
680
+ if exit_code != 0:
681
+ click.echo(
682
+ click.style(
683
+ f"Worker subprocess {i + 1} failed with exit code {exit_code}",
684
+ fg="red",
685
+ ),
686
+ err=True,
687
+ )
688
+
689
+ except Exception as e:
690
+ click.echo(
691
+ click.style(f"ERROR: Error managing worker subprocesses: {e}", fg="red"),
692
+ err=True,
693
+ )
694
+ # Terminate any running processes if an error occurs in the manager
695
+ for p in processes:
696
+ if p.poll() is None: # If process is still running
697
+ terminate_worker_process(p, logger)
698
+ finally:
699
+ logger.info("All worker subprocesses terminated or completed.")
700
+ # Restore original signal handlers
701
+ signal.signal(signal.SIGINT, original_sigint_handler)
702
+ signal.signal(signal.SIGTERM, original_sigterm_handler)
703
+ # Any other cleanup for the parent process
704
+ # No loop to check or close here as this part is synchronous
705
+ logger.info("Parent process for multi-worker management is exiting.")
706
+
707
+
397
708
  @worker_cli.command("watch")
398
709
  @click.option(
399
710
  "--path",
@@ -468,65 +779,3 @@ def check_command(settings_object_path: str):
468
779
  else:
469
780
  click.echo(click.style("Health check FAILED.", fg="red"))
470
781
  sys.exit(1)
471
-
472
-
473
- @rrq.group("dlq")
474
- def dlq_cli():
475
- """Manage the Dead Letter Queue (DLQ)."""
476
- pass
477
-
478
-
479
- @dlq_cli.command("requeue")
480
- @click.option(
481
- "--settings",
482
- "settings_object_path",
483
- type=str,
484
- required=False,
485
- default=None,
486
- help=(
487
- "Python settings path for application worker settings "
488
- "(e.g., myapp.worker_config.rrq_settings). "
489
- "Must include `job_registry: JobRegistry` if requeueing requires handler resolution."
490
- ),
491
- )
492
- @click.option(
493
- "--dlq-name",
494
- "dlq_name",
495
- type=str,
496
- required=False,
497
- default=None,
498
- help="Name of the DLQ (without prefix). Defaults to settings.default_dlq_name.",
499
- )
500
- @click.option(
501
- "--queue",
502
- "target_queue",
503
- type=str,
504
- required=False,
505
- default=None,
506
- help="Name of the target queue (without prefix). Defaults to settings.default_queue_name.",
507
- )
508
- @click.option(
509
- "--limit",
510
- type=int,
511
- required=False,
512
- default=None,
513
- help="Maximum number of DLQ jobs to requeue; all if not set.",
514
- )
515
- def dlq_requeue_command(
516
- settings_object_path: str,
517
- dlq_name: str,
518
- target_queue: str,
519
- limit: int,
520
- ):
521
- """Requeue jobs from the dead letter queue back into a live queue."""
522
- rrq_settings = _load_app_settings(settings_object_path)
523
- dlq_to_use = dlq_name or rrq_settings.default_dlq_name
524
- queue_to_use = target_queue or rrq_settings.default_queue_name
525
- job_store = JobStore(settings=rrq_settings)
526
- click.echo(
527
- f"Requeuing jobs from DLQ '{dlq_to_use}' to queue '{queue_to_use}' (limit: {limit or 'all'})..."
528
- )
529
- count = asyncio.run(job_store.requeue_dlq(dlq_to_use, queue_to_use, limit))
530
- click.echo(
531
- f"Requeued {count} job(s) from DLQ '{dlq_to_use}' to queue '{queue_to_use}'."
532
- )
@@ -0,0 +1 @@
1
+ """RRQ CLI module"""
@@ -0,0 +1,102 @@
1
+ """Base classes and utilities for RRQ CLI commands"""
2
+
3
+ import asyncio
4
+ import importlib
5
+ import os
6
+ import pkgutil
7
+ from abc import ABC, abstractmethod
8
+ from typing import Callable
9
+
10
+ import click
11
+
12
+ from ..settings import RRQSettings
13
+ from ..store import JobStore
14
+
15
+
16
+ class BaseCommand(ABC):
17
+ """Base class for all RRQ CLI commands"""
18
+
19
+ @abstractmethod
20
+ def register(self, cli_group: click.Group) -> None:
21
+ """Register the command with the CLI group"""
22
+ pass
23
+
24
+
25
+ class AsyncCommand(BaseCommand):
26
+ """Base class for async CLI commands"""
27
+
28
+ def make_async(self, func: Callable) -> Callable:
29
+ """Wrapper to run async functions in click commands"""
30
+
31
+ def wrapper(*args, **kwargs):
32
+ return asyncio.run(func(*args, **kwargs))
33
+
34
+ return wrapper
35
+
36
+
37
+ def load_app_settings(settings_object_path: str | None = None) -> RRQSettings:
38
+ """Load the settings object from the given path.
39
+
40
+ If not provided, the RRQ_SETTINGS environment variable will be used.
41
+ If the environment variable is not set, will create a default settings object.
42
+ """
43
+ # Import the original function from cli.py
44
+ from ..cli import _load_app_settings
45
+
46
+ return _load_app_settings(settings_object_path)
47
+
48
+
49
+ def resolve_settings_source(
50
+ settings_object_path: str | None = None,
51
+ ) -> tuple[str | None, str]:
52
+ """Resolve the settings path and its source."""
53
+ # Import the original function from cli.py
54
+ from ..cli import _resolve_settings_source
55
+
56
+ return _resolve_settings_source(settings_object_path)
57
+
58
+
59
+ def auto_discover_commands(package_path: str) -> list[type[BaseCommand]]:
60
+ """Auto-discover command classes in the given package"""
61
+ commands = []
62
+
63
+ # Get the package module
64
+ try:
65
+ package = importlib.import_module(package_path)
66
+ package_dir = os.path.dirname(package.__file__)
67
+ except ImportError:
68
+ # Return empty list for non-existent packages
69
+ return commands
70
+
71
+ # Iterate through all modules in the package
72
+ for _, module_name, is_pkg in pkgutil.iter_modules([package_dir]):
73
+ if is_pkg:
74
+ continue
75
+
76
+ # Import the module
77
+ module_path = f"{package_path}.{module_name}"
78
+ try:
79
+ module = importlib.import_module(module_path)
80
+
81
+ # Look for BaseCommand subclasses
82
+ for attr_name in dir(module):
83
+ attr = getattr(module, attr_name)
84
+ if (
85
+ isinstance(attr, type)
86
+ and issubclass(attr, BaseCommand)
87
+ and attr not in (BaseCommand, AsyncCommand)
88
+ ):
89
+ commands.append(attr)
90
+ except ImportError:
91
+ # Skip modules that can't be imported
92
+ continue
93
+
94
+ return commands
95
+
96
+
97
+ async def get_job_store(settings: RRQSettings) -> JobStore:
98
+ """Create and return a JobStore instance"""
99
+ job_store = JobStore(settings=settings)
100
+ # Test connection
101
+ await job_store.redis.ping()
102
+ return job_store
@@ -0,0 +1 @@
1
+ """RRQ CLI commands module"""