datalab-platform 1.0.3__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 (46) 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/docks.py +3 -2
  11. datalab/gui/main.py +221 -2
  12. datalab/gui/settings.py +10 -0
  13. datalab/gui/tour.py +2 -3
  14. datalab/locale/fr/LC_MESSAGES/datalab.mo +0 -0
  15. datalab/locale/fr/LC_MESSAGES/datalab.po +95 -1
  16. datalab/tests/__init__.py +32 -1
  17. datalab/tests/backbone/config_unit_test.py +1 -1
  18. datalab/tests/backbone/main_app_test.py +4 -0
  19. datalab/tests/backbone/memory_leak.py +1 -1
  20. datalab/tests/features/common/createobject_unit_test.py +1 -1
  21. datalab/tests/features/common/misc_app_test.py +5 -0
  22. datalab/tests/features/control/call_method_unit_test.py +104 -0
  23. datalab/tests/features/control/embedded1_unit_test.py +8 -0
  24. datalab/tests/features/control/remoteclient_app_test.py +39 -35
  25. datalab/tests/features/control/simpleclient_unit_test.py +7 -3
  26. datalab/tests/features/hdf5/h5browser2_unit.py +1 -1
  27. datalab/tests/features/image/background_dialog_test.py +2 -2
  28. datalab/tests/features/image/imagetools_unit_test.py +1 -1
  29. datalab/tests/features/signal/baseline_dialog_test.py +1 -1
  30. datalab/tests/webapi_test.py +395 -0
  31. datalab/webapi/__init__.py +95 -0
  32. datalab/webapi/actions.py +318 -0
  33. datalab/webapi/adapter.py +642 -0
  34. datalab/webapi/controller.py +379 -0
  35. datalab/webapi/routes.py +576 -0
  36. datalab/webapi/schema.py +198 -0
  37. datalab/webapi/serialization.py +388 -0
  38. datalab/widgets/status.py +61 -0
  39. {datalab_platform-1.0.3.dist-info → datalab_platform-1.1.0.dist-info}/METADATA +11 -7
  40. {datalab_platform-1.0.3.dist-info → datalab_platform-1.1.0.dist-info}/RECORD +46 -34
  41. {datalab_platform-1.0.3.dist-info → datalab_platform-1.1.0.dist-info}/WHEEL +1 -1
  42. /datalab/data/icons/{libre-gui-link.svg → control/libre-gui-link.svg} +0 -0
  43. /datalab/data/icons/{libre-gui-unlink.svg → control/libre-gui-unlink.svg} +0 -0
  44. {datalab_platform-1.0.3.dist-info → datalab_platform-1.1.0.dist-info}/entry_points.txt +0 -0
  45. {datalab_platform-1.0.3.dist-info → datalab_platform-1.1.0.dist-info}/licenses/LICENSE +0 -0
  46. {datalab_platform-1.0.3.dist-info → datalab_platform-1.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,379 @@
1
+ # Copyright (c) DataLab Platform Developers, BSD 3-Clause License
2
+ # See LICENSE file for details
3
+
4
+ """
5
+ Web API Controller
6
+ ==================
7
+
8
+ Manages the lifecycle of the Web API server embedded in DataLab.
9
+
10
+ The controller is responsible for:
11
+
12
+ - Starting and stopping the Uvicorn server
13
+ - Token generation and management
14
+ - Port selection
15
+ - Thread-safe server lifecycle
16
+
17
+ Usage
18
+ -----
19
+
20
+ ::
21
+
22
+ from datalab.webapi import get_webapi_controller
23
+
24
+ controller = get_webapi_controller()
25
+ controller.set_main_window(main_window)
26
+ controller.start()
27
+
28
+ # Later...
29
+ controller.stop()
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import os
36
+ import socket
37
+ import threading
38
+ from pathlib import Path
39
+ from typing import TYPE_CHECKING
40
+
41
+ from qtpy.QtCore import QObject, Signal
42
+
43
+ if TYPE_CHECKING:
44
+ from datalab.gui.main import DLMainWindow
45
+
46
+ # Default port for Web API (predictable for auto-discovery)
47
+ DEFAULT_WEBAPI_PORT = 18080
48
+
49
+
50
+ def get_connection_file_path() -> Path:
51
+ """Get the path to the connection info file.
52
+
53
+ The file is stored in a platform-specific location:
54
+ - Windows: %APPDATA%/DataLab/webapi_connection.json
55
+ - Linux/Mac: ~/.config/datalab/webapi_connection.json
56
+
57
+ Returns:
58
+ Path to the connection file.
59
+ """
60
+ if os.name == "nt":
61
+ # Windows: use APPDATA
62
+ base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
63
+ else:
64
+ # Linux/Mac: use XDG_CONFIG_HOME or ~/.config
65
+ base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
66
+
67
+ config_dir = base / "datalab"
68
+ config_dir.mkdir(parents=True, exist_ok=True)
69
+ return config_dir / "webapi_connection.json"
70
+
71
+
72
+ class WebApiController(QObject):
73
+ """Controller for the DataLab Web API server.
74
+
75
+ This class manages the lifecycle of an embedded Uvicorn server running
76
+ FastAPI routes for the Web API.
77
+
78
+ Signals:
79
+ server_started: Emitted when server starts (url, token)
80
+ server_stopped: Emitted when server stops
81
+ server_error: Emitted on server error (message)
82
+ """
83
+
84
+ # Qt signals for status updates
85
+ server_started = Signal(str, str) # url, token
86
+ server_stopped = Signal()
87
+ server_error = Signal(str)
88
+
89
+ def __init__(self) -> None:
90
+ """Initialize the controller."""
91
+ super().__init__()
92
+ self._main_window: DLMainWindow | None = None
93
+ self._server_thread: threading.Thread | None = None
94
+ self._uvicorn_server = None
95
+ self._adapter = None
96
+ self._token: str | None = None
97
+ self._url: str | None = None
98
+ self._running = False
99
+ self._lock = threading.Lock()
100
+
101
+ def set_main_window(self, main_window: DLMainWindow) -> None:
102
+ """Set the DataLab main window reference.
103
+
104
+ Args:
105
+ main_window: The DataLab main window.
106
+ """
107
+ self._main_window = main_window
108
+
109
+ @property
110
+ def is_running(self) -> bool:
111
+ """Check if the server is running."""
112
+ with self._lock:
113
+ return self._running
114
+
115
+ @property
116
+ def url(self) -> str | None:
117
+ """Get the server URL."""
118
+ return self._url
119
+
120
+ @property
121
+ def token(self) -> str | None:
122
+ """Get the authentication token."""
123
+ return self._token
124
+
125
+ def _find_available_port(
126
+ self, start_port: int = DEFAULT_WEBAPI_PORT, max_attempts: int = 100
127
+ ) -> int:
128
+ """Find an available port.
129
+
130
+ Args:
131
+ start_port: Port to start searching from.
132
+ max_attempts: Maximum number of ports to try.
133
+
134
+ Returns:
135
+ Available port number.
136
+
137
+ Raises:
138
+ RuntimeError: If no available port found.
139
+ """
140
+ for port in range(start_port, start_port + max_attempts):
141
+ try:
142
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
143
+ s.bind(("127.0.0.1", port))
144
+ return port
145
+ except OSError:
146
+ continue
147
+ raise RuntimeError(
148
+ f"No available port found in range {start_port}-{start_port + max_attempts}"
149
+ )
150
+
151
+ def start(
152
+ self,
153
+ host: str | None = None,
154
+ port: int | None = None,
155
+ token: str | None = None,
156
+ ) -> tuple[str, str]:
157
+ """Start the Web API server.
158
+
159
+ Args:
160
+ host: Host to bind to. Defaults to DATALAB_WEBAPI_HOST or "127.0.0.1".
161
+ port: Port to bind to. Defaults to DATALAB_WEBAPI_PORT or 18080.
162
+ token: Authentication token. Defaults to DATALAB_WEBAPI_TOKEN or generated.
163
+
164
+ Returns:
165
+ Tuple of (url, token).
166
+
167
+ Raises:
168
+ RuntimeError: If server already running or main window not set.
169
+ """
170
+ if self._main_window is None:
171
+ raise RuntimeError("Main window not set. Call set_main_window() first.")
172
+
173
+ with self._lock:
174
+ if self._running:
175
+ raise RuntimeError("Server already running")
176
+ self._running = True
177
+
178
+ try:
179
+ # Import here to allow graceful failure if deps not installed
180
+
181
+ # pylint: disable=import-outside-toplevel
182
+ import uvicorn
183
+ from fastapi import FastAPI
184
+ from fastapi.middleware.cors import CORSMiddleware
185
+
186
+ from datalab.webapi.adapter import WorkspaceAdapter
187
+ from datalab.webapi.routes import (
188
+ generate_auth_token,
189
+ router,
190
+ set_adapter,
191
+ set_auth_token,
192
+ set_localhost_no_token,
193
+ set_server_url,
194
+ )
195
+
196
+ # Resolve configuration
197
+ host = host or os.environ.get("DATALAB_WEBAPI_HOST", "127.0.0.1")
198
+ if port is None:
199
+ env_port = os.environ.get("DATALAB_WEBAPI_PORT")
200
+ port = int(env_port) if env_port else self._find_available_port()
201
+
202
+ token = (
203
+ token or os.environ.get("DATALAB_WEBAPI_TOKEN") or generate_auth_token()
204
+ )
205
+
206
+ # Check localhost bypass setting
207
+ from datalab.config import Conf
208
+
209
+ localhost_no_token = Conf.main.webapi_localhost_no_token.get()
210
+
211
+ # Create adapter
212
+ self._adapter = WorkspaceAdapter(self._main_window)
213
+
214
+ # Configure routes
215
+ set_adapter(self._adapter)
216
+ set_auth_token(token)
217
+ set_localhost_no_token(localhost_no_token)
218
+
219
+ self._url = f"http://{host}:{port}"
220
+ set_server_url(self._url)
221
+ self._token = token
222
+
223
+ # Create FastAPI app
224
+ app = FastAPI(
225
+ title="DataLab Web API",
226
+ description="HTTP/JSON API for DataLab workspace access",
227
+ version="1.0.0",
228
+ )
229
+
230
+ # Add CORS middleware for JupyterLite and other browser-based clients
231
+ app.add_middleware(
232
+ CORSMiddleware,
233
+ allow_origins=["*"], # Allow all origins for JupyterLite
234
+ allow_credentials=True,
235
+ allow_methods=["*"],
236
+ allow_headers=["*"],
237
+ )
238
+
239
+ # Add Private Network Access headers for browser-to-localhost requests
240
+ # This allows JupyterLite (HTTPS public origin) to reach local DataLab
241
+ # See: https://wicg.github.io/private-network-access/
242
+ @app.middleware("http")
243
+ async def add_private_network_access_headers(request, call_next):
244
+ # Handle preflight OPTIONS requests for Private Network Access
245
+ if request.method == "OPTIONS":
246
+ # Check if this is a Private Network Access preflight
247
+ if request.headers.get("Access-Control-Request-Private-Network"):
248
+ # Return a proper preflight response with PNA header
249
+ from starlette.responses import Response
250
+
251
+ response = Response(
252
+ status_code=204,
253
+ headers={
254
+ "Access-Control-Allow-Origin": request.headers.get(
255
+ "Origin", "*"
256
+ ),
257
+ "Access-Control-Allow-Methods": (
258
+ "GET, POST, PUT, DELETE, PATCH, OPTIONS"
259
+ ),
260
+ "Access-Control-Allow-Headers": request.headers.get(
261
+ "Access-Control-Request-Headers", "*"
262
+ ),
263
+ "Access-Control-Allow-Credentials": "true",
264
+ "Access-Control-Allow-Private-Network": "true",
265
+ "Access-Control-Max-Age": "86400",
266
+ },
267
+ )
268
+ return response
269
+
270
+ response = await call_next(request)
271
+ # Add PNA header to all responses (for non-preflight requests)
272
+ response.headers["Access-Control-Allow-Private-Network"] = "true"
273
+ return response
274
+
275
+ app.include_router(router)
276
+
277
+ # Configure Uvicorn
278
+ config = uvicorn.Config(
279
+ app,
280
+ host=host,
281
+ port=port,
282
+ log_level="warning",
283
+ access_log=False,
284
+ log_config=None, # Disable Uvicorn's logging config
285
+ )
286
+ self._uvicorn_server = uvicorn.Server(config)
287
+
288
+ # Write connection file for client auto-discovery
289
+ self._write_connection_file()
290
+
291
+ # Start server in thread
292
+ self._server_thread = threading.Thread(
293
+ target=self._run_server,
294
+ name="DataLab-WebAPI",
295
+ daemon=True,
296
+ )
297
+ self._server_thread.start()
298
+
299
+ # Emit signal
300
+ self.server_started.emit(self._url, self._token)
301
+
302
+ return self._url, self._token
303
+
304
+ except Exception as e:
305
+ with self._lock:
306
+ self._running = False
307
+ self.server_error.emit(str(e))
308
+ raise
309
+
310
+ def _run_server(self) -> None:
311
+ """Run the Uvicorn server (called in thread)."""
312
+ try:
313
+ self._uvicorn_server.run()
314
+ except Exception as e: # pylint: disable=broad-exception-caught
315
+ self.server_error.emit(str(e))
316
+ finally:
317
+ with self._lock:
318
+ self._running = False
319
+ self.server_stopped.emit()
320
+
321
+ def stop(self) -> None:
322
+ """Stop the Web API server."""
323
+ with self._lock:
324
+ if not self._running:
325
+ return
326
+ self._running = False
327
+
328
+ # Remove connection file
329
+ self._remove_connection_file()
330
+
331
+ if self._uvicorn_server is not None:
332
+ self._uvicorn_server.should_exit = True
333
+
334
+ if self._server_thread is not None:
335
+ self._server_thread.join(timeout=5.0)
336
+ self._server_thread = None
337
+
338
+ self._uvicorn_server = None
339
+ self._adapter = None
340
+ self._url = None
341
+ self._token = None
342
+
343
+ self.server_stopped.emit()
344
+
345
+ def _write_connection_file(self) -> None:
346
+ """Write connection info to file for client auto-discovery."""
347
+ try:
348
+ connection_info = {
349
+ "url": self._url,
350
+ "token": self._token,
351
+ "pid": os.getpid(),
352
+ }
353
+ connection_file = get_connection_file_path()
354
+ connection_file.write_text(json.dumps(connection_info, indent=2))
355
+ except Exception: # pylint: disable=broad-exception-caught
356
+ # Non-critical: don't fail server start if file write fails
357
+ pass
358
+
359
+ def _remove_connection_file(self) -> None:
360
+ """Remove the connection file."""
361
+ try:
362
+ connection_file = get_connection_file_path()
363
+ if connection_file.exists():
364
+ connection_file.unlink()
365
+ except Exception: # pylint: disable=broad-exception-caught
366
+ # Non-critical: ignore errors during cleanup
367
+ pass
368
+
369
+ def get_connection_info(self) -> dict:
370
+ """Get connection information for clients.
371
+
372
+ Returns:
373
+ Dictionary with url, token, and running status.
374
+ """
375
+ return {
376
+ "running": self.is_running,
377
+ "url": self._url,
378
+ "token": self._token,
379
+ }