diff --git a/FRONTEND.md b/FRONTEND.md
new file mode 100644
index 0000000..b8ff9fe
--- /dev/null
+++ b/FRONTEND.md
@@ -0,0 +1,65 @@
+# Web Frontend for Indigo Analysis API
+
+The API includes a web frontend hosted at the root path (`/`).
+
+## Features
+
+- **Analysis Discovery**: Browse all available analyses registered in the system
+- **Dynamic Form Generation**: Automatically generates input forms based on analysis parameters
+- **Real-time Results**: Submits jobs to the queue and polls for results
+- **Result History**: Tracks all submitted jobs and their results (stored in browser's localStorage)
+- **Type-Aware Inputs**: Automatically converts form inputs to appropriate types (int, float, bool, list)
+- **Beautiful UI**: Modern gradient design with smooth animations and transitions
+
+## Accessing the Frontend
+
+Once the API server is running:
+
+```bash
+indigoapi serve
+```
+
+Open your browser and navigate to:
+```
+http://localhost:8000
+```
+
+## How to Use
+
+1. **Select an Analysis**: Click on any analysis in the left panel to select it
+2. **Fill in Parameters**: The form on the right will dynamically populate with the analysis parameters
+3. **Submit**: Click "Submit Analysis" to queue the job
+4. **View Results**: Results appear in the bottom panel and update in real-time
+5. **Track History**: All submitted jobs are displayed with their status and results
+
+
+### Frontend Files
+
+- **`templates/index.html`** - Main HTML template
+- **`static/app.js`** - API client and UI logic
+- **`static/style.css`** - Responsive styling with gradient design
+
+### Backend Integration
+
+The frontend communicates with the following API endpoints:
+
+- `GET /get_analyses` - Fetch list of available analyses with parameters
+- `POST /analyse` - Submit a new analysis job
+- `GET /result/latest` - Get the most recent result
+- `GET /result/id/{request_id}` - Get result by request ID
+- `GET /health` - Check API availability
+- `GET /endpoints` - Get all available endpoints
+
+
+## Example Workflow
+
+1. Server starts with analyses registered (e.g., "double", "sum_numbers")
+2. User opens frontend at http://localhost:8000
+3. User selects "double" analysis from the list
+4. Form shows parameter "number" with type hint
+5. User enters "5" and clicks "Submit Analysis"
+6. Request ID is displayed in a success message
+7. Results panel shows the job with "running" status
+8. Frontend polls for updates every 2 seconds
+9. When complete, status changes and result is displayed
+10. History is persisted automatically
diff --git a/README.md b/README.md
index 25b1a6a..7ec52b9 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,11 @@ The app accepts analysis jobs via HTTP or the client and stores results in memor
```
+## Using the WebUI
+
+You can also navigate to the url or the ip address to be met with:
+
+
### Request flow
diff --git a/config.yaml b/config.yaml
index bee591f..bb1ce66 100644
--- a/config.yaml
+++ b/config.yaml
@@ -16,7 +16,7 @@ plugins:
- ./plugins # local folder for plugins
github_repos: # list of Https GitHub repos with analysis code (ending with .git)
- https://github.com/DiamondLightSource/xrpd-toolbox.git
- register_all: False # whether to register all analyses found in plugins or only those decorated
+ register_all: True # whether to register all analyses found in plugins or only those decorated
rabbitmq:
enabled: True
host: "i15-1-rabbitmq-daq.diamond.ac.uk"
@@ -28,3 +28,6 @@ rabbitmq:
- "/topic/gda.messages.scan" # gda scans
- "/topic/gda.messages.processing" # dawn stuff
- "/topic/public.analysis.trigger" # custom topic for triggering analyses from other services
+
+webui:
+ hide_non_webui_jobs: false # If true, only jobs submitted via the web UI are shown
diff --git a/helm/indigoapi/values.yaml b/helm/indigoapi/values.yaml
index e0014f0..8d281f1 100644
--- a/helm/indigoapi/values.yaml
+++ b/helm/indigoapi/values.yaml
@@ -38,6 +38,9 @@ resources:
memory: 4Gi
livenessProbe:
+
+webui:
+ showAllJobsInWebui: false # Set to true to show all jobs in the web UI, not just those submitted via the web UI
httpGet:
path: /healthz
port: http
diff --git a/images/webui.png b/images/webui.png
new file mode 100644
index 0000000..21303cd
Binary files /dev/null and b/images/webui.png differ
diff --git a/src/indigoapi/analyses/delay.py b/src/indigoapi/analyses/delay.py
new file mode 100644
index 0000000..dface5c
--- /dev/null
+++ b/src/indigoapi/analyses/delay.py
@@ -0,0 +1,11 @@
+import time
+
+from indigoapi.analysis_core.decorator import analysis
+
+
+@analysis()
+def delay(seconds: float):
+ """Simulate a long-running analysis by sleeping for specified number of seconds."""
+
+ time.sleep(seconds)
+ return f"Slept for {seconds} seconds"
diff --git a/src/indigoapi/analyses/peak_fitting.py b/src/indigoapi/analyses/peak_fitting.py
index 6ecc549..8412db8 100644
--- a/src/indigoapi/analyses/peak_fitting.py
+++ b/src/indigoapi/analyses/peak_fitting.py
@@ -9,7 +9,7 @@ def gaussian(x: np.ndarray, amplitude: float, x0: float, sigma: float) -> np.nda
@analysis()
-def gaussian_fit(x: list[int | float], y: list):
+def gaussian_fit(x: list[int | float], y: list[int | float]):
"""
data = {
"x": [...],
diff --git a/src/indigoapi/api/routes.py b/src/indigoapi/api/routes.py
index 68d71ec..191d746 100644
--- a/src/indigoapi/api/routes.py
+++ b/src/indigoapi/api/routes.py
@@ -6,7 +6,7 @@
from fastapi.routing import APIRoute
from indigoapi.analysis_core.registry import get_analysis, list_analyses
-from indigoapi.models import AnalysisRequest, AnalysisResult
+from indigoapi.models import AnalysisRequest, AnalysisResponse, AnalysisResult
from indigoapi.task_queue import QueueManager
ROUTER = APIRouter()
@@ -17,6 +17,7 @@
RESULT_LATEST_ROUTE = "/result/latest"
RESULT_BY_ID_ROUTE = "/result/id/{request_id}"
ENDPOINTS_ROUTE = "/endpoints"
+RESULTS_ALL_ROUTE = "/results/all"
@ROUTER.get(HEALTH_ROUTE)
@@ -43,7 +44,7 @@ async def available_analyses() -> list[dict[str, Any]]:
else "Any",
}
)
- return_annotation = (
+ annotations = (
str(sig.return_annotation)
if sig.return_annotation != inspect.Signature.empty
else "Any"
@@ -52,21 +53,22 @@ async def available_analyses() -> list[dict[str, Any]]:
{
"name": name,
"parameters": params,
- "return_annotation": return_annotation,
+ "annotations": annotations,
+ "docstring": func.__doc__ or "",
}
)
return analyses_info
@ROUTER.post(ANALYSE_ROUTE)
-async def analyse(request: Request, job: AnalysisRequest):
+async def analyse(request: Request, job: AnalysisRequest) -> AnalysisResponse:
queue: QueueManager = request.app.state.queue_manager
- await queue.enqueue(job)
- return {"request_id": job.request_id}
+ analysis_response = await queue.enqueue(job)
+ return analysis_response
@ROUTER.get(RESULT_LATEST_ROUTE, response_model=AnalysisResult)
-async def get_latest_result(request: Request):
+async def get_latest_result(request: Request) -> AnalysisResult:
queue_manager = request.app.state.queue_manager
@@ -81,7 +83,7 @@ async def result(request: Request, request_id: UUID):
queue: QueueManager = request.app.state.queue_manager
if request_id not in queue.results:
raise HTTPException(404, "Result not found")
- result, duration = queue.results[request_id]
+ result = queue.results[request_id]
return result
@@ -96,3 +98,13 @@ async def get_endpoints():
for route in ROUTER.routes
if isinstance(route, APIRoute)
]
+
+
+# New endpoint to return all jobs/results if enabled in config
+@ROUTER.get(RESULTS_ALL_ROUTE)
+async def get_all_results(request: Request):
+ queue: QueueManager = request.app.state.queue_manager
+ # Return all jobs (pending, running, completed, failed), sorted by created_at
+ results = list(queue.results.values())
+ results.sort(key=lambda r: getattr(r, "created_at", None) or 0, reverse=True)
+ return results
diff --git a/src/indigoapi/client.py b/src/indigoapi/client.py
index b18eb41..946e5a4 100644
--- a/src/indigoapi/client.py
+++ b/src/indigoapi/client.py
@@ -5,7 +5,6 @@
from typing import Any
from uuid import UUID
-import numpy as np
import requests
from indigoapi.api.routes import (
@@ -15,8 +14,10 @@
HEALTH_ROUTE,
RESULT_BY_ID_ROUTE,
RESULT_LATEST_ROUTE,
+ RESULTS_ALL_ROUTE,
)
-from indigoapi.models import AnalysisRequest, AnalysisResult
+from indigoapi.models import AnalysisRequest, AnalysisResponse, AnalysisResult
+from indigoapi.utils.serialisers import serialise
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -33,13 +34,10 @@ def __init__(
session: requests.Session | None = None,
):
self.base_url = base_url.rstrip("/")
-
- self.base_url = base_url.rstrip("/")
-
self.latest_request_id: UUID | None = None
self.session = session or requests.Session()
- def list_analyses(
+ def available_analyses(
self, as_strings: bool = True
) -> list[dict[str, Any]] | list[str]:
resp = self.session.get(f"{self.base_url}{ANALYSES_ROUTE}")
@@ -57,16 +55,14 @@ def _format_analysis_signature(self, analysis: dict[str, Any]) -> str:
param_str += f" = {param['default']}"
params.append(param_str)
- return_annotation = analysis.get("return_annotation", "Any")
+ annotations = analysis.get("annotations", "Any")
if params:
params_block = ",\n ".join(params)
signature = (
- f"{analysis['name']}(\n"
- f" {params_block},\n"
- f" ) -> {return_annotation}:"
+ f"{analysis['name']}(\n {params_block},\n ) -> {annotations}:"
)
else:
- signature = f"{analysis['name']}() -> {return_annotation}:"
+ signature = f"{analysis['name']}() -> {annotations}:"
return signature
@@ -75,25 +71,6 @@ def health(self) -> dict[str, Any]:
resp.raise_for_status()
return resp.json()
- def _convert_to_serialisable(self, obj: Any) -> Any:
-
- if isinstance(obj, np.ndarray):
- return obj.tolist()
-
- if isinstance(obj, np.integer):
- return int(obj)
-
- if isinstance(obj, np.floating):
- return float(obj)
-
- if isinstance(obj, dict):
- return {k: self._convert_to_serialisable(v) for k, v in obj.items()}
-
- if isinstance(obj, (list, tuple, set)):
- return [self._convert_to_serialisable(v) for v in obj]
-
- return obj
-
def submit(self, analysis: str | Callable, **inputs: Any) -> UUID:
"""
Submit an analysis job.
@@ -102,7 +79,7 @@ def submit(self, analysis: str | Callable, **inputs: Any) -> UUID:
client.submit("gaussian_fit", x=x, y=y)
"""
- inputs = self._convert_to_serialisable(inputs)
+ inputs = serialise(inputs)
analysis_name = (
analysis.__name__ if isinstance(analysis, Callable) else analysis
@@ -113,7 +90,10 @@ def submit(self, analysis: str | Callable, **inputs: Any) -> UUID:
resp = self.session.post(f"{self.base_url}{ANALYSE_ROUTE}", json=json)
- resp.raise_for_status()
+ resp.raise_for_status() # raise for 404 or other non-200 errors
+
+ analysis_response = AnalysisResponse.model_validate(resp.json())
+ analysis_response.is_accepted() # will raise if not accepted
request_id = UUID(resp.json()["request_id"])
self.latest_request_id = request_id
@@ -123,17 +103,15 @@ def submit(self, analysis: str | Callable, **inputs: Any) -> UUID:
def request_result(self, request_id: UUID) -> AnalysisResult | None:
route = RESULT_BY_ID_ROUTE.format(request_id=request_id)
-
resp = self.session.get(f"{self.base_url}{route}")
if resp.status_code == 404:
return None
resp.raise_for_status()
-
response = resp.json()
- return AnalysisResult(**response)
+ return AnalysisResult.model_validate(response)
def get_result(
self,
@@ -157,6 +135,7 @@ def get_result(
return AnalysisResult(
status="error",
analysis_name="",
+ inputs={},
result=None,
created_at=datetime.now(),
finished_at=datetime.now(),
@@ -172,6 +151,7 @@ def get_last_submitted_result(
return AnalysisResult(
status="error",
analysis_name="",
+ inputs={},
result=None,
created_at=datetime.now(),
finished_at=datetime.now(),
@@ -188,6 +168,11 @@ def get_endpoints(self):
resp.raise_for_status()
return resp.json()
+ def get_all_results(self):
+ resp = self.session.get(f"{self.base_url}{RESULTS_ALL_ROUTE}")
+ resp.raise_for_status()
+ return resp.json()
+
def get_request_id_result(
self,
request_id: UUID,
@@ -209,23 +194,26 @@ def get_request_id_result(
time.sleep(poll_interval)
-if __name__ == "__main__":
- import numpy as np
-
- from indigoapi.analyses.peak_fitting import gaussian
+# if __name__ == "__main__":
+# from indigoapi.analyses.peak_fitting import gaussian
- x = np.linspace(0, 20, 200)
+# x = np.round(np.linspace(0, 20, 50), 3)
+# y = np.round(gaussian(x, 10, 5, 1) + (np.random.rand(x.shape[-1]) / 5), 3)
- y = gaussian(x, 10, 5, 1) + (np.random.rand(x.shape[-1]) / 5)
+# client = AnalysisClient()
- client = AnalysisClient()
+# for i in range(5):
+# id = client.submit("double", number=i)
+# result = client.get_request_id_result(id)
+# client.submit("gaussian_fit", x=x, y=y)
- # client.submit(gaussian_fit.__name__, x=x, y=y)
+# for i in client.get_all_results():
+# print(i, "\n")
- client.submit("beam_energy_to_wavelength", beam_energy=15)
+# available_analyses = client.available_analyses(as_strings=True)
- print(client.get_result())
+# for analysis in available_analyses:
+# print(analysis)
- # print(client.get_endpoints())
- # for i in client.list_analyses()[0:4]:
- # print(i)
+# client.submit("gaussian_fit", num=x, y=y)
+# print(client.get_result())
diff --git a/src/indigoapi/config.py b/src/indigoapi/config.py
index faa33e6..34d0eff 100644
--- a/src/indigoapi/config.py
+++ b/src/indigoapi/config.py
@@ -51,6 +51,11 @@ class PluginsConfig(BaseModel):
register_all: bool = False
+# New config for web UI options
+class WebUIConfig(BaseModel):
+ hide_non_webui_jobs: bool = False
+
+
class Config(BaseSettings):
server: ServerConfig = ServerConfig()
queue: QueueConfig = QueueConfig()
@@ -58,6 +63,7 @@ class Config(BaseSettings):
cleanup: CleanupConfig = CleanupConfig()
plugins: PluginsConfig = PluginsConfig()
rabbitmq: RabbitMQConfig = RabbitMQConfig()
+ webui: WebUIConfig = WebUIConfig()
model_config = SettingsConfigDict(
env_nested_delimiter="__",
diff --git a/src/indigoapi/models.py b/src/indigoapi/models.py
index e25fdb5..5f63174 100644
--- a/src/indigoapi/models.py
+++ b/src/indigoapi/models.py
@@ -2,7 +2,7 @@
from typing import Any, Literal
from uuid import UUID, uuid4
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
class AnalysisBaseModel(BaseModel):
@@ -12,21 +12,39 @@ def __getitem__(self, key):
class AnalysisRequest(AnalysisBaseModel):
analysis_name: str
- inputs: dict
- request_id: UUID = uuid4()
- created_at: datetime = datetime.now()
+ inputs: dict[str, Any]
+ request_id: UUID = Field(default_factory=uuid4)
+ created_at: datetime = Field(default_factory=datetime.now)
class AnalysisResult(AnalysisBaseModel):
request_id: UUID | None = None
status: Literal["error", "failed", "running", "completed"]
analysis_name: str
+ inputs: dict[str, Any] | None = None
result: Any
created_at: datetime
- finished_at: datetime
+ finished_at: datetime | None = None
+
+
+class AnalysisResponse(AnalysisBaseModel):
+ request_id: UUID | None = None
+ analysis_name: str
+ inputs: dict[str, Any] | None = None
+ details: str | None = None
+ accepted: bool = False
+
+ def is_accepted(self) -> bool:
+ if not self.accepted:
+ raise ValueError(
+ f"Analysis '{self.analysis_name}' "
+ f"with inputs {self.inputs} "
+ f"was not accepted for processing: "
+ f"{self.details}"
+ )
+
+ return True
if __name__ == "__main__":
request = AnalysisRequest(analysis_name="double", inputs={"number": 5})
-
- print(request.request_id)
diff --git a/src/indigoapi/server.py b/src/indigoapi/server.py
index b59f634..fa7821b 100644
--- a/src/indigoapi/server.py
+++ b/src/indigoapi/server.py
@@ -3,17 +3,20 @@
import asyncio
import logging
from contextlib import asynccontextmanager
+from pathlib import Path
from fastapi import FastAPI
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
from xrpd_toolbox.utils.messenger import Messenger
+import indigoapi
+from indigoapi._version import __version__
from indigoapi.analysis_core import MODULE_NAMES, initialize_analyses
from indigoapi.api.routes import ROUTER
from indigoapi.config import Config
from indigoapi.task_queue import QueueManager, RabbitMQListener, cleanup_results
-from . import __version__
-
config: Config = Config.load_config()
@@ -86,12 +89,26 @@ def start_api() -> FastAPI:
logger.info(f"version: {__version__}")
app = FastAPI(
- title="indigoapi",
+ title=indigoapi.__name__.capitalize(),
version=__version__,
description="An API for fast data analysis jobs",
lifespan=lifespan,
)
+ # Include API routes
app.include_router(ROUTER)
+ # Serve static files
+ static_dir = Path(__file__).parent / "static"
+ if static_dir.exists():
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
+
+ # Serve index.html for the root path
+ @app.get("/")
+ async def serve_index():
+ index_file = Path(__file__).parent / "templates" / "index.html"
+ if index_file.exists():
+ return FileResponse(index_file)
+ return {"message": "Indigo Analysis API. Visit /docs for API documentation"}
+
return app
diff --git a/src/indigoapi/static/app.js b/src/indigoapi/static/app.js
new file mode 100644
index 0000000..fff01da
--- /dev/null
+++ b/src/indigoapi/static/app.js
@@ -0,0 +1,748 @@
+const REFRESH_INTERVAL_MS = 5000; // Configurable refresh interval
+
+class AnalysisAPI {
+ constructor(baseURL = '') {
+ this.baseURL = baseURL || window.location.origin;
+ }
+
+ // New: fetch all jobs if allowed by backend
+ async getAllResults() {
+ const response = await fetch(`${this.baseURL}/results/all`);
+ if (!response.ok) throw new Error('Not allowed or failed to fetch all results');
+ return response.json();
+ }
+
+ async getAnalyses() {
+ const response = await fetch(`${this.baseURL}/get_analyses`);
+ if (!response.ok) throw new Error('Failed to fetch analyses');
+ return response.json();
+ }
+
+ async submitAnalysis(analysisName, inputs) {
+ const response = await fetch(`${this.baseURL}/analyse`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ analysis_name: analysisName,
+ inputs: inputs
+ })
+ });
+
+ if (!response.ok) throw new Error('Failed to submit analysis');
+ return response.json();
+ }
+
+ async getResult(requestId) {
+ const response = await fetch(`${this.baseURL}/result/id/${requestId}`);
+
+ if (!response.ok) throw new Error('Result not found');
+ return response.json();
+ }
+
+ async getLatestResult() {
+ const response = await fetch(`${this.baseURL}/result/latest`);
+
+ if (!response.ok) throw new Error('No results available');
+ return response.json();
+ }
+
+ async getHealth() {
+ const response = await fetch(`${this.baseURL}/health`);
+
+ if (!response.ok) throw new Error('API not available');
+ return response.json();
+ }
+}
+
+class AnalysisUI {
+ constructor() {
+ this.api = new AnalysisAPI();
+ this.analyses = [];
+ this.selectedAnalysis = null;
+ this.requestHistory = [];
+ this.pollIntervals = new Map();
+
+ this.init();
+ }
+
+ async init() {
+ // Check API health
+ try {
+ await this.api.getHealth();
+ } catch (error) {
+ this.showError('API is not available. Make sure the server is running.');
+ return;
+ }
+
+ // Load analyses
+ await this.loadAnalyses();
+
+ // Try to load all jobs from backend if allowed
+ let loadedFromBackend = false;
+
+ try {
+ const allResults = await this.api.getAllResults();
+
+ console.log('Loaded jobs from backend:', allResults);
+
+ if (Array.isArray(allResults) && allResults.length > 0) {
+ // Convert backend results to requestHistory format
+ this.requestHistory = allResults.map(r => ({
+ requestId: r.request_id || r.id || '',
+ analysisName: r.analysis_name || r.name || 'Unknown',
+ inputs: r.inputs || {},
+ status: r.status || 'unknown',
+ result: typeof r.result !== 'undefined' ? r.result : null,
+ createdAt: r.created_at || r.createdAt || '',
+ finishedAt: r.finished_at || r.finishedAt || ''
+ }));
+
+ this.renderResults();
+ loadedFromBackend = true;
+ }
+ } catch (e) {
+ console.error('Error loading jobs from backend:', e);
+
+ // Fallback to local storage if not allowed
+ }
+
+ if (!loadedFromBackend) {
+ this.loadHistoryFromStorage();
+ }
+
+ this.setupEventListeners();
+ }
+
+ async loadAnalyses() {
+ try {
+ this.analyses = await this.api.getAnalyses();
+ this.renderAnalysesList();
+ } catch (error) {
+ this.showError('Failed to load analyses: ' + error.message);
+ }
+ }
+
+ renderAnalysesList() {
+ const list = document.getElementById('analyses-list');
+
+ list.innerHTML = '';
+
+ if (this.analyses.length === 0) {
+ list.innerHTML = '
No analyses available
';
+ return;
+ }
+
+ this.analyses.forEach((analysis) => {
+ const item = document.createElement('div');
+
+ item.className = 'analysis-item';
+
+ if (this.selectedAnalysis?.name === analysis.name) {
+ item.classList.add('selected');
+ }
+
+ const paramsText = analysis.parameters
+ .map(p => `${p.name}: ${p.annotation}`)
+ .join(', ');
+
+ item.innerHTML = `
+ ${analysis.name}
+ ${paramsText || 'No parameters'}
+ `;
+
+ item.addEventListener('click', () => this.selectAnalysis(analysis));
+
+ list.appendChild(item);
+ });
+ }
+
+ selectAnalysis(analysis) {
+ this.selectedAnalysis = analysis;
+
+ this.renderAnalysesList();
+ this.renderInputForm();
+ }
+
+ renderInputForm() {
+ const form = document.getElementById('dynamic-inputs');
+
+ form.innerHTML = '';
+
+ if (!this.selectedAnalysis) {
+ form.innerHTML =
+ 'Select an analysis to view its parameters
';
+ return;
+ }
+
+ const params = this.selectedAnalysis.parameters;
+
+ if (params.length === 0) {
+ form.innerHTML =
+ 'This analysis has no parameters
';
+ return;
+ }
+
+ params.forEach(param => {
+ const group = document.createElement('div');
+
+ group.className = 'form-group';
+
+ const label = document.createElement('label');
+ label.textContent = param.name;
+
+ const inputType = this.getUIInputType(param.annotation);
+
+ let input;
+
+ if (inputType === 'textarea') {
+ input = document.createElement('textarea');
+
+ input.placeholder =
+ `Enter values (comma-separated or JSON array):\n` +
+ `e.g., [1.0, 2.5, 3.7, 4.2]`;
+
+ input.className = 'array-input';
+ input.rows = 4;
+
+ } else if (inputType === 'checkbox') {
+
+ input = document.createElement('input');
+
+ input.type = 'checkbox';
+ input.className = 'checkbox-input';
+
+ } else if (inputType === 'json') {
+
+ input = document.createElement('textarea');
+
+ input.placeholder =
+ `Enter JSON value:\n` +
+ `e.g., {"key": "value"}`;
+
+ input.className = 'json-input';
+ input.rows = 3;
+
+ } else {
+
+ input = document.createElement('input');
+
+ input.type = inputType;
+
+ input.placeholder = param.default
+ ? `Default: ${param.default}`
+ : `Enter ${param.name}`;
+ }
+
+ input.id = `param-${param.name}`;
+ input.dataset.type = param.annotation;
+
+ const typeHint = document.createElement('div');
+
+ typeHint.className = 'parameter-type';
+ typeHint.textContent = `Type: ${param.annotation}`;
+
+ group.appendChild(label);
+ group.appendChild(input);
+ group.appendChild(typeHint);
+
+ form.appendChild(group);
+ });
+ }
+
+ getUIInputType(annotation) {
+ const ann = annotation.toLowerCase();
+
+ // Check for list/array types
+ if (
+ ann.includes('list') ||
+ ann.includes('sequence') ||
+ ann.includes('ndarray') ||
+ ann.includes('array')
+ ) {
+ return 'textarea';
+ }
+
+ // Primitive types
+ if (ann.includes('int')) return 'number';
+ if (ann.includes('float')) return 'number';
+ if (ann.includes('bool')) return 'checkbox';
+ if (ann.includes('str')) return 'text';
+
+ // Complex types
+ if (
+ ann.includes('dict') ||
+ ann.includes('any') ||
+ ann.includes('object')
+ ) {
+ return 'json';
+ }
+
+ return 'text';
+ }
+
+ setupEventListeners() {
+ document
+ .getElementById('submit-btn')
+ .addEventListener('click', () => this.submitAnalysis());
+
+ document
+ .getElementById('clear-btn')
+ .addEventListener('click', () => this.clearHistory());
+
+ // Refresh every N seconds
+ setInterval(() => this.pollForUpdates(), REFRESH_INTERVAL_MS);
+ }
+
+ async submitAnalysis() {
+ if (!this.selectedAnalysis) {
+ this.showError('Please select an analysis first');
+ return;
+ }
+
+ const inputs = this.gatherInputs();
+
+ if (!inputs) return;
+
+ const submitBtn = document.getElementById('submit-btn');
+
+ const originalText = submitBtn.textContent;
+
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Submitting...';
+
+ try {
+ const result = await this.api.submitAnalysis(
+ this.selectedAnalysis.name,
+ inputs
+ );
+
+ this.showSuccess(
+ `Analysis submitted! Request ID: ${result.request_id}`
+ );
+
+ const requestEntry = {
+ requestId: result.request_id,
+ analysisName: this.selectedAnalysis.name,
+ inputs: inputs,
+ status: 'running',
+ result: null,
+ createdAt: new Date().toISOString()
+ };
+
+ this.requestHistory.unshift(requestEntry);
+
+ this.saveHistoryToStorage();
+ this.renderResults();
+
+ this.startPollingForResult(result.request_id);
+
+ } catch (error) {
+
+ this.showError(
+ 'Failed to submit analysis: ' + error.message
+ );
+
+ } finally {
+
+ submitBtn.disabled = false;
+ submitBtn.textContent = originalText;
+ }
+ }
+
+ gatherInputs() {
+ const inputs = {};
+ const params = this.selectedAnalysis.parameters;
+
+ for (const param of params) {
+ const input = document.getElementById(`param-${param.name}`);
+
+ if (!input) continue;
+
+ let value;
+
+ const ann = param.annotation.toLowerCase();
+
+ // Checkbox
+ if (input.type === 'checkbox') {
+ value = input.checked;
+ } else {
+ value = input.value;
+ }
+
+ if (!value && value !== false && value !== 0) {
+ this.showError(`Please fill in ${param.name}`);
+ return null;
+ }
+
+ // Convert type
+ if (
+ ann.includes('list') ||
+ ann.includes('sequence') ||
+ ann.includes('ndarray') ||
+ ann.includes('array')
+ ) {
+
+ value = this.parseArrayInput(value, ann);
+
+ if (value === null) {
+ this.showError(
+ `Invalid array format for ${param.name}.`
+ );
+ return null;
+ }
+
+ } else if (ann.includes('int')) {
+
+ value = parseInt(value, 10);
+
+ if (isNaN(value)) {
+ this.showError(`${param.name} must be a valid integer`);
+ return null;
+ }
+
+ } else if (ann.includes('float')) {
+
+ value = parseFloat(value);
+
+ if (isNaN(value)) {
+ this.showError(`${param.name} must be a valid number`);
+ return null;
+ }
+
+ } else if (
+ ann.includes('dict') ||
+ ann.includes('object') ||
+ (
+ ann.includes('any') &&
+ input.classList.contains('json-input')
+ )
+ ) {
+
+ try {
+ value = JSON.parse(value);
+ } catch (e) {
+ this.showError(
+ `${param.name} must be valid JSON: ${e.message}`
+ );
+ return null;
+ }
+ }
+
+ inputs[param.name] = value;
+ }
+
+ return inputs;
+ }
+
+ parseArrayInput(value, annotation) {
+ // Try JSON first
+ try {
+ const parsed = JSON.parse(value);
+
+ if (Array.isArray(parsed)) {
+ return this.convertArrayElements(parsed, annotation);
+ }
+
+ } catch (e) {
+ // Ignore
+ }
+
+ // Fallback to comma-separated
+ const values = value
+ .split(',')
+ .map(v => v.trim())
+ .filter(v => v.length > 0);
+
+ if (values.length === 0) return null;
+
+ return this.convertArrayElements(values, annotation);
+ }
+
+ convertArrayElements(arr, annotation) {
+ const ann = annotation.toLowerCase();
+
+ // Integer arrays
+ if (
+ ann.includes('int')
+ ) {
+ return arr.map(v => {
+ const n = parseInt(v, 10);
+
+ if (isNaN(n)) {
+ throw new Error(`Invalid integer value: ${v}`);
+ }
+
+ return n;
+ });
+ }
+
+ // Float-like arrays
+ if (
+ ann.includes('float') ||
+ ann.includes('ndarray') ||
+ ann.includes('numpy') ||
+ ann.includes('array')
+ ) {
+ return arr.map(v => {
+ const n = parseFloat(v);
+
+ if (isNaN(n)) {
+ throw new Error(`Invalid float value: ${v}`);
+ }
+
+ return n;
+ });
+ }
+
+ // Default: strings
+ return arr.map(v => String(v));
+ }
+
+ startPollingForResult(requestId) {
+ if (this.pollIntervals.has(requestId)) return;
+
+ const interval = setInterval(async () => {
+ try {
+ const result = await this.api.getResult(requestId);
+
+ const entry = this.requestHistory.find(
+ r => r.requestId === requestId
+ );
+
+ if (entry) {
+ entry.status = result.status;
+ entry.result = result.result;
+ entry.finishedAt = result.finished_at;
+
+ this.saveHistoryToStorage();
+ this.renderResults();
+
+ if (result.status !== 'running') {
+ clearInterval(interval);
+ this.pollIntervals.delete(requestId);
+ }
+ }
+
+ } catch (error) {
+ // Still waiting
+ }
+
+ }, REFRESH_INTERVAL_MS);
+
+ this.pollIntervals.set(requestId, interval);
+ }
+
+ async pollForUpdates() {
+ try {
+ // Reload all results from backend
+ const allResults = await this.api.getAllResults();
+
+ if (Array.isArray(allResults)) {
+ this.requestHistory = allResults.map(r => ({
+ requestId: r.request_id || r.id || '',
+ analysisName: r.analysis_name || r.name || 'Unknown',
+ inputs: r.inputs || {},
+ status: r.status || 'unknown',
+ result: typeof r.result !== 'undefined' ? r.result : null,
+ createdAt: r.created_at || r.createdAt || '',
+ finishedAt: r.finished_at || r.finishedAt || ''
+ }));
+
+ this.saveHistoryToStorage();
+ this.renderResults();
+ }
+
+ } catch (error) {
+ console.error('Auto-refresh failed:', error);
+
+ // Fallback:
+ // continue polling local running jobs
+ this.requestHistory.forEach(entry => {
+ if (entry.status === 'running') {
+ this.startPollingForResult(entry.requestId);
+ }
+ });
+ }
+ }
+
+ renderResults() {
+ const container = document.getElementById('results-container');
+
+ if (
+ !Array.isArray(this.requestHistory) ||
+ this.requestHistory.length === 0
+ ) {
+ container.innerHTML =
+ 'No analysis results yet
';
+
+ return;
+ }
+
+ container.innerHTML = this.requestHistory.map((entry, index) => {
+
+ let statusHtml =
+ `` +
+ `${entry.status}`;
+
+ if (entry.status === 'running') {
+ statusHtml += '';
+
+ } else if (
+ entry.status === 'failed' ||
+ entry.status === 'error'
+ ) {
+ statusHtml +=
+ ' Error';
+ }
+
+ return `
+
+
+
+
+ Request ID: ${entry.requestId || ''}
+
+
+ ${entry.inputs &&
+ Object.keys(entry.inputs).length > 0
+ ? `
+
+ Inputs:
+ ${JSON.stringify(entry.inputs)}
+
+ `
+ : ''
+ }
+
+ ${typeof entry.result !== 'undefined' &&
+ entry.result !== null
+ ? `
+
+ Result:
+ ${this.formatResult(entry.result)}
+
+ `
+ : ''
+ }
+
+
+ ${entry.createdAt
+ ? `Submitted: ${new Date(
+ entry.createdAt
+ ).toLocaleString()}`
+ : ''
+ }
+
+ ${entry.finishedAt
+ ? ` | Finished: ${new Date(
+ entry.finishedAt
+ ).toLocaleString()}`
+ : ''
+ }
+
+
+ `;
+ }).join('');
+ }
+
+ formatResult(result) {
+ if (typeof result === 'object') {
+ return JSON.stringify(result, null, 2);
+ }
+
+ return String(result);
+ }
+
+ deleteResult(index) {
+ const requestId = this.requestHistory[index].requestId;
+
+ if (this.pollIntervals.has(requestId)) {
+ clearInterval(this.pollIntervals.get(requestId));
+ this.pollIntervals.delete(requestId);
+ }
+
+ this.requestHistory.splice(index, 1);
+
+ this.saveHistoryToStorage();
+ this.renderResults();
+ }
+
+ clearHistory() {
+ if (confirm('Are you sure you want to clear all results?')) {
+
+ this.pollIntervals.forEach(interval => clearInterval(interval));
+
+ this.pollIntervals.clear();
+
+ this.requestHistory = [];
+
+ this.saveHistoryToStorage();
+ this.renderResults();
+
+ this.showSuccess('History cleared');
+ }
+ }
+
+ saveHistoryToStorage() {
+ localStorage.setItem(
+ 'analysisHistory',
+ JSON.stringify(this.requestHistory)
+ );
+ }
+
+ loadHistoryFromStorage() {
+ const stored = localStorage.getItem('analysisHistory');
+
+ if (stored) {
+ try {
+ this.requestHistory = JSON.parse(stored).slice(0, 50);
+
+ this.renderResults();
+
+ } catch (error) {
+
+ console.error('Failed to load history:', error);
+ }
+ }
+ }
+
+ showError(message) {
+ this.showMessage(message, 'error-message');
+ }
+
+ showSuccess(message) {
+ this.showMessage(message, 'success-message');
+ }
+
+ showMessage(message, className) {
+ const container = document.getElementById('messages');
+
+ const msg = document.createElement('div');
+
+ msg.className = className;
+ msg.textContent = message;
+
+ container.appendChild(msg);
+
+ setTimeout(() => msg.remove(), 5000);
+ }
+}
+
+// Initialize UI when DOM is ready
+let ui;
+
+document.addEventListener('DOMContentLoaded', () => {
+ ui = new AnalysisUI();
+});
diff --git a/src/indigoapi/static/style.css b/src/indigoapi/static/style.css
new file mode 100644
index 0000000..ba5ed01
--- /dev/null
+++ b/src/indigoapi/static/style.css
@@ -0,0 +1,457 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ min-height: 100vh;
+ padding: 20px;
+ color: #333;
+}
+
+.container {
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+header {
+ background: white;
+ padding: 20px 30px;
+ border-radius: 12px;
+ box-shadow: 0 2px 15px rgba(0, 0, 0, 0.1);
+ margin-bottom: 30px;
+}
+
+header h1 {
+ color: #667eea;
+ font-size: 28px;
+ margin-bottom: 5px;
+}
+
+header p {
+ color: #666;
+ font-size: 14px;
+}
+
+.main-content {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 30px;
+ margin-bottom: 30px;
+}
+
+.card {
+ background: white;
+ border-radius: 12px;
+ padding: 30px;
+ box-shadow: 0 2px 15px rgba(0, 0, 0, 0.1);
+}
+
+.card h2 {
+ color: #667eea;
+ font-size: 20px;
+ margin-bottom: 20px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.card h2::before {
+ content: '';
+ display: inline-block;
+ width: 4px;
+ height: 20px;
+ background: #667eea;
+ border-radius: 2px;
+}
+
+.form-group {
+ margin-bottom: 20px;
+}
+
+label {
+ display: block;
+ margin-bottom: 8px;
+ font-weight: 500;
+ color: #333;
+ font-size: 14px;
+}
+
+select,
+input {
+ width: 100%;
+ padding: 10px 12px;
+ border: 2px solid #e0e0e0;
+ border-radius: 8px;
+ font-size: 14px;
+ transition: all 0.3s ease;
+ font-family: inherit;
+}
+
+select:focus,
+input:focus {
+ outline: none;
+ border-color: #667eea;
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+textarea {
+ width: 100%;
+ padding: 10px 12px;
+ border: 2px solid #e0e0e0;
+ border-radius: 8px;
+ font-size: 14px;
+ transition: all 0.3s ease;
+ font-family: 'Courier New', monospace;
+ resize: vertical;
+}
+
+textarea:focus {
+ outline: none;
+ border-color: #667eea;
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+.array-input {
+ font-size: 13px;
+}
+
+.json-input {
+ font-size: 13px;
+}
+
+input[type="checkbox"] {
+ width: auto;
+ height: 18px;
+ margin-top: 5px;
+ cursor: pointer;
+ accent-color: #667eea;
+}
+
+.checkbox-input {
+ display: flex;
+ align-items: center;
+}
+
+input[type="number"] {
+ width: 100%;
+}
+
+.dynamic-inputs {
+ margin-top: 15px;
+ padding-top: 15px;
+ border-top: 2px solid #f0f0f0;
+}
+
+.input-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+ margin-bottom: 12px;
+ align-items: flex-end;
+}
+
+.input-row input {
+ width: 100%;
+}
+
+.form-group input[type="checkbox"] {
+ width: auto;
+ height: 20px;
+ margin-top: 5px;
+ cursor: pointer;
+}
+
+.form-group input[type="checkbox"]+.parameter-type {
+ margin-top: 5px;
+}
+
+button {
+ padding: 12px 24px;
+ border: none;
+ border-radius: 8px;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ width: 100%;
+}
+
+.btn-primary {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+}
+
+.btn-primary:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
+}
+
+.btn-primary:active {
+ transform: translateY(0);
+}
+
+.btn-primary:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.btn-secondary {
+ background: #f0f0f0;
+ color: #333;
+ margin-top: 10px;
+}
+
+.btn-secondary:hover {
+ background: #e0e0e0;
+}
+
+.btn-danger {
+ background: #ff6b6b;
+ color: white;
+ padding: 6px 12px;
+ font-size: 12px;
+ width: auto;
+}
+
+.btn-danger:hover {
+ background: #ff5252;
+}
+
+.analyses-list {
+ max-height: 400px;
+ overflow-y: auto;
+}
+
+.analysis-item {
+ padding: 12px;
+ margin-bottom: 10px;
+ background: #f8f9ff;
+ border-left: 4px solid #667eea;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.analysis-item:hover {
+ background: #f0f2ff;
+ transform: translateX(5px);
+}
+
+.analysis-item.selected {
+ background: #e8ebff;
+ border-left-color: #764ba2;
+}
+
+.analysis-item-name {
+ font-weight: 600;
+ color: #333;
+ margin-bottom: 5px;
+}
+
+.analysis-item-params {
+ font-size: 12px;
+ color: #666;
+}
+
+.status-badge {
+ display: inline-block;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.status-completed {
+ background: #d4edda;
+ color: #155724;
+}
+
+.status-running {
+ background: #fff3cd;
+ color: #856404;
+}
+
+.status-failed {
+ background: #f8d7da;
+ color: #721c24;
+}
+
+.status-error {
+ background: #f8d7da;
+ color: #721c24;
+}
+
+.results-container {
+ margin-top: 20px;
+}
+
+.result-item {
+ background: #f8f9ff;
+ border-left: 4px solid #667eea;
+ padding: 15px;
+ margin-bottom: 15px;
+ border-radius: 4px;
+}
+
+.result-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
+}
+
+.result-id {
+ font-family: 'Courier New', monospace;
+ font-size: 12px;
+ color: #666;
+}
+
+.result-content {
+ color: #333;
+ font-size: 14px;
+ word-break: break-all;
+}
+
+.result-time {
+ font-size: 12px;
+ color: #999;
+ margin-top: 10px;
+}
+
+.loading {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ background: #667eea;
+ border-radius: 50%;
+ animation: pulse 1.5s ease-in-out infinite;
+ margin-left: 5px;
+}
+
+@keyframes pulse {
+
+ 0%,
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+
+ 50% {
+ opacity: 0.5;
+ transform: scale(1.2);
+ }
+}
+
+.error-message {
+ background: #f8d7da;
+ color: #721c24;
+ padding: 12px;
+ border-radius: 8px;
+ margin-bottom: 15px;
+ border-left: 4px solid #f5c6cb;
+}
+
+.success-message {
+ background: #d4edda;
+ color: #155724;
+ padding: 12px;
+ border-radius: 8px;
+ margin-bottom: 15px;
+ border-left: 4px solid #c3e6cb;
+}
+
+.info-message {
+ background: #d1ecf1;
+ color: #0c5460;
+ padding: 12px;
+ border-radius: 8px;
+ margin-bottom: 15px;
+ border-left: 4px solid #bee5eb;
+}
+
+.no-results {
+ text-align: center;
+ color: #999;
+ padding: 40px 20px;
+ font-style: italic;
+}
+
+.full-width {
+ grid-column: 1 / -1;
+}
+
+@media (max-width: 768px) {
+ .main-content {
+ grid-template-columns: 1fr;
+ }
+
+ header h1 {
+ font-size: 22px;
+ }
+
+ .input-row {
+ grid-template-columns: 1fr;
+ }
+
+ .result-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px;
+ }
+}
+
+.loading-spinner {
+ display: inline-block;
+ width: 20px;
+ height: 20px;
+ border: 3px solid #f3f3f3;
+ border-top: 3px solid #667eea;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin-left: 10px;
+}
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+.parameter-type {
+ font-size: 11px;
+ color: #999;
+ font-family: 'Courier New', monospace;
+ margin-top: 3px;
+}
+
+.scrollable {
+ overflow-y: auto;
+ max-height: 600px;
+}
+
+::-webkit-scrollbar {
+ width: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: #f1f1f1;
+ border-radius: 10px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #667eea;
+ border-radius: 10px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #764ba2;
+}
diff --git a/src/indigoapi/task_queue/manager.py b/src/indigoapi/task_queue/manager.py
index d1b55c2..7c7fbf2 100644
--- a/src/indigoapi/task_queue/manager.py
+++ b/src/indigoapi/task_queue/manager.py
@@ -1,32 +1,136 @@
import asyncio
+import inspect
import logging
-import time
from datetime import datetime
from uuid import UUID
from xrpd_toolbox.utils.messenger import DEFAULT_DII_PROCESSED_DESTINATION, Messenger
from indigoapi.analysis_core.registry import get_analysis
-from indigoapi.models import AnalysisRequest, AnalysisResult
+from indigoapi.models import AnalysisRequest, AnalysisResponse, AnalysisResult
+from indigoapi.utils.serialisers import deserialise, serialise
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
+def convert_inputs(inputs: dict, annotations: dict) -> dict:
+ """
+ Convert a dictionary of raw JSON inputs using
+ a dictionary of parameter annotations.
+ """
+
+ converted = {}
+
+ for key, value in inputs.items():
+ annotation = annotations.get(key, inspect.Parameter.empty)
+
+ converted[key] = deserialise(value, annotation)
+
+ return converted
+
+
+def get_function_annotations(func) -> dict:
+ """
+ Extract parameter annotations from a function.
+ """
+
+ sig = inspect.signature(func)
+
+ return {name: param.annotation for name, param in sig.parameters.items()}
+
+
+def validate_inputs(func, inputs: dict):
+ sig = inspect.signature(func)
+
+ errors = []
+
+ for name, param in sig.parameters.items():
+ # Missing required parameter
+ if name not in inputs and param.default is inspect.Parameter.empty:
+ errors.append(f"Missing required parameter: {name}")
+ continue
+
+ if name not in inputs:
+ continue
+
+ value = inputs[name]
+ annotation = param.annotation
+
+ try:
+ deserialise(value, annotation)
+
+ except Exception as e:
+ errors.append(f"Invalid value for '{name}': {e}")
+
+ # Unknown extra parameters
+ extra = set(inputs) - set(sig.parameters)
+
+ if extra:
+ errors.append(f"Unknown parameters: {sorted(extra)}")
+
+ if errors:
+ raise ValueError("\n".join(errors))
+
+
class QueueManager:
def __init__(self, workers: int = 2, messenger: Messenger | None = None):
self.queue: asyncio.Queue[AnalysisRequest] = asyncio.Queue(maxsize=0) # 0 = inf
- self.results: dict[UUID, tuple[AnalysisResult, float]] = {}
+ self.results: dict[UUID, AnalysisResult] = {}
self.workers = workers
self.latest_result: AnalysisResult | None = None
self.messenger = messenger
logger.info(self.queue)
- async def enqueue(self, job: AnalysisRequest):
+ async def enqueue(self, job: AnalysisRequest) -> AnalysisResponse:
job.created_at = datetime.now()
logger.info(job)
- await self.queue.put(job)
+
+ pending_result = AnalysisResult(
+ request_id=job.request_id,
+ analysis_name=job.analysis_name,
+ inputs=job.inputs,
+ status="running",
+ result=None,
+ created_at=job.created_at,
+ finished_at=None,
+ )
+
+ try:
+ analysis_fn = get_analysis(job.analysis_name) # will raise if no analysis
+ validate_inputs(analysis_fn, job.inputs) # will raise if inputs are invalid
+ self.results[job.request_id] = pending_result
+ await self.queue.put(job)
+ analysis_response = AnalysisResponse(
+ request_id=job.request_id,
+ analysis_name=job.analysis_name,
+ inputs=job.inputs,
+ accepted=True,
+ )
+
+ except Exception as e:
+ pending_result.status = "failed"
+ pending_result.result = str(e)
+ pending_result.finished_at = datetime.now()
+ self.results[job.request_id] = pending_result
+ self.latest_result = pending_result
+
+ analysis_response = AnalysisResponse(
+ request_id=job.request_id,
+ analysis_name=job.analysis_name,
+ details=str(e),
+ inputs=job.inputs,
+ accepted=False,
+ )
+
+ if self.messenger is not None:
+ self.messenger.send_message(
+ DEFAULT_DII_PROCESSED_DESTINATION,
+ analysis_response.model_dump_json(),
+ )
+
+ return analysis_response
async def worker(self):
while True:
@@ -34,34 +138,39 @@ async def worker(self):
try:
analysis_fn = get_analysis(job.analysis_name)
+ annotations = get_function_annotations(analysis_fn)
- result_value = await analysis_fn(**job.inputs)
+ # validate_inputs(analysis_fn, job.inputs)
+ converted_inputs = convert_inputs(job.inputs, annotations)
+ result_value = await analysis_fn(**converted_inputs)
- analysis_result = AnalysisResult(
- request_id=job.request_id,
- analysis_name=job.analysis_name,
- status="completed",
- result=result_value,
- created_at=job.created_at,
- finished_at=datetime.now(),
- )
+ # Convert numpy and other non-serializable types
+ result_value = serialise(result_value)
+
+ result = result_value
+ status = "completed"
+ finished_at = datetime.now()
except Exception as e:
- analysis_result = AnalysisResult(
- request_id=job.request_id,
- analysis_name=job.analysis_name,
- status="failed",
- result=str(e),
- created_at=job.created_at,
- finished_at=datetime.now(),
- )
+ result = str(e)
+ status = "failed"
+ finished_at = datetime.now()
+
+ finally:
+ self.queue.task_done()
- if self.messenger is not None:
- self.messenger.send_message(
- DEFAULT_DII_PROCESSED_DESTINATION,
- analysis_result.model_dump_json(),
- )
+ # get pending result and update with final status and result
+ analysis_result: AnalysisResult = self.results[job.request_id]
+ analysis_result.status = status
+ analysis_result.result = result
+ analysis_result.finished_at = finished_at
- self.results[job.request_id] = (analysis_result, time.time())
+ self.results[job.request_id] = analysis_result
# store latest result
self.latest_result = analysis_result
+
+ if self.messenger is not None:
+ self.messenger.send_message(
+ DEFAULT_DII_PROCESSED_DESTINATION,
+ analysis_result.model_dump_json(),
+ )
diff --git a/src/indigoapi/templates/index.html b/src/indigoapi/templates/index.html
new file mode 100644
index 0000000..72ae7ba
--- /dev/null
+++ b/src/indigoapi/templates/index.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+ Indigo Analysis API
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Analysis Parameters
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/indigoapi/utils/serialisers.py b/src/indigoapi/utils/serialisers.py
new file mode 100644
index 0000000..3ad3e56
--- /dev/null
+++ b/src/indigoapi/utils/serialisers.py
@@ -0,0 +1,95 @@
+"""Utilities for serializing and converting data types."""
+
+import inspect
+from typing import Any, get_args, get_origin
+
+import numpy as np
+
+
+def serialise(result: Any) -> Any:
+ """
+ Convert numpy and other non-JSON-serializable types to native Python types.
+
+ Recursively handles:
+ - numpy scalars (int64, float64, etc.) → Python int/float
+ - numpy arrays → Python lists
+ - numpy bool → Python bool
+ - Nested structures (lists, dicts, tuples)
+
+ Args:
+ result: The result value to serialize
+
+ Returns:
+ A JSON-serializable version of the result
+ """
+ if result is None:
+ return None
+
+ # Handle numpy types
+ if isinstance(result, np.ndarray):
+ return result.tolist()
+ elif isinstance(result, (np.integer, np.floating)):
+ return result.item()
+ elif isinstance(result, np.bool_):
+ return bool(result)
+ elif isinstance(result, np.complexfloating):
+ return complex(result)
+
+ # Handle Python collections recursively
+ elif isinstance(result, dict):
+ return {key: serialise(value) for key, value in result.items()}
+ elif isinstance(result, (list, tuple)):
+ return [serialise(item) for item in result]
+ elif isinstance(result, set):
+ return [serialise(item) for item in result]
+
+ # Return other types as-is
+ return result
+
+
+def deserialise(value: Any, annotation: Any) -> Any:
+ """
+ Convert a JSON-deserialized value into the expected Python type
+ based on a function parameter annotation.
+ """
+
+ if annotation is inspect.Parameter.empty:
+ return value
+
+ ann_str = str(annotation).lower()
+
+ if annotation is np.ndarray or "ndarray" in ann_str or "numpy" in ann_str:
+ return np.array(value, dtype=float)
+
+ if annotation is int:
+ return int(value)
+
+ if annotation is float:
+ return float(value)
+
+ if annotation is bool:
+ return bool(value)
+
+ if annotation is str:
+ return str(value)
+
+ origin = get_origin(annotation)
+
+ if origin is list:
+ item_type = get_args(annotation)[0]
+
+ return [deserialise(v, item_type) for v in value]
+
+ if origin is tuple:
+ item_types = get_args(annotation)
+
+ return tuple(deserialise(v, t) for v, t in zip(value, item_types, strict=True))
+
+ if origin is dict:
+ key_type, val_type = get_args(annotation)
+
+ return {
+ deserialise(k, key_type): deserialise(v, val_type) for k, v in value.items()
+ }
+
+ return value
diff --git a/tests/test_api_routes.py b/tests/test_api_routes.py
index ad57ff6..1730824 100644
--- a/tests/test_api_routes.py
+++ b/tests/test_api_routes.py
@@ -38,3 +38,34 @@ def test_api_result_latest_and_not_found():
missing_response = client.get("/result/id/00000000-0000-0000-0000-000000000000")
assert missing_response.status_code == 404
+
+
+def test_api_latest_result_not_found():
+ app = start_api()
+ with TestClient(app) as client:
+ response = client.get("/result/latest")
+ assert response.status_code == 404
+ assert response.json()["detail"] == "No results yet"
+
+
+def test_api_result_by_id_and_all_results():
+ app = start_api()
+ with TestClient(app) as client:
+ result = AnalysisResult(
+ request_id=uuid.uuid4(),
+ analysis_name="double",
+ status="completed",
+ result=10,
+ created_at=datetime.now(),
+ finished_at=datetime.now(),
+ )
+ client.app.state.queue_manager.results[result.request_id] = result # type: ignore
+
+ response = client.get(f"/result/id/{result.request_id}")
+ assert response.status_code == 200
+ assert response.json()["analysis_name"] == "double"
+
+ all_response = client.get("/results/all")
+ assert all_response.status_code == 200
+ assert len(all_response.json()) == 1
+ assert all_response.json()[0]["analysis_name"] == "double"
diff --git a/tests/test_client.py b/tests/test_client.py
index cb506cf..1891786 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -1,4 +1,5 @@
import uuid
+from datetime import datetime
from unittest.mock import Mock
import numpy as np
@@ -6,12 +7,12 @@
from indigoapi.client import AnalysisClient
from indigoapi.models import AnalysisResult
+from indigoapi.utils.serialisers import serialise
-def test_client_convert_to_serialisable():
- client = AnalysisClient(session=Mock())
+def test_serialise():
- converted = client._convert_to_serialisable(
+ converted = serialise(
{
"x": np.array([1, 2]),
"n": np.int64(3),
@@ -31,7 +32,11 @@ def test_client_convert_to_serialisable():
def test_client_submit_and_latest_request_id():
response_id = str(uuid.uuid4())
response = Mock()
- response.json.return_value = {"request_id": response_id}
+ response.json.return_value = {
+ "request_id": response_id,
+ "analysis_name": "double",
+ "accepted": True,
+ }
response.raise_for_status = Mock()
session = Mock()
@@ -45,6 +50,31 @@ def test_client_submit_and_latest_request_id():
session.post.assert_called_once()
+def test_client_submit_with_callable_analysis_uses_function_name():
+ response_id = str(uuid.uuid4())
+ response = Mock()
+ response.json.return_value = {
+ "request_id": response_id,
+ "analysis_name": "fake_analysis",
+ "accepted": True,
+ }
+ response.raise_for_status = Mock()
+
+ session = Mock()
+ session.post.return_value = response
+
+ def fake_analysis(x):
+ return x
+
+ client = AnalysisClient(base_url="http://test", session=session)
+ request_id = client.submit(fake_analysis, x=1)
+
+ assert request_id == uuid.UUID(response_id)
+ assert client.latest_request_id == request_id
+ session.post.assert_called_once()
+ assert session.post.call_args.kwargs["json"]["analysis_name"] == "fake_analysis"
+
+
def test_client_request_result_404():
response = Mock(status_code=404)
response.raise_for_status = Mock()
@@ -98,6 +128,81 @@ def test_client_health_and_endpoints():
assert client.get_endpoints() == [{"path": "/health", "methods": ["GET"]}]
+def test_client_available_analyses_raw():
+ response = Mock()
+ response.status_code = 200
+ response.raise_for_status = Mock()
+ response.json.return_value = [
+ {
+ "name": "gaussian_fit",
+ "parameters": [],
+ "annotations": "AnalysisResult",
+ }
+ ]
+
+ session = Mock()
+ session.get.return_value = response
+
+ client = AnalysisClient(base_url="http://test", session=session)
+ analyses = client.available_analyses(as_strings=False)
+
+ assert isinstance(analyses, list)
+
+
+def test_client_get_all_results():
+ response = Mock()
+ response.status_code = 200
+ response.raise_for_status = Mock()
+ response.json.return_value = [
+ {
+ "request_id": str(uuid.uuid4()),
+ "analysis_name": "double",
+ "status": "completed",
+ "result": 4,
+ "created_at": "2024-01-01T00:00:00",
+ "finished_at": "2024-01-01T00:00:01",
+ }
+ ]
+
+ session = Mock()
+ session.get.return_value = response
+
+ client = AnalysisClient(session=session)
+ results = client.get_all_results()
+
+ assert isinstance(results, list)
+ assert results[0]["analysis_name"] == "double"
+
+
+def test_client_get_last_submitted_result_no_latest():
+ client = AnalysisClient(session=Mock())
+ result = client.get_last_submitted_result(timeout=0.01, poll_interval=0.01)
+
+ assert result.status == "error"
+ assert result.analysis_name == ""
+
+
+def test_client_get_request_id_result_success(monkeypatch):
+ request_id = uuid.uuid4()
+ expected_result = AnalysisResult(
+ request_id=request_id,
+ analysis_name="double",
+ status="completed",
+ inputs={"number": 2},
+ result=4,
+ created_at=datetime.now(),
+ finished_at=datetime.now(),
+ )
+
+ client = AnalysisClient(session=Mock())
+ client.request_result = Mock(return_value=expected_result)
+
+ result = client.get_request_id_result(request_id, timeout=0.1, poll_interval=0.01)
+
+ assert result is expected_result
+ client.request_result.assert_called_once_with(request_id)
+
+
def test_client_list_analyses_as_strings():
session = Mock()
session.get.return_value.json.return_value = [
@@ -107,14 +212,14 @@ def test_client_list_analyses_as_strings():
{"name": "x", "annotation": "np.ndarray", "default": None},
{"name": "y", "annotation": "np.ndarray", "default": None},
],
- "return_annotation": "AnalysisResult",
+ "annotations": "AnalysisResult",
}
]
session.get.return_value.status_code = 200
session.get.return_value.raise_for_status = Mock()
client = AnalysisClient(base_url="http://test", session=session)
- signatures = client.list_analyses(as_strings=True)
+ signatures = client.available_analyses(as_strings=True)
assert isinstance(signatures, list)
assert isinstance(signatures[0], str)
@@ -166,3 +271,102 @@ def test_client_get_last_submitted_result():
assert isinstance(result, AnalysisResult)
assert result.request_id == request_id
+
+
+def test_client_request_result_success():
+ request_id = uuid.uuid4()
+ response = Mock(status_code=200)
+ response.raise_for_status = Mock()
+ response.json.return_value = {
+ "request_id": str(request_id),
+ "analysis_name": "double",
+ "status": "completed",
+ "result": 4,
+ "created_at": "2024-01-01T00:00:00",
+ "finished_at": "2024-01-01T00:00:01",
+ }
+
+ session = Mock()
+ session.get.return_value = response
+
+ client = AnalysisClient(session=session)
+ result = client.request_result(request_id)
+
+ assert isinstance(result, AnalysisResult)
+ assert result.request_id == request_id
+ assert result.status == "completed"
+
+
+def test_client_get_result_retries_after_failure(monkeypatch):
+ failure_response = Mock()
+ failure_response.raise_for_status.side_effect = RuntimeError("temporary failure")
+
+ success_response = Mock()
+ success_response.raise_for_status = Mock()
+ success_response.json.return_value = {
+ "request_id": str(uuid.uuid4()),
+ "analysis_name": "double",
+ "status": "completed",
+ "result": 4,
+ "created_at": "2024-01-01T00:00:00",
+ "finished_at": "2024-01-01T00:00:01",
+ }
+
+ session = Mock()
+ session.get.side_effect = [failure_response, success_response]
+
+ client = AnalysisClient(session=session)
+ monkeypatch.setattr("indigoapi.client.time.sleep", lambda _: None)
+
+ result = client.get_result(timeout=1.0, poll_interval=0.01)
+
+ assert isinstance(result, AnalysisResult)
+ assert result.analysis_name == "double"
+ assert session.get.call_count == 2
+
+
+def test_client_submit_rejected_analysis_raises_value_error():
+ response = Mock()
+ response.raise_for_status = Mock()
+ response.json.return_value = {
+ "request_id": str(uuid.uuid4()),
+ "analysis_name": "bad_analysis",
+ "inputs": {"x": 1},
+ "details": "Invalid input",
+ "accepted": False,
+ }
+
+ session = Mock()
+ session.post.return_value = response
+
+ client = AnalysisClient(base_url="http://test", session=session)
+
+ with pytest.raises(ValueError, match="was not accepted for processing"):
+ client.submit("bad_analysis", x=1)
+
+
+def test_client_format_analysis_signature_variants():
+ client = AnalysisClient(session=Mock())
+
+ no_params_signature = client._format_analysis_signature(
+ {
+ "name": "ping",
+ "parameters": [],
+ "annotations": "bool",
+ }
+ )
+ assert no_params_signature == "ping() -> bool:"
+
+ param_signature = client._format_analysis_signature(
+ {
+ "name": "gaussian_fit",
+ "parameters": [
+ {"name": "x", "annotation": "np.ndarray", "default": None},
+ {"name": "scale", "annotation": "float", "default": 1.0},
+ ],
+ "annotations": "AnalysisResult",
+ }
+ )
+ assert "x: np.ndarray" in param_signature
+ assert "scale: float = 1.0" in param_signature
+ assert param_signature.endswith("-> AnalysisResult:")
diff --git a/tests/test_config.py b/tests/test_config.py
index d815c22..f50f794 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -1,4 +1,4 @@
-from indigoapi.config import Config
+from indigoapi.config import Config, RabbitMQConfig
def test_config_loads_path_from_env(tmp_path, monkeypatch):
@@ -23,3 +23,10 @@ def test_config_default_values():
cfg = Config()
assert cfg.results.ttl_seconds == 3600
assert cfg.cleanup.interval_seconds == 300
+
+
+def test_rabbitmq_address_property():
+ cfg = RabbitMQConfig(
+ host="example.com", username="alice", password="secret", port=1234
+ )
+ assert cfg.address == "stomp://alice:secret@example.com:1234/"
diff --git a/tests/test_gaussian_fit.py b/tests/test_gaussian_fit.py
index cc6821a..37ab2ca 100644
--- a/tests/test_gaussian_fit.py
+++ b/tests/test_gaussian_fit.py
@@ -41,7 +41,7 @@ def test_client_lists_analyses():
# Now queue_manager exists
client = AnalysisClient(base_url=str(client_http.base_url), session=client_http) # type: ignore
- client.list_analyses()
+ client.available_analyses()
def test_client_lists_analyses_as_strings():
@@ -51,7 +51,7 @@ def test_client_lists_analyses_as_strings():
with TestClient(app) as client_http:
client = AnalysisClient(base_url=str(client_http.base_url), session=client_http) # type: ignore
- signatures = client.list_analyses(as_strings=True)
+ signatures = client.available_analyses(as_strings=True)
assert isinstance(signatures, list)
assert any(isinstance(sig, str) for sig in signatures)
diff --git a/tests/test_models.py b/tests/test_models.py
index a81c970..b9a4b95 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -1,6 +1,8 @@
from datetime import datetime
-from indigoapi.models import AnalysisRequest, AnalysisResult
+import pytest
+
+from indigoapi.models import AnalysisRequest, AnalysisResponse, AnalysisResult
def test_analysis_request_item_access():
@@ -27,3 +29,17 @@ def test_analysis_request_defaults():
request = AnalysisRequest(analysis_name="double", inputs={})
assert request.request_id is not None
assert request.created_at is not None
+
+
+def test_analysis_response_is_accepted():
+ response = AnalysisResponse(analysis_name="double", accepted=True)
+ assert response.is_accepted()
+
+
+def test_analysis_response_rejects_unaccepted():
+ response = AnalysisResponse(
+ analysis_name="double", accepted=False, details="invalid"
+ )
+
+ with pytest.raises(ValueError, match="was not accepted"):
+ response.is_accepted()
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index c2642ce..2ee254a 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -19,6 +19,16 @@ async def test_sync_plugin():
assert result == 10
+@pytest.mark.asyncio
+async def test_sleep_plugin():
+
+ fn = get_analysis("delay")
+
+ result = await fn(5)
+
+ assert result == "Slept for 5 seconds"
+
+
@pytest.mark.asyncio
async def test_sum_numbers():
diff --git a/tests/test_queue_manager.py b/tests/test_queue_manager.py
index 68a898c..367fbf1 100644
--- a/tests/test_queue_manager.py
+++ b/tests/test_queue_manager.py
@@ -6,16 +6,7 @@
from indigoapi.models import AnalysisRequest
from indigoapi.task_queue import QueueManager
-
-
-async def wait_for_result(queue_manager, request_id, timeout=1.0):
- start = asyncio.get_running_loop().time()
- while True:
- if request_id in queue_manager.results:
- return
- if asyncio.get_running_loop().time() - start > timeout:
- raise TimeoutError()
- await asyncio.sleep(0.01)
+from indigoapi.task_queue.manager import validate_inputs
@pytest.mark.asyncio
@@ -33,7 +24,7 @@ async def fake_analysis(number):
await queue_manager.enqueue(job)
worker_task = asyncio.create_task(queue_manager.worker())
- await asyncio.wait_for(wait_for_result(queue_manager, job.request_id), timeout=1.0)
+ await asyncio.wait_for(queue_manager.queue.join(), timeout=1.0)
assert job.request_id in queue_manager.results
assert queue_manager.latest_result is not None
@@ -44,6 +35,59 @@ async def fake_analysis(number):
await worker_task
+@pytest.mark.asyncio
+async def test_queue_manager_enqueue_success_sends_message(monkeypatch):
+ class FakeMessenger:
+ def __init__(self):
+ self.sent = []
+
+ def send_message(self, destination, message):
+ self.sent.append((destination, message))
+
+ messenger = FakeMessenger()
+ queue_manager = QueueManager(
+ workers=1,
+ messenger=cast(Messenger, messenger),
+ )
+
+ async def fake_analysis(number):
+ return number * 2
+
+ monkeypatch.setattr(
+ "indigoapi.analysis_core.registry.get_analysis", lambda name: fake_analysis
+ )
+
+ job = AnalysisRequest(analysis_name="double", inputs={"number": 2})
+ response = await queue_manager.enqueue(job)
+
+ assert response.accepted is True
+ assert messenger.sent
+
+
+def test_validate_inputs_missing_required_parameter():
+ def analysis(x: int, y: int = 1):
+ return x + y
+
+ with pytest.raises(ValueError, match="Missing required parameter: x"):
+ validate_inputs(analysis, {"y": 2})
+
+
+def test_validate_inputs_invalid_value_type():
+ def analysis(x: int):
+ return x
+
+ with pytest.raises(ValueError, match="Invalid value for 'x'"):
+ validate_inputs(analysis, {"x": "not-an-int"})
+
+
+def test_validate_inputs_unknown_extra_parameter():
+ def analysis(x: int):
+ return x
+
+ with pytest.raises(ValueError, match="Unknown parameters"):
+ validate_inputs(analysis, {"x": 1, "extra": 2})
+
+
@pytest.mark.asyncio
async def test_queue_manager_worker_failure_sends_message(monkeypatch):
class FakeMessenger:
@@ -59,17 +103,19 @@ def send_message(self, destination, message):
messenger=cast(Messenger, messenger),
)
+ async def failing_analysis():
+ raise KeyError("missing")
+
monkeypatch.setattr(
"indigoapi.analysis_core.registry.get_analysis",
- lambda name: (_ for _ in ()).throw(KeyError("missing")),
+ lambda name: failing_analysis,
)
job = AnalysisRequest(analysis_name="missing", inputs={})
await queue_manager.enqueue(job)
worker_task = asyncio.create_task(queue_manager.worker())
- await asyncio.wait_for(wait_for_result(queue_manager, job.request_id), timeout=1.0)
-
+ await asyncio.wait_for(queue_manager.queue.join(), timeout=1.0)
assert job.request_id in queue_manager.results
assert queue_manager.latest_result is not None
assert queue_manager.latest_result.status == "failed"
@@ -78,3 +124,35 @@ def send_message(self, destination, message):
worker_task.cancel()
with pytest.raises(asyncio.CancelledError):
await worker_task
+
+
+@pytest.mark.asyncio
+async def test_queue_manager_enqueue_failure_sets_latest_result_and_sends_message(
+ monkeypatch,
+):
+ class FakeMessenger:
+ def __init__(self):
+ self.sent = []
+
+ def send_message(self, destination, message):
+ self.sent.append((destination, message))
+
+ messenger = FakeMessenger()
+ queue_manager = QueueManager(
+ workers=1,
+ messenger=cast(Messenger, messenger),
+ )
+
+ monkeypatch.setattr(
+ "indigoapi.analysis_core.registry.get_analysis",
+ lambda name: (_ for _ in ()).throw(KeyError("missing")),
+ )
+
+ job = AnalysisRequest(analysis_name="missing", inputs={})
+ response = await queue_manager.enqueue(job)
+
+ assert response.accepted is False
+ assert queue_manager.latest_result is not None
+ assert queue_manager.latest_result.status == "failed"
+ assert messenger.sent
+ assert job.request_id in queue_manager.results
diff --git a/tests/test_serialisers.py b/tests/test_serialisers.py
new file mode 100644
index 0000000..8db5e77
--- /dev/null
+++ b/tests/test_serialisers.py
@@ -0,0 +1,56 @@
+import inspect
+
+import numpy as np
+import pytest
+
+from indigoapi.utils.serialisers import deserialise, serialise
+
+
+@pytest.mark.parametrize(
+ "value, expected",
+ [
+ (np.int64(5), 5),
+ (np.float32(1.5), 1.5),
+ (np.bool_(True), True),
+ (np.complex128(1 + 2j), 1 + 2j),
+ (np.array([1, 2]), [1, 2]),
+ (np.array([[1, 2], [3, 4]]), [[1, 2], [3, 4]]),
+ ({1, 2}, [1, 2]),
+ (None, None),
+ ],
+)
+def test_serialise_numpy_scalars_and_collections(value, expected):
+ result = serialise(value)
+
+ if isinstance(value, set):
+ assert sorted(result) == expected
+ else:
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "value, annotation, expected",
+ [
+ ("1", int, 1),
+ ("1.5", float, 1.5),
+ (1, bool, True),
+ (1, str, "1"),
+ ([1, 2, 3], np.ndarray, np.array([1.0, 2.0, 3.0])),
+ (["1", "2"], list[int], [1, 2]),
+ (("1", 2), tuple[str, int], ("1", 2)),
+ ({"a": "1"}, dict[str, int], {"a": 1}),
+ ],
+)
+def test_deserialise_native_annotations_and_nested_types(value, annotation, expected):
+ result = deserialise(value, annotation)
+
+ if annotation is np.ndarray:
+ assert isinstance(result, np.ndarray)
+ assert result.tolist() == expected.tolist()
+ else:
+ assert result == expected
+
+
+def test_deserialise_no_annotation_returns_original_value():
+ value = {"x": 1}
+ assert deserialise(value, inspect.Parameter.empty) == value
diff --git a/tests/test_server.py b/tests/test_server.py
new file mode 100644
index 0000000..b4939eb
--- /dev/null
+++ b/tests/test_server.py
@@ -0,0 +1,30 @@
+from pathlib import Path
+
+from fastapi.testclient import TestClient
+
+from indigoapi.server import start_api
+
+
+def test_start_api_serves_root_index():
+ app = start_api()
+ with TestClient(app) as client:
+ response = client.get("/")
+ assert response.status_code == 200
+ assert "Indigo" in response.text or "