celery-fastapi 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.
celery_fastapi/core.py ADDED
@@ -0,0 +1,759 @@
1
+ """Core functionality for Celery FastAPI."""
2
+
3
+ import inspect
4
+ from datetime import datetime
5
+ from typing import Any, Callable, get_type_hints
6
+
7
+ from celery import Celery
8
+ from celery.result import AsyncResult
9
+ from fastapi import FastAPI, HTTPException, Query
10
+ from pydantic import BaseModel, Field, create_model
11
+
12
+ # Celery execution options - shared fields for all task payloads
13
+ CELERY_OPTIONS_FIELDS: dict[str, Any] = {
14
+ "countdown": (
15
+ float | None,
16
+ Field(default=None, description="Seconds to wait before executing"),
17
+ ),
18
+ "eta": (
19
+ datetime | None,
20
+ Field(default=None, description="Datetime when task should execute (UTC)"),
21
+ ),
22
+ "expires": (
23
+ float | datetime | None,
24
+ Field(default=None, description="Task expiration time"),
25
+ ),
26
+ "retry": (
27
+ bool | None,
28
+ Field(default=None, description="Retry on failure"),
29
+ ),
30
+ "retry_policy": (
31
+ dict[str, Any] | None,
32
+ Field(default=None, description="Retry policy options"),
33
+ ),
34
+ "queue": (
35
+ str | None,
36
+ Field(default=None, description="Override the default queue"),
37
+ ),
38
+ "exchange": (
39
+ str | None,
40
+ Field(default=None, description="Override the default exchange"),
41
+ ),
42
+ "routing_key": (
43
+ str | None,
44
+ Field(default=None, description="Override the default routing key"),
45
+ ),
46
+ "priority": (
47
+ int | None,
48
+ Field(default=None, ge=0, le=9, description="Task priority (0-9)"),
49
+ ),
50
+ "serializer": (
51
+ str | None,
52
+ Field(default=None, description="Serializer (json, pickle, yaml, msgpack)"),
53
+ ),
54
+ "compression": (
55
+ str | None,
56
+ Field(default=None, description="Compression method"),
57
+ ),
58
+ "headers": (
59
+ dict[str, Any] | None,
60
+ Field(default=None, description="Custom message headers"),
61
+ ),
62
+ "link": (
63
+ list[str] | None,
64
+ Field(default=None, description="Tasks to call on success"),
65
+ ),
66
+ "link_error": (
67
+ list[str] | None,
68
+ Field(default=None, description="Tasks to call on error"),
69
+ ),
70
+ "task_id": (
71
+ str | None,
72
+ Field(default=None, description="Custom task ID"),
73
+ ),
74
+ "shadow": (
75
+ str | None,
76
+ Field(default=None, description="Override task name in logs"),
77
+ ),
78
+ "ignore_result": (
79
+ bool | None,
80
+ Field(default=None, description="Don't store the result"),
81
+ ),
82
+ "time_limit": (
83
+ float | None,
84
+ Field(default=None, description="Hard time limit (seconds)"),
85
+ ),
86
+ "soft_time_limit": (
87
+ float | None,
88
+ Field(default=None, description="Soft time limit (seconds)"),
89
+ ),
90
+ }
91
+
92
+
93
+ class GenericTaskPayload(BaseModel):
94
+ """Generic payload for triggering any task by name."""
95
+
96
+ task_name: str = Field(description="Full task name (e.g., 'myapp.tasks.add')")
97
+ queue: str = Field(description="Queue to send the task to (required)")
98
+ args: list[Any] = Field(
99
+ default_factory=list, description="Positional arguments for the task"
100
+ )
101
+ kwargs: dict[str, Any] = Field(
102
+ default_factory=dict, description="Keyword arguments for the task"
103
+ )
104
+
105
+ # Celery execution options
106
+ countdown: float | None = Field(default=None, description="Seconds to wait")
107
+ eta: datetime | None = Field(default=None, description="Execute at datetime (UTC)")
108
+ expires: float | datetime | None = Field(default=None, description="Expiration")
109
+ retry: bool | None = Field(default=None, description="Retry on failure")
110
+ retry_policy: dict[str, Any] | None = Field(
111
+ default=None, description="Retry policy"
112
+ )
113
+ exchange: str | None = Field(default=None, description="Override exchange")
114
+ routing_key: str | None = Field(default=None, description="Override routing key")
115
+ priority: int | None = Field(default=None, ge=0, le=9, description="Priority (0-9)")
116
+ serializer: str | None = Field(default=None, description="Serializer")
117
+ compression: str | None = Field(default=None, description="Compression")
118
+ headers: dict[str, Any] | None = Field(default=None, description="Custom headers")
119
+ task_id: str | None = Field(default=None, description="Custom task ID")
120
+ ignore_result: bool | None = Field(default=None, description="Don't store result")
121
+ time_limit: float | None = Field(default=None, description="Hard time limit")
122
+ soft_time_limit: float | None = Field(default=None, description="Soft time limit")
123
+
124
+ model_config = {
125
+ "json_schema_extra": {
126
+ "examples": [
127
+ {
128
+ "task_name": "myapp.tasks.add",
129
+ "queue": "celery",
130
+ "args": [1, 2],
131
+ "kwargs": {},
132
+ },
133
+ {
134
+ "task_name": "myapp.tasks.process",
135
+ "queue": "high_priority",
136
+ "args": [],
137
+ "kwargs": {"data": "test"},
138
+ "countdown": 60,
139
+ },
140
+ ]
141
+ }
142
+ }
143
+
144
+
145
+ def _python_type_to_json_type(py_type: type) -> str:
146
+ """Convert Python type to JSON schema type string."""
147
+ if py_type in (int,):
148
+ return "integer"
149
+ elif py_type in (float,):
150
+ return "number"
151
+ elif py_type in (bool,):
152
+ return "boolean"
153
+ elif py_type in (str,):
154
+ return "string"
155
+ elif py_type in (list, tuple):
156
+ return "array"
157
+ elif py_type in (dict,):
158
+ return "object"
159
+ return "string"
160
+
161
+
162
+ def _create_task_payload_model(
163
+ task_name: str,
164
+ task_func: Callable[..., Any],
165
+ default_queue: str,
166
+ ) -> type[BaseModel]:
167
+ """
168
+ Create a dynamic Pydantic model for a specific task based on its signature.
169
+
170
+ This generates a model with the actual parameter names and types from the task.
171
+ """
172
+ # Get function signature and type hints
173
+ sig = inspect.signature(task_func)
174
+ try:
175
+ type_hints = get_type_hints(task_func)
176
+ except Exception: # noqa: BLE001
177
+ type_hints = {}
178
+
179
+ # Build field definitions for task arguments
180
+ field_definitions: dict[str, Any] = {}
181
+ example_kwargs: dict[str, Any] = {}
182
+
183
+ for param_name, param in sig.parameters.items():
184
+ if param_name in ("self", "cls"):
185
+ continue
186
+
187
+ # Get type annotation
188
+ param_type = type_hints.get(param_name, Any)
189
+
190
+ # Handle Optional types and defaults
191
+ has_default = param.default is not inspect.Parameter.empty
192
+ default_value = param.default if has_default else ...
193
+
194
+ # Create example value based on type
195
+ if param_type is int:
196
+ example_kwargs[param_name] = 1 if not has_default else default_value
197
+ elif param_type is float:
198
+ example_kwargs[param_name] = 1.0 if not has_default else default_value
199
+ elif param_type is str:
200
+ example_kwargs[param_name] = "example" if not has_default else default_value
201
+ elif param_type is bool:
202
+ example_kwargs[param_name] = True if not has_default else default_value
203
+ elif has_default:
204
+ example_kwargs[param_name] = default_value
205
+
206
+ # Make field optional if it has a default
207
+ if has_default:
208
+ field_definitions[param_name] = (
209
+ param_type | None,
210
+ Field(default=default_value, description=f"Parameter: {param_name}"),
211
+ )
212
+ else:
213
+ field_definitions[param_name] = (
214
+ param_type,
215
+ Field(description=f"Parameter: {param_name} (required)"),
216
+ )
217
+
218
+ # Add Celery options fields
219
+ field_definitions.update(CELERY_OPTIONS_FIELDS)
220
+
221
+ # Create the model class name
222
+ model_name = f"{task_name.replace('.', '_').title().replace('_', '')}Payload"
223
+
224
+ # Create and return the dynamic model
225
+ model: type[BaseModel] = create_model(model_name, **field_definitions) # type: ignore[call-overload]
226
+ model.__doc__ = f"Payload for {task_name} task. Default queue: {default_queue}"
227
+
228
+ # Set model config for examples
229
+ if example_kwargs:
230
+ model.model_config = {"json_schema_extra": {"examples": [example_kwargs]}}
231
+
232
+ return model
233
+
234
+
235
+ class TaskResponse(BaseModel):
236
+ """Response model for task submission."""
237
+
238
+ task_id: str = Field(description="Unique identifier for the submitted task")
239
+ status: str = Field(default="PENDING", description="Initial task status")
240
+
241
+
242
+ class TaskStatusResponse(BaseModel):
243
+ """Response model for task status."""
244
+
245
+ task_id: str
246
+ state: str
247
+ result: Any | None = None
248
+ traceback: str | None = None
249
+ date_done: datetime | None = None
250
+ info: dict[str, Any] | None = None
251
+
252
+
253
+ class TaskListResponse(BaseModel):
254
+ """Response model for listing all tasks."""
255
+
256
+ active: dict[str, list[dict[str, Any]]]
257
+ scheduled: dict[str, list[dict[str, Any]]]
258
+ reserved: dict[str, list[dict[str, Any]]]
259
+ revoked: dict[str, list[str]]
260
+
261
+
262
+ class TaskRevokePayload(BaseModel):
263
+ """Payload for revoking tasks."""
264
+
265
+ terminate: bool = Field(
266
+ default=False, description="Terminate the task if currently executing"
267
+ )
268
+ signal: str = Field(
269
+ default="SIGTERM", description="Signal to send (SIGTERM, SIGKILL)"
270
+ )
271
+
272
+
273
+ class CeleryFastAPIBridge:
274
+ """
275
+ Bridge class that connects Celery tasks to FastAPI endpoints.
276
+
277
+ This class automatically generates REST API endpoints for all registered
278
+ Celery tasks, providing a seamless way to trigger and monitor tasks via HTTP.
279
+
280
+ Important: This bridge only exposes and manages tasks registered in the
281
+ provided Celery app instance, not tasks from the entire cluster.
282
+
283
+ Example:
284
+ ```python
285
+ from celery import Celery
286
+ from fastapi import FastAPI
287
+ from celery_fastapi import CeleryFastAPIBridge
288
+
289
+ celery_app = Celery('tasks', broker='redis://localhost:6379/0')
290
+ fastapi_app = FastAPI()
291
+
292
+ bridge = CeleryFastAPIBridge(celery_app, fastapi_app)
293
+ bridge.register_routes()
294
+ ```
295
+ """
296
+
297
+ def __init__(
298
+ self,
299
+ celery_app: Celery,
300
+ fastapi_app: FastAPI | None = None,
301
+ *,
302
+ prefix: str = "",
303
+ include_status_endpoints: bool = True,
304
+ task_filter: Callable[[str], bool] | None = None,
305
+ ) -> None:
306
+ """
307
+ Initialize the Celery FastAPI Bridge.
308
+
309
+ Args:
310
+ celery_app: The Celery application instance.
311
+ fastapi_app: Optional FastAPI application instance. If not provided,
312
+ a new one will be created.
313
+ prefix: URL prefix for all generated endpoints.
314
+ include_status_endpoints: Whether to include task status and listing endpoints.
315
+ task_filter: Optional callable to filter which tasks to expose.
316
+ Takes task name, returns True to include, False to exclude.
317
+ """
318
+ self.celery_app = celery_app
319
+ self.fastapi_app = fastapi_app or FastAPI()
320
+ self.prefix = prefix.rstrip("/")
321
+ self.include_status_endpoints = include_status_endpoints
322
+ self.task_filter = task_filter or (lambda name: not name.startswith("celery."))
323
+ self._registered = False
324
+
325
+ # Store the registered task names from THIS app only
326
+ self._app_task_names: set[str] = set()
327
+ for name in self.celery_app.tasks.keys():
328
+ if self.task_filter(name):
329
+ self._app_task_names.add(name)
330
+
331
+ def register_routes(self) -> FastAPI:
332
+ """
333
+ Register all Celery task routes on the FastAPI application.
334
+
335
+ Returns:
336
+ The FastAPI application with registered routes.
337
+ """
338
+ if self._registered:
339
+ return self.fastapi_app
340
+
341
+ self._register_task_endpoints()
342
+
343
+ if self.include_status_endpoints:
344
+ self._register_status_endpoints()
345
+
346
+ self._registered = True
347
+ return self.fastapi_app
348
+
349
+ def _register_task_endpoints(self) -> None:
350
+ """Register POST endpoints for each Celery task."""
351
+ # Get the default queue name from Celery config (defaults to 'celery')
352
+ default_queue = self.celery_app.conf.task_default_queue or "celery"
353
+
354
+ for name, task in self.celery_app.tasks.items():
355
+ if not self.task_filter(name):
356
+ continue
357
+
358
+ queue_name = getattr(task, "queue", None) or default_queue
359
+ endpoint = name.replace(".", "/")
360
+ route_path = f"{self.prefix}/{endpoint}"
361
+
362
+ # Create endpoint handler with proper closure
363
+ self._create_task_endpoint(name, queue_name, route_path)
364
+
365
+ def _create_task_endpoint(
366
+ self, task_name: str, queue_name: str, route_path: str
367
+ ) -> None:
368
+ """Create a POST endpoint for a specific task with custom payload model."""
369
+ # Get the task function to inspect its signature
370
+ task = self.celery_app.tasks.get(task_name)
371
+ task_func = getattr(task, "run", None) if task else None
372
+
373
+ # Create a custom payload model for this task
374
+ if task_func:
375
+ PayloadModel = _create_task_payload_model(task_name, task_func, queue_name)
376
+ else:
377
+ # Fallback to generic model if we can't inspect the task
378
+ PayloadModel = GenericTaskPayload
379
+
380
+ # Create the endpoint handler
381
+ async def run_task(
382
+ payload: PayloadModel, # type: ignore[valid-type]
383
+ task_name_override: str | None = Query(
384
+ default=None,
385
+ alias="_task_name",
386
+ description=f"Override task name (default: {task_name})",
387
+ ),
388
+ queue_override: str | None = Query(
389
+ default=None,
390
+ alias="_queue",
391
+ description=f"Override queue (default: {queue_name})",
392
+ ),
393
+ ) -> TaskResponse:
394
+ """Execute a Celery task asynchronously."""
395
+ # Determine actual task name and queue
396
+ actual_task_name = task_name_override or task_name
397
+ actual_queue = (
398
+ queue_override or getattr(payload, "queue", None) or queue_name
399
+ )
400
+
401
+ # Extract task arguments from payload
402
+ # Get all field names that are task parameters (not Celery options)
403
+ celery_option_names = set(CELERY_OPTIONS_FIELDS.keys())
404
+ task_kwargs: dict[str, Any] = {}
405
+
406
+ for field_name in payload.model_fields:
407
+ if field_name not in celery_option_names:
408
+ value = getattr(payload, field_name, None)
409
+ if value is not None:
410
+ task_kwargs[field_name] = value
411
+
412
+ # Build send_task options
413
+ send_options: dict[str, Any] = {
414
+ "args": [],
415
+ "kwargs": task_kwargs,
416
+ "queue": actual_queue,
417
+ }
418
+
419
+ # Add Celery options if set
420
+ for opt_name in celery_option_names:
421
+ value = getattr(payload, opt_name, None)
422
+ if value is not None and opt_name != "queue": # queue handled above
423
+ send_options[opt_name] = value
424
+
425
+ result = self.celery_app.send_task(actual_task_name, **send_options)
426
+ return TaskResponse(task_id=result.id, status="PENDING")
427
+
428
+ # Set a descriptive name for the endpoint
429
+ run_task.__name__ = f"run_{task_name.replace('.', '_')}"
430
+ run_task.__doc__ = f"Execute '{task_name}' task. Default queue: '{queue_name}'."
431
+
432
+ self.fastapi_app.post(
433
+ route_path,
434
+ response_model=TaskResponse,
435
+ tags=["tasks"],
436
+ summary=f"Run {task_name}",
437
+ description=f"Submit '{task_name}' task for async execution.\n\nDefault queue: `{queue_name}`\n\nUse `_task_name` and `_queue` query params to override.",
438
+ )(run_task)
439
+
440
+ def _register_status_endpoints(self) -> None:
441
+ """Register task status, listing, and control endpoints."""
442
+
443
+ @self.fastapi_app.get(
444
+ f"{self.prefix}/tasks/{{task_id}}",
445
+ response_model=TaskStatusResponse,
446
+ tags=["task-status"],
447
+ summary="Get task status",
448
+ )
449
+ async def get_task_status(task_id: str) -> TaskStatusResponse:
450
+ """
451
+ Get the status of a specific task by its ID.
452
+
453
+ Returns detailed information including state, result, and traceback.
454
+
455
+ Raises:
456
+ HTTPException: 404 if the task is not found.
457
+ """
458
+ result = AsyncResult(task_id, app=self.celery_app)
459
+
460
+ if result.state == "PENDING":
461
+ raise HTTPException(
462
+ status_code=404, detail=f"Task '{task_id}' not found"
463
+ )
464
+
465
+ # Build info dict with additional metadata
466
+ info: dict[str, Any] = {}
467
+ if hasattr(result, "info") and result.info:
468
+ if isinstance(result.info, dict):
469
+ info = result.info
470
+ elif isinstance(result.info, Exception):
471
+ info = {"error": str(result.info)}
472
+
473
+ return TaskStatusResponse(
474
+ task_id=task_id,
475
+ state=result.state,
476
+ result=result.result if result.ready() else None,
477
+ traceback=result.traceback,
478
+ date_done=result.date_done,
479
+ info=info if info else None,
480
+ )
481
+
482
+ @self.fastapi_app.delete(
483
+ f"{self.prefix}/tasks/{{task_id}}",
484
+ tags=["task-status"],
485
+ summary="Revoke a task",
486
+ )
487
+ async def revoke_task(
488
+ task_id: str,
489
+ payload: TaskRevokePayload | None = None,
490
+ ) -> dict[str, str]:
491
+ """
492
+ Revoke a pending or running task.
493
+
494
+ Args:
495
+ task_id: The task ID to revoke.
496
+ payload: Optional revoke options (terminate, signal).
497
+
498
+ Returns:
499
+ Confirmation of revocation request.
500
+ """
501
+ payload = payload or TaskRevokePayload()
502
+ self.celery_app.control.revoke(
503
+ task_id,
504
+ terminate=payload.terminate,
505
+ signal=payload.signal,
506
+ )
507
+ return {"status": "revoked", "task_id": task_id}
508
+
509
+ @self.fastapi_app.get(
510
+ f"{self.prefix}/tasks/{{task_id}}/result",
511
+ tags=["task-status"],
512
+ summary="Get task result",
513
+ )
514
+ async def get_task_result(task_id: str, timeout: float | None = None) -> Any:
515
+ """
516
+ Get the result of a completed task.
517
+
518
+ Args:
519
+ task_id: The task ID.
520
+ timeout: Optional timeout in seconds to wait for result.
521
+
522
+ Returns:
523
+ The task result.
524
+
525
+ Raises:
526
+ HTTPException: 404 if not found, 202 if not ready, 500 on failure.
527
+ """
528
+ result = AsyncResult(task_id, app=self.celery_app)
529
+
530
+ if result.state == "PENDING":
531
+ raise HTTPException(
532
+ status_code=404, detail=f"Task '{task_id}' not found"
533
+ )
534
+
535
+ if not result.ready():
536
+ if timeout:
537
+ try:
538
+ return result.get(timeout=timeout)
539
+ except TimeoutError as exc:
540
+ raise HTTPException(
541
+ status_code=202,
542
+ detail=f"Task '{task_id}' not ready within timeout",
543
+ ) from exc
544
+ raise HTTPException(
545
+ status_code=202,
546
+ detail=f"Task '{task_id}' is still {result.state}",
547
+ )
548
+
549
+ if result.failed():
550
+ raise HTTPException(
551
+ status_code=500,
552
+ detail=f"Task '{task_id}' failed: {result.traceback}",
553
+ )
554
+
555
+ return result.result
556
+
557
+ @self.fastapi_app.get(
558
+ f"{self.prefix}/tasks",
559
+ response_model=TaskListResponse,
560
+ tags=["task-status"],
561
+ summary="List all tasks",
562
+ )
563
+ async def list_all_tasks() -> TaskListResponse:
564
+ """
565
+ List active, scheduled, reserved, and revoked tasks for THIS app only.
566
+
567
+ Only shows tasks that are registered in this Celery application,
568
+ filtering out tasks from other apps in the cluster.
569
+ """
570
+ inspector = self.celery_app.control.inspect()
571
+
572
+ # Helper to filter tasks by this app's registered task names
573
+ def filter_tasks(
574
+ tasks_by_worker: dict[str, list[dict[str, Any]]] | None,
575
+ ) -> dict[str, list[dict[str, Any]]]:
576
+ if not tasks_by_worker:
577
+ return {}
578
+ filtered: dict[str, list[dict[str, Any]]] = {}
579
+ for worker, task_list in tasks_by_worker.items():
580
+ filtered_list = [
581
+ t for t in task_list if t.get("name") in self._app_task_names
582
+ ]
583
+ if filtered_list:
584
+ filtered[worker] = filtered_list
585
+ return filtered
586
+
587
+ return TaskListResponse(
588
+ active=filter_tasks(inspector.active()),
589
+ scheduled=filter_tasks(inspector.scheduled()),
590
+ reserved=filter_tasks(inspector.reserved()),
591
+ revoked=inspector.revoked()
592
+ or {}, # Revoked is just task IDs, can't filter
593
+ )
594
+
595
+ @self.fastapi_app.get(
596
+ f"{self.prefix}/workers",
597
+ tags=["workers"],
598
+ summary="List workers",
599
+ )
600
+ async def list_workers() -> dict[str, Any]:
601
+ """
602
+ Get information about Celery workers that can execute THIS app's tasks.
603
+
604
+ Filters registered tasks to only show tasks from this application.
605
+ """
606
+ inspector = self.celery_app.control.inspect()
607
+
608
+ # Filter registered tasks to only show this app's tasks
609
+ registered = inspector.registered() or {}
610
+ filtered_registered: dict[str, list[str]] = {}
611
+ for worker, tasks in registered.items():
612
+ filtered_tasks = [t for t in tasks if t in self._app_task_names]
613
+ if filtered_tasks:
614
+ filtered_registered[worker] = filtered_tasks
615
+
616
+ return {
617
+ "ping": inspector.ping() or {},
618
+ "stats": inspector.stats() or {},
619
+ "registered": filtered_registered,
620
+ "active_queues": inspector.active_queues() or {},
621
+ }
622
+
623
+ @self.fastapi_app.get(
624
+ f"{self.prefix}/available-tasks",
625
+ tags=["tasks"],
626
+ summary="List available tasks",
627
+ )
628
+ async def list_available_tasks() -> dict[str, Any]:
629
+ """
630
+ List all tasks available in THIS Celery application.
631
+
632
+ Returns the task names and their configuration (queue, etc.)
633
+ that are registered in this specific app instance.
634
+ """
635
+ # Get the default queue name from Celery config
636
+ default_queue = self.celery_app.conf.task_default_queue or "celery"
637
+
638
+ tasks_info: list[dict[str, Any]] = []
639
+ for name in sorted(self._app_task_names):
640
+ task = self.celery_app.tasks.get(name)
641
+ if task:
642
+ tasks_info.append(
643
+ {
644
+ "name": name,
645
+ "queue": getattr(task, "queue", None) or default_queue,
646
+ "rate_limit": getattr(task, "rate_limit", None),
647
+ "time_limit": getattr(task, "time_limit", None),
648
+ "soft_time_limit": getattr(task, "soft_time_limit", None),
649
+ "max_retries": getattr(task, "max_retries", None),
650
+ "default_retry_delay": getattr(
651
+ task, "default_retry_delay", None
652
+ ),
653
+ }
654
+ )
655
+
656
+ return {
657
+ "app_name": self.celery_app.main,
658
+ "task_count": len(tasks_info),
659
+ "tasks": tasks_info,
660
+ }
661
+
662
+ @self.fastapi_app.get(
663
+ f"{self.prefix}/queues",
664
+ tags=["workers"],
665
+ summary="List active queues",
666
+ )
667
+ async def list_queues() -> dict[str, Any]:
668
+ """Get information about active queues."""
669
+ inspector = self.celery_app.control.inspect()
670
+ return {"queues": inspector.active_queues() or {}}
671
+
672
+ @self.fastapi_app.post(
673
+ f"{self.prefix}/purge",
674
+ tags=["workers"],
675
+ summary="Purge all tasks",
676
+ )
677
+ async def purge_tasks() -> dict[str, int]:
678
+ """Purge all pending tasks from the queue."""
679
+ count = self.celery_app.control.purge()
680
+ return {"purged": count or 0}
681
+
682
+ @self.fastapi_app.post(
683
+ f"{self.prefix}/trigger",
684
+ response_model=TaskResponse,
685
+ tags=["tasks"],
686
+ summary="Trigger any task",
687
+ )
688
+ async def trigger_generic_task(payload: GenericTaskPayload) -> TaskResponse:
689
+ """
690
+ Trigger any Celery task by name.
691
+
692
+ This endpoint allows triggering any task in the cluster,
693
+ not just tasks registered in this application.
694
+
695
+ Useful for:
696
+ - Triggering tasks from other applications
697
+ - Dynamic task invocation
698
+ - Testing and debugging
699
+
700
+ Note: queue is required - you must specify which queue to send the task to.
701
+ """
702
+ # Build send_task options - queue is required in GenericTaskPayload
703
+ send_options: dict[str, Any] = {
704
+ "args": payload.args,
705
+ "kwargs": payload.kwargs,
706
+ "queue": payload.queue,
707
+ }
708
+
709
+ # Add optional Celery parameters
710
+ if payload.countdown is not None:
711
+ send_options["countdown"] = payload.countdown
712
+ if payload.eta is not None:
713
+ send_options["eta"] = payload.eta
714
+ if payload.expires is not None:
715
+ send_options["expires"] = payload.expires
716
+ if payload.retry is not None:
717
+ send_options["retry"] = payload.retry
718
+ if payload.retry_policy is not None:
719
+ send_options["retry_policy"] = payload.retry_policy
720
+ if payload.exchange is not None:
721
+ send_options["exchange"] = payload.exchange
722
+ if payload.routing_key is not None:
723
+ send_options["routing_key"] = payload.routing_key
724
+ if payload.priority is not None:
725
+ send_options["priority"] = payload.priority
726
+ if payload.serializer is not None:
727
+ send_options["serializer"] = payload.serializer
728
+ if payload.compression is not None:
729
+ send_options["compression"] = payload.compression
730
+ if payload.headers is not None:
731
+ send_options["headers"] = payload.headers
732
+ if payload.task_id is not None:
733
+ send_options["task_id"] = payload.task_id
734
+ if payload.ignore_result is not None:
735
+ send_options["ignore_result"] = payload.ignore_result
736
+ if payload.time_limit is not None:
737
+ send_options["time_limit"] = payload.time_limit
738
+ if payload.soft_time_limit is not None:
739
+ send_options["soft_time_limit"] = payload.soft_time_limit
740
+
741
+ result = self.celery_app.send_task(payload.task_name, **send_options)
742
+ return TaskResponse(task_id=result.id, status="PENDING")
743
+
744
+ def get_registered_routes(self) -> list[dict[str, str]]:
745
+ """
746
+ Get a list of all registered routes.
747
+
748
+ Returns:
749
+ List of dictionaries containing path and method for each route.
750
+ """
751
+ routes: list[dict[str, str]] = []
752
+ for route in self.fastapi_app.routes:
753
+ if hasattr(route, "path") and hasattr(route, "methods"):
754
+ path = getattr(route, "path")
755
+ methods = getattr(route, "methods")
756
+ for method in methods:
757
+ if method != "HEAD":
758
+ routes.append({"path": path, "method": method})
759
+ return routes