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.
Files changed (45) hide show
  1. datalab/__init__.py +1 -1
  2. datalab/config.py +4 -0
  3. datalab/control/baseproxy.py +160 -0
  4. datalab/control/remote.py +175 -1
  5. datalab/data/doc/DataLab_en.pdf +0 -0
  6. datalab/data/doc/DataLab_fr.pdf +0 -0
  7. datalab/data/icons/control/copy_connection_info.svg +11 -0
  8. datalab/data/icons/control/start_webapi_server.svg +19 -0
  9. datalab/data/icons/control/stop_webapi_server.svg +7 -0
  10. datalab/gui/main.py +221 -2
  11. datalab/gui/settings.py +10 -0
  12. datalab/gui/tour.py +2 -3
  13. datalab/locale/fr/LC_MESSAGES/datalab.mo +0 -0
  14. datalab/locale/fr/LC_MESSAGES/datalab.po +87 -1
  15. datalab/tests/__init__.py +32 -1
  16. datalab/tests/backbone/config_unit_test.py +1 -1
  17. datalab/tests/backbone/main_app_test.py +4 -0
  18. datalab/tests/backbone/memory_leak.py +1 -1
  19. datalab/tests/features/common/createobject_unit_test.py +1 -1
  20. datalab/tests/features/common/misc_app_test.py +5 -0
  21. datalab/tests/features/control/call_method_unit_test.py +104 -0
  22. datalab/tests/features/control/embedded1_unit_test.py +8 -0
  23. datalab/tests/features/control/remoteclient_app_test.py +39 -35
  24. datalab/tests/features/control/simpleclient_unit_test.py +7 -3
  25. datalab/tests/features/hdf5/h5browser2_unit.py +1 -1
  26. datalab/tests/features/image/background_dialog_test.py +2 -2
  27. datalab/tests/features/image/imagetools_unit_test.py +1 -1
  28. datalab/tests/features/signal/baseline_dialog_test.py +1 -1
  29. datalab/tests/webapi_test.py +395 -0
  30. datalab/webapi/__init__.py +95 -0
  31. datalab/webapi/actions.py +318 -0
  32. datalab/webapi/adapter.py +642 -0
  33. datalab/webapi/controller.py +379 -0
  34. datalab/webapi/routes.py +576 -0
  35. datalab/webapi/schema.py +198 -0
  36. datalab/webapi/serialization.py +388 -0
  37. datalab/widgets/status.py +61 -0
  38. {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/METADATA +6 -2
  39. {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/RECORD +45 -33
  40. /datalab/data/icons/{libre-gui-link.svg → control/libre-gui-link.svg} +0 -0
  41. /datalab/data/icons/{libre-gui-unlink.svg → control/libre-gui-unlink.svg} +0 -0
  42. {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/WHEEL +0 -0
  43. {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/entry_points.txt +0 -0
  44. {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/licenses/LICENSE +0 -0
  45. {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/top_level.txt +0 -0
@@ -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