celery-fastapi 0.1.2__tar.gz → 0.1.4__tar.gz

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.
File without changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: celery-fastapi
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Automatic REST API generation for Celery tasks with FastAPI
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -35,7 +35,7 @@ Provides-Extra: server
35
35
  Provides-Extra: standard
36
36
  Provides-Extra: ujson
37
37
  Provides-Extra: uvicorn
38
- Requires-Dist: celery (>=5.3.0)
38
+ Requires-Dist: celery (>=5.3.0,<=5.6.3)
39
39
  Requires-Dist: eventlet (>=0.33.0) ; extra == "eventlet"
40
40
  Requires-Dist: fastapi (>=0.100.0)
41
41
  Requires-Dist: gevent (>=23.0.0) ; extra == "gevent"
@@ -60,8 +60,12 @@ Description-Content-Type: text/markdown
60
60
  [![CI](https://github.com/karailker/celery-fastapi/actions/workflows/ci.yml/badge.svg)](https://github.com/karailker/celery-fastapi/actions/workflows/ci.yml)
61
61
  [![PyPI version](https://badge.fury.io/py/celery-fastapi.svg)](https://badge.fury.io/py/celery-fastapi)
62
62
  [![Python Version](https://img.shields.io/pypi/pyversions/celery-fastapi.svg)](https://pypi.org/project/celery-fastapi/)
63
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/celery-fastapi?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/celery-fastapi)
63
64
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
64
65
 
66
+ ![GitHub Repo stars](https://img.shields.io/github/stars/karailker/celery-fastapi)
67
+ <!-- ![GitHub forks](https://img.shields.io/github/forks/karailker/celery-fastapi) -->
68
+
65
69
  Automatic REST API generation for Celery tasks with FastAPI. This package seamlessly bridges Celery and FastAPI, automatically creating REST endpoints for all your registered Celery tasks.
66
70
 
67
71
  ## Features
@@ -77,6 +81,9 @@ Automatic REST API generation for Celery tasks with FastAPI. This package seamle
77
81
  - 🔒 **Production ready** - Full uvicorn/gunicorn support with SSL, workers, and all options
78
82
  - ⚡ **Full Celery options** - All task options (countdown, eta, priority, etc.)
79
83
  - 🔌 **Pool support** - Compatible with eventlet, gevent, prefork, and solo pools
84
+ - 🧮 **Batch execution** - Submit groups of tasks in a single request via `/tasks/batch`
85
+ - 🛡️ **Input validation** - Pydantic-driven validation on task_name/queue at trust boundary
86
+ - 🔌 **WebSocket streaming** - Live task status updates via `/tasks/{task_id}/ws`
80
87
 
81
88
  ## Requirements
82
89
 
@@ -160,25 +167,29 @@ uvicorn myapp:app --reload
160
167
 
161
168
  ```bash
162
169
  # Start the server (development)
163
- celery-fastapi serve myapp.celery:celery_app --port 8000 --reload
170
+ celery-fastapi serve examples.celery_app:celery_app --port 8000 --reload
164
171
 
165
172
  # Production with multiple workers
166
- celery-fastapi serve myapp.celery:celery_app -w 4 --host 0.0.0.0
173
+ celery-fastapi serve examples.celery_app:celery_app -w 4 --host 0.0.0.0
174
+
175
+ # With custom worker hostname (for health checks)
176
+ export CELERY_WORKER_HOSTNAME="celery@worker1"
177
+ celery-fastapi serve examples.celery_app:celery_app --port 8000
167
178
 
168
179
  # With SSL
169
- celery-fastapi serve myapp.celery:celery_app --ssl-keyfile key.pem --ssl-certfile cert.pem
180
+ celery-fastapi serve examples.celery_app:celery_app --ssl-keyfile key.pem --ssl-certfile cert.pem
170
181
 
171
182
  # Using gunicorn (production)
172
- celery-fastapi serve-gunicorn myapp.celery:celery_app -w 4 -k uvicorn.workers.UvicornWorker
183
+ celery-fastapi serve-gunicorn examples.celery_app:celery_app -w 4 -k uvicorn.workers.UvicornWorker
173
184
 
174
185
  # List available routes
175
- celery-fastapi routes myapp.celery:celery_app
186
+ celery-fastapi routes examples.celery_app:celery_app
176
187
 
177
188
  # List registered tasks
178
- celery-fastapi tasks myapp.celery:celery_app
189
+ celery-fastapi tasks examples.celery_app:celery_app
179
190
 
180
191
  # Show active workers
181
- celery-fastapi workers myapp.celery:celery_app
192
+ celery-fastapi workers examples.celery_app:celery_app
182
193
  ```
183
194
 
184
195
  ## API Endpoints
@@ -272,6 +283,44 @@ GET /queues
272
283
  POST /purge
273
284
  ```
274
285
 
286
+ ### Health Check and Monitoring
287
+
288
+ ```bash
289
+ # Health check for local Celery worker
290
+ GET /healthz
291
+
292
+ # Response
293
+ {
294
+ "status": "healthy",
295
+ "celery_app": "example_tasks",
296
+ "broker_connected": true,
297
+ "worker_hostname": "celery@worker1",
298
+ "worker_online": true
299
+ }
300
+
301
+ # Ping local Celery worker
302
+ GET /ping
303
+
304
+ # Response
305
+ {
306
+ "worker_hostname": "celery@worker1",
307
+ "online": true,
308
+ "response": {"ok": "pong"}
309
+ }
310
+ ```
311
+
312
+ **Note:** Health and ping endpoints automatically discover the local worker using:
313
+ 1. `CELERY_WORKER_HOSTNAME` environment variable (recommended for custom hostnames)
314
+ 2. Hostname matching (when worker and API share the same hostname)
315
+ 3. Single worker fallback (when only one worker has this app's tasks)
316
+
317
+ **For custom worker hostnames**, set the environment variable:
318
+ ```bash
319
+ export CELERY_WORKER_HOSTNAME="celery@worker1"
320
+ celery -A examples.celery_app worker --hostname worker1
321
+ celery-fastapi serve examples.celery_app:celery_app --port 8000
322
+ ```
323
+
275
324
  ### List All Tasks
276
325
 
277
326
  ```bash
@@ -351,7 +400,7 @@ Commands:
351
400
  workers Show active Celery workers
352
401
 
353
402
  # Serve options (uvicorn)
354
- celery-fastapi serve myapp:celery_app \
403
+ celery-fastapi serve examples.celery_app:celery_app \
355
404
  --host 0.0.0.0 \
356
405
  --port 8000 \
357
406
  --reload \
@@ -364,7 +413,7 @@ celery-fastapi serve myapp:celery_app \
364
413
  --forwarded-allow-ips '*'
365
414
 
366
415
  # Serve options (gunicorn)
367
- celery-fastapi serve-gunicorn myapp:celery_app \
416
+ celery-fastapi serve-gunicorn examples.celery_app:celery_app \
368
417
  --bind 0.0.0.0:8000 \
369
418
  --workers 4 \
370
419
  --worker-class uvicorn.workers.UvicornWorker \
@@ -3,8 +3,12 @@
3
3
  [![CI](https://github.com/karailker/celery-fastapi/actions/workflows/ci.yml/badge.svg)](https://github.com/karailker/celery-fastapi/actions/workflows/ci.yml)
4
4
  [![PyPI version](https://badge.fury.io/py/celery-fastapi.svg)](https://badge.fury.io/py/celery-fastapi)
5
5
  [![Python Version](https://img.shields.io/pypi/pyversions/celery-fastapi.svg)](https://pypi.org/project/celery-fastapi/)
6
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/celery-fastapi?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/celery-fastapi)
6
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
8
 
9
+ ![GitHub Repo stars](https://img.shields.io/github/stars/karailker/celery-fastapi)
10
+ <!-- ![GitHub forks](https://img.shields.io/github/forks/karailker/celery-fastapi) -->
11
+
8
12
  Automatic REST API generation for Celery tasks with FastAPI. This package seamlessly bridges Celery and FastAPI, automatically creating REST endpoints for all your registered Celery tasks.
9
13
 
10
14
  ## Features
@@ -20,6 +24,9 @@ Automatic REST API generation for Celery tasks with FastAPI. This package seamle
20
24
  - 🔒 **Production ready** - Full uvicorn/gunicorn support with SSL, workers, and all options
21
25
  - ⚡ **Full Celery options** - All task options (countdown, eta, priority, etc.)
22
26
  - 🔌 **Pool support** - Compatible with eventlet, gevent, prefork, and solo pools
27
+ - 🧮 **Batch execution** - Submit groups of tasks in a single request via `/tasks/batch`
28
+ - 🛡️ **Input validation** - Pydantic-driven validation on task_name/queue at trust boundary
29
+ - 🔌 **WebSocket streaming** - Live task status updates via `/tasks/{task_id}/ws`
23
30
 
24
31
  ## Requirements
25
32
 
@@ -103,25 +110,29 @@ uvicorn myapp:app --reload
103
110
 
104
111
  ```bash
105
112
  # Start the server (development)
106
- celery-fastapi serve myapp.celery:celery_app --port 8000 --reload
113
+ celery-fastapi serve examples.celery_app:celery_app --port 8000 --reload
107
114
 
108
115
  # Production with multiple workers
109
- celery-fastapi serve myapp.celery:celery_app -w 4 --host 0.0.0.0
116
+ celery-fastapi serve examples.celery_app:celery_app -w 4 --host 0.0.0.0
117
+
118
+ # With custom worker hostname (for health checks)
119
+ export CELERY_WORKER_HOSTNAME="celery@worker1"
120
+ celery-fastapi serve examples.celery_app:celery_app --port 8000
110
121
 
111
122
  # With SSL
112
- celery-fastapi serve myapp.celery:celery_app --ssl-keyfile key.pem --ssl-certfile cert.pem
123
+ celery-fastapi serve examples.celery_app:celery_app --ssl-keyfile key.pem --ssl-certfile cert.pem
113
124
 
114
125
  # Using gunicorn (production)
115
- celery-fastapi serve-gunicorn myapp.celery:celery_app -w 4 -k uvicorn.workers.UvicornWorker
126
+ celery-fastapi serve-gunicorn examples.celery_app:celery_app -w 4 -k uvicorn.workers.UvicornWorker
116
127
 
117
128
  # List available routes
118
- celery-fastapi routes myapp.celery:celery_app
129
+ celery-fastapi routes examples.celery_app:celery_app
119
130
 
120
131
  # List registered tasks
121
- celery-fastapi tasks myapp.celery:celery_app
132
+ celery-fastapi tasks examples.celery_app:celery_app
122
133
 
123
134
  # Show active workers
124
- celery-fastapi workers myapp.celery:celery_app
135
+ celery-fastapi workers examples.celery_app:celery_app
125
136
  ```
126
137
 
127
138
  ## API Endpoints
@@ -215,6 +226,44 @@ GET /queues
215
226
  POST /purge
216
227
  ```
217
228
 
229
+ ### Health Check and Monitoring
230
+
231
+ ```bash
232
+ # Health check for local Celery worker
233
+ GET /healthz
234
+
235
+ # Response
236
+ {
237
+ "status": "healthy",
238
+ "celery_app": "example_tasks",
239
+ "broker_connected": true,
240
+ "worker_hostname": "celery@worker1",
241
+ "worker_online": true
242
+ }
243
+
244
+ # Ping local Celery worker
245
+ GET /ping
246
+
247
+ # Response
248
+ {
249
+ "worker_hostname": "celery@worker1",
250
+ "online": true,
251
+ "response": {"ok": "pong"}
252
+ }
253
+ ```
254
+
255
+ **Note:** Health and ping endpoints automatically discover the local worker using:
256
+ 1. `CELERY_WORKER_HOSTNAME` environment variable (recommended for custom hostnames)
257
+ 2. Hostname matching (when worker and API share the same hostname)
258
+ 3. Single worker fallback (when only one worker has this app's tasks)
259
+
260
+ **For custom worker hostnames**, set the environment variable:
261
+ ```bash
262
+ export CELERY_WORKER_HOSTNAME="celery@worker1"
263
+ celery -A examples.celery_app worker --hostname worker1
264
+ celery-fastapi serve examples.celery_app:celery_app --port 8000
265
+ ```
266
+
218
267
  ### List All Tasks
219
268
 
220
269
  ```bash
@@ -294,7 +343,7 @@ Commands:
294
343
  workers Show active Celery workers
295
344
 
296
345
  # Serve options (uvicorn)
297
- celery-fastapi serve myapp:celery_app \
346
+ celery-fastapi serve examples.celery_app:celery_app \
298
347
  --host 0.0.0.0 \
299
348
  --port 8000 \
300
349
  --reload \
@@ -307,7 +356,7 @@ celery-fastapi serve myapp:celery_app \
307
356
  --forwarded-allow-ips '*'
308
357
 
309
358
  # Serve options (gunicorn)
310
- celery-fastapi serve-gunicorn myapp:celery_app \
359
+ celery-fastapi serve-gunicorn examples.celery_app:celery_app \
311
360
  --bind 0.0.0.0:8000 \
312
361
  --workers 4 \
313
362
  --worker-class uvicorn.workers.UvicornWorker \
@@ -14,7 +14,7 @@ from celery_fastapi.core import (
14
14
  TaskStatusResponse,
15
15
  )
16
16
 
17
- __version__ = "0.1.0"
17
+ __version__ = "0.1.4"
18
18
  __all__ = [
19
19
  "CeleryFastAPIBridge",
20
20
  "create_app",
@@ -85,6 +85,7 @@ def create_app(
85
85
  prefix: str = "",
86
86
  include_status_endpoints: bool = True,
87
87
  fastapi_kwargs: dict[str, Any] | None = None,
88
+ rate_limit: int | None = None,
88
89
  ) -> FastAPI:
89
90
  """
90
91
  Create a FastAPI application with Celery task endpoints.
@@ -143,6 +144,7 @@ def create_app(
143
144
  fastapi_app=fastapi_app,
144
145
  prefix=prefix,
145
146
  include_status_endpoints=include_status_endpoints,
147
+ rate_limit=rate_limit,
146
148
  )
147
149
 
148
150
  # Register all routes
@@ -799,7 +799,7 @@ def workers(
799
799
 
800
800
  @app.callback()
801
801
  def main(
802
- version: Annotated[
802
+ version: Annotated[ # noqa: ARG001
803
803
  bool | None,
804
804
  typer.Option(
805
805
  "--version",
@@ -816,7 +816,6 @@ def main(
816
816
  Generate FastAPI endpoints for your Celery tasks automatically.
817
817
  Supports uvicorn and gunicorn for production deployment.
818
818
  """
819
- pass
820
819
 
821
820
 
822
821
  if __name__ == "__main__":
@@ -1,13 +1,24 @@
1
1
  """Core functionality for Celery FastAPI."""
2
2
 
3
+ import asyncio
3
4
  import inspect
5
+ import time
6
+ from collections import defaultdict, deque
7
+ from collections.abc import Callable
4
8
  from datetime import datetime
5
- from typing import Any, Callable, get_type_hints
9
+ from typing import Any, get_type_hints
6
10
 
7
11
  from celery import Celery
8
12
  from celery.result import AsyncResult
9
- from fastapi import FastAPI, HTTPException, Query
10
- from pydantic import BaseModel, Field, create_model
13
+ from fastapi import (
14
+ FastAPI,
15
+ HTTPException,
16
+ Query,
17
+ Request,
18
+ WebSocket,
19
+ WebSocketDisconnect,
20
+ )
21
+ from pydantic import BaseModel, Field, ValidationInfo, create_model, field_validator
11
22
 
12
23
  # Celery execution options - shared fields for all task payloads
13
24
  CELERY_OPTIONS_FIELDS: dict[str, Any] = {
@@ -141,6 +152,20 @@ class GenericTaskPayload(BaseModel):
141
152
  }
142
153
  }
143
154
 
155
+ @field_validator("task_name", "queue")
156
+ @classmethod
157
+ def _validate_names(cls, value: str, info: ValidationInfo) -> str:
158
+ # Trust boundary: reject control chars / injection attempts.
159
+ if not value or not value.strip():
160
+ raise ValueError("must be a non-empty string")
161
+ if any(ord(c) < 0x20 for c in value):
162
+ raise ValueError("control characters are not allowed")
163
+ if not all(c.isalnum() or c in "._-" for c in value):
164
+ raise ValueError("only alphanumeric, '.', '_', '-' allowed")
165
+ if info.field_name == "task_name" and len(value) > 255:
166
+ raise ValueError("task_name exceeds 255 character limit")
167
+ return value
168
+
144
169
 
145
170
  def _python_type_to_json_type(py_type: type) -> str:
146
171
  """Convert Python type to JSON schema type string."""
@@ -222,7 +247,7 @@ def _create_task_payload_model(
222
247
  model_name = f"{task_name.replace('.', '_').title().replace('_', '')}Payload"
223
248
 
224
249
  # Create and return the dynamic model
225
- model: type[BaseModel] = create_model(model_name, **field_definitions) # type: ignore[call-overload]
250
+ model: type[BaseModel] = create_model(model_name, **field_definitions)
226
251
  model.__doc__ = f"Payload for {task_name} task. Default queue: {default_queue}"
227
252
 
228
253
  # Set model config for examples
@@ -270,6 +295,95 @@ class TaskRevokePayload(BaseModel):
270
295
  )
271
296
 
272
297
 
298
+ class BatchTaskItem(BaseModel):
299
+ """A single task to execute as part of a batch."""
300
+
301
+ task_name: str = Field(description="Full task name (e.g., 'myapp.tasks.add')")
302
+ queue: str | None = Field(default=None, description="Queue override")
303
+ args: list[Any] = Field(
304
+ default_factory=list, description="Positional arguments for the task"
305
+ )
306
+ kwargs: dict[str, Any] = Field(
307
+ default_factory=dict, description="Keyword arguments for the task"
308
+ )
309
+ countdown: float | None = Field(default=None, description="Seconds to wait")
310
+
311
+
312
+ class BatchTaskRequest(BaseModel):
313
+ """Payload for batch task execution."""
314
+
315
+ tasks: list[BatchTaskItem] = Field(
316
+ description="List of tasks to execute as a group"
317
+ )
318
+
319
+
320
+ class BatchTaskResponse(BaseModel):
321
+ """Response model for batch task submission."""
322
+
323
+ group_id: str = Field(description="Group ID for the submitted batch")
324
+ task_ids: list[str] = Field(description="Individual task IDs in the group")
325
+ task_count: int = Field(description="Number of tasks submitted")
326
+ status: str = Field(default="PENDING", description="Group status")
327
+
328
+
329
+ class HealthCheckResponse(BaseModel):
330
+ """Response model for health check endpoint."""
331
+
332
+ status: str = Field(description="Overall health status (healthy/unhealthy)")
333
+ celery_app: str = Field(description="Celery application name")
334
+ broker_connected: bool = Field(description="Whether broker connection is active")
335
+ worker_hostname: str | None = Field(
336
+ default=None, description="Target worker hostname"
337
+ )
338
+ worker_online: bool = Field(description="Whether the target worker is online")
339
+
340
+
341
+ class PingResponse(BaseModel):
342
+ """Response model for worker ping endpoint."""
343
+
344
+ worker_hostname: str | None = Field(
345
+ default=None, description="Target worker hostname"
346
+ )
347
+ online: bool = Field(description="Whether the worker responded to ping")
348
+ response: dict[str, str] | None = Field(
349
+ default=None, description="Ping response from the worker"
350
+ )
351
+
352
+
353
+ class RateLimiter:
354
+ """Simple in-memory sliding-window rate limiter.
355
+
356
+ Tracks request counts per client key (defaults to client IP) within a
357
+ fixed time window. When the limit is exceeded, `check` raises HTTP 429.
358
+
359
+ ponytail: in-memory, single-process. Replace with Redis-backed limiter
360
+ (e.g., ``limits`` package) when running multiple uvicorn workers.
361
+ """
362
+
363
+ def __init__(self, limit: int, window_seconds: int = 60) -> None:
364
+ self.limit = limit
365
+ self.window_seconds = window_seconds
366
+ self._hits: dict[str, deque[float]] = defaultdict(deque)
367
+
368
+ def check(self, key: str) -> None:
369
+ """Record a hit for ``key``; raise HTTPException(429) if over limit."""
370
+ now = time.monotonic()
371
+ window = self._hits[key]
372
+ cutoff = now - self.window_seconds
373
+
374
+ # Drop hits outside the window
375
+ while window and window[0] < cutoff:
376
+ window.popleft()
377
+
378
+ if len(window) >= self.limit:
379
+ raise HTTPException(
380
+ status_code=429,
381
+ detail=f"Rate limit exceeded: {self.limit} requests per {self.window_seconds}s",
382
+ )
383
+
384
+ window.append(now)
385
+
386
+
273
387
  class CeleryFastAPIBridge:
274
388
  """
275
389
  Bridge class that connects Celery tasks to FastAPI endpoints.
@@ -302,6 +416,7 @@ class CeleryFastAPIBridge:
302
416
  prefix: str = "",
303
417
  include_status_endpoints: bool = True,
304
418
  task_filter: Callable[[str], bool] | None = None,
419
+ rate_limit: int | None = None,
305
420
  ) -> None:
306
421
  """
307
422
  Initialize the Celery FastAPI Bridge.
@@ -314,17 +429,20 @@ class CeleryFastAPIBridge:
314
429
  include_status_endpoints: Whether to include task status and listing endpoints.
315
430
  task_filter: Optional callable to filter which tasks to expose.
316
431
  Takes task name, returns True to include, False to exclude.
432
+ rate_limit: Optional maximum number of requests per minute per client.
433
+ If set, requests exceeding the limit get HTTP 429.
317
434
  """
318
435
  self.celery_app = celery_app
319
436
  self.fastapi_app = fastapi_app or FastAPI()
320
437
  self.prefix = prefix.rstrip("/")
321
438
  self.include_status_endpoints = include_status_endpoints
322
439
  self.task_filter = task_filter or (lambda name: not name.startswith("celery."))
440
+ self.rate_limiter = RateLimiter(rate_limit) if rate_limit else None
323
441
  self._registered = False
324
442
 
325
443
  # Store the registered task names from THIS app only
326
444
  self._app_task_names: set[str] = set()
327
- for name in self.celery_app.tasks.keys():
445
+ for name in self.celery_app.tasks:
328
446
  if self.task_filter(name):
329
447
  self._app_task_names.add(name)
330
448
 
@@ -379,6 +497,7 @@ class CeleryFastAPIBridge:
379
497
 
380
498
  # Create the endpoint handler
381
499
  async def run_task(
500
+ request: Request,
382
501
  payload: PayloadModel, # type: ignore[valid-type]
383
502
  task_name_override: str | None = Query(
384
503
  default=None,
@@ -392,6 +511,11 @@ class CeleryFastAPIBridge:
392
511
  ),
393
512
  ) -> TaskResponse:
394
513
  """Execute a Celery task asynchronously."""
514
+ # Rate limit check (per client IP)
515
+ if self.rate_limiter is not None:
516
+ client_ip = request.client.host if request.client else "unknown"
517
+ self.rate_limiter.check(client_ip)
518
+
395
519
  # Determine actual task name and queue
396
520
  actual_task_name = task_name_override or task_name
397
521
  actual_queue = (
@@ -403,7 +527,9 @@ class CeleryFastAPIBridge:
403
527
  celery_option_names = set(CELERY_OPTIONS_FIELDS.keys())
404
528
  task_kwargs: dict[str, Any] = {}
405
529
 
406
- for field_name in payload.model_fields:
530
+ # Access model_fields from the class, not the instance (Pydantic V2.11+)
531
+ payload_fields = type(payload).model_fields # type: ignore[attr-defined]
532
+ for field_name in payload_fields:
407
533
  if field_name not in celery_option_names:
408
534
  value = getattr(payload, field_name, None)
409
535
  if value is not None:
@@ -554,6 +680,39 @@ class CeleryFastAPIBridge:
554
680
 
555
681
  return result.result
556
682
 
683
+ @self.fastapi_app.websocket(f"{self.prefix}/tasks/{{task_id}}/ws")
684
+ async def stream_task_status(websocket: WebSocket, task_id: str) -> None:
685
+ """
686
+ Stream task status updates via WebSocket.
687
+
688
+ Polls the task state every 0.5s and sends JSON frames until the
689
+ task reaches a terminal state (SUCCESS/FAILURE/REVOKED), then closes.
690
+
691
+ ponytail: polling-based. For high-throughput streams, replace with
692
+ Celery events (celery_app.events.Receiver) or Redis pub/sub.
693
+ """
694
+ await websocket.accept()
695
+ result = AsyncResult(task_id, app=self.celery_app)
696
+ try:
697
+ while True:
698
+ state = result.state
699
+ frame: dict[str, Any] = {
700
+ "task_id": task_id,
701
+ "state": state,
702
+ "ready": result.ready(),
703
+ }
704
+ if result.ready():
705
+ if result.failed():
706
+ frame["error"] = str(result.traceback or "task failed")
707
+ else:
708
+ frame["result"] = result.result
709
+ await websocket.send_json(frame)
710
+ break
711
+ await websocket.send_json(frame)
712
+ await asyncio.sleep(0.5)
713
+ except WebSocketDisconnect:
714
+ pass
715
+
557
716
  @self.fastapi_app.get(
558
717
  f"{self.prefix}/tasks",
559
718
  response_model=TaskListResponse,
@@ -669,6 +828,208 @@ class CeleryFastAPIBridge:
669
828
  inspector = self.celery_app.control.inspect()
670
829
  return {"queues": inspector.active_queues() or {}}
671
830
 
831
+ def _find_local_worker() -> str | None:
832
+ """
833
+ Find the Celery worker running on the same host.
834
+
835
+ Celery worker hostnames follow the pattern: prefix@hostname
836
+ where prefix can be any format (e.g., celery, appname_uuid, etc.).
837
+
838
+ Matching strategy:
839
+ 1. CELERY_WORKER_HOSTNAME environment variable (RECOMMENDED)
840
+ Set this to the exact worker name when starting both worker and API
841
+ Example: export CELERY_WORKER_HOSTNAME="celery@worker1"
842
+
843
+ 2. Hostname matching (works when worker and API share same hostname):
844
+ - Exact match: worker hostname part equals socket.gethostname()
845
+ - Partial match: handles FQDN vs short hostname
846
+
847
+ 3. Single worker fallback (only when one worker exists for this app)
848
+
849
+ Note: When using custom --hostname, always set CELERY_WORKER_HOSTNAME
850
+ to ensure correct worker discovery.
851
+
852
+ Examples:
853
+ - Standard: celery@myhost (auto-discovered)
854
+ - Custom: celery@worker1 (needs CELERY_WORKER_HOSTNAME="celery@worker1")
855
+ - UUID: myapp_abc123@myhost (auto-discovered if hostname matches)
856
+ """
857
+ import os
858
+ import socket
859
+
860
+ # Strategy 1: Check for explicit environment variable (RECOMMENDED)
861
+ env_worker = os.environ.get("CELERY_WORKER_HOSTNAME")
862
+ if env_worker:
863
+ try:
864
+ inspector = self.celery_app.control.inspect(timeout=1.0)
865
+ env_ping_response: dict[str, Any] = inspector.ping() or {}
866
+ if env_worker in env_ping_response:
867
+ return str(env_worker)
868
+ # If env var is set but worker not found, log and continue
869
+ except Exception: # noqa: BLE001
870
+ pass
871
+
872
+ local_hostname = socket.gethostname()
873
+
874
+ try:
875
+ inspector = self.celery_app.control.inspect(timeout=1.0)
876
+
877
+ # Get all active workers
878
+ ping_response: dict[str, Any] = inspector.ping() or {}
879
+ if not ping_response:
880
+ return None
881
+
882
+ # Strategy 2: Hostname-based matching
883
+ # First pass: exact hostname match
884
+ for worker_name in ping_response:
885
+ if "@" in worker_name:
886
+ _, worker_host = worker_name.rsplit("@", 1)
887
+ if worker_host == local_hostname:
888
+ return str(worker_name)
889
+ elif worker_name == local_hostname:
890
+ return str(worker_name)
891
+
892
+ # Second pass: partial hostname match (FQDN vs short name)
893
+ for worker_name in ping_response:
894
+ if "@" in worker_name:
895
+ _, worker_host = worker_name.rsplit("@", 1)
896
+ if (
897
+ local_hostname in worker_host
898
+ or worker_host in local_hostname
899
+ ):
900
+ return str(worker_name)
901
+
902
+ # Strategy 3: Filter by app tasks and check for single worker
903
+ registered: dict[str, list[str]] = inspector.registered() or {}
904
+ app_workers = []
905
+ for worker_name in ping_response:
906
+ worker_tasks = registered.get(worker_name, [])
907
+ if any(task in self._app_task_names for task in worker_tasks):
908
+ app_workers.append(worker_name)
909
+
910
+ # If only one worker has this app's tasks, assume it's local
911
+ if len(app_workers) == 1:
912
+ return str(app_workers[0])
913
+
914
+ # If multiple workers with same tasks exist and hostname doesn't match,
915
+ # we cannot determine which is local without CELERY_WORKER_HOSTNAME
916
+ return None
917
+ except Exception: # noqa: BLE001
918
+ return None
919
+
920
+ @self.fastapi_app.get(
921
+ f"{self.prefix}/healthz",
922
+ response_model=HealthCheckResponse,
923
+ tags=["health"],
924
+ summary="Health check",
925
+ )
926
+ async def health_check(
927
+ worker: str | None = Query(
928
+ default=None,
929
+ description=(
930
+ "Explicit worker hostname to check (e.g., 'celery@worker1'). "
931
+ "If provided, bypasses local worker discovery."
932
+ ),
933
+ ),
934
+ ) -> HealthCheckResponse:
935
+ """
936
+ Check the health of a Celery worker.
937
+
938
+ If a `worker` query parameter is provided, checks that specific worker.
939
+ Otherwise, automatically discovers the worker running on the same host by
940
+ matching the system hostname with active Celery worker hostnames.
941
+
942
+ Returns:
943
+ Health status including broker connection and worker availability.
944
+
945
+ This endpoint is useful for:
946
+ - Kubernetes/Docker health probes (sidecar pattern)
947
+ - Load balancer health checks
948
+ - Monitoring systems
949
+ """
950
+ local_worker = worker # Use explicit worker if provided
951
+ try:
952
+ broker_connected = True
953
+ if local_worker is None:
954
+ local_worker = _find_local_worker()
955
+ # When a specific worker is requested, verify it's online via inspection.
956
+ # In environments without active workers, this will be False.
957
+ inspector = self.celery_app.control.inspect(
958
+ destination=[local_worker] if local_worker else None
959
+ )
960
+ ping_response = inspector.ping() or {}
961
+ worker_online = local_worker in ping_response if local_worker else False
962
+
963
+ except Exception: # noqa: BLE001
964
+ broker_connected = False
965
+ local_worker = None
966
+ worker_online = False
967
+
968
+ # Determine overall health status
969
+ status = "healthy" if broker_connected and worker_online else "unhealthy"
970
+
971
+ return HealthCheckResponse(
972
+ status=status,
973
+ celery_app=self.celery_app.main,
974
+ broker_connected=broker_connected,
975
+ worker_hostname=local_worker,
976
+ worker_online=worker_online,
977
+ )
978
+
979
+ @self.fastapi_app.get(
980
+ f"{self.prefix}/ping",
981
+ response_model=PingResponse,
982
+ tags=["health"],
983
+ summary="Ping worker",
984
+ )
985
+ async def ping_worker(
986
+ worker: str | None = Query(
987
+ default=None,
988
+ description=(
989
+ "Explicit worker hostname to ping (e.g., 'celery@worker1'). "
990
+ "If provided, bypasses local worker discovery."
991
+ ),
992
+ ),
993
+ ) -> PingResponse:
994
+ """
995
+ Ping a Celery worker.
996
+
997
+ If a `worker` query parameter is provided, pings that specific worker.
998
+ Otherwise, automatically discovers the local Celery worker by matching
999
+ the system hostname with active Celery worker hostnames.
1000
+
1001
+ Returns:
1002
+ Ping response from the discovered or specified worker.
1003
+
1004
+ This endpoint is useful for:
1005
+ - Checking specific worker responsiveness
1006
+ - Diagnosing connection issues
1007
+ - Verifying worker health in containerized deployments
1008
+ """
1009
+ local_worker = worker # Use explicit worker if provided
1010
+ if not local_worker:
1011
+ local_worker = _find_local_worker()
1012
+
1013
+ if not local_worker:
1014
+ # No worker found or specified
1015
+ return PingResponse(
1016
+ worker_hostname=None,
1017
+ online=False,
1018
+ response=None,
1019
+ )
1020
+
1021
+ # Ping the specific worker
1022
+ inspector = self.celery_app.control.inspect(destination=[local_worker])
1023
+ ping_response = inspector.ping() or {}
1024
+
1025
+ worker_response = ping_response.get(local_worker)
1026
+
1027
+ return PingResponse(
1028
+ worker_hostname=local_worker,
1029
+ online=worker_response is not None,
1030
+ response=worker_response,
1031
+ )
1032
+
672
1033
  @self.fastapi_app.post(
673
1034
  f"{self.prefix}/purge",
674
1035
  tags=["workers"],
@@ -741,6 +1102,79 @@ class CeleryFastAPIBridge:
741
1102
  result = self.celery_app.send_task(payload.task_name, **send_options)
742
1103
  return TaskResponse(task_id=result.id, status="PENDING")
743
1104
 
1105
+ @self.fastapi_app.post(
1106
+ f"{self.prefix}/tasks/batch",
1107
+ response_model=BatchTaskResponse,
1108
+ tags=["tasks"],
1109
+ summary="Execute multiple tasks in a group",
1110
+ )
1111
+ async def batch_execute_tasks(
1112
+ payload: BatchTaskRequest,
1113
+ ) -> BatchTaskResponse:
1114
+ """
1115
+ Execute multiple Celery tasks as a single group.
1116
+
1117
+ The tasks are submitted as a Celery group, enabling coordinated
1118
+ execution and result collection.
1119
+
1120
+ Returns:
1121
+ Group ID, individual task IDs, and count of submitted tasks.
1122
+ """
1123
+ from celery import group
1124
+
1125
+ # Validate: at least one task required
1126
+ if not payload.tasks:
1127
+ raise HTTPException(status_code=422, detail="No tasks provided")
1128
+
1129
+ # Build Celery signatures for each task
1130
+ signatures = []
1131
+ for item in payload.tasks:
1132
+ if not item.task_name:
1133
+ raise HTTPException(
1134
+ status_code=422, detail="task_name is required for each task"
1135
+ )
1136
+ sig = self.celery_app.signature(
1137
+ item.task_name,
1138
+ args=item.args,
1139
+ kwargs=item.kwargs,
1140
+ queue=item.queue,
1141
+ )
1142
+ signatures.append(sig)
1143
+
1144
+ # Submit the group
1145
+ job = group(*signatures)
1146
+ result = job.apply_async()
1147
+
1148
+ task_ids = [r.id for r in result.children or []]
1149
+
1150
+ return BatchTaskResponse(
1151
+ group_id=result.id,
1152
+ task_ids=task_ids,
1153
+ task_count=len(payload.tasks),
1154
+ status="PENDING",
1155
+ )
1156
+
1157
+ @self.fastapi_app.post(
1158
+ f"{self.prefix}/tasks/batch/revoke",
1159
+ summary="Revoke multiple tasks",
1160
+ )
1161
+ async def batch_revoke_tasks(
1162
+ payload: dict[str, list[str]],
1163
+ ) -> dict[str, Any]:
1164
+ """
1165
+ Revoke multiple tasks by their task IDs.
1166
+
1167
+ Task IDs should be provided in the `task_ids` field.
1168
+ """
1169
+ task_ids = payload.get("task_ids", [])
1170
+ if not task_ids:
1171
+ raise HTTPException(status_code=422, detail="task_ids is required")
1172
+
1173
+ for task_id in task_ids:
1174
+ self.celery_app.control.revoke(task_id)
1175
+
1176
+ return {"status": "revoked", "task_ids": task_ids, "count": len(task_ids)}
1177
+
744
1178
  def get_registered_routes(self) -> list[dict[str, str]]:
745
1179
  """
746
1180
  Get a list of all registered routes.
@@ -751,8 +1185,8 @@ class CeleryFastAPIBridge:
751
1185
  routes: list[dict[str, str]] = []
752
1186
  for route in self.fastapi_app.routes:
753
1187
  if hasattr(route, "path") and hasattr(route, "methods"):
754
- path = getattr(route, "path")
755
- methods = getattr(route, "methods")
1188
+ path = route.path
1189
+ methods = route.methods
756
1190
  for method in methods:
757
1191
  if method != "HEAD":
758
1192
  routes.append({"path": path, "method": method})
@@ -10,7 +10,7 @@ if TYPE_CHECKING:
10
10
 
11
11
  def create_gunicorn_app(
12
12
  app: Any, options: dict[str, Any] | None = None
13
- ) -> "BaseApplication":
13
+ ) -> BaseApplication:
14
14
  """Create a Gunicorn application instance.
15
15
 
16
16
  Args:
@@ -31,7 +31,7 @@ def create_gunicorn_app(
31
31
  "Install with: pip install celery-fastapi[gunicorn]"
32
32
  ) from exc
33
33
 
34
- class _GunicornApp(BaseApplication): # type: ignore[misc]
34
+ class _GunicornApp(BaseApplication):
35
35
  """Internal Gunicorn application wrapper."""
36
36
 
37
37
  def __init__(
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "celery-fastapi"
3
- version = "0.1.2"
3
+ version = "0.1.4" # placeholder; overridden by poetry-dynamic-versioning at build time
4
4
  description = "Automatic REST API generation for Celery tasks with FastAPI"
5
5
  authors = ["ilkerkara <ilkerkara@outlook.com.tr>"]
6
6
  license = "MIT"
@@ -29,7 +29,7 @@ packages = [{ include = "celery_fastapi" }]
29
29
  [tool.poetry.dependencies]
30
30
  python = "^3.11"
31
31
  fastapi = ">=0.100.0"
32
- celery = ">=5.3.0"
32
+ celery = ">=5.3.0,<=5.6.3"
33
33
  pydantic = ">=2.0.0"
34
34
 
35
35
  # Server dependencies (optional)
@@ -107,8 +107,20 @@ rich = ">=13.0.0"
107
107
  celery-fastapi = "celery_fastapi.cli:app"
108
108
 
109
109
  [build-system]
110
- requires = ["poetry-core>=1.0.0"]
111
- build-backend = "poetry.core.masonry.api"
110
+ requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]
111
+ build-backend = "poetry_dynamic_versioning.backend"
112
+
113
+ [tool.poetry.requires-plugins]
114
+ poetry-dynamic-versioning = { version = ">=1.0.0,<2.0.0", extras = ["plugin"] }
115
+
116
+ [tool.poetry-dynamic-versioning]
117
+ enable = false
118
+ vcs = "git"
119
+ style = "semver"
120
+
121
+ [tool.poetry-dynamic-versioning.substitution]
122
+ # Sync __version__ in source with the tag-derived version at build time.
123
+ files = ["celery_fastapi/__init__.py"]
112
124
 
113
125
  [tool.ruff]
114
126
  target-version = "py311"