datalab-platform 1.0.4__py3-none-any.whl → 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- datalab/__init__.py +1 -1
- datalab/config.py +4 -0
- datalab/control/baseproxy.py +160 -0
- datalab/control/remote.py +175 -1
- datalab/data/doc/DataLab_en.pdf +0 -0
- datalab/data/doc/DataLab_fr.pdf +0 -0
- datalab/data/icons/control/copy_connection_info.svg +11 -0
- datalab/data/icons/control/start_webapi_server.svg +19 -0
- datalab/data/icons/control/stop_webapi_server.svg +7 -0
- datalab/gui/main.py +221 -2
- datalab/gui/settings.py +10 -0
- datalab/gui/tour.py +2 -3
- datalab/locale/fr/LC_MESSAGES/datalab.mo +0 -0
- datalab/locale/fr/LC_MESSAGES/datalab.po +87 -1
- datalab/tests/__init__.py +32 -1
- datalab/tests/backbone/config_unit_test.py +1 -1
- datalab/tests/backbone/main_app_test.py +4 -0
- datalab/tests/backbone/memory_leak.py +1 -1
- datalab/tests/features/common/createobject_unit_test.py +1 -1
- datalab/tests/features/common/misc_app_test.py +5 -0
- datalab/tests/features/control/call_method_unit_test.py +104 -0
- datalab/tests/features/control/embedded1_unit_test.py +8 -0
- datalab/tests/features/control/remoteclient_app_test.py +39 -35
- datalab/tests/features/control/simpleclient_unit_test.py +7 -3
- datalab/tests/features/hdf5/h5browser2_unit.py +1 -1
- datalab/tests/features/image/background_dialog_test.py +2 -2
- datalab/tests/features/image/imagetools_unit_test.py +1 -1
- datalab/tests/features/signal/baseline_dialog_test.py +1 -1
- datalab/tests/webapi_test.py +395 -0
- datalab/webapi/__init__.py +95 -0
- datalab/webapi/actions.py +318 -0
- datalab/webapi/adapter.py +642 -0
- datalab/webapi/controller.py +379 -0
- datalab/webapi/routes.py +576 -0
- datalab/webapi/schema.py +198 -0
- datalab/webapi/serialization.py +388 -0
- datalab/widgets/status.py +61 -0
- {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/METADATA +6 -2
- {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/RECORD +45 -33
- /datalab/data/icons/{libre-gui-link.svg → control/libre-gui-link.svg} +0 -0
- /datalab/data/icons/{libre-gui-unlink.svg → control/libre-gui-unlink.svg} +0 -0
- {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/WHEEL +0 -0
- {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/entry_points.txt +0 -0
- {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/top_level.txt +0 -0
datalab/webapi/routes.py
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
# Copyright (c) DataLab Platform Developers, BSD 3-Clause License
|
|
2
|
+
# See LICENSE file for details
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
Web API Routes
|
|
6
|
+
==============
|
|
7
|
+
|
|
8
|
+
FastAPI route definitions for the DataLab Web API.
|
|
9
|
+
|
|
10
|
+
This module defines all HTTP endpoints for the API. Routes are organized by
|
|
11
|
+
function:
|
|
12
|
+
|
|
13
|
+
- ``/api/v1/status`` - Server status
|
|
14
|
+
- ``/api/v1/objects`` - Workspace object operations
|
|
15
|
+
- ``/api/v1/objects/{name}/data`` - Binary data transfer
|
|
16
|
+
|
|
17
|
+
Security
|
|
18
|
+
--------
|
|
19
|
+
|
|
20
|
+
All routes (except ``/api/v1/status``) require bearer token authentication.
|
|
21
|
+
The token is generated when the server starts and must be included in the
|
|
22
|
+
``Authorization`` header.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import secrets
|
|
28
|
+
import traceback
|
|
29
|
+
from typing import Annotated, Optional
|
|
30
|
+
from urllib.parse import quote
|
|
31
|
+
|
|
32
|
+
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status
|
|
33
|
+
|
|
34
|
+
from datalab import __version__
|
|
35
|
+
from datalab.webapi.adapter import WorkspaceAdapter
|
|
36
|
+
from datalab.webapi.schema import (
|
|
37
|
+
ApiStatus,
|
|
38
|
+
CalcRequest,
|
|
39
|
+
CalcResponse,
|
|
40
|
+
ErrorResponse,
|
|
41
|
+
MetadataPatchRequest,
|
|
42
|
+
ObjectListResponse,
|
|
43
|
+
ObjectMetadata,
|
|
44
|
+
SelectObjectsRequest,
|
|
45
|
+
SelectObjectsResponse,
|
|
46
|
+
)
|
|
47
|
+
from datalab.webapi.serialization import (
|
|
48
|
+
deserialize_object_from_npz,
|
|
49
|
+
object_to_metadata,
|
|
50
|
+
serialize_object_to_npz,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Router for the API
|
|
54
|
+
router = APIRouter(prefix="/api/v1", tags=["workspace"])
|
|
55
|
+
|
|
56
|
+
# Global references (set by controller at startup)
|
|
57
|
+
_ADAPTER: WorkspaceAdapter | None = None
|
|
58
|
+
_AUTH_TOKEN: str | None = None
|
|
59
|
+
_SERVER_URL: str | None = None
|
|
60
|
+
_LOCALHOST_NO_TOKEN: bool = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def set_adapter(adapter: WorkspaceAdapter) -> None:
|
|
64
|
+
"""Set the workspace adapter for route handlers."""
|
|
65
|
+
global _ADAPTER # noqa: PLW0603 # pylint: disable=global-statement
|
|
66
|
+
_ADAPTER = adapter
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def set_auth_token(token: str) -> None:
|
|
70
|
+
"""Set the authentication token."""
|
|
71
|
+
global _AUTH_TOKEN # noqa: PLW0603 # pylint: disable=global-statement
|
|
72
|
+
_AUTH_TOKEN = token
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def set_server_url(url: str) -> None:
|
|
76
|
+
"""Set the server URL."""
|
|
77
|
+
global _SERVER_URL # noqa: PLW0603 # pylint: disable=global-statement
|
|
78
|
+
_SERVER_URL = url
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def set_localhost_no_token(enabled: bool) -> None:
|
|
82
|
+
"""Set whether localhost connections can bypass authentication."""
|
|
83
|
+
global _LOCALHOST_NO_TOKEN # noqa: PLW0603 # pylint: disable=global-statement
|
|
84
|
+
_LOCALHOST_NO_TOKEN = enabled
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def generate_auth_token() -> str:
|
|
88
|
+
"""Generate a secure random authentication token."""
|
|
89
|
+
return secrets.token_urlsafe(32)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_adapter() -> WorkspaceAdapter:
|
|
93
|
+
"""Dependency: Get the workspace adapter."""
|
|
94
|
+
if _ADAPTER is None:
|
|
95
|
+
raise HTTPException(
|
|
96
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
97
|
+
detail="Workspace adapter not initialized",
|
|
98
|
+
)
|
|
99
|
+
return _ADAPTER
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def verify_token(
|
|
103
|
+
request: Request, authorization: Annotated[Optional[str], Header()] = None
|
|
104
|
+
) -> Optional[str]:
|
|
105
|
+
"""Dependency: Verify the bearer token.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
request: The incoming request (for client IP check).
|
|
109
|
+
authorization: Authorization header value.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The validated token, or None if localhost bypass is enabled.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
HTTPException: If token is missing or invalid.
|
|
116
|
+
"""
|
|
117
|
+
# Check for localhost bypass
|
|
118
|
+
if _LOCALHOST_NO_TOKEN:
|
|
119
|
+
client_ip = request.client.host if request.client else None
|
|
120
|
+
if client_ip in ("127.0.0.1", "::1", "localhost"):
|
|
121
|
+
# Localhost bypass enabled and request is from localhost
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
if _AUTH_TOKEN is None:
|
|
125
|
+
raise HTTPException(
|
|
126
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
127
|
+
detail="Authentication not configured",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if authorization is None:
|
|
131
|
+
raise HTTPException(
|
|
132
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
133
|
+
detail="Missing Authorization header",
|
|
134
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Parse "Bearer <token>"
|
|
138
|
+
parts = authorization.split()
|
|
139
|
+
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
140
|
+
raise HTTPException(
|
|
141
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
142
|
+
detail="Invalid Authorization header format",
|
|
143
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if not secrets.compare_digest(parts[1], _AUTH_TOKEN):
|
|
147
|
+
raise HTTPException(
|
|
148
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
149
|
+
detail="Invalid token",
|
|
150
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return parts[1]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# =============================================================================
|
|
157
|
+
# Status endpoint (no auth required)
|
|
158
|
+
# =============================================================================
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@router.get("/status", response_model=ApiStatus, tags=["status"])
|
|
162
|
+
async def get_status() -> ApiStatus:
|
|
163
|
+
"""Get API server status.
|
|
164
|
+
|
|
165
|
+
This endpoint does not require authentication and can be used to check
|
|
166
|
+
if the server is running and to get the API version.
|
|
167
|
+
"""
|
|
168
|
+
return ApiStatus(
|
|
169
|
+
running=True,
|
|
170
|
+
version=__version__,
|
|
171
|
+
api_version="v1",
|
|
172
|
+
url=_SERVER_URL,
|
|
173
|
+
workspace_mode="live",
|
|
174
|
+
localhost_no_token=_LOCALHOST_NO_TOKEN,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# =============================================================================
|
|
179
|
+
# Object listing and metadata (JSON control plane)
|
|
180
|
+
# =============================================================================
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@router.get(
|
|
184
|
+
"/objects",
|
|
185
|
+
response_model=ObjectListResponse,
|
|
186
|
+
responses={401: {"model": ErrorResponse}},
|
|
187
|
+
)
|
|
188
|
+
async def list_objects(
|
|
189
|
+
_token: str = Depends(verify_token),
|
|
190
|
+
adapter: WorkspaceAdapter = Depends(get_adapter),
|
|
191
|
+
) -> ObjectListResponse:
|
|
192
|
+
"""List all objects in the workspace.
|
|
193
|
+
|
|
194
|
+
Returns metadata for all signals and images currently in DataLab.
|
|
195
|
+
"""
|
|
196
|
+
try:
|
|
197
|
+
objects_list = adapter.list_objects()
|
|
198
|
+
result = []
|
|
199
|
+
|
|
200
|
+
for name, _panel in objects_list:
|
|
201
|
+
try:
|
|
202
|
+
obj = adapter.get_object(name)
|
|
203
|
+
meta = object_to_metadata(obj, name)
|
|
204
|
+
result.append(ObjectMetadata(**meta))
|
|
205
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
206
|
+
# Skip objects that can't be serialized
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
return ObjectListResponse(objects=result, count=len(result))
|
|
210
|
+
|
|
211
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
212
|
+
raise HTTPException(
|
|
213
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
214
|
+
detail=str(e),
|
|
215
|
+
) from e
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@router.get(
|
|
219
|
+
"/objects/{name}",
|
|
220
|
+
response_model=ObjectMetadata,
|
|
221
|
+
responses={
|
|
222
|
+
401: {"model": ErrorResponse},
|
|
223
|
+
404: {"model": ErrorResponse},
|
|
224
|
+
500: {"model": ErrorResponse},
|
|
225
|
+
},
|
|
226
|
+
)
|
|
227
|
+
async def get_object_metadata(
|
|
228
|
+
name: str,
|
|
229
|
+
_token: str = Depends(verify_token),
|
|
230
|
+
adapter: WorkspaceAdapter = Depends(get_adapter),
|
|
231
|
+
) -> ObjectMetadata:
|
|
232
|
+
"""Get metadata for a specific object.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
name: Object name/title.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Object metadata (without data arrays).
|
|
239
|
+
"""
|
|
240
|
+
try:
|
|
241
|
+
obj = adapter.get_object(name)
|
|
242
|
+
meta = object_to_metadata(obj, name)
|
|
243
|
+
return ObjectMetadata(**meta)
|
|
244
|
+
|
|
245
|
+
except KeyError as e:
|
|
246
|
+
raise HTTPException(
|
|
247
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
248
|
+
detail=f"Object '{name}' not found",
|
|
249
|
+
) from e
|
|
250
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
251
|
+
raise HTTPException(
|
|
252
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
253
|
+
detail=f"Error retrieving metadata for '{name}': {e}",
|
|
254
|
+
) from e
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@router.delete(
|
|
258
|
+
"/objects/{name}",
|
|
259
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
260
|
+
responses={
|
|
261
|
+
401: {"model": ErrorResponse},
|
|
262
|
+
404: {"model": ErrorResponse},
|
|
263
|
+
500: {"model": ErrorResponse},
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
async def delete_object(
|
|
267
|
+
name: str,
|
|
268
|
+
_token: str = Depends(verify_token),
|
|
269
|
+
adapter: WorkspaceAdapter = Depends(get_adapter),
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Delete an object from the workspace.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
name: Object name/title.
|
|
275
|
+
"""
|
|
276
|
+
try:
|
|
277
|
+
adapter.remove_object(name)
|
|
278
|
+
except KeyError as e:
|
|
279
|
+
raise HTTPException(
|
|
280
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
281
|
+
detail=f"Object '{name}' not found",
|
|
282
|
+
) from e
|
|
283
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
284
|
+
raise HTTPException(
|
|
285
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
286
|
+
detail=f"Error deleting '{name}': {e}",
|
|
287
|
+
) from e
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@router.patch(
|
|
291
|
+
"/objects/{name}/metadata",
|
|
292
|
+
response_model=ObjectMetadata,
|
|
293
|
+
responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
|
294
|
+
)
|
|
295
|
+
async def update_object_metadata(
|
|
296
|
+
name: str,
|
|
297
|
+
patch: MetadataPatchRequest,
|
|
298
|
+
_token: str = Depends(verify_token),
|
|
299
|
+
adapter: WorkspaceAdapter = Depends(get_adapter),
|
|
300
|
+
) -> ObjectMetadata:
|
|
301
|
+
"""Update object metadata.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
name: Object name/title.
|
|
305
|
+
patch: Metadata fields to update.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Updated object metadata.
|
|
309
|
+
"""
|
|
310
|
+
try:
|
|
311
|
+
# Convert patch to dict, excluding None values
|
|
312
|
+
updates = patch.model_dump(exclude_none=True)
|
|
313
|
+
adapter.update_metadata(name, updates)
|
|
314
|
+
|
|
315
|
+
# Return updated metadata
|
|
316
|
+
obj = adapter.get_object(name)
|
|
317
|
+
meta = object_to_metadata(obj, name)
|
|
318
|
+
return ObjectMetadata(**meta)
|
|
319
|
+
|
|
320
|
+
except KeyError as e:
|
|
321
|
+
raise HTTPException(
|
|
322
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
323
|
+
detail=f"Object '{name}' not found",
|
|
324
|
+
) from e
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# =============================================================================
|
|
328
|
+
# Binary data transfer (NPZ format)
|
|
329
|
+
# =============================================================================
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@router.get(
|
|
333
|
+
"/objects/{name}/data",
|
|
334
|
+
responses={
|
|
335
|
+
200: {"content": {"application/x-npz": {}}},
|
|
336
|
+
401: {"model": ErrorResponse},
|
|
337
|
+
404: {"model": ErrorResponse},
|
|
338
|
+
500: {"model": ErrorResponse},
|
|
339
|
+
},
|
|
340
|
+
)
|
|
341
|
+
async def get_object_data(
|
|
342
|
+
name: str,
|
|
343
|
+
compress: bool = True,
|
|
344
|
+
_token: str = Depends(verify_token),
|
|
345
|
+
adapter: WorkspaceAdapter = Depends(get_adapter),
|
|
346
|
+
) -> Response:
|
|
347
|
+
"""Get object data in NPZ format.
|
|
348
|
+
|
|
349
|
+
Returns the object's numerical arrays (x/y for signals, data for images)
|
|
350
|
+
plus metadata in a NumPy NPZ archive.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
name: Object name/title.
|
|
354
|
+
compress: If True (default), use ZIP deflate compression.
|
|
355
|
+
Set to False for faster serialization (10-30x) at the cost of
|
|
356
|
+
larger size (~10% increase for typical image data).
|
|
357
|
+
Recommended: False for large images and fast local connections.
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
Binary NPZ archive.
|
|
361
|
+
"""
|
|
362
|
+
try:
|
|
363
|
+
obj = adapter.get_object(name)
|
|
364
|
+
npz_data = serialize_object_to_npz(obj, compress=compress)
|
|
365
|
+
|
|
366
|
+
# Build Content-Disposition header with safe filename
|
|
367
|
+
# Use RFC 5987 encoding for non-ASCII characters, or fallback to ASCII
|
|
368
|
+
try:
|
|
369
|
+
# Try ASCII first (will fail for Unicode chars)
|
|
370
|
+
name.encode("ascii")
|
|
371
|
+
content_disposition = f'attachment; filename="{name}.npz"'
|
|
372
|
+
except UnicodeEncodeError:
|
|
373
|
+
# Use RFC 5987 encoding for Unicode filenames
|
|
374
|
+
encoded_name = quote(name, safe="")
|
|
375
|
+
content_disposition = f"attachment; filename*=UTF-8''{encoded_name}.npz"
|
|
376
|
+
|
|
377
|
+
return Response(
|
|
378
|
+
content=npz_data,
|
|
379
|
+
media_type="application/x-npz",
|
|
380
|
+
headers={
|
|
381
|
+
"Content-Disposition": content_disposition,
|
|
382
|
+
},
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
except KeyError as e:
|
|
386
|
+
raise HTTPException(
|
|
387
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
388
|
+
detail=f"Object '{name}' not found",
|
|
389
|
+
) from e
|
|
390
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
391
|
+
tb = traceback.format_exc()
|
|
392
|
+
raise HTTPException(
|
|
393
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
394
|
+
detail=f"Error retrieving object '{name}': {e}\n\nTraceback:\n{tb}",
|
|
395
|
+
) from e
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@router.put(
|
|
399
|
+
"/objects/{name}/data",
|
|
400
|
+
status_code=status.HTTP_201_CREATED,
|
|
401
|
+
response_model=ObjectMetadata,
|
|
402
|
+
responses={
|
|
403
|
+
401: {"model": ErrorResponse},
|
|
404
|
+
409: {"model": ErrorResponse},
|
|
405
|
+
422: {"model": ErrorResponse},
|
|
406
|
+
},
|
|
407
|
+
)
|
|
408
|
+
async def put_object_data(
|
|
409
|
+
name: str,
|
|
410
|
+
request: Request,
|
|
411
|
+
overwrite: bool = False,
|
|
412
|
+
_token: str = Depends(verify_token),
|
|
413
|
+
adapter: WorkspaceAdapter = Depends(get_adapter),
|
|
414
|
+
) -> ObjectMetadata:
|
|
415
|
+
"""Create or replace an object from NPZ data.
|
|
416
|
+
|
|
417
|
+
The request body must be a NumPy NPZ archive containing the object data
|
|
418
|
+
(x.npy/y.npy for signals, data.npy for images) and metadata.json.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
name: Object name/title.
|
|
422
|
+
request: FastAPI request (body contains NPZ archive bytes).
|
|
423
|
+
overwrite: If True, replace existing object.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
Metadata of the created object.
|
|
427
|
+
"""
|
|
428
|
+
try:
|
|
429
|
+
# Read body from request
|
|
430
|
+
body = await request.body()
|
|
431
|
+
|
|
432
|
+
# Check if exists
|
|
433
|
+
if adapter.object_exists(name) and not overwrite:
|
|
434
|
+
raise HTTPException(
|
|
435
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
436
|
+
detail=f"Object '{name}' already exists. Use overwrite=true.",
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
# Deserialize
|
|
440
|
+
obj = deserialize_object_from_npz(body)
|
|
441
|
+
obj.title = name
|
|
442
|
+
|
|
443
|
+
# Add to workspace
|
|
444
|
+
adapter.add_object(obj, overwrite=overwrite)
|
|
445
|
+
|
|
446
|
+
# Return metadata
|
|
447
|
+
meta = object_to_metadata(obj, name)
|
|
448
|
+
return ObjectMetadata(**meta)
|
|
449
|
+
|
|
450
|
+
except HTTPException:
|
|
451
|
+
raise # Re-raise HTTPException without wrapping
|
|
452
|
+
except ValueError as e:
|
|
453
|
+
raise HTTPException(
|
|
454
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
455
|
+
detail=f"Invalid NPZ format: {e}",
|
|
456
|
+
) from e
|
|
457
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
458
|
+
raise HTTPException(
|
|
459
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
460
|
+
detail=f"Error adding object '{name}': {e}",
|
|
461
|
+
) from e
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# =============================================================================
|
|
465
|
+
# Computation endpoints
|
|
466
|
+
# =============================================================================
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@router.post(
|
|
470
|
+
"/select",
|
|
471
|
+
response_model=SelectObjectsResponse,
|
|
472
|
+
responses={
|
|
473
|
+
401: {"model": ErrorResponse},
|
|
474
|
+
404: {"model": ErrorResponse},
|
|
475
|
+
422: {"model": ErrorResponse},
|
|
476
|
+
},
|
|
477
|
+
)
|
|
478
|
+
async def select_objects(
|
|
479
|
+
request: SelectObjectsRequest,
|
|
480
|
+
_token: str = Depends(verify_token),
|
|
481
|
+
adapter: WorkspaceAdapter = Depends(get_adapter),
|
|
482
|
+
) -> SelectObjectsResponse:
|
|
483
|
+
"""Select objects in a panel.
|
|
484
|
+
|
|
485
|
+
Selects the specified objects by name, making them the active selection
|
|
486
|
+
for subsequent operations like ``calc``.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
request: Selection request with object names and optional panel.
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
List of selected object names and the panel.
|
|
493
|
+
"""
|
|
494
|
+
try:
|
|
495
|
+
panel_str = request.panel.value if request.panel else None
|
|
496
|
+
selected, panel = adapter.select_objects(request.selection, panel_str)
|
|
497
|
+
return SelectObjectsResponse(selected=selected, panel=panel)
|
|
498
|
+
|
|
499
|
+
except KeyError as e:
|
|
500
|
+
raise HTTPException(
|
|
501
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
502
|
+
detail=str(e),
|
|
503
|
+
) from e
|
|
504
|
+
except ValueError as e:
|
|
505
|
+
raise HTTPException(
|
|
506
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
507
|
+
detail=str(e),
|
|
508
|
+
) from e
|
|
509
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
510
|
+
raise HTTPException(
|
|
511
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
512
|
+
detail=f"Error selecting objects: {e}",
|
|
513
|
+
) from e
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
@router.post(
|
|
517
|
+
"/calc",
|
|
518
|
+
response_model=CalcResponse,
|
|
519
|
+
responses={
|
|
520
|
+
401: {"model": ErrorResponse},
|
|
521
|
+
404: {"model": ErrorResponse},
|
|
522
|
+
422: {"model": ErrorResponse},
|
|
523
|
+
500: {"model": ErrorResponse},
|
|
524
|
+
},
|
|
525
|
+
)
|
|
526
|
+
async def calc(
|
|
527
|
+
request: CalcRequest,
|
|
528
|
+
_token: str = Depends(verify_token),
|
|
529
|
+
adapter: WorkspaceAdapter = Depends(get_adapter),
|
|
530
|
+
) -> CalcResponse:
|
|
531
|
+
"""Call a computation function.
|
|
532
|
+
|
|
533
|
+
Executes a DataLab computation function on the currently selected objects.
|
|
534
|
+
Use the ``/select`` endpoint first to select the objects to process.
|
|
535
|
+
|
|
536
|
+
Common computation functions include:
|
|
537
|
+
|
|
538
|
+
- Signal: ``normalize``, ``fft``, ``ifft``, ``moving_average``, ``derivative``
|
|
539
|
+
- Image: ``normalize``, ``rotate``, ``flip``, ``denoise``, ``threshold``
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
request: Computation request with function name and optional parameters.
|
|
543
|
+
|
|
544
|
+
Returns:
|
|
545
|
+
Success status and names of any newly created result objects.
|
|
546
|
+
|
|
547
|
+
Example request::
|
|
548
|
+
|
|
549
|
+
{
|
|
550
|
+
"name": "normalize",
|
|
551
|
+
"param": {"method": "maximum"}
|
|
552
|
+
}
|
|
553
|
+
"""
|
|
554
|
+
try:
|
|
555
|
+
success, new_names = adapter.calc(request.name, request.param)
|
|
556
|
+
return CalcResponse(
|
|
557
|
+
success=success,
|
|
558
|
+
function=request.name,
|
|
559
|
+
result_names=new_names,
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
except ValueError as e:
|
|
563
|
+
raise HTTPException(
|
|
564
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
565
|
+
detail=f"Unknown computation function '{request.name}': {e}",
|
|
566
|
+
) from e
|
|
567
|
+
except RuntimeError as e:
|
|
568
|
+
raise HTTPException(
|
|
569
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
570
|
+
detail=str(e),
|
|
571
|
+
) from e
|
|
572
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
573
|
+
raise HTTPException(
|
|
574
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
575
|
+
detail=f"Computation error: {e}",
|
|
576
|
+
) from e
|