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.
- fastapi_progress-0.1.0/PKG-INFO +80 -0
- fastapi_progress-0.1.0/README.md +67 -0
- fastapi_progress-0.1.0/pyproject.toml +26 -0
- fastapi_progress-0.1.0/src/fastapi_progress/__init__.py +15 -0
- fastapi_progress-0.1.0/src/fastapi_progress/context.py +14 -0
- fastapi_progress-0.1.0/src/fastapi_progress/core.py +29 -0
- fastapi_progress-0.1.0/src/fastapi_progress/decorators.py +37 -0
- fastapi_progress-0.1.0/src/fastapi_progress/globals.py +11 -0
- fastapi_progress-0.1.0/src/fastapi_progress/py.typed +0 -0
- fastapi_progress-0.1.0/src/fastapi_progress/state/__init__.py +5 -0
- fastapi_progress-0.1.0/src/fastapi_progress/state/base.py +15 -0
- fastapi_progress-0.1.0/src/fastapi_progress/state/memory.py +47 -0
- fastapi_progress-0.1.0/src/fastapi_progress/state/redis.py +54 -0
|
@@ -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,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()
|