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
|
@@ -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
|
+
}
|