aqworker 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.
- aqworker/__init__.py +18 -0
- aqworker/__main__.py +0 -0
- aqworker/cli/__init__.py +3 -0
- aqworker/cli/cli.py +180 -0
- aqworker/cli/commands.py +67 -0
- aqworker/cli/loader.py +97 -0
- aqworker/constants.py +31 -0
- aqworker/core.py +264 -0
- aqworker/handler/__init__.py +7 -0
- aqworker/handler/base.py +24 -0
- aqworker/handler/registry.py +32 -0
- aqworker/job/__init__.py +25 -0
- aqworker/job/models.py +101 -0
- aqworker/job/queue.py +588 -0
- aqworker/job/service.py +365 -0
- aqworker/logger.py +64 -0
- aqworker/worker/__init__.py +3 -0
- aqworker/worker/base.py +386 -0
- aqworker/worker/dispatcher.py +35 -0
- aqworker/worker/registry.py +88 -0
- aqworker-0.1.0.dist-info/METADATA +615 -0
- aqworker-0.1.0.dist-info/RECORD +24 -0
- aqworker-0.1.0.dist-info/WHEEL +4 -0
- aqworker-0.1.0.dist-info/entry_points.txt +3 -0
aqworker/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AQWorker - Async Queue Worker for Python applications.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from aqworker.core import AQWorker
|
|
6
|
+
from aqworker.handler.base import BaseHandler
|
|
7
|
+
from aqworker.handler.registry import HandlerRegistry
|
|
8
|
+
from aqworker.worker.base import BaseWorker, WorkerConfig
|
|
9
|
+
from aqworker.worker.registry import WorkerRegistry
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AQWorker",
|
|
13
|
+
"WorkerRegistry",
|
|
14
|
+
"HandlerRegistry",
|
|
15
|
+
"BaseWorker",
|
|
16
|
+
"BaseHandler",
|
|
17
|
+
"WorkerConfig",
|
|
18
|
+
]
|
aqworker/__main__.py
ADDED
|
File without changes
|
aqworker/cli/__init__.py
ADDED
aqworker/cli/cli.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command line entry points for aqworker.
|
|
3
|
+
|
|
4
|
+
Expose management commands via `python -m aqworker` or the installed
|
|
5
|
+
console script `aqworker`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from aqworker.cli.commands import (
|
|
15
|
+
RegistryError,
|
|
16
|
+
get_queue_stats,
|
|
17
|
+
list_handler_descriptors,
|
|
18
|
+
list_queue_names,
|
|
19
|
+
list_worker_names,
|
|
20
|
+
run_worker,
|
|
21
|
+
)
|
|
22
|
+
from aqworker.cli.loader import get_aqworker_file, load_aqworker_from_file
|
|
23
|
+
from aqworker.core import AQWorker
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(name="aqworker", help="AQWorker management CLI")
|
|
26
|
+
|
|
27
|
+
# Create a default AQWorker instance for CLI
|
|
28
|
+
# Users should register workers/handlers before using CLI commands
|
|
29
|
+
_default_aq_worker = AQWorker()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_aqworker_instance(file_path: Optional[str] = None) -> AQWorker:
|
|
33
|
+
"""
|
|
34
|
+
Get AQWorker instance from file or use default.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
file_path: Optional path to Python file containing AQWorker instance
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
AQWorker instance
|
|
41
|
+
"""
|
|
42
|
+
# Priority: file_path argument > env variable > default
|
|
43
|
+
if file_path:
|
|
44
|
+
try:
|
|
45
|
+
return load_aqworker_from_file(file_path)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
typer.echo(f"Error loading AQWorker from {file_path}: {e}", err=True)
|
|
48
|
+
raise typer.Exit(code=1) from e
|
|
49
|
+
|
|
50
|
+
env_file = get_aqworker_file()
|
|
51
|
+
if env_file:
|
|
52
|
+
try:
|
|
53
|
+
return load_aqworker_from_file(env_file)
|
|
54
|
+
except Exception as e:
|
|
55
|
+
typer.echo(
|
|
56
|
+
f"Error loading AQWorker from AQWORKER_FILE={env_file}: {e}", err=True
|
|
57
|
+
)
|
|
58
|
+
raise typer.Exit(code=1) from e
|
|
59
|
+
|
|
60
|
+
return _default_aq_worker
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _handle_registry_error(func):
|
|
64
|
+
"""Decorator-like helper to convert RegistryError to Typer exits."""
|
|
65
|
+
|
|
66
|
+
def wrapper(*args, **kwargs):
|
|
67
|
+
try:
|
|
68
|
+
return func(*args, **kwargs)
|
|
69
|
+
except RegistryError as exc:
|
|
70
|
+
typer.echo(str(exc))
|
|
71
|
+
raise typer.Exit(code=1) from exc
|
|
72
|
+
|
|
73
|
+
return wrapper
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command("list:aq_worker")
|
|
77
|
+
def list_workers(
|
|
78
|
+
file_path: Optional[str] = typer.Argument(
|
|
79
|
+
None, help="Path to Python file containing AQWorker instance"
|
|
80
|
+
)
|
|
81
|
+
):
|
|
82
|
+
"""List available aq_worker names."""
|
|
83
|
+
|
|
84
|
+
@_handle_registry_error
|
|
85
|
+
def _impl():
|
|
86
|
+
aq_worker = _get_aqworker_instance(file_path)
|
|
87
|
+
for name in list_worker_names(aq_worker):
|
|
88
|
+
typer.echo(name)
|
|
89
|
+
|
|
90
|
+
_impl()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command("list:handlers")
|
|
94
|
+
def list_handlers(
|
|
95
|
+
file_path: Optional[str] = typer.Argument(
|
|
96
|
+
None, help="Path to Python file containing AQWorker instance"
|
|
97
|
+
)
|
|
98
|
+
):
|
|
99
|
+
"""List registered handlers names."""
|
|
100
|
+
|
|
101
|
+
@_handle_registry_error
|
|
102
|
+
def _impl():
|
|
103
|
+
aq_worker = _get_aqworker_instance(file_path)
|
|
104
|
+
for name, dotted in list_handler_descriptors(aq_worker):
|
|
105
|
+
typer.echo(f"{name} ({dotted})")
|
|
106
|
+
|
|
107
|
+
_impl()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command("list:queue")
|
|
111
|
+
def list_queues(
|
|
112
|
+
file_path: Optional[str] = typer.Argument(
|
|
113
|
+
None, help="Path to Python file containing AQWorker instance"
|
|
114
|
+
)
|
|
115
|
+
):
|
|
116
|
+
"""List queues declared by registered workers."""
|
|
117
|
+
|
|
118
|
+
@_handle_registry_error
|
|
119
|
+
def _impl():
|
|
120
|
+
aq_worker = _get_aqworker_instance(file_path)
|
|
121
|
+
for queue in list_queue_names(aq_worker):
|
|
122
|
+
typer.echo(queue)
|
|
123
|
+
|
|
124
|
+
_impl()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command("start")
|
|
128
|
+
def start_worker(
|
|
129
|
+
worker_name: str = typer.Argument(..., help="Worker name to start"),
|
|
130
|
+
file_path: Optional[str] = typer.Argument(
|
|
131
|
+
None, help="Path to Python file containing AQWorker instance"
|
|
132
|
+
),
|
|
133
|
+
):
|
|
134
|
+
"""Start a aq_worker by name."""
|
|
135
|
+
|
|
136
|
+
@_handle_registry_error
|
|
137
|
+
def _impl():
|
|
138
|
+
aq_worker = _get_aqworker_instance(file_path)
|
|
139
|
+
run_worker(aq_worker, worker_name)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
_impl()
|
|
143
|
+
except typer.Exit:
|
|
144
|
+
raise
|
|
145
|
+
except KeyboardInterrupt:
|
|
146
|
+
typer.echo("Worker interrupted, shutting down...")
|
|
147
|
+
except Exception as exc: # pragma: no cover
|
|
148
|
+
typer.echo(f"Failed to run aq_worker: {exc}")
|
|
149
|
+
raise typer.Exit(code=1) from exc
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@app.command("stats")
|
|
153
|
+
def queue_stats(
|
|
154
|
+
queue_name: str = typer.Argument(..., help="Queue name to get statistics for"),
|
|
155
|
+
file_path: Optional[str] = typer.Argument(
|
|
156
|
+
None, help="Path to Python file containing AQWorker instance"
|
|
157
|
+
),
|
|
158
|
+
):
|
|
159
|
+
"""Print queue statistics for a given queue."""
|
|
160
|
+
|
|
161
|
+
@_handle_registry_error
|
|
162
|
+
def _impl():
|
|
163
|
+
aq_worker = _get_aqworker_instance(file_path)
|
|
164
|
+
job_service = aq_worker.job_service
|
|
165
|
+
stats = get_queue_stats(queue_name, job_service)
|
|
166
|
+
if not stats:
|
|
167
|
+
typer.echo(f"No stats available for queue '{queue_name}'.")
|
|
168
|
+
raise typer.Exit(code=1)
|
|
169
|
+
|
|
170
|
+
typer.echo(f"Queue: {queue_name}")
|
|
171
|
+
typer.echo(f" Pending: {stats.get('pending', 0)}")
|
|
172
|
+
typer.echo(f" Processing: {stats.get('processing', 0)}")
|
|
173
|
+
typer.echo(f" Completed: {stats.get('completed', 0)}")
|
|
174
|
+
typer.echo(f" Failed: {stats.get('failed', 0)}")
|
|
175
|
+
|
|
176
|
+
_impl()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
app()
|
aqworker/cli/commands.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reusable logic for CLI commands, extracted for easier testing.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from aqworker.core import AQWorker
|
|
9
|
+
from aqworker.job.service import JobService
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from aqworker.worker.base import BaseWorker
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RegistryError(RuntimeError):
|
|
16
|
+
"""Raised when required registry data is missing."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def list_worker_names(aq_worker: AQWorker) -> List[str]:
|
|
20
|
+
"""Return sorted aq_worker names."""
|
|
21
|
+
return sorted(defn.name for defn in aq_worker.worker_registry.list_definitions())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def list_handler_descriptors(aq_worker: AQWorker) -> List[Tuple[str, str]]:
|
|
25
|
+
"""Return (name, dotted path) tuples for handlers."""
|
|
26
|
+
handlers = aq_worker.handler_registry.snapshot()
|
|
27
|
+
descriptors = []
|
|
28
|
+
for name, handler_cls in handlers.items():
|
|
29
|
+
descriptors.append((name, f"{handler_cls.__module__}.{handler_cls.__name__}"))
|
|
30
|
+
return sorted(descriptors, key=lambda item: item[0])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def list_queue_names(aq_worker: AQWorker) -> List[str]:
|
|
34
|
+
"""Return sorted queue names inferred from workers."""
|
|
35
|
+
definitions = aq_worker.worker_registry.list_definitions()
|
|
36
|
+
queues = set[str]()
|
|
37
|
+
for definition in definitions:
|
|
38
|
+
queues.update(definition.queue_names)
|
|
39
|
+
return sorted(queues)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_worker(aq_worker: AQWorker, worker_name: str) -> "BaseWorker":
|
|
43
|
+
"""Construct an aq_worker instance without starting it."""
|
|
44
|
+
return aq_worker.create_worker(worker_name) # type: ignore[no-any-return]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run_worker(aq_worker: AQWorker, worker_name: str) -> None:
|
|
48
|
+
"""Run a aq_worker event loop."""
|
|
49
|
+
worker = build_worker(aq_worker, worker_name)
|
|
50
|
+
asyncio.run(worker.run())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def get_queue_stats_async(
|
|
54
|
+
queue_name: str, job_service: Optional[JobService] = None
|
|
55
|
+
) -> Dict[str, int]:
|
|
56
|
+
"""Async helper for queue stats (handy for unit tests)."""
|
|
57
|
+
if job_service is None:
|
|
58
|
+
raise ValueError("job_service is required for get_queue_stats_async")
|
|
59
|
+
stats = await job_service.get_queue_stats([queue_name])
|
|
60
|
+
return stats or {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_queue_stats(
|
|
64
|
+
queue_name: str, job_service: Optional[JobService] = None
|
|
65
|
+
) -> Dict[str, int]:
|
|
66
|
+
"""Sync wrapper for queue stats."""
|
|
67
|
+
return asyncio.run(get_queue_stats_async(queue_name, job_service))
|
aqworker/cli/loader.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility to load AQWorker instance from a Python file.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from aqworker.core import AQWorker
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_aqworker_from_file(file_path: str) -> AQWorker:
|
|
15
|
+
"""
|
|
16
|
+
Load AQWorker instance from a Python file.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
file_path: Path to Python file containing AQWorker instance
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
AQWorker instance found in the file
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
FileNotFoundError: If file doesn't exist
|
|
26
|
+
ValueError: If no AQWorker instance found in file
|
|
27
|
+
"""
|
|
28
|
+
file_path_obj = Path(file_path)
|
|
29
|
+
|
|
30
|
+
# Resolve absolute path
|
|
31
|
+
if not file_path_obj.is_absolute():
|
|
32
|
+
# Try relative to current working directory
|
|
33
|
+
abs_path = Path.cwd() / file_path_obj
|
|
34
|
+
if not abs_path.exists():
|
|
35
|
+
# Try relative to project root (if we can detect it)
|
|
36
|
+
abs_path = file_path_obj.resolve()
|
|
37
|
+
else:
|
|
38
|
+
abs_path = file_path_obj
|
|
39
|
+
|
|
40
|
+
if not abs_path.exists():
|
|
41
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
42
|
+
|
|
43
|
+
if not abs_path.suffix == ".py":
|
|
44
|
+
raise ValueError(f"File must be a Python file (.py): {file_path}")
|
|
45
|
+
|
|
46
|
+
# Convert path to module path
|
|
47
|
+
# e.g., examples/simple/aq_worker.py -> examples.simple.aq_worker
|
|
48
|
+
module_name = abs_path.stem
|
|
49
|
+
|
|
50
|
+
# Load the module
|
|
51
|
+
spec = importlib.util.spec_from_file_location(module_name, abs_path)
|
|
52
|
+
if spec is None or spec.loader is None:
|
|
53
|
+
raise ValueError(f"Could not load module from file: {file_path}")
|
|
54
|
+
|
|
55
|
+
module = importlib.util.module_from_spec(spec)
|
|
56
|
+
|
|
57
|
+
# Add parent directory to sys.path if needed for imports
|
|
58
|
+
parent_dir = str(abs_path.parent)
|
|
59
|
+
if parent_dir not in sys.path:
|
|
60
|
+
sys.path.insert(0, parent_dir)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
spec.loader.exec_module(module)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
raise ValueError(f"Error loading module from {file_path}: {e}") from e
|
|
66
|
+
|
|
67
|
+
# Find AQWorker instance in the module
|
|
68
|
+
# Try common names first
|
|
69
|
+
common_names = ["aq_worker", "aq_worker", "aqworker", "aq"]
|
|
70
|
+
for name in common_names:
|
|
71
|
+
if hasattr(module, name):
|
|
72
|
+
attr = getattr(module, name)
|
|
73
|
+
if isinstance(attr, AQWorker):
|
|
74
|
+
return attr
|
|
75
|
+
|
|
76
|
+
# Search all attributes for AQWorker instances
|
|
77
|
+
for attr_name in dir(module):
|
|
78
|
+
if not attr_name.startswith("_"): # Skip private attributes
|
|
79
|
+
attr = getattr(module, attr_name)
|
|
80
|
+
if isinstance(attr, AQWorker):
|
|
81
|
+
return attr
|
|
82
|
+
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"No AQWorker instance found in {file_path}. "
|
|
85
|
+
f"Expected an instance variable named one of: {', '.join(common_names)}, "
|
|
86
|
+
f"or any attribute of type AQWorker."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_aqworker_file() -> Optional[str]:
|
|
91
|
+
"""
|
|
92
|
+
Get AQWorker file path from environment variable.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
File path if AQWORKER_FILE is set, None otherwise
|
|
96
|
+
"""
|
|
97
|
+
return os.environ.get("AQWORKER_FILE")
|
aqworker/constants.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Constants for the aq_worker system.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
LOGGER_NAME = "aqworker"
|
|
6
|
+
|
|
7
|
+
# Queue prefixes
|
|
8
|
+
JOB_QUEUE_PREFIX = "aqw"
|
|
9
|
+
JOB_STATUS_PREFIX = "aqw:job:"
|
|
10
|
+
JOB_LOCK_PREFIX = "aqw:jl:"
|
|
11
|
+
|
|
12
|
+
# Special queue names
|
|
13
|
+
PROCESSING_QUEUE = f"{JOB_QUEUE_PREFIX}:processing"
|
|
14
|
+
COMPLETED_QUEUE = f"{JOB_QUEUE_PREFIX}:completed"
|
|
15
|
+
FAILED_QUEUE = f"{JOB_QUEUE_PREFIX}:failed"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Queue name patterns
|
|
19
|
+
def get_queue_name(queue_name: str) -> str:
|
|
20
|
+
"""Get full queue name for a specific queue."""
|
|
21
|
+
return f"{JOB_QUEUE_PREFIX}:{queue_name}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_job_status_key(job_id: str) -> str:
|
|
25
|
+
"""Get job status key for a job ID."""
|
|
26
|
+
return f"{JOB_STATUS_PREFIX}{job_id}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_job_lock_key(job_id: str) -> str:
|
|
30
|
+
"""Get Redis key used for per-job locks."""
|
|
31
|
+
return f"{JOB_LOCK_PREFIX}{job_id}"
|
aqworker/core.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core AQWorker class - Main entry point for the aq_worker system.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import inspect
|
|
7
|
+
import pkgutil
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, Optional, Type
|
|
9
|
+
|
|
10
|
+
from aqworker.handler.base import BaseHandler
|
|
11
|
+
from aqworker.handler.registry import HandlerRegistry
|
|
12
|
+
from aqworker.job.service import JobService
|
|
13
|
+
from aqworker.logger import logger
|
|
14
|
+
from aqworker.worker.dispatcher import HandlerDispatcher
|
|
15
|
+
from aqworker.worker.registry import WorkerRegistry
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from worker.base import BaseWorker
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AQWorker:
|
|
22
|
+
"""
|
|
23
|
+
Main AQWorker instance containing WorkerRegistry, HandlerRegistry, and JobService.
|
|
24
|
+
|
|
25
|
+
This is the primary entry point for registering workers and handlers, and managing jobs.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
worker_registry: Optional[WorkerRegistry] = None,
|
|
31
|
+
handler_registry: Optional[HandlerRegistry] = None,
|
|
32
|
+
job_service: Optional[JobService] = None,
|
|
33
|
+
redis_host: str = "localhost",
|
|
34
|
+
redis_port: int = 6379,
|
|
35
|
+
redis_db: int = 0,
|
|
36
|
+
include_packages: Optional[Iterable[str]] = None,
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
39
|
+
Initialize AQWorker with registries and job service.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
worker_registry: Optional WorkerRegistry instance. Creates new one if not provided.
|
|
43
|
+
handler_registry: Optional HandlerRegistry instance. Creates new one if not provided.
|
|
44
|
+
job_service: Optional JobService instance. Creates new one if not provided.
|
|
45
|
+
redis_host: Redis server hostname. Defaults to "localhost".
|
|
46
|
+
Only used when job_service is not provided.
|
|
47
|
+
redis_port: Redis server port. Defaults to 6379.
|
|
48
|
+
Only used when job_service is not provided.
|
|
49
|
+
redis_db: Redis database number. Defaults to 0.
|
|
50
|
+
Only used when job_service is not provided.
|
|
51
|
+
include_packages: Optional iterable of package/module names to import
|
|
52
|
+
for handler auto-discovery.
|
|
53
|
+
"""
|
|
54
|
+
self.worker_registry = worker_registry or WorkerRegistry()
|
|
55
|
+
self.handler_registry = handler_registry or HandlerRegistry()
|
|
56
|
+
self.job_service: Optional[JobService] = job_service or JobService(
|
|
57
|
+
redis_host=redis_host, redis_port=redis_port, redis_db=redis_db
|
|
58
|
+
)
|
|
59
|
+
self.handler_dispatcher = HandlerDispatcher(self.handler_registry)
|
|
60
|
+
self._workers: Dict[str, Optional[Type["BaseWorker"]]] = {}
|
|
61
|
+
self._include_packages = list(include_packages or [])
|
|
62
|
+
|
|
63
|
+
if self._include_packages:
|
|
64
|
+
self.autodiscover_handlers(self._include_packages)
|
|
65
|
+
|
|
66
|
+
def register_worker(
|
|
67
|
+
self, worker_class: type["BaseWorker"], name: Optional[str] = None
|
|
68
|
+
) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Register a aq_worker class.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
worker_class: Worker class to register
|
|
74
|
+
name: Optional custom name for the aq_worker
|
|
75
|
+
"""
|
|
76
|
+
self.worker_registry.register(worker_class, name)
|
|
77
|
+
# Clear cached workers when new aq_worker is registered
|
|
78
|
+
self._workers = {}
|
|
79
|
+
|
|
80
|
+
def register_handler(self, handler_class: type["BaseHandler"]) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Register a handler class.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
handler_class: Handler class to register
|
|
86
|
+
"""
|
|
87
|
+
self.handler_registry.register(handler_class)
|
|
88
|
+
|
|
89
|
+
def listen(self, job_service: JobService) -> None:
|
|
90
|
+
"""
|
|
91
|
+
Register Job Service
|
|
92
|
+
Args:
|
|
93
|
+
job_service: JobService instance
|
|
94
|
+
"""
|
|
95
|
+
self.job_service = job_service
|
|
96
|
+
|
|
97
|
+
def create_worker(
|
|
98
|
+
self, worker_type: str, custom_config: Optional[Dict[str, Any]] = None
|
|
99
|
+
) -> "BaseWorker":
|
|
100
|
+
"""
|
|
101
|
+
Create a aq_worker instance.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
worker_type: Type of aq_worker to create
|
|
105
|
+
custom_config: Custom configuration to override defaults
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Worker instance
|
|
109
|
+
"""
|
|
110
|
+
if not self._workers:
|
|
111
|
+
# Copy snapshot of registered workers
|
|
112
|
+
self._workers = {
|
|
113
|
+
name: self.worker_registry.get(name)
|
|
114
|
+
for name in self.worker_registry.list_names()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if worker_type not in self._workers:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"Unknown aq_worker type: {worker_type}. Available: {list(self._workers.keys())}"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Create aq_worker instance (will use worker_config from class)
|
|
123
|
+
worker_class = self._workers[worker_type]
|
|
124
|
+
if worker_class is None:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"Unknown aq_worker type: {worker_type}. Available: {list(self._workers.keys())}"
|
|
127
|
+
)
|
|
128
|
+
return worker_class(
|
|
129
|
+
custom_config,
|
|
130
|
+
handler_dispatcher=self.handler_dispatcher,
|
|
131
|
+
job_service=self.job_service,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def get_available_workers(self) -> list[str]:
|
|
135
|
+
"""Get list of available aq_worker types."""
|
|
136
|
+
if not self._workers:
|
|
137
|
+
self._workers = {
|
|
138
|
+
name: self.worker_registry.get(name)
|
|
139
|
+
for name in self.worker_registry.list_names()
|
|
140
|
+
}
|
|
141
|
+
return list(self._workers.keys())
|
|
142
|
+
|
|
143
|
+
def handler(self, name: Optional[str] = None) -> Callable[..., Any]:
|
|
144
|
+
"""
|
|
145
|
+
Decorator to convert a function into a handler and automatically register it.
|
|
146
|
+
|
|
147
|
+
Usage:
|
|
148
|
+
aq_worker = AQWorker()
|
|
149
|
+
|
|
150
|
+
@aq_worker.handler(name='send_email')
|
|
151
|
+
async def send_email(data: dict) -> bool:
|
|
152
|
+
# Handler logic
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
# Or with default name (function name)
|
|
156
|
+
@aq_worker.handler()
|
|
157
|
+
def process_data(data: dict) -> bool:
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
name: Optional handler name. If not provided, uses function name.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
The original function (decorator registers handler automatically)
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
168
|
+
# Determine if function is async
|
|
169
|
+
is_async = inspect.iscoroutinefunction(func)
|
|
170
|
+
|
|
171
|
+
# Get handler name
|
|
172
|
+
handler_name = name or func.__name__
|
|
173
|
+
|
|
174
|
+
# Create handler class dynamically
|
|
175
|
+
class FunctionHandler(BaseHandler):
|
|
176
|
+
"""Handler class created from function decorator."""
|
|
177
|
+
|
|
178
|
+
name = handler_name
|
|
179
|
+
|
|
180
|
+
async def handle(self, data: Dict[str, Any]) -> bool:
|
|
181
|
+
"""Process job data by calling the original function."""
|
|
182
|
+
if is_async:
|
|
183
|
+
return bool(await func(data))
|
|
184
|
+
else:
|
|
185
|
+
return bool(func(data))
|
|
186
|
+
|
|
187
|
+
# Set class name for better debugging
|
|
188
|
+
FunctionHandler.__name__ = f"{func.__name__}Handler"
|
|
189
|
+
FunctionHandler.__qualname__ = f"{func.__qualname__}Handler"
|
|
190
|
+
|
|
191
|
+
# Preserve original function for reference
|
|
192
|
+
FunctionHandler._original_func = func # type: ignore[attr-defined]
|
|
193
|
+
|
|
194
|
+
# Automatically register handler
|
|
195
|
+
self.register_handler(FunctionHandler)
|
|
196
|
+
|
|
197
|
+
# Return original function so it can still be called directly if needed
|
|
198
|
+
return func
|
|
199
|
+
|
|
200
|
+
return decorator
|
|
201
|
+
|
|
202
|
+
def autodiscover_handlers(self, packages: Iterable[str]) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Import every module inside the provided packages so handler decorators run.
|
|
205
|
+
"""
|
|
206
|
+
before_snapshot = set(self.handler_registry.snapshot().keys())
|
|
207
|
+
visited: set[str] = set()
|
|
208
|
+
for package_name in packages:
|
|
209
|
+
self._import_package(package_name, visited)
|
|
210
|
+
|
|
211
|
+
current_handlers = sorted(self.handler_registry.snapshot().keys())
|
|
212
|
+
if current_handlers:
|
|
213
|
+
logger.info(
|
|
214
|
+
"Available handlers after discovery: %s",
|
|
215
|
+
", ".join(current_handlers),
|
|
216
|
+
)
|
|
217
|
+
elif before_snapshot:
|
|
218
|
+
logger.info("Handler registry unchanged during discovery.")
|
|
219
|
+
else:
|
|
220
|
+
logger.warning("No handlers registered after discovery.")
|
|
221
|
+
|
|
222
|
+
def _import_package(self, module_name: str, visited: set[str]) -> None:
|
|
223
|
+
if not module_name or module_name in visited:
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
visited.add(module_name)
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
module = importlib.import_module(module_name)
|
|
230
|
+
except ModuleNotFoundError as exc:
|
|
231
|
+
logger.warning("Include package '%s' not found: %s", module_name, exc)
|
|
232
|
+
return
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
logger.exception(
|
|
235
|
+
"Failed to import include package '%s': %s", module_name, exc
|
|
236
|
+
)
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
self._register_module_handlers(module)
|
|
240
|
+
|
|
241
|
+
module_path = getattr(module, "__path__", None)
|
|
242
|
+
if not module_path:
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
for _finder, name, _ispkg in pkgutil.walk_packages(
|
|
246
|
+
module_path, prefix=f"{module.__name__}."
|
|
247
|
+
):
|
|
248
|
+
self._import_package(name, visited)
|
|
249
|
+
|
|
250
|
+
def _register_module_handlers(self, module) -> None:
|
|
251
|
+
for attribute_name in dir(module):
|
|
252
|
+
attribute = getattr(module, attribute_name, None)
|
|
253
|
+
if (
|
|
254
|
+
inspect.isclass(attribute)
|
|
255
|
+
and issubclass(attribute, BaseHandler)
|
|
256
|
+
and attribute is not BaseHandler
|
|
257
|
+
):
|
|
258
|
+
try:
|
|
259
|
+
self.register_handler(attribute)
|
|
260
|
+
except ValueError:
|
|
261
|
+
# Skip duplicates with informative log for debugging
|
|
262
|
+
logger.debug(
|
|
263
|
+
"Handler '%s' already registered", attribute.get_name()
|
|
264
|
+
)
|
aqworker/handler/base.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BaseHandler(ABC):
|
|
6
|
+
"""Abstract base for job handlers."""
|
|
7
|
+
|
|
8
|
+
name: Optional[str] = None
|
|
9
|
+
|
|
10
|
+
@classmethod
|
|
11
|
+
def get_name(cls) -> str:
|
|
12
|
+
return cls.name or cls.__name__
|
|
13
|
+
|
|
14
|
+
async def handle(self, data: Dict[str, Any]) -> bool:
|
|
15
|
+
"""
|
|
16
|
+
Process job data.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
data: Job data dictionary containing all information needed for processing
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
True if processing succeeded, False otherwise
|
|
23
|
+
"""
|
|
24
|
+
raise NotImplementedError
|