Add admin panel, WebSocket support, and API versioning
CI / Lint & Format (push) Failing after 28s
CI / Tests (push) Has been skipped
CI / Security Scan (push) Failing after 8s
CI / Docker Build (push) Has been skipped

Introduce an admin portal (React + Nginx), WebSocket routing, and
API versioning middleware with `/api/v1/` prefix deprecation.
Add master API key authentication, new Prometheus metrics for AI
token consumption and active WebSockets, and extend S3 config
with a public endpoint URL. Update test paths and fixtures to
align with the new routing structure.
This commit is contained in:
2026-06-22 11:25:22 -04:00
parent d68deb9c74
commit 2f2a5e9b4a
158 changed files with 64811 additions and 503 deletions
+36
View File
@@ -0,0 +1,36 @@
# Imago Python SDK
The official Python client for interacting with the Imago backend API.
## Installation
```bash
pip install imago-client
```
## Basic Usage
```python
import asyncio
from imago_client import HubClient
async def main():
async with HubClient("http://localhost:8000", api_key="your-api-key") as client:
# Upload an image
with open("photo.jpg", "rb") as f:
image = await client.images.upload(f.read(), "photo.jpg")
print(f"Uploaded! ID: {image.id}")
# Stream real-time pipeline events
stream = client.images.stream_pipeline(image.id, "your-api-key")
async for event in stream.stream_events():
print(f"Event: {event}")
# Get processed data
processed = await client.images.get(image.id)
if processed.ai:
print("AI Description:", processed.ai.description)
if __name__ == "__main__":
asyncio.run(main())
```
+40
View File
@@ -0,0 +1,40 @@
"""
Imago Client SDK for Python.
A modern, async Python client for the Imago backend.
"""
from .client import HubClient
from .exceptions import (
ImagoError,
AuthError,
QuotaError,
NotFoundError,
APIError,
PipelineError,
)
from .models import (
HubImage,
ExifData,
OcrData,
AiData,
ProcessingInfo,
)
from .websocket import PipelineStream, AdminMonitorStream
__all__ = [
"HubClient",
"ImagoError",
"AuthError",
"QuotaError",
"NotFoundError",
"APIError",
"PipelineError",
"HubImage",
"ExifData",
"OcrData",
"AiData",
"ProcessingInfo",
"PipelineStream",
"AdminMonitorStream",
]
+64
View File
@@ -0,0 +1,64 @@
"""
Main client for the Imago SDK.
"""
from typing import Optional, Any
import httpx
from .resources.images import ImagesResource
from .resources.ai import AIResource
from .resources.auth import AuthResource
class HubClient:
"""
Main asynchronous client for interacting with the Imago backend API.
Example:
async with HubClient("http://localhost:8000", api_key="my-key") as client:
image = await client.images.get(1)
"""
def __init__(
self,
base_url: str,
api_key: Optional[str] = None,
timeout: float = 30.0,
**kwargs: Any
):
"""
Initializes the HubClient.
Args:
base_url: The root URL of the Imago API.
api_key: The X-API-Key used for authentication.
timeout: Default timeout for requests.
kwargs: Additional arguments passed to httpx.AsyncClient.
"""
self.base_url = base_url.rstrip('/')
self.api_key = api_key
headers = kwargs.pop("headers", {})
if api_key:
headers["X-API-Key"] = api_key
self._http_client = httpx.AsyncClient(
base_url=self.base_url,
headers=headers,
timeout=timeout,
**kwargs
)
# Initialize resources
self.images = ImagesResource(self._http_client)
self.ai = AIResource(self._http_client)
self.auth = AuthResource(self._http_client)
async def __aenter__(self) -> "HubClient":
await self._http_client.__aenter__()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
await self._http_client.__aexit__(exc_type, exc_val, exc_tb)
async def close(self) -> None:
"""Closes the underlying HTTP client."""
await self._http_client.aclose()
+25
View File
@@ -0,0 +1,25 @@
"""
Exceptions for the Imago Client SDK.
"""
class ImagoError(Exception):
"""Base exception for all Imago SDK errors."""
class AuthError(ImagoError):
"""Raised when authentication fails (401)."""
class QuotaError(ImagoError):
"""Raised when quota limits are reached (429 or 403 quota)."""
class NotFoundError(ImagoError):
"""Raised when a requested resource is not found (404)."""
class APIError(ImagoError):
"""Raised when the API returns an unexpected error."""
def __init__(self, message: str, status_code: int = None, details: dict = None):
super().__init__(message)
self.status_code = status_code
self.details = details or {}
class PipelineError(ImagoError):
"""Raised when an error occurs in the AI processing pipeline."""
+84
View File
@@ -0,0 +1,84 @@
"""
Data models for the Imago Client SDK.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from datetime import datetime
@dataclass
class ExifCamera:
make: Optional[str] = None
model: Optional[str] = None
lens: Optional[str] = None
iso: Optional[int] = None
aperture: Optional[str] = None
shutter_speed: Optional[str] = None
focal_length: Optional[str] = None
flash: Optional[int] = None
orientation: Optional[int] = None
software: Optional[str] = None
taken_at: Optional[datetime] = None
@dataclass
class ExifGPS:
latitude: Optional[float] = None
longitude: Optional[float] = None
altitude: Optional[float] = None
has_gps: bool = False
maps_url: Optional[str] = None
@dataclass
class ExifData:
camera: Optional[ExifCamera] = None
gps: Optional[ExifGPS] = None
raw: Optional[Dict[str, Any]] = None
@dataclass
class OcrData:
has_text: bool = False
text: Optional[str] = None
language: Optional[str] = None
confidence: Optional[float] = None
@dataclass
class AiData:
description: Optional[str] = None
tags: List[str] = field(default_factory=list)
confidence: Optional[float] = None
model_used: Optional[str] = None
processed_at: Optional[datetime] = None
prompt_tokens: Optional[int] = None
output_tokens: Optional[int] = None
@dataclass
class ProcessingInfo:
status: str
error: Optional[str] = None
started_at: Optional[datetime] = None
done_at: Optional[datetime] = None
@dataclass
class HubImage:
id: int
uuid: str
original_name: str
mime_type: Optional[str] = None
file_size: Optional[int] = None
width: Optional[int] = None
height: Optional[int] = None
uploaded_at: Optional[datetime] = None
processing_status: str = "pending"
client_id: Optional[str] = None
client_name: Optional[str] = None
thumbnail_path: Optional[str] = None
# Extended data
exif: Optional[ExifData] = None
ocr: Optional[OcrData] = None
ai: Optional[AiData] = None
processing: Optional[ProcessingInfo] = None
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "HubImage":
# Simplified factory method from a JSON API response
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__ and k not in ("exif", "ocr", "ai", "processing")})
+1
View File
@@ -0,0 +1 @@
"""Resource modules for the Imago SDK."""
+25
View File
@@ -0,0 +1,25 @@
"""
AI endpoints interaction.
"""
from typing import Any, Dict
from httpx import AsyncClient
from ..utils import with_retry
class AIResource:
"""Resource defining AI generative operations."""
def __init__(self, client: AsyncClient):
self._client = client
async def _handle_request(self, method: str, path: str, **kwargs) -> Any:
async def _req():
resp = await self._client.request(method, path, **kwargs)
resp.raise_for_status()
return resp.json()
return await with_retry(_req)
async def summarize_url(self, url: str, language: str = "français") -> Dict[str, Any]:
return await self._handle_request("POST", "/api/v1/ai/summarize", json={"url": url, "language": language})
async def draft_task(self, description: str, context: str = "") -> Dict[str, Any]:
return await self._handle_request("POST", "/api/v1/ai/draft-task", json={"description": description, "context": context})
+34
View File
@@ -0,0 +1,34 @@
"""
Client API keys management (requires admin scope)
"""
from typing import Any, Dict
from httpx import AsyncClient
from ..utils import with_retry
class AuthResource:
"""Resource for administering client accounts (admin only)."""
def __init__(self, client: AsyncClient):
self._client = client
async def _handle_request(self, method: str, path: str, **kwargs) -> Any:
async def _req():
resp = await self._client.request(method, path, **kwargs)
resp.raise_for_status()
return resp.json()
return await with_retry(_req)
async def create_client(self, name: str, is_premium: bool = False, scopes: list[str] = None) -> Dict[str, Any]:
data = {"name": name, "is_premium": is_premium}
if scopes:
data["scopes"] = scopes
return await self._handle_request("POST", "/api/v1/auth/clients", json=data)
async def list_clients(self) -> list[Dict[str, Any]]:
return await self._handle_request("GET", "/api/v1/auth/clients")
async def rotate_key(self, client_id: str) -> Dict[str, str]:
return await self._handle_request("POST", f"/api/v1/auth/clients/{client_id}/rotate-key")
async def delete_client(self, client_id: str) -> None:
await self._handle_request("DELETE", f"/api/v1/auth/clients/{client_id}")
+63
View File
@@ -0,0 +1,63 @@
"""
Client module for interacting with Imago API resources.
"""
from typing import Any, Dict, Optional, List
import io
from httpx import AsyncClient, Response
from ..models import HubImage
from ..websocket import PipelineStream
from ..utils import with_retry
class ImagesResource:
"""Resource defining image operations in the Imago API."""
def __init__(self, client: AsyncClient):
self._client = client
self.ws_url = f"{self._client.base_url}"
async def _handle_request(self, method: str, path: str, **kwargs) -> Any:
async def _req():
resp = await self._client.request(method, path, **kwargs)
resp.raise_for_status()
return resp.json()
return await with_retry(_req)
async def list(self, page: int = 1, size: int = 50, tags: Optional[str] = None) -> Dict[str, Any]:
params: dict = {"page": page, "size": size}
if tags is not None:
params["tags"] = tags
return await self._handle_request("GET", "/api/v1/images", params=params)
async def get(self, image_id: int) -> HubImage:
data = await self._handle_request("GET", f"/api/v1/images/{image_id}")
return HubImage.from_dict(data)
async def get_status(self, image_id: int) -> Dict[str, Any]:
return await self._handle_request("GET", f"/api/v1/images/{image_id}/status")
async def upload(self, file_content: bytes, filename: str, content_type: str = "image/jpeg") -> HubImage:
files = {"file": (filename, file_content, content_type)}
data = await self._handle_request("POST", "/api/v1/images/upload", files=files)
return HubImage.from_dict(data)
async def delete(self, image_id: int) -> Dict[str, Any]:
return await self._handle_request("DELETE", f"/api/v1/images/{image_id}")
async def get_all_tags(self) -> List[str]:
return await self._handle_request("GET", "/api/v1/images/tags/all")
async def reprocess(self, image_id: int) -> Dict[str, Any]:
return await self._handle_request("POST", f"/api/v1/images/{image_id}/reprocess")
async def get_download_url(self, image_id: int) -> str:
data = await self._handle_request("GET", f"/api/v1/images/{image_id}/download-url")
return data.get("download_url")
async def get_thumbnail_url(self, image_id: int) -> str:
data = await self._handle_request("GET", f"/api/v1/images/{image_id}/thumbnail-url")
return data.get("thumbnail_url")
def stream_pipeline(self, image_id: int, api_key: str) -> PipelineStream:
"""Returns a PipelineStream object for tracking the pipeline status in real-time."""
return PipelineStream(self.ws_url, api_key, image_id)
+77
View File
@@ -0,0 +1,77 @@
"""
Utility functions for the Imago Client SDK.
"""
import asyncio
import logging
from typing import Any, Callable, Coroutine, TypeVar
from httpx import HTTPStatusError, RequestError
from .exceptions import APIError, AuthError, NotFoundError, QuotaError, ImagoError
logger = logging.getLogger("imago_client")
T = TypeVar("T")
async def with_retry(
coro_func: Callable[[], Coroutine[Any, Any, T]],
max_retries: int = 3,
initial_backoff: float = 1.0,
max_backoff: float = 10.0,
backoff_factor: float = 2.0,
) -> T:
"""Executes an async function with exponential backoff on retryable HTTP errors."""
retries = 0
backoff = initial_backoff
while True:
try:
return await coro_func()
except RequestError as e:
if retries >= max_retries:
raise ImagoError(f"Request failed after {max_retries} retries: {e}") from e
logger.warning(f"Request failed ({e}), retrying in {backoff:.1f}s...")
await asyncio.sleep(backoff)
retries += 1
backoff = min(backoff * backoff_factor, max_backoff)
except HTTPStatusError as e:
# Handle specific status codes
status = e.response.status_code
if status in (408, 429, 500, 502, 503, 504):
if status == 429 and retries >= max_retries:
raise QuotaError("Rate limit exceeded") from e
if retries >= max_retries:
raise APIError(f"HTTP {status} failed after {max_retries} retries", status_code=status) from e
# specific handling for 429 Retry-After header
if status == 429 and "Retry-After" in e.response.headers:
try:
backoff = float(e.response.headers["Retry-After"])
except ValueError:
pass
logger.warning(f"HTTP {status}, retrying in {backoff:.1f}s...")
await asyncio.sleep(backoff)
retries += 1
backoff = min(backoff * backoff_factor, max_backoff)
else:
_handle_http_error(e)
def _handle_http_error(e: HTTPStatusError) -> None:
"""Raises appropriate SDK exceptions based on HTTP status code."""
status = e.response.status_code
try:
details = e.response.json()
except Exception:
details = {"detail": e.response.text}
if status in (401, 403):
# 403 might indicate missing scope or quota limit depending on details
if status == 403 and "quota" in str(details).lower():
raise QuotaError("Quota exceeded")
raise AuthError(f"Authentication failed: {details.get('detail')}")
elif status == 404:
raise NotFoundError("Resource not found")
else:
raise APIError(f"API returned {status}: {details.get('detail')}", status_code=status, details=details)
+71
View File
@@ -0,0 +1,71 @@
"""
WebSocket client for receiving real-time pipeline events.
"""
import asyncio
import json
import logging
from typing import AsyncGenerator, Dict, Any, Optional
import websockets
from websockets.exceptions import ConnectionClosed
from .exceptions import AuthError, PipelineError
logger = logging.getLogger("imago_client.websocket")
class PipelineStream:
"""Stream of real-time pipeline events for an image using WebSockets."""
def __init__(self, ws_url: str, api_key: str, image_id: int):
self.ws_url = ws_url.replace("http://", "ws://").replace("https://", "wss://")
self.api_key = api_key
self.image_id = image_id
async def stream_events(self) -> AsyncGenerator[Dict[str, Any], None]:
"""Connects to the WebSocket and yields events."""
url = f"{self.ws_url}/pipeline/{self.image_id}?token={self.api_key}"
try:
async with websockets.connect(url) as websocket:
async for message in websocket:
try:
event = json.loads(message)
yield event
# Stop iterating if the pipeline is finished
if event.get("event") in ("pipeline.done", "pipeline.error"):
break
except json.JSONDecodeError:
logger.warning(f"Failed to decode message: {message}")
except ConnectionClosed as e:
if e.code == 4001:
raise AuthError("No authentication token provided for WebSocket") from e
elif e.code == 4003:
raise AuthError("Forbidden or unauthorized to access this pipeline stream") from e
else:
raise PipelineError(f"WebSocket connection closed unexpectedly: {e.code} - {e.reason}") from e
except Exception as e:
raise PipelineError(f"WebSocket error: {e}") from e
class AdminMonitorStream:
"""Admin stream for listening to all pipeline events."""
def __init__(self, ws_url: str, api_key: str):
self.ws_url = ws_url.replace("http://", "ws://").replace("https://", "wss://")
self.api_key = api_key
async def stream_events(self) -> AsyncGenerator[Dict[str, Any], None]:
url = f"{self.ws_url}/admin/monitor?token={self.api_key}"
try:
async with websockets.connect(url) as websocket:
async for message in websocket:
try:
event = json.loads(message)
yield event
except json.JSONDecodeError:
logger.warning(f"Failed to decode message: {message}")
except ConnectionClosed as e:
if e.code in (4001, 4003):
raise AuthError("Unauthorized to access admin stream") from e
raise PipelineError(f"Connection closed: {e.code} - {e.reason}") from e
+31
View File
@@ -0,0 +1,31 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "imago-client"
version = "0.1.0"
description = "Official Python SDK for the Imago API"
readme = "README.md"
authors = [
{ name = "Imago Project" }
]
requires-python = ">=3.10"
dependencies = [
"httpx>=0.23.0",
"websockets>=13.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"respx>=0.20.0",
]
[tool.hatch.build.targets.wheel]
packages = ["imago_client"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
+54
View File
@@ -0,0 +1,54 @@
import pytest
import respx
from httpx import Response
from imago_client import HubClient, ImagoError, APIError, QuotaError, AuthError
@pytest.fixture
def base_url():
return "http://testserver"
@pytest.fixture
def api_key():
return "sk-test-key"
@pytest.mark.asyncio
async def test_client_initialization(base_url, api_key):
client = HubClient(base_url, api_key=api_key)
assert client.base_url == "http://testserver"
assert client.api_key == "sk-test-key"
assert "X-API-Key" in client._http_client.headers
await client.close()
@pytest.mark.asyncio
@respx.mock
async def test_auth_error(base_url, api_key):
respx.get(f"{base_url}/api/v1/images").mock(return_value=Response(401, json={"detail": "Unauthorized"}))
async with HubClient(base_url, api_key="wrong") as client:
with pytest.raises(AuthError):
await client.images.list()
@pytest.mark.asyncio
@respx.mock
async def test_quota_error(base_url, api_key):
respx.get(f"{base_url}/api/v1/images").mock(return_value=Response(429, json={"detail": "Rate Limit Exceeded"}))
async with HubClient(base_url, api_key=api_key) as client:
with pytest.raises(QuotaError):
await client.images.list()
@pytest.mark.asyncio
@respx.mock
async def test_api_error_retry(base_url, api_key):
route = respx.get(f"{base_url}/api/v1/images")
route.side_effect = [
Response(502, json={"error": "Bad Gateway"}),
Response(200, json={"items": []})
]
async with HubClient(base_url, api_key=api_key) as client:
# Should automatically retry on 502
response = await client.images.list()
assert response == {"items": []}
assert route.call_count == 2
+76
View File
@@ -0,0 +1,76 @@
import pytest
import respx
from httpx import Response
from imago_client import HubClient, HubImage
@pytest.fixture
def base_url():
return "http://testserver"
@pytest.fixture
def api_key():
return "sk-test-key"
@pytest.mark.asyncio
@respx.mock
async def test_get_image(base_url, api_key):
mock_data = {
"id": 1,
"uuid": "fake-uuid",
"original_name": "test.jpg",
"processing_status": "done"
}
respx.get(f"{base_url}/api/v1/images/1").mock(return_value=Response(200, json=mock_data))
async with HubClient(base_url, api_key=api_key) as client:
img = await client.images.get(1)
assert isinstance(img, HubImage)
assert img.id == 1
assert img.uuid == "fake-uuid"
assert img.processing_status == "done"
assert img.original_name == "test.jpg"
@pytest.mark.asyncio
@respx.mock
async def test_upload_image(base_url, api_key):
mock_data = {
"id": 2,
"uuid": "new-uuid",
"original_name": "upload.jpg",
"processing_status": "pending"
}
respx.post(f"{base_url}/api/v1/images/upload").mock(return_value=Response(200, json=mock_data))
async with HubClient(base_url, api_key=api_key) as client:
img = await client.images.upload(b"fakebytes", "upload.jpg", "image/jpeg")
assert isinstance(img, HubImage)
assert img.id == 2
assert img.processing_status == "pending"
@pytest.mark.asyncio
@respx.mock
async def test_list_images(base_url, api_key):
mock_response = {
"total": 1,
"page": 1,
"size": 50,
"items": [
{
"id": 1,
"uuid": "fake-uuid",
"original_name": "test.jpg",
"processing_status": "done"
}
]
}
respx.get(f"{base_url}/api/v1/images?page=1&size=50").mock(return_value=Response(200, json=mock_response))
async with HubClient(base_url, api_key=api_key) as client:
result = await client.images.list()
assert result["total"] == 1
assert len(result["items"]) == 1
assert result["items"][0]["id"] == 1