fastapi-progress 0.1.0__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.
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.3
2
+ Name: fastapi-progress
3
+ Version: 0.1.0
4
+ Summary: Zero-config background task progress tracker for FastAPI using WebSockets.
5
+ Author: Aldair Andrade
6
+ Author-email: Aldair Andrade <demianmaster2003@gmail.com>
7
+ Requires-Dist: fastapi>=0.138.1
8
+ Requires-Dist: redis>=8.0.1
9
+ Requires-Dist: uvicorn[standard]>=0.49.0
10
+ Requires-Dist: websockets>=16.0
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+
14
+ # fastapi-progress
15
+
16
+ A zero-config library to track the progress of FastAPI background tasks and stream it to the frontend via WebSockets in real-time.
17
+
18
+ ## Features
19
+
20
+ - **Zero-config WebSockets:** Automatically injects a `/ws/progress/{task_id}` endpoint.
21
+ - **Elegant Decorator:** Use `@track_progress` on your background tasks.
22
+ - **Context-aware Updates:** Simply call `await progress.update(50, "Processing")` from anywhere in your task without passing task IDs around.
23
+ - **Hybrid State Backend:** Includes an in-memory backend for simple deployments, and a Redis backend for distributed setups (e.g., Celery, multiple Uvicorn workers).
24
+
25
+ ## Installation
26
+
27
+ Using `uv` (recommended):
28
+ ```bash
29
+ uv add fastapi-progress
30
+ ```
31
+ Or pip:
32
+ ```bash
33
+ pip install fastapi-progress
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ```python
39
+ import asyncio
40
+ import uuid
41
+ from fastapi import FastAPI, BackgroundTasks
42
+ from fastapi_progress import init_progress, track_progress, progress
43
+
44
+ app = FastAPI()
45
+
46
+ # 1. Initialize to inject the websocket route
47
+ init_progress(app)
48
+
49
+ # 2. Decorate your background task
50
+ @track_progress(task_id_param="task_id")
51
+ async def heavy_work(task_id: str):
52
+ await progress.update(10, "Starting work...")
53
+ await asyncio.sleep(1)
54
+
55
+ await progress.update(50, "Processing data...")
56
+ await asyncio.sleep(2)
57
+
58
+ await progress.update(100, "Done!")
59
+
60
+ # 3. Trigger the task
61
+ @app.post("/do-work")
62
+ async def start_work(tasks: BackgroundTasks):
63
+ task_id = str(uuid.uuid4())
64
+ tasks.add_task(heavy_work, task_id=task_id)
65
+ return {"task_id": task_id}
66
+ ```
67
+
68
+ ## Using Redis (For Production)
69
+
70
+ If you are running multiple Gunicorn/Uvicorn workers, you must use Redis to share the state across processes.
71
+
72
+ ```python
73
+ from redis.asyncio import Redis
74
+ from fastapi_progress import init_progress, RedisBackend
75
+
76
+ redis_client = Redis(host="localhost", port=6379)
77
+ backend = RedisBackend(redis_client)
78
+
79
+ init_progress(app, backend=backend)
80
+ ```
@@ -0,0 +1,67 @@
1
+ # fastapi-progress
2
+
3
+ A zero-config library to track the progress of FastAPI background tasks and stream it to the frontend via WebSockets in real-time.
4
+
5
+ ## Features
6
+
7
+ - **Zero-config WebSockets:** Automatically injects a `/ws/progress/{task_id}` endpoint.
8
+ - **Elegant Decorator:** Use `@track_progress` on your background tasks.
9
+ - **Context-aware Updates:** Simply call `await progress.update(50, "Processing")` from anywhere in your task without passing task IDs around.
10
+ - **Hybrid State Backend:** Includes an in-memory backend for simple deployments, and a Redis backend for distributed setups (e.g., Celery, multiple Uvicorn workers).
11
+
12
+ ## Installation
13
+
14
+ Using `uv` (recommended):
15
+ ```bash
16
+ uv add fastapi-progress
17
+ ```
18
+ Or pip:
19
+ ```bash
20
+ pip install fastapi-progress
21
+ ```
22
+
23
+ ## Quick Start
24
+
25
+ ```python
26
+ import asyncio
27
+ import uuid
28
+ from fastapi import FastAPI, BackgroundTasks
29
+ from fastapi_progress import init_progress, track_progress, progress
30
+
31
+ app = FastAPI()
32
+
33
+ # 1. Initialize to inject the websocket route
34
+ init_progress(app)
35
+
36
+ # 2. Decorate your background task
37
+ @track_progress(task_id_param="task_id")
38
+ async def heavy_work(task_id: str):
39
+ await progress.update(10, "Starting work...")
40
+ await asyncio.sleep(1)
41
+
42
+ await progress.update(50, "Processing data...")
43
+ await asyncio.sleep(2)
44
+
45
+ await progress.update(100, "Done!")
46
+
47
+ # 3. Trigger the task
48
+ @app.post("/do-work")
49
+ async def start_work(tasks: BackgroundTasks):
50
+ task_id = str(uuid.uuid4())
51
+ tasks.add_task(heavy_work, task_id=task_id)
52
+ return {"task_id": task_id}
53
+ ```
54
+
55
+ ## Using Redis (For Production)
56
+
57
+ If you are running multiple Gunicorn/Uvicorn workers, you must use Redis to share the state across processes.
58
+
59
+ ```python
60
+ from redis.asyncio import Redis
61
+ from fastapi_progress import init_progress, RedisBackend
62
+
63
+ redis_client = Redis(host="localhost", port=6379)
64
+ backend = RedisBackend(redis_client)
65
+
66
+ init_progress(app, backend=backend)
67
+ ```
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "fastapi-progress"
3
+ version = "0.1.0"
4
+ description = "Zero-config background task progress tracker for FastAPI using WebSockets."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Aldair Andrade", email = "demianmaster2003@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "fastapi>=0.138.1",
12
+ "redis>=8.0.1",
13
+ "uvicorn[standard]>=0.49.0",
14
+ "websockets>=16.0",
15
+ ]
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.10.2,<0.11.0"]
19
+ build-backend = "uv_build"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "httpx>=0.28.1",
24
+ "pytest>=9.1.1",
25
+ "pytest-asyncio>=1.4.0",
26
+ ]
@@ -0,0 +1,15 @@
1
+ from .core import init_progress
2
+ from .decorators import track_progress
3
+ from .context import progress
4
+ from .state.base import StateBackend
5
+ from .state.memory import MemoryBackend
6
+ from .state.redis import RedisBackend
7
+
8
+ __all__ = [
9
+ "init_progress",
10
+ "track_progress",
11
+ "progress",
12
+ "StateBackend",
13
+ "MemoryBackend",
14
+ "RedisBackend"
15
+ ]
@@ -0,0 +1,14 @@
1
+ from contextvars import ContextVar
2
+ from typing import Any
3
+ from .globals import get_backend
4
+
5
+ current_task_id: ContextVar[str | None] = ContextVar("current_task_id", default=None)
6
+
7
+ class Progress:
8
+ async def update(self, progress: int, message: str = "", metadata: dict[str, Any] | None = None) -> None:
9
+ task_id = current_task_id.get()
10
+ if task_id:
11
+ backend = get_backend()
12
+ await backend.update(task_id, progress, message, metadata)
13
+
14
+ progress = Progress()
@@ -0,0 +1,29 @@
1
+ from typing import Any
2
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
3
+
4
+ from .globals import set_backend, get_backend
5
+ from .state.base import StateBackend
6
+
7
+ def init_progress(app: FastAPI, backend: StateBackend | None = None, prefix: str = "/ws/progress") -> None:
8
+ if backend:
9
+ set_backend(backend)
10
+
11
+ @app.websocket(f"{prefix}/{{task_id}}")
12
+ async def progress_websocket(websocket: WebSocket, task_id: str) -> None:
13
+ await websocket.accept()
14
+ state_backend = get_backend()
15
+
16
+ try:
17
+ async for data in state_backend.subscribe(task_id):
18
+ await websocket.send_json(data)
19
+ if data.get("progress") == 100 or data.get("message", "").startswith("Error:"):
20
+ break
21
+ except WebSocketDisconnect:
22
+ pass
23
+ except Exception:
24
+ pass
25
+ finally:
26
+ try:
27
+ await websocket.close()
28
+ except Exception:
29
+ pass
@@ -0,0 +1,37 @@
1
+ import uuid
2
+ from functools import wraps
3
+ from typing import Callable, Any, Coroutine, TypeVar, ParamSpec
4
+
5
+ from .context import current_task_id
6
+ from .globals import get_backend
7
+
8
+ P = ParamSpec("P")
9
+ R = TypeVar("R")
10
+
11
+ def track_progress(task_id_param: str | None = None) -> Callable[[Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]]]:
12
+ def decorator(func: Callable[P, Coroutine[Any, Any, R]]) -> Callable[P, Coroutine[Any, Any, R]]:
13
+ @wraps(func)
14
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
15
+ task_id = None
16
+ if task_id_param and task_id_param in kwargs:
17
+ task_id = str(kwargs[task_id_param])
18
+ else:
19
+ task_id = str(uuid.uuid4())
20
+
21
+ token = current_task_id.set(task_id)
22
+ backend = get_backend()
23
+
24
+ await backend.update(task_id, 0, "Task started")
25
+
26
+ try:
27
+ result = await func(*args, **kwargs)
28
+ await backend.update(task_id, 100, "Task completed")
29
+ return result
30
+ except Exception as e:
31
+ await backend.update(task_id, 0, f"Error: {str(e)}")
32
+ raise e
33
+ finally:
34
+ current_task_id.reset(token)
35
+
36
+ return wrapper
37
+ return decorator
@@ -0,0 +1,11 @@
1
+ from .state.base import StateBackend
2
+ from .state.memory import MemoryBackend
3
+
4
+ _active_backend: StateBackend = MemoryBackend()
5
+
6
+ def set_backend(backend: StateBackend) -> None:
7
+ global _active_backend
8
+ _active_backend = backend
9
+
10
+ def get_backend() -> StateBackend:
11
+ return _active_backend
File without changes
@@ -0,0 +1,5 @@
1
+ from .base import StateBackend
2
+ from .memory import MemoryBackend
3
+ from .redis import RedisBackend
4
+
5
+ __all__ = ["StateBackend", "MemoryBackend", "RedisBackend"]
@@ -0,0 +1,15 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import AsyncGenerator, Any
3
+
4
+ class StateBackend(ABC):
5
+ @abstractmethod
6
+ async def update(self, task_id: str, progress: int, message: str = "", metadata: dict[str, Any] | None = None) -> None:
7
+ pass
8
+
9
+ @abstractmethod
10
+ async def get(self, task_id: str) -> dict[str, Any] | None:
11
+ pass
12
+
13
+ @abstractmethod
14
+ async def subscribe(self, task_id: str) -> AsyncGenerator[dict[str, Any], None]:
15
+ pass
@@ -0,0 +1,47 @@
1
+ import asyncio
2
+ from typing import AsyncGenerator, Any
3
+ from .base import StateBackend
4
+
5
+ class MemoryBackend(StateBackend):
6
+ def __init__(self):
7
+ self._state: dict[str, dict[str, Any]] = {}
8
+ self._subscriptions: dict[str, list[asyncio.Queue]] = {}
9
+
10
+ async def update(self, task_id: str, progress: int, message: str = "", metadata: dict[str, Any] | None = None) -> None:
11
+ data = {
12
+ "task_id": task_id,
13
+ "progress": progress,
14
+ "message": message,
15
+ "metadata": metadata or {}
16
+ }
17
+ self._state[task_id] = data
18
+
19
+ if task_id in self._subscriptions:
20
+ for queue in self._subscriptions[task_id]:
21
+ await queue.put(data)
22
+
23
+ async def get(self, task_id: str) -> dict[str, Any] | None:
24
+ return self._state.get(task_id)
25
+
26
+ async def subscribe(self, task_id: str) -> AsyncGenerator[dict[str, Any], None]:
27
+ queue = asyncio.Queue()
28
+ if task_id not in self._subscriptions:
29
+ self._subscriptions[task_id] = []
30
+ self._subscriptions[task_id].append(queue)
31
+
32
+ try:
33
+ current = await self.get(task_id)
34
+ if current:
35
+ yield current
36
+
37
+ while True:
38
+ data = await queue.get()
39
+ yield data
40
+ finally:
41
+ if task_id in self._subscriptions:
42
+ try:
43
+ self._subscriptions[task_id].remove(queue)
44
+ except ValueError:
45
+ pass
46
+ if not self._subscriptions[task_id]:
47
+ del self._subscriptions[task_id]
@@ -0,0 +1,54 @@
1
+ import json
2
+ from typing import AsyncGenerator, Any
3
+ from .base import StateBackend
4
+
5
+ try:
6
+ from redis.asyncio import Redis
7
+ except ImportError:
8
+ Redis = Any
9
+
10
+ class RedisBackend(StateBackend):
11
+ def __init__(self, redis_client: 'Redis', prefix: str = "fastapi_progress"):
12
+ self.redis = redis_client
13
+ self.prefix = prefix
14
+
15
+ def _key(self, task_id: str) -> str:
16
+ return f"{self.prefix}:state:{task_id}"
17
+
18
+ def _channel(self, task_id: str) -> str:
19
+ return f"{self.prefix}:channel:{task_id}"
20
+
21
+ async def update(self, task_id: str, progress: int, message: str = "", metadata: dict[str, Any] | None = None) -> None:
22
+ data = {
23
+ "task_id": task_id,
24
+ "progress": progress,
25
+ "message": message,
26
+ "metadata": metadata or {}
27
+ }
28
+ payload = json.dumps(data)
29
+
30
+ await self.redis.setex(self._key(task_id), 86400, payload)
31
+ await self.redis.publish(self._channel(task_id), payload)
32
+
33
+ async def get(self, task_id: str) -> dict[str, Any] | None:
34
+ payload = await self.redis.get(self._key(task_id))
35
+ if payload:
36
+ return json.loads(payload)
37
+ return None
38
+
39
+ async def subscribe(self, task_id: str) -> AsyncGenerator[dict[str, Any], None]:
40
+ pubsub = self.redis.pubsub()
41
+ await pubsub.subscribe(self._channel(task_id))
42
+
43
+ try:
44
+ current = await self.get(task_id)
45
+ if current:
46
+ yield current
47
+
48
+ async for message in pubsub.listen():
49
+ if message["type"] == "message":
50
+ data = json.loads(message["data"])
51
+ yield data
52
+ finally:
53
+ await pubsub.unsubscribe(self._channel(task_id))
54
+ await pubsub.close()