Add admin panel, WebSocket support, and API versioning
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:
@@ -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())
|
||||
```
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
@@ -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")})
|
||||
@@ -0,0 +1 @@
|
||||
"""Resource modules for the Imago SDK."""
|
||||
@@ -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})
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user