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 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
@@ -0,0 +1,3 @@
1
+ from aqworker.cli.cli import app
2
+
3
+ __all__ = ["app"]
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()
@@ -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
+ )
@@ -0,0 +1,7 @@
1
+ from .base import BaseHandler
2
+ from .registry import HandlerRegistry
3
+
4
+ __all__ = [
5
+ "BaseHandler",
6
+ "HandlerRegistry",
7
+ ]
@@ -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