arrayview 0.45.0__tar.gz → 0.45.1__tar.gz
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.
- {arrayview-0.45.0 → arrayview-0.45.1}/PKG-INFO +9 -1
- {arrayview-0.45.0 → arrayview-0.45.1}/README.md +8 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/pyproject.toml +1 -1
- arrayview-0.45.1/src/arrayview/_bootstrap_app.py +120 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_launch_plan.py +6 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_launcher.py +48 -6
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_platform.py +6 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_server.py +142 -106
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_session.py +121 -1
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_viewer.html +397 -148
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_api.py +89 -9
- arrayview-0.45.1/tests/test_bootstrap_app.py +114 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_browser.py +294 -2
- arrayview-0.45.1/tests/test_roi_hud.py +183 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/visual_smoke.py +18 -6
- {arrayview-0.45.0 → arrayview-0.45.1}/.gitignore +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/LICENSE +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/ARCHITECTURE.md +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/__init__.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/__main__.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_analysis.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_app.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_config.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_diagnostics.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_dicom.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_diff.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_extra_ports.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_icon.png +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_imaging.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_instance_registry.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_io.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_launch_trace.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_lifecycle.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_overlays.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_render.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_analysis.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_drop.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_export.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_loading.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_persistence.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_preferences.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_preload.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_query.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_rendering.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_segmentation.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_state.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_vectorfield.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_routes_websocket.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_session_spec.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_shell.html +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_source_safety.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_synthetic_mri.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_torch.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_tutorial.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_vectorfield.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_vscode_browser.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_vscode_extension.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_vscode_signal.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/_watch.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/src/arrayview/todo.md +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/capture_patient_loading.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/capture_v_animation.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/conftest.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/lifecycle_matrix.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_1d_arrays.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_backend_shared.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_cli.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_colorbar_hover_highlight.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_config.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_dicom.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_display_opener_results.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_doctor_commands.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_drop_import.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_instance_registry.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_invocation_contract_matrix.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_io.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_large_arrays.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_launch_blackbox.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_launch_diagnostics.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_launch_execution_ownership.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_launch_plan.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_launch_trace.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_lifecycle_contract.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_loading_server.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_mode_consistency.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_mode_matrix.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_mode_roundtrip.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_nifti_series.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_preferences_ui.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_public_invocation_regressions.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_render_failure_reporting.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_render_progress.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_safe_server_stop.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_server_identity_status.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_session_spec.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_source_safety.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_spawn_ownership.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_torch.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_tutorial.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_tutorial_browser.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_value_formatting.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_view_handle_lifecycle.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_view_launch_execution.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_vscode_ack_protocol.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/test_window_level_preview.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/ui_audit.py +0 -0
- {arrayview-0.45.0 → arrayview-0.45.1}/tests/v_anim_frames/.gitkeep +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: arrayview
|
|
3
|
-
Version: 0.45.
|
|
3
|
+
Version: 0.45.1
|
|
4
4
|
Summary: Fast multi-dimensional array viewer
|
|
5
5
|
Project-URL: Home, https://github.com/oscarvanderheide/arrayview
|
|
6
6
|
Project-URL: Source, https://github.com/oscarvanderheide/arrayview
|
|
@@ -59,6 +59,14 @@ uvx arrayview path/to/dicom-series/
|
|
|
59
59
|
Press `v` for the three-plane ortho view. `Shift+3` replaces the current slice,
|
|
60
60
|
or all three ortho panes, with interactive 3D cutaway renders.
|
|
61
61
|
Drop another file onto an open viewer to compare, open separately, or overlay it.
|
|
62
|
+
Press `Shift+R` for ROI analysis: a compact HUD lists each region's mean and standard
|
|
63
|
+
deviation. The HUD sits beside the image when space allows; drag its header
|
|
64
|
+
to move it. Hover a row or region to highlight its counterpart, and use each
|
|
65
|
+
row's trash icon to delete that region. Open the detailed analysis with the
|
|
66
|
+
button beside the HUD's column headings.
|
|
67
|
+
Hold a flood-fill seed and drag up/down to adjust sensitivity; a translucent tapered gauge
|
|
68
|
+
at the starting point shows the current value and the 1%–100% range. The
|
|
69
|
+
pointer hides while adjusting; only the gauge marker moves.
|
|
62
70
|
|
|
63
71
|
|
|
64
72
|
Check the [docs](https://oscarvanderheide.github.io/arrayview/) to learn more.
|
|
@@ -22,6 +22,14 @@ uvx arrayview path/to/dicom-series/
|
|
|
22
22
|
Press `v` for the three-plane ortho view. `Shift+3` replaces the current slice,
|
|
23
23
|
or all three ortho panes, with interactive 3D cutaway renders.
|
|
24
24
|
Drop another file onto an open viewer to compare, open separately, or overlay it.
|
|
25
|
+
Press `Shift+R` for ROI analysis: a compact HUD lists each region's mean and standard
|
|
26
|
+
deviation. The HUD sits beside the image when space allows; drag its header
|
|
27
|
+
to move it. Hover a row or region to highlight its counterpart, and use each
|
|
28
|
+
row's trash icon to delete that region. Open the detailed analysis with the
|
|
29
|
+
button beside the HUD's column headings.
|
|
30
|
+
Hold a flood-fill seed and drag up/down to adjust sensitivity; a translucent tapered gauge
|
|
31
|
+
at the starting point shows the current value and the 1%–100% range. The
|
|
32
|
+
pointer hides while adjusting; only the gauge marker moves.
|
|
25
33
|
|
|
26
34
|
|
|
27
35
|
Check the [docs](https://oscarvanderheide.github.io/arrayview/) to learn more.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Answer the health check while the real server is still loading.
|
|
2
|
+
|
|
3
|
+
A freshly spawned daemon binds its port within ~150 ms, but importing the web
|
|
4
|
+
framework and the route modules takes another ~300-500 ms (more on a busy
|
|
5
|
+
host). Nothing that arrives in that window can be served by the real app, and
|
|
6
|
+
the launcher, the VS Code opener and the tunnel proxy all wait on ``/ping``
|
|
7
|
+
before they will open a viewer tab.
|
|
8
|
+
|
|
9
|
+
This tiny ASGI app is what uvicorn serves from the moment the socket is bound:
|
|
10
|
+
|
|
11
|
+
* ``GET /ping`` is answered immediately with the daemon's real identity (the
|
|
12
|
+
same payload the real route returns, built from ``_session``), so the tab
|
|
13
|
+
can be opened while the framework is still importing;
|
|
14
|
+
* every other request simply waits in line and is handed to the real app the
|
|
15
|
+
moment it is attached, so the page download overlaps the import instead of
|
|
16
|
+
starting after it;
|
|
17
|
+
* if the real app fails to load, waiting requests get a 503 and the daemon
|
|
18
|
+
exits, which is what happened before too — just later.
|
|
19
|
+
|
|
20
|
+
No ordering that the launch relies on changes: ``/ping`` still reports the
|
|
21
|
+
identity that the registry record carries, and the viewer page still cannot
|
|
22
|
+
arrive before the server that serves it exists.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import json
|
|
29
|
+
import threading
|
|
30
|
+
from typing import Awaitable, Callable
|
|
31
|
+
|
|
32
|
+
Scope = dict
|
|
33
|
+
Receive = Callable[[], Awaitable[dict]]
|
|
34
|
+
Send = Callable[[dict], Awaitable[None]]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BootstrapApp:
|
|
38
|
+
"""ASGI app that answers ``/ping`` itself until the real app is attached."""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self._app = None
|
|
42
|
+
self._error: BaseException | None = None
|
|
43
|
+
self._ready = threading.Event()
|
|
44
|
+
|
|
45
|
+
# ── wiring (called from whichever thread loads the real app) ──────────
|
|
46
|
+
def attach(self, app) -> None:
|
|
47
|
+
self._app = app
|
|
48
|
+
self._ready.set()
|
|
49
|
+
|
|
50
|
+
def fail(self, error: BaseException) -> None:
|
|
51
|
+
self._error = error
|
|
52
|
+
self._ready.set()
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def ready(self) -> bool:
|
|
56
|
+
return self._app is not None
|
|
57
|
+
|
|
58
|
+
# ── ASGI ──────────────────────────────────────────────────────────────
|
|
59
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
60
|
+
kind = scope.get("type")
|
|
61
|
+
if kind == "lifespan":
|
|
62
|
+
await self._lifespan(receive, send)
|
|
63
|
+
return
|
|
64
|
+
if (
|
|
65
|
+
self._app is None
|
|
66
|
+
and kind == "http"
|
|
67
|
+
and scope.get("method") == "GET"
|
|
68
|
+
and scope.get("path") == "/ping"
|
|
69
|
+
):
|
|
70
|
+
await self._ping(send)
|
|
71
|
+
return
|
|
72
|
+
if not self._ready.is_set():
|
|
73
|
+
loop = asyncio.get_running_loop()
|
|
74
|
+
await loop.run_in_executor(None, self._ready.wait)
|
|
75
|
+
if self._app is None:
|
|
76
|
+
await self._unavailable(kind, send)
|
|
77
|
+
return
|
|
78
|
+
await self._app(scope, receive, send)
|
|
79
|
+
|
|
80
|
+
async def _lifespan(self, receive: Receive, send: Send) -> None:
|
|
81
|
+
# The real app registers no startup/shutdown hooks, so uvicorn's
|
|
82
|
+
# lifespan protocol is answered here and never forwarded.
|
|
83
|
+
while True:
|
|
84
|
+
message = await receive()
|
|
85
|
+
if message["type"] == "lifespan.startup":
|
|
86
|
+
await send({"type": "lifespan.startup.complete"})
|
|
87
|
+
elif message["type"] == "lifespan.shutdown":
|
|
88
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
async def _ping(self, send: Send) -> None:
|
|
92
|
+
import arrayview._session as _session_mod
|
|
93
|
+
|
|
94
|
+
payload = _session_mod.ping_payload(
|
|
95
|
+
active_sessions=len(_session_mod.SESSIONS)
|
|
96
|
+
)
|
|
97
|
+
await _send_json(send, 200, payload)
|
|
98
|
+
|
|
99
|
+
async def _unavailable(self, kind: str | None, send: Send) -> None:
|
|
100
|
+
detail = f"ArrayView server failed to start: {self._error!r}"
|
|
101
|
+
if kind == "websocket":
|
|
102
|
+
await send({"type": "websocket.close", "code": 1011, "reason": detail[:120]})
|
|
103
|
+
return
|
|
104
|
+
await _send_json(send, 503, {"error": detail, "type": "ServerStartupFailed"})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def _send_json(send: Send, status: int, payload: dict) -> None:
|
|
108
|
+
body = json.dumps(payload).encode("utf-8")
|
|
109
|
+
await send(
|
|
110
|
+
{
|
|
111
|
+
"type": "http.response.start",
|
|
112
|
+
"status": status,
|
|
113
|
+
"headers": [
|
|
114
|
+
(b"content-type", b"application/json"),
|
|
115
|
+
(b"content-length", str(len(body)).encode("ascii")),
|
|
116
|
+
(b"cache-control", b"no-store"),
|
|
117
|
+
],
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
await send({"type": "http.response.body", "body": body})
|
|
@@ -9,6 +9,7 @@ import os
|
|
|
9
9
|
import socket
|
|
10
10
|
import sys
|
|
11
11
|
import time
|
|
12
|
+
import urllib.error
|
|
12
13
|
import urllib.request
|
|
13
14
|
import uuid
|
|
14
15
|
|
|
@@ -710,6 +711,11 @@ def _ping_arrayview_server(port: int) -> dict | None:
|
|
|
710
711
|
payload = json.loads(resp.read().decode("utf-8"))
|
|
711
712
|
if payload.get("ok") is True and payload.get("service") == "arrayview":
|
|
712
713
|
return payload
|
|
714
|
+
except urllib.error.URLError as exc:
|
|
715
|
+
# Nobody is listening on the port: retrying cannot change that,
|
|
716
|
+
# and the two 50 ms pauses were paid on every fresh launch.
|
|
717
|
+
if isinstance(exc.reason, ConnectionRefusedError):
|
|
718
|
+
return None
|
|
713
719
|
except Exception:
|
|
714
720
|
pass
|
|
715
721
|
if attempt < 2:
|
|
@@ -155,15 +155,17 @@ def _register_server_runtime(port: int, owner_mode: str):
|
|
|
155
155
|
os.chmod(log_directory, 0o700)
|
|
156
156
|
except OSError:
|
|
157
157
|
pass
|
|
158
|
+
# Identity lives in _session, not _server, so registering does not pull in
|
|
159
|
+
# the web framework: the daemon can answer /ping before that import runs.
|
|
158
160
|
record = InstanceRecord.create(
|
|
159
161
|
port=port,
|
|
160
|
-
protocol_version=
|
|
162
|
+
protocol_version=_session_mod.SERVER_PROTOCOL_VERSION,
|
|
161
163
|
package_version=__version__,
|
|
162
164
|
owner_mode=owner_mode,
|
|
163
165
|
log_path=str(log_directory / f"server-{os.getpid()}.log"),
|
|
164
166
|
)
|
|
165
167
|
registry.write(record)
|
|
166
|
-
|
|
168
|
+
_session_mod.configure_server_runtime(
|
|
167
169
|
instance_id=record.instance_id,
|
|
168
170
|
process_start=record.process_start,
|
|
169
171
|
owner_mode=record.owner_mode,
|
|
@@ -4622,13 +4624,24 @@ def _serve_daemon(
|
|
|
4622
4624
|
port=record.port,
|
|
4623
4625
|
)
|
|
4624
4626
|
|
|
4627
|
+
# Serve from the bootstrap app right away: /ping answers with this
|
|
4628
|
+
# daemon's identity while the web framework is still importing, so the
|
|
4629
|
+
# launcher and the opener can open the tab ~0.4 s sooner and the page
|
|
4630
|
+
# download overlaps the import. Every other request waits in line and is
|
|
4631
|
+
# served by the real app the moment it is attached.
|
|
4632
|
+
from arrayview._bootstrap_app import BootstrapApp
|
|
4633
|
+
|
|
4634
|
+
bootstrap = BootstrapApp()
|
|
4635
|
+
uvicorn_server: list = []
|
|
4636
|
+
|
|
4625
4637
|
def _run_uvicorn_on_socket():
|
|
4626
4638
|
config = _uvicorn().Config(
|
|
4627
|
-
|
|
4639
|
+
bootstrap,
|
|
4628
4640
|
log_level="error",
|
|
4629
4641
|
timeout_keep_alive=30,
|
|
4630
4642
|
)
|
|
4631
4643
|
server = _uvicorn().Server(config)
|
|
4644
|
+
uvicorn_server.append(server)
|
|
4632
4645
|
asyncio.run(server.serve(sockets=sock))
|
|
4633
4646
|
|
|
4634
4647
|
# Start uvicorn immediately — the window can open before data is ready.
|
|
@@ -4638,14 +4651,43 @@ def _serve_daemon(
|
|
|
4638
4651
|
).start()
|
|
4639
4652
|
_trace_launch_event("backend.http_thread_started", port=port)
|
|
4640
4653
|
|
|
4641
|
-
#
|
|
4642
|
-
|
|
4654
|
+
# Let the listener come up before any CPU-heavy import starts: with the
|
|
4655
|
+
# framework import, the colormap warm-up and the data load all competing
|
|
4656
|
+
# for the interpreter, uvicorn's own startup was measured taking ~0.6 s
|
|
4657
|
+
# instead of ~0.03 s, which delayed the first /ping answer by the same.
|
|
4658
|
+
serving_deadline = time.monotonic() + 5.0
|
|
4659
|
+
while time.monotonic() < serving_deadline:
|
|
4660
|
+
if uvicorn_server and getattr(uvicorn_server[0], "started", False):
|
|
4661
|
+
break
|
|
4662
|
+
time.sleep(0.002)
|
|
4663
|
+
_trace_launch_event("backend.serving", port=port)
|
|
4664
|
+
|
|
4665
|
+
def _attach_full_app():
|
|
4666
|
+
try:
|
|
4667
|
+
bootstrap.attach(_server_mod().app)
|
|
4668
|
+
except BaseException as exc: # noqa: BLE001 — any failure here is fatal
|
|
4669
|
+
bootstrap.fail(exc)
|
|
4670
|
+
_trace_launch_event("daemon.exiting", reason="server_import_failed")
|
|
4671
|
+
_vprint(f"[ArrayView] server failed to start: {exc!r}", flush=True)
|
|
4672
|
+
try:
|
|
4673
|
+
registry.remove(record.instance_id)
|
|
4674
|
+
except Exception:
|
|
4675
|
+
pass
|
|
4676
|
+
_cleanup_source_staging_dirs(source_staging_dirs)
|
|
4677
|
+
os._exit(1)
|
|
4678
|
+
_trace_launch_event("backend.app_attached", port=port)
|
|
4679
|
+
# Pre-warm colormap LUTs (the page and the first frame both need
|
|
4680
|
+
# them). Sequenced after the framework import rather than alongside
|
|
4681
|
+
# it: run together they contend for the interpreter and both finish
|
|
4682
|
+
# later than they do one after the other.
|
|
4643
4683
|
try:
|
|
4644
4684
|
from arrayview._render import _init_luts
|
|
4645
4685
|
_init_luts()
|
|
4646
4686
|
except Exception:
|
|
4647
4687
|
pass
|
|
4648
|
-
|
|
4688
|
+
_trace_launch_event("backend.luts_ready", port=port)
|
|
4689
|
+
|
|
4690
|
+
threading.Thread(target=_attach_full_app, daemon=True).start()
|
|
4649
4691
|
|
|
4650
4692
|
def _load():
|
|
4651
4693
|
from arrayview._io import (
|
|
@@ -102,6 +102,12 @@ def _in_jupyter() -> bool:
|
|
|
102
102
|
global _JUPYTER_CACHE
|
|
103
103
|
if _JUPYTER_CACHE is not None:
|
|
104
104
|
return _JUPYTER_CACHE
|
|
105
|
+
# A notebook kernel has IPython loaded before any user code runs, so a
|
|
106
|
+
# process without it cannot be one. Importing IPython just to ask costs
|
|
107
|
+
# ~250 ms on every terminal launch; skip it when it is not already there.
|
|
108
|
+
if "IPython" not in sys.modules:
|
|
109
|
+
_JUPYTER_CACHE = False
|
|
110
|
+
return False
|
|
105
111
|
try:
|
|
106
112
|
from IPython import get_ipython
|
|
107
113
|
|
|
@@ -9,7 +9,6 @@ import json
|
|
|
9
9
|
import os
|
|
10
10
|
import time
|
|
11
11
|
import uuid
|
|
12
|
-
from dataclasses import dataclass, replace
|
|
13
12
|
from urllib.parse import urlencode
|
|
14
13
|
from fastapi import FastAPI, HTTPException, Request
|
|
15
14
|
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
|
@@ -25,7 +24,6 @@ from arrayview._session import (
|
|
|
25
24
|
COLORMAPS,
|
|
26
25
|
)
|
|
27
26
|
from arrayview import __version__ as _av_version
|
|
28
|
-
from arrayview._instance_registry import process_start_identity
|
|
29
27
|
import arrayview._session as _session_mod # for mutable VIEWER_SOCKETS
|
|
30
28
|
|
|
31
29
|
from arrayview._render import (
|
|
@@ -68,75 +66,17 @@ from arrayview._imaging import ensure_image as _pil_image, ensure_imageops as _p
|
|
|
68
66
|
app = FastAPI()
|
|
69
67
|
|
|
70
68
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"viewer-phase-journal",
|
|
80
|
-
"shell-websocket",
|
|
81
|
-
"dir-collection-case-inference",
|
|
82
|
-
"staged-drop-import",
|
|
69
|
+
# Identity, capabilities and the /ping contract are owned by _session.py so a
|
|
70
|
+
# booting daemon can answer /ping before this module has imported. Re-exported
|
|
71
|
+
# here so callers and tests keep addressing them through the server module.
|
|
72
|
+
from arrayview._session import ( # noqa: E402
|
|
73
|
+
SERVER_CAPABILITIES,
|
|
74
|
+
SERVER_PROTOCOL_VERSION,
|
|
75
|
+
ServerRuntimeState,
|
|
76
|
+
configure_server_runtime,
|
|
83
77
|
)
|
|
84
78
|
|
|
85
79
|
|
|
86
|
-
def _environment_port() -> int | None:
|
|
87
|
-
value = os.environ.get("ARRAYVIEW_SERVER_PORT")
|
|
88
|
-
if not value:
|
|
89
|
-
return None
|
|
90
|
-
try:
|
|
91
|
-
port = int(value)
|
|
92
|
-
except ValueError:
|
|
93
|
-
return None
|
|
94
|
-
return port if 0 < port < 65536 else None
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def _environment_started_at() -> float:
|
|
98
|
-
value = os.environ.get("ARRAYVIEW_STARTED_AT")
|
|
99
|
-
if value:
|
|
100
|
-
try:
|
|
101
|
-
return float(value)
|
|
102
|
-
except ValueError:
|
|
103
|
-
pass
|
|
104
|
-
return time.time()
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
@dataclass(frozen=True)
|
|
108
|
-
class ServerRuntimeState:
|
|
109
|
-
"""Stable identity and ownership metadata for this server process."""
|
|
110
|
-
|
|
111
|
-
instance_id: str
|
|
112
|
-
process_start: str
|
|
113
|
-
owner_mode: str
|
|
114
|
-
started_at: float
|
|
115
|
-
port: int | None
|
|
116
|
-
protocol_version: str = SERVER_PROTOCOL_VERSION
|
|
117
|
-
capabilities: tuple[str, ...] = SERVER_CAPABILITIES
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if _session_mod.SERVER_RUNTIME is None:
|
|
121
|
-
_session_mod.SERVER_RUNTIME = ServerRuntimeState(
|
|
122
|
-
instance_id=os.environ.get("ARRAYVIEW_INSTANCE_ID") or str(uuid.uuid4()),
|
|
123
|
-
process_start=(
|
|
124
|
-
os.environ.get("ARRAYVIEW_PROCESS_START")
|
|
125
|
-
or process_start_identity(os.getpid())
|
|
126
|
-
or f"pid-only:{os.getpid()}"
|
|
127
|
-
),
|
|
128
|
-
owner_mode=os.environ.get("ARRAYVIEW_OWNER_MODE", "unknown"),
|
|
129
|
-
started_at=_environment_started_at(),
|
|
130
|
-
port=_environment_port(),
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def configure_server_runtime(**changes) -> ServerRuntimeState:
|
|
135
|
-
"""Set launch metadata once the listener's final ownership/port is known."""
|
|
136
|
-
_session_mod.SERVER_RUNTIME = replace(_session_mod.SERVER_RUNTIME, **changes)
|
|
137
|
-
return _session_mod.SERVER_RUNTIME
|
|
138
|
-
|
|
139
|
-
|
|
140
80
|
@app.exception_handler(Exception)
|
|
141
81
|
async def _generic_exception_handler(request: Request, exc: Exception):
|
|
142
82
|
import traceback
|
|
@@ -164,6 +104,42 @@ _GSAP_JS: str = (
|
|
|
164
104
|
_pkg_files("arrayview").joinpath("gsap.min.js").read_text(encoding="utf-8")
|
|
165
105
|
)
|
|
166
106
|
|
|
107
|
+
|
|
108
|
+
def _split_viewer_template(template: str) -> tuple[str, str, str]:
|
|
109
|
+
"""Split the viewer into a per-launch page and one static script.
|
|
110
|
+
|
|
111
|
+
Everything in the main script below ``__AV_STATIC_SCRIPT_BELOW__`` is
|
|
112
|
+
byte-identical on every launch (all substituted values sit above the
|
|
113
|
+
marker), so it is served as ``viewer-<hash>.js`` with an immutable cache
|
|
114
|
+
header instead of being re-sent inside every page. On a VS Code tunnel
|
|
115
|
+
that is ~400 KB gzipped per open that the browser now keeps. The source
|
|
116
|
+
file stays one self-contained HTML document; the split happens here.
|
|
117
|
+
Returns ``(page_template, static_js, static_hash)``; without the marker
|
|
118
|
+
the whole file is served inline exactly as before.
|
|
119
|
+
"""
|
|
120
|
+
marker = "__AV_STATIC_SCRIPT_BELOW__"
|
|
121
|
+
marker_at = template.find(marker)
|
|
122
|
+
if marker_at < 0:
|
|
123
|
+
return template, "", ""
|
|
124
|
+
script_start = template.index("\n", marker_at) + 1
|
|
125
|
+
script_end = template.index("</script>", script_start)
|
|
126
|
+
static_js = template[script_start:script_end]
|
|
127
|
+
import hashlib
|
|
128
|
+
|
|
129
|
+
digest = hashlib.sha256(static_js.encode("utf-8")).hexdigest()[:16]
|
|
130
|
+
page = (
|
|
131
|
+
template[:script_start]
|
|
132
|
+
+ f' </script>\n <script src="viewer-{digest}.js">'
|
|
133
|
+
+ template[script_end:]
|
|
134
|
+
)
|
|
135
|
+
return page, static_js, digest
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_VIEWER_PAGE_TEMPLATE, _VIEWER_STATIC_JS, _VIEWER_STATIC_JS_HASH = (
|
|
139
|
+
_split_viewer_template(_VIEWER_HTML_TEMPLATE)
|
|
140
|
+
)
|
|
141
|
+
_VIEWER_STATIC_JS_GZIP: bytes | None = None
|
|
142
|
+
|
|
167
143
|
# The viewer page is ~2 MB of single-file HTML. On loopback that is free, but a
|
|
168
144
|
# VS Code tunnel relays every byte through a cloud endpoint, where the same
|
|
169
145
|
# payload has been observed taking 8-18 s. gzip cuts it to ~380 KB (5x), so the
|
|
@@ -207,11 +183,53 @@ def _text_response(
|
|
|
207
183
|
return Response(content=packed, media_type=media_type, headers=out)
|
|
208
184
|
|
|
209
185
|
|
|
186
|
+
@app.get("/viewer-{digest}.js")
|
|
187
|
+
def serve_viewer_static_js(digest: str, request: Request):
|
|
188
|
+
"""Serve the launch-independent part of the viewer script.
|
|
189
|
+
|
|
190
|
+
The digest is in the URL, so a changed viewer gets a new address and the
|
|
191
|
+
browser may keep this one forever; a stale address is simply unknown.
|
|
192
|
+
"""
|
|
193
|
+
if not _VIEWER_STATIC_JS or digest != _VIEWER_STATIC_JS_HASH:
|
|
194
|
+
return Response(status_code=404)
|
|
195
|
+
headers = {"Cache-Control": "public, max-age=31536000, immutable"}
|
|
196
|
+
if not _accepts_gzip(request):
|
|
197
|
+
return Response(
|
|
198
|
+
content=_VIEWER_STATIC_JS,
|
|
199
|
+
media_type="application/javascript",
|
|
200
|
+
headers=headers,
|
|
201
|
+
)
|
|
202
|
+
global _VIEWER_STATIC_JS_GZIP
|
|
203
|
+
if _VIEWER_STATIC_JS_GZIP is None:
|
|
204
|
+
import gzip
|
|
205
|
+
|
|
206
|
+
_VIEWER_STATIC_JS_GZIP = gzip.compress(_VIEWER_STATIC_JS.encode("utf-8"), 6)
|
|
207
|
+
headers["Content-Encoding"] = "gzip"
|
|
208
|
+
headers["Vary"] = "Accept-Encoding"
|
|
209
|
+
return Response(
|
|
210
|
+
content=_VIEWER_STATIC_JS_GZIP,
|
|
211
|
+
media_type="application/javascript",
|
|
212
|
+
headers=headers,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
_GSAP_ETAG = '"' + __import__("hashlib").sha256(_GSAP_JS.encode("utf-8")).hexdigest()[:16] + '"'
|
|
217
|
+
|
|
218
|
+
|
|
210
219
|
@app.get("/gsap.min.js")
|
|
211
220
|
def serve_gsap(request: Request):
|
|
212
|
-
"""Serve vendored GSAP library
|
|
221
|
+
"""Serve the vendored GSAP library.
|
|
222
|
+
|
|
223
|
+
Cached for a day and revalidated by ETag after that, so a repeat launch
|
|
224
|
+
costs one small round trip instead of another 28 KB through the tunnel.
|
|
225
|
+
"""
|
|
226
|
+
if request.headers.get("if-none-match") == _GSAP_ETAG:
|
|
227
|
+
return Response(status_code=304, headers={"ETag": _GSAP_ETAG})
|
|
213
228
|
return _text_response(
|
|
214
|
-
_GSAP_JS,
|
|
229
|
+
_GSAP_JS,
|
|
230
|
+
request=request,
|
|
231
|
+
media_type="application/javascript",
|
|
232
|
+
headers={"Cache-Control": "max-age=86400", "ETag": _GSAP_ETAG},
|
|
215
233
|
)
|
|
216
234
|
|
|
217
235
|
|
|
@@ -347,40 +365,7 @@ async def cold_start_port(payload: dict):
|
|
|
347
365
|
@app.get("/ping")
|
|
348
366
|
def ping():
|
|
349
367
|
"""Health marker so clients can verify this is an ArrayView server."""
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
runtime = _session_mod.SERVER_RUNTIME
|
|
353
|
-
return {
|
|
354
|
-
"ok": True,
|
|
355
|
-
"service": "arrayview",
|
|
356
|
-
"pid": os.getpid(),
|
|
357
|
-
"uid": os.geteuid() if hasattr(os, "geteuid") else None,
|
|
358
|
-
"hostname": socket.gethostname(),
|
|
359
|
-
"protocol_version": runtime.protocol_version,
|
|
360
|
-
"package_version": _av_version,
|
|
361
|
-
"instance_id": runtime.instance_id,
|
|
362
|
-
"process_start": runtime.process_start,
|
|
363
|
-
"owner_mode": runtime.owner_mode,
|
|
364
|
-
"started_at": runtime.started_at,
|
|
365
|
-
"port": runtime.port,
|
|
366
|
-
"capabilities": list(runtime.capabilities),
|
|
367
|
-
"active_sessions": len(SESSIONS),
|
|
368
|
-
"active_viewer_sockets": _session_mod.VIEWER_SOCKETS,
|
|
369
|
-
"active_shell_sockets": len(_session_mod.SHELL_SOCKETS),
|
|
370
|
-
"viewer_sockets": _session_mod.VIEWER_SOCKETS,
|
|
371
|
-
"viewer_connections_seen": _session_mod.VIEWER_CONNECTIONS_SEEN,
|
|
372
|
-
"shell_sockets": len(_session_mod.SHELL_SOCKETS),
|
|
373
|
-
"shell_request_ids": sorted(_session_mod.SHELL_REQUEST_IDS),
|
|
374
|
-
"active_viewer_requests": sorted(
|
|
375
|
-
f"{sid}:{request_id}"
|
|
376
|
-
for (sid, request_id), count in _session_mod.VIEWER_REQUEST_COUNTS.items()
|
|
377
|
-
if count > 0
|
|
378
|
-
),
|
|
379
|
-
"native_ready_requests": sorted(
|
|
380
|
-
f"{sid}:{request_id}"
|
|
381
|
-
for sid, request_id in _session_mod.NATIVE_READY_REQUESTS
|
|
382
|
-
),
|
|
383
|
-
}
|
|
368
|
+
return _session_mod.ping_payload(active_sessions=len(SESSIONS))
|
|
384
369
|
|
|
385
370
|
|
|
386
371
|
@app.get("/status")
|
|
@@ -428,13 +413,50 @@ def _trace_page_request(
|
|
|
428
413
|
pass
|
|
429
414
|
|
|
430
415
|
|
|
416
|
+
def _boot_metadata_json(sid: str | None) -> str:
|
|
417
|
+
"""The array's description, for the page to carry instead of asking.
|
|
418
|
+
|
|
419
|
+
The viewer otherwise waits for the server to push this over the freshly
|
|
420
|
+
opened WebSocket, which costs half a round trip before it can request its
|
|
421
|
+
first frame — noticeable over a VS Code tunnel. The server already knows
|
|
422
|
+
which session the page is for, so it writes the description into the page.
|
|
423
|
+
|
|
424
|
+
Returns ``"null"`` whenever the answer is not already known (no sid, still
|
|
425
|
+
loading, or anything unexpected); the viewer then asks the way it always
|
|
426
|
+
has. This must never wait for a session: a pending one is exactly the case
|
|
427
|
+
the WebSocket push handles better.
|
|
428
|
+
"""
|
|
429
|
+
if not sid:
|
|
430
|
+
return "null"
|
|
431
|
+
try:
|
|
432
|
+
if sid in _session_mod.PENDING_SESSIONS:
|
|
433
|
+
return "null"
|
|
434
|
+
session = SESSIONS.get(sid)
|
|
435
|
+
if session is None:
|
|
436
|
+
return "null"
|
|
437
|
+
from arrayview._analysis import _build_metadata
|
|
438
|
+
|
|
439
|
+
return json.dumps(_build_metadata(session))
|
|
440
|
+
except Exception:
|
|
441
|
+
return "null"
|
|
442
|
+
|
|
443
|
+
|
|
431
444
|
def _viewer_ui_response(
|
|
432
445
|
request: Request,
|
|
433
446
|
*,
|
|
434
447
|
sid: str | None,
|
|
435
448
|
query_val: str,
|
|
449
|
+
asset_base: str = "",
|
|
436
450
|
):
|
|
437
|
-
"""Render one viewer page with its launch query already chosen.
|
|
451
|
+
"""Render one viewer page with its launch query already chosen.
|
|
452
|
+
|
|
453
|
+
``asset_base`` is prepended to the page's script addresses. The plain
|
|
454
|
+
route keeps them relative so the page also works behind a Jupyter proxy
|
|
455
|
+
prefix; the private tunnel route passes "/" so the addresses are the
|
|
456
|
+
same on every launch and the browser can reuse the cached script
|
|
457
|
+
(relative addresses under /_av/<tab>/... change with every tab key, which
|
|
458
|
+
made the built-in browser download the 400 KB script on every open).
|
|
459
|
+
"""
|
|
438
460
|
_init_luts()
|
|
439
461
|
_cfg_colormaps = get_viewer_colormaps()
|
|
440
462
|
_valid_cfg_colormaps = (
|
|
@@ -451,12 +473,13 @@ def _viewer_ui_response(
|
|
|
451
473
|
_default_ortho_layout = json.dumps(get_viewer_ortho_layout())
|
|
452
474
|
_default_dimbar_mode = json.dumps(get_viewer_dimbar_mode())
|
|
453
475
|
html = (
|
|
454
|
-
|
|
476
|
+
_VIEWER_PAGE_TEMPLATE.replace("__COLORMAPS__", str(_active_colormaps))
|
|
455
477
|
.replace("__COLORMAP_GRADIENT_STOPS__", json.dumps(COLORMAP_GRADIENT_STOPS))
|
|
456
478
|
.replace("__LABEL_COLORS__", json.dumps(LABEL_COLORS.astype(int).tolist()))
|
|
457
479
|
.replace("__COMPLEX_MODES__", str(COMPLEX_MODES))
|
|
458
480
|
.replace("__REAL_MODES__", str(REAL_MODES))
|
|
459
481
|
.replace("__ARRAYVIEW_QUERY__", query_val)
|
|
482
|
+
.replace("__ARRAYVIEW_BOOT_METADATA__", _boot_metadata_json(sid))
|
|
460
483
|
.replace("__DEFAULT_THEME_IDX__", str(_default_theme_idx))
|
|
461
484
|
.replace("__DEFAULT_ROUNDED_PANES__", _default_rounded_panes)
|
|
462
485
|
.replace("__DEFAULT_ORTHO_LAYOUT__", _default_ortho_layout)
|
|
@@ -464,6 +487,9 @@ def _viewer_ui_response(
|
|
|
464
487
|
.replace("__BODY_CLASS__", "av-loading" if sid else "")
|
|
465
488
|
.replace("__ARRAYVIEW_VERSION__", _av_version)
|
|
466
489
|
)
|
|
490
|
+
if asset_base:
|
|
491
|
+
html = html.replace('<script src="gsap.min.js">', f'<script src="{asset_base}gsap.min.js">')
|
|
492
|
+
html = html.replace('<script src="viewer-', f'<script src="{asset_base}viewer-')
|
|
467
493
|
return _text_response(
|
|
468
494
|
html,
|
|
469
495
|
request=request,
|
|
@@ -479,6 +505,15 @@ def get_short_viewer_ui(
|
|
|
479
505
|
navigation_key: str,
|
|
480
506
|
):
|
|
481
507
|
"""Resolve a private integrated-browser launch without exposing its query."""
|
|
508
|
+
# The page's script tags use relative addresses so they also work behind
|
|
509
|
+
# a Jupyter proxy prefix. Under this nested route the browser resolves
|
|
510
|
+
# them to /_av/<tab_key>/<asset>, so the assets are answered here too.
|
|
511
|
+
# (Before this, gsap.min.js silently 404'd on every tunnel launch and the
|
|
512
|
+
# viewer ran without its animation library.)
|
|
513
|
+
if navigation_key == "gsap.min.js":
|
|
514
|
+
return serve_gsap(request)
|
|
515
|
+
if navigation_key.startswith("viewer-") and navigation_key.endswith(".js"):
|
|
516
|
+
return serve_viewer_static_js(navigation_key[len("viewer-"):-3], request)
|
|
482
517
|
try:
|
|
483
518
|
from arrayview._launch_trace import emit_route_launch_event
|
|
484
519
|
|
|
@@ -535,6 +570,7 @@ def get_short_viewer_ui(
|
|
|
535
570
|
request,
|
|
536
571
|
sid=sid,
|
|
537
572
|
query_val=json.dumps(bootstrap_query),
|
|
573
|
+
asset_base="/",
|
|
538
574
|
)
|
|
539
575
|
|
|
540
576
|
|