arrayview 0.40.0__tar.gz → 0.41.0__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.40.0 → arrayview-0.41.0}/PKG-INFO +1 -1
- {arrayview-0.40.0 → arrayview-0.41.0}/pyproject.toml +1 -1
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/ARCHITECTURE.md +1 -1
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_launch_plan.py +9 -1
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_launcher.py +103 -13
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_lifecycle.py +4 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_query.py +70 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_server.py +107 -21
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_session.py +4 -0
- arrayview-0.41.0/src/arrayview/_tutorial.py +194 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_viewer.html +848 -134
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_vscode_browser.py +38 -23
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_vscode_extension.py +48 -20
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_vscode_signal.py +3 -0
- arrayview-0.41.0/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/lifecycle_matrix.py +1 -1
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_browser.py +44 -69
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_display_opener_results.py +40 -1
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_launch_plan.py +36 -3
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_lifecycle_contract.py +141 -17
- arrayview-0.41.0/tests/test_tutorial_browser.py +402 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_vscode_ack_protocol.py +20 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/visual_smoke.py +23 -11
- arrayview-0.40.0/src/arrayview/_tutorial.py +0 -106
- arrayview-0.40.0/src/arrayview/arrayview-opener.vsix +0 -0
- arrayview-0.40.0/tests/test_command_reachability.py +0 -140
- arrayview-0.40.0/tests/test_cross_mode_parametrized.py +0 -214
- arrayview-0.40.0/tests/test_interactions.py +0 -2132
- arrayview-0.40.0/tests/test_tutorial_browser.py +0 -197
- arrayview-0.40.0/tests/test_view_component_integration.py +0 -1675
- arrayview-0.40.0/tests/test_view_component_unit.py +0 -165
- {arrayview-0.40.0 → arrayview-0.41.0}/.gitignore +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/LICENSE +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/README.md +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/__init__.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/__main__.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_analysis.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_app.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_config.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_diagnostics.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_dicom.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_diff.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_icon.png +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_imaging.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_instance_registry.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_io.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_launch_trace.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_overlays.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_platform.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_render.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_analysis.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_drop.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_export.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_loading.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_persistence.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_preferences.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_preload.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_rendering.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_segmentation.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_state.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_vectorfield.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_routes_websocket.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_session_spec.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_shell.html +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_synthetic_mri.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_torch.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_vectorfield.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/src/arrayview/todo.md +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/capture_patient_loading.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/capture_v_animation.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/conftest.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_1d_arrays.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_api.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_backend_shared.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_cli.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_colorbar_hover_highlight.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_config.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_dicom.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_doctor_commands.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_drop_import.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_instance_registry.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_invocation_contract_matrix.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_io.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_large_arrays.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_launch_blackbox.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_launch_diagnostics.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_launch_execution_ownership.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_launch_trace.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_loading_server.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_mode_consistency.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_mode_matrix.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_mode_roundtrip.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_nifti_series.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_preferences_ui.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_public_invocation_regressions.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_render_failure_reporting.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_render_progress.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_safe_server_stop.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_server_identity_status.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_session_spec.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_spawn_ownership.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_torch.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_tutorial.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_value_formatting.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_view_handle_lifecycle.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_view_launch_execution.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/test_window_level_preview.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/ui_audit.py +0 -0
- {arrayview-0.40.0 → arrayview-0.41.0}/tests/v_anim_frames/.gitkeep +0 -0
|
@@ -169,7 +169,7 @@ A refactoring in progress that introduces a View/Layer/Slicer/LayoutStrategy/Mod
|
|
|
169
169
|
**Status:** Phases 1–15 complete. Phase 12 (keybind collapse) and Phase 17 (final cleanup) pending completion of render-pipeline migration from legacy globals to `displayState`.
|
|
170
170
|
|
|
171
171
|
### Command Registry
|
|
172
|
-
All keybinds flow through a VS Code-style command registry in `_viewer.html`. Three tables: `commands` (id → `{title, when, run}`), `keybinds` (key+modifiers → command id), and `makeContext(state)` (mode/state flag bag). `dispatchCommand(e)` is wired as a prefix to the keydown handler; on a match it evaluates `when` against the context and runs the command, otherwise falls through. The help overlay is rendered at runtime from the `GUIDE_TABS` static data structure in `_viewer.html` — when adding or changing a keybind, update `GUIDE_TABS` manually. A `/`-triggered command palette fuzzy-searches all commands.
|
|
172
|
+
All keybinds flow through a VS Code-style command registry in `_viewer.html`. Three tables: `commands` (id → `{title, when, run}`), `keybinds` (key+modifiers → command id), and `makeContext(state)` (mode/state flag bag). `dispatchCommand(e)` is wired as a prefix to the keydown handler; on a match it evaluates `when` against the context and runs the command, otherwise falls through. The help overlay is rendered at runtime from the `GUIDE_TABS` static data structure in `_viewer.html` — when adding or changing a keybind, update `GUIDE_TABS` manually. A `/`-triggered command palette fuzzy-searches all commands.
|
|
173
173
|
|
|
174
174
|
### Reconcilers
|
|
175
175
|
Functions in the "UI Validation and Reconciliation" section (~line 13666) that enforce consistent UI state. When mode changes happen, reconcilers update visibility of containers, colorbars, dynamic islands, and compare sub-mode UI. There are four:
|
|
@@ -267,6 +267,8 @@ def _completion_target(
|
|
|
267
267
|
if plan.display is Display.BROWSER:
|
|
268
268
|
if _placement(evidence) is Placement.SSH:
|
|
269
269
|
return CompletionTarget.GUIDANCE_PRINTED
|
|
270
|
+
if _placement(evidence) is Placement.VSCODE_REMOTE:
|
|
271
|
+
return CompletionTarget.FRAME_READY
|
|
270
272
|
return CompletionTarget.DISPATCH_ACCEPTED
|
|
271
273
|
if invocation in {Invocation.CLI, Invocation.VSCODE_EXPLORER}:
|
|
272
274
|
return CompletionTarget.FRAME_READY
|
|
@@ -412,7 +414,9 @@ def plan_launch(
|
|
|
412
414
|
window=None if remote_jupyter else window,
|
|
413
415
|
inline=False if remote_jupyter else intent.inline,
|
|
414
416
|
environment=environment,
|
|
415
|
-
native_available=facts.native_backend is not None,
|
|
417
|
+
native_available=facts.native_backend is not None,
|
|
418
|
+
in_vscode_tunnel=facts.in_vscode_tunnel,
|
|
419
|
+
reasons=reasons,
|
|
416
420
|
)
|
|
417
421
|
if display is Display.VSCODE and environment not in {
|
|
418
422
|
Environment.VSCODE_LOCAL, Environment.VSCODE_REMOTE
|
|
@@ -436,6 +440,7 @@ def _display_policy(
|
|
|
436
440
|
inline: bool | None,
|
|
437
441
|
environment: Environment,
|
|
438
442
|
native_available: bool,
|
|
443
|
+
in_vscode_tunnel: bool,
|
|
439
444
|
reasons: list[str],
|
|
440
445
|
) -> tuple[Display, Display | None, bool]:
|
|
441
446
|
if window == "none":
|
|
@@ -461,6 +466,9 @@ def _display_policy(
|
|
|
461
466
|
return Display.VSCODE, None, False
|
|
462
467
|
return Display.VSCODE, Display.BROWSER, True
|
|
463
468
|
if window == "browser":
|
|
469
|
+
if environment is Environment.VSCODE_REMOTE and in_vscode_tunnel:
|
|
470
|
+
reasons.append("remote_external_browser")
|
|
471
|
+
return Display.BROWSER, None, False
|
|
464
472
|
if environment is Environment.VSCODE_REMOTE:
|
|
465
473
|
reasons.append("remote_browser_redirected_to_vscode")
|
|
466
474
|
return Display.VSCODE, None, False
|
|
@@ -1813,6 +1813,9 @@ def _handle_cli_existing_server(
|
|
|
1813
1813
|
expected_server_id=expected_server_id,
|
|
1814
1814
|
native_request_id=native_request_id,
|
|
1815
1815
|
)
|
|
1816
|
+
# Inside this try on purpose: the generated bundle is removed in the
|
|
1817
|
+
# finally below, and these sessions read from it.
|
|
1818
|
+
_register_tutorial_extras(port, expected_server_id)
|
|
1816
1819
|
except Exception as e:
|
|
1817
1820
|
err = str(e)
|
|
1818
1821
|
stale_stack_server = (
|
|
@@ -2065,7 +2068,14 @@ def _handle_cli_spawned_daemon(
|
|
|
2065
2068
|
and is_remote
|
|
2066
2069
|
) or (launch_context is None and not use_native_shell):
|
|
2067
2070
|
_configure_vscode_port_preview(
|
|
2068
|
-
port,
|
|
2071
|
+
port,
|
|
2072
|
+
in_vscode=not is_remote,
|
|
2073
|
+
is_remote=is_remote,
|
|
2074
|
+
is_tunnel=(
|
|
2075
|
+
launch_context.evidence.in_vscode_tunnel
|
|
2076
|
+
if launch_context is not None
|
|
2077
|
+
else None
|
|
2078
|
+
),
|
|
2069
2079
|
)
|
|
2070
2080
|
|
|
2071
2081
|
daemon_connect_timeout = (
|
|
@@ -2201,6 +2211,9 @@ def _handle_cli_spawned_daemon(
|
|
|
2201
2211
|
print(f"Error while loading compare array: {e}")
|
|
2202
2212
|
sys.exit(1)
|
|
2203
2213
|
|
|
2214
|
+
# Before the bundle is cleaned up below: these sessions read from it.
|
|
2215
|
+
_register_tutorial_extras(port, daemon_server_id)
|
|
2216
|
+
|
|
2204
2217
|
try:
|
|
2205
2218
|
_open_cli_spawned_view(
|
|
2206
2219
|
port=port,
|
|
@@ -2709,6 +2722,77 @@ _FAILED_LOAD_EXIT_GRACE_SECONDS = float(
|
|
|
2709
2722
|
)
|
|
2710
2723
|
|
|
2711
2724
|
|
|
2725
|
+
# The tutorial's extra sessions. Two of its sections cannot share the main
|
|
2726
|
+
# session — a vector field disables statistical projections for whatever
|
|
2727
|
+
# session it is attached to, and a ragged collection is a single session made
|
|
2728
|
+
# of differently shaped files — so they are registered separately and the tour
|
|
2729
|
+
# navigates to them by sid. Set once per CLI invocation, read when the viewer
|
|
2730
|
+
# URL is built; empty for every launch that is not the tutorial, in which case
|
|
2731
|
+
# the tour simply does not offer those sections.
|
|
2732
|
+
_TUTORIAL_BUNDLE = None
|
|
2733
|
+
_TUTORIAL_EXTRAS: dict[str, str] = {}
|
|
2734
|
+
|
|
2735
|
+
|
|
2736
|
+
def _register_tutorial_extras(port: int, expected_server_id: str | None = None) -> None:
|
|
2737
|
+
"""Register the tutorial's vector-field and ragged-collection sessions.
|
|
2738
|
+
|
|
2739
|
+
Best effort: a failure here costs two sections, not the tour, so it is
|
|
2740
|
+
traced and swallowed rather than allowed to fail a launch that has already
|
|
2741
|
+
loaded the main array.
|
|
2742
|
+
|
|
2743
|
+
The collection loads eagerly on purpose. The generated bundle is deleted as
|
|
2744
|
+
soon as registration returns, and a lazy collection would still be holding
|
|
2745
|
+
paths into it.
|
|
2746
|
+
"""
|
|
2747
|
+
bundle = _TUTORIAL_BUNDLE
|
|
2748
|
+
if bundle is None:
|
|
2749
|
+
return
|
|
2750
|
+
try:
|
|
2751
|
+
_register_tutorial_extras_impl(port, bundle, expected_server_id)
|
|
2752
|
+
finally:
|
|
2753
|
+
from arrayview._tutorial import cleanup_tutorial_bundle
|
|
2754
|
+
|
|
2755
|
+
cleanup_tutorial_bundle(bundle.extras_directory)
|
|
2756
|
+
|
|
2757
|
+
|
|
2758
|
+
def _register_tutorial_extras_impl(port, bundle, expected_server_id) -> None:
|
|
2759
|
+
try:
|
|
2760
|
+
flow = _load_session_from_filepath(
|
|
2761
|
+
port,
|
|
2762
|
+
bundle.flow_file,
|
|
2763
|
+
"flow",
|
|
2764
|
+
expected_server_id=expected_server_id,
|
|
2765
|
+
)
|
|
2766
|
+
flow_sid = flow.get("sid")
|
|
2767
|
+
if flow_sid and "error" not in flow:
|
|
2768
|
+
attached = _attach_vectorfield_to_session(
|
|
2769
|
+
port,
|
|
2770
|
+
str(flow_sid),
|
|
2771
|
+
bundle.flow_field_file,
|
|
2772
|
+
expected_server_id=expected_server_id,
|
|
2773
|
+
)
|
|
2774
|
+
if "error" not in attached:
|
|
2775
|
+
_TUTORIAL_EXTRAS["flow"] = str(flow_sid)
|
|
2776
|
+
except Exception as exc: # pragma: no cover - best effort
|
|
2777
|
+
_trace_launch_event("tutorial.extra_failed", extra="flow", error=str(exc))
|
|
2778
|
+
|
|
2779
|
+
try:
|
|
2780
|
+
stack = _load_session_from_filepath(
|
|
2781
|
+
port,
|
|
2782
|
+
"",
|
|
2783
|
+
"cases",
|
|
2784
|
+
dir_patterns=[bundle.stack_pattern],
|
|
2785
|
+
collection_stack="ragged",
|
|
2786
|
+
collection_load="eager",
|
|
2787
|
+
expected_server_id=expected_server_id,
|
|
2788
|
+
)
|
|
2789
|
+
stack_sid = stack.get("sid")
|
|
2790
|
+
if stack_sid and "error" not in stack:
|
|
2791
|
+
_TUTORIAL_EXTRAS["stack"] = str(stack_sid)
|
|
2792
|
+
except Exception as exc: # pragma: no cover - best effort
|
|
2793
|
+
_trace_launch_event("tutorial.extra_failed", extra="stack", error=str(exc))
|
|
2794
|
+
|
|
2795
|
+
|
|
2712
2796
|
def _join_query_values(values: _CSVValues) -> str:
|
|
2713
2797
|
if isinstance(values, str):
|
|
2714
2798
|
return values
|
|
@@ -2743,6 +2827,11 @@ def _viewer_query(
|
|
|
2743
2827
|
parts.append(f"dim_y={dims[1]}")
|
|
2744
2828
|
if inline:
|
|
2745
2829
|
parts.append("inline=1")
|
|
2830
|
+
if _TUTORIAL_EXTRAS:
|
|
2831
|
+
# The tour needs these to survive the navigations it makes between
|
|
2832
|
+
# its own sessions, so they ride in the URL rather than in memory.
|
|
2833
|
+
for key, extra_sid in sorted(_TUTORIAL_EXTRAS.items()):
|
|
2834
|
+
parts.append(f"tutorial_{key}_sid={extra_sid}")
|
|
2746
2835
|
return "?" + "&".join(parts)
|
|
2747
2836
|
|
|
2748
2837
|
|
|
@@ -3397,9 +3486,8 @@ def view(
|
|
|
3397
3486
|
launch_context=_launch_context,
|
|
3398
3487
|
)
|
|
3399
3488
|
|
|
3400
|
-
# VS Code tunnel/remote: use the server + WebSocket path.
|
|
3401
|
-
#
|
|
3402
|
-
# calls ensurePortPublic() before asExternalUri.
|
|
3489
|
+
# VS Code tunnel/remote: use the server + WebSocket path. The opener owns
|
|
3490
|
+
# private client-side delivery for tunnel sessions.
|
|
3403
3491
|
# Fall through to the server-based path below (don't return early).
|
|
3404
3492
|
|
|
3405
3493
|
# With a planned existing server: register arrays via /load. Keep display
|
|
@@ -3644,7 +3732,10 @@ def view(
|
|
|
3644
3732
|
and _launch_plan.display is Display.VSCODE
|
|
3645
3733
|
):
|
|
3646
3734
|
_configure_vscode_port_preview(
|
|
3647
|
-
port,
|
|
3735
|
+
port,
|
|
3736
|
+
in_vscode=False,
|
|
3737
|
+
is_remote=True,
|
|
3738
|
+
is_tunnel=_launch_context.evidence.in_vscode_tunnel,
|
|
3648
3739
|
)
|
|
3649
3740
|
_platform_mod._jupyter_server_port = port
|
|
3650
3741
|
except Exception:
|
|
@@ -4935,6 +5026,8 @@ def _configure_tutorial_args(args):
|
|
|
4935
5026
|
"""Turn a file-less CLI request into the generated tutorial bundle."""
|
|
4936
5027
|
from arrayview._tutorial import create_tutorial_bundle
|
|
4937
5028
|
|
|
5029
|
+
global _TUTORIAL_BUNDLE
|
|
5030
|
+
|
|
4938
5031
|
tutorial = create_tutorial_bundle()
|
|
4939
5032
|
args.files = [tutorial.base_file, tutorial.compare_file]
|
|
4940
5033
|
args.overlay = [f"Regions={tutorial.overlay_file}"]
|
|
@@ -4942,6 +5035,8 @@ def _configure_tutorial_args(args):
|
|
|
4942
5035
|
args._demo_cleanup = False
|
|
4943
5036
|
args._tutorial_cleanup_dir = tutorial.directory
|
|
4944
5037
|
args.rgb = False
|
|
5038
|
+
_TUTORIAL_BUNDLE = tutorial
|
|
5039
|
+
_TUTORIAL_EXTRAS.clear()
|
|
4945
5040
|
return tutorial
|
|
4946
5041
|
|
|
4947
5042
|
|
|
@@ -5123,8 +5218,7 @@ def arrayview():
|
|
|
5123
5218
|
action="store_true",
|
|
5124
5219
|
help=(
|
|
5125
5220
|
"Start a persistent server on the given port without loading any data. "
|
|
5126
|
-
"Useful
|
|
5127
|
-
"in the Ports tab, then use 'arrayview FILE' freely."
|
|
5221
|
+
"Useful for reusing one ArrayView server across repeated launches."
|
|
5128
5222
|
),
|
|
5129
5223
|
)
|
|
5130
5224
|
parser.add_argument(
|
|
@@ -5522,8 +5616,7 @@ def arrayview():
|
|
|
5522
5616
|
_revalidate_launch_server(serve_context, args.port)
|
|
5523
5617
|
print(
|
|
5524
5618
|
f"[ArrayView] Server already running on port {args.port}. "
|
|
5525
|
-
"
|
|
5526
|
-
"then run: arrayview your_file.npy"
|
|
5619
|
+
"Run: arrayview your_file.npy"
|
|
5527
5620
|
)
|
|
5528
5621
|
return
|
|
5529
5622
|
|
|
@@ -5553,6 +5646,7 @@ def arrayview():
|
|
|
5553
5646
|
args.port,
|
|
5554
5647
|
in_vscode=serve_context.evidence.in_vscode_terminal,
|
|
5555
5648
|
is_remote=serve_context.evidence.is_vscode_remote,
|
|
5649
|
+
is_tunnel=serve_context.evidence.in_vscode_tunnel,
|
|
5556
5650
|
)
|
|
5557
5651
|
script = (
|
|
5558
5652
|
"from arrayview._launcher import _serve_empty; "
|
|
@@ -5874,10 +5968,6 @@ def arrayview():
|
|
|
5874
5968
|
"--window native is not available over a VS Code remote/tunnel "
|
|
5875
5969
|
"session; opening a VS Code tab instead."
|
|
5876
5970
|
),
|
|
5877
|
-
"remote_browser_redirected_to_vscode": (
|
|
5878
|
-
"--window browser cannot reach a browser on the remote host; "
|
|
5879
|
-
"opening a VS Code tab instead."
|
|
5880
|
-
),
|
|
5881
5971
|
"native_unavailable": (
|
|
5882
5972
|
"--window native found no usable GUI backend (no display server "
|
|
5883
5973
|
"or no Qt/GTK for pywebview); opening a browser instead."
|
|
@@ -13,6 +13,7 @@ from arrayview._session import (
|
|
|
13
13
|
SESSIONS,
|
|
14
14
|
VIEWER_CONNECTION_EPOCHS,
|
|
15
15
|
VIEWER_PHASE_JOURNALS,
|
|
16
|
+
VIEWER_LAUNCH_ROUTES,
|
|
16
17
|
VIEWER_RELEASE_TASKS,
|
|
17
18
|
)
|
|
18
19
|
|
|
@@ -88,6 +89,9 @@ def release_session(sid: str, *, cancel_if_missing: bool = False) -> bool:
|
|
|
88
89
|
if retire_journals:
|
|
89
90
|
journals = VIEWER_PHASE_JOURNALS.pop(sid, {})
|
|
90
91
|
for journal in journals.values():
|
|
92
|
+
navigation_key = journal.get("navigation_key")
|
|
93
|
+
if navigation_key:
|
|
94
|
+
VIEWER_LAUNCH_ROUTES.pop(navigation_key, None)
|
|
91
95
|
journal_task = journal.get("release_task")
|
|
92
96
|
if journal_task is not None and not journal_task.done():
|
|
93
97
|
try:
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import io
|
|
3
|
+
import re
|
|
4
|
+
from urllib.parse import parse_qs
|
|
3
5
|
|
|
4
6
|
import numpy as np
|
|
5
7
|
from fastapi import Depends, HTTPException, Request, Response
|
|
@@ -73,6 +75,52 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
|
|
|
73
75
|
journals = _session_mod.VIEWER_PHASE_JOURNALS.setdefault(sid, {})
|
|
74
76
|
if phase == "launch-prepared":
|
|
75
77
|
previous_journal = journals.get(request_id)
|
|
78
|
+
viewer_query = body.get("viewer_query")
|
|
79
|
+
tab_key = body.get("tab_key")
|
|
80
|
+
navigation_key = body.get("navigation_key")
|
|
81
|
+
navigation_attempt = body.get("navigation_attempt")
|
|
82
|
+
short_route = any(
|
|
83
|
+
value is not None
|
|
84
|
+
for value in (
|
|
85
|
+
viewer_query,
|
|
86
|
+
tab_key,
|
|
87
|
+
navigation_key,
|
|
88
|
+
navigation_attempt,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
if short_route:
|
|
92
|
+
if (
|
|
93
|
+
not isinstance(viewer_query, str)
|
|
94
|
+
or not viewer_query.startswith("?")
|
|
95
|
+
or len(viewer_query) > 65536
|
|
96
|
+
):
|
|
97
|
+
raise HTTPException(status_code=400, detail="Invalid viewer query")
|
|
98
|
+
parsed_query = parse_qs(
|
|
99
|
+
viewer_query.removeprefix("?"),
|
|
100
|
+
keep_blank_values=True,
|
|
101
|
+
)
|
|
102
|
+
if parsed_query.get("sid") != [sid]:
|
|
103
|
+
raise HTTPException(
|
|
104
|
+
status_code=409,
|
|
105
|
+
detail="Viewer primary session changed",
|
|
106
|
+
)
|
|
107
|
+
if any(key.startswith("_av_") for key in parsed_query):
|
|
108
|
+
raise HTTPException(
|
|
109
|
+
status_code=400,
|
|
110
|
+
detail="Reserved viewer query field",
|
|
111
|
+
)
|
|
112
|
+
if (
|
|
113
|
+
not isinstance(tab_key, str)
|
|
114
|
+
or re.fullmatch(r"[A-Za-z0-9_-]{16,64}", tab_key) is None
|
|
115
|
+
or not isinstance(navigation_key, str)
|
|
116
|
+
or re.fullmatch(r"[A-Za-z0-9_-]{16,64}", navigation_key) is None
|
|
117
|
+
or not isinstance(navigation_attempt, int)
|
|
118
|
+
or not 0 <= navigation_attempt <= 32
|
|
119
|
+
):
|
|
120
|
+
raise HTTPException(
|
|
121
|
+
status_code=400,
|
|
122
|
+
detail="Invalid short viewer route identity",
|
|
123
|
+
)
|
|
76
124
|
previous_release_task = (
|
|
77
125
|
previous_journal.get("release_task")
|
|
78
126
|
if previous_journal is not None
|
|
@@ -83,9 +131,23 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
|
|
|
83
131
|
and not previous_release_task.done()
|
|
84
132
|
):
|
|
85
133
|
previous_release_task.cancel()
|
|
134
|
+
previous_navigation_key = (
|
|
135
|
+
previous_journal.get("navigation_key")
|
|
136
|
+
if previous_journal is not None
|
|
137
|
+
else None
|
|
138
|
+
)
|
|
139
|
+
if previous_navigation_key:
|
|
140
|
+
_session_mod.VIEWER_LAUNCH_ROUTES.pop(
|
|
141
|
+
previous_navigation_key,
|
|
142
|
+
None,
|
|
143
|
+
)
|
|
86
144
|
journal = journals[request_id] = {
|
|
87
145
|
"token": token,
|
|
88
146
|
"window_id": window_id,
|
|
147
|
+
"viewer_query": viewer_query,
|
|
148
|
+
"tab_key": tab_key,
|
|
149
|
+
"navigation_key": navigation_key,
|
|
150
|
+
"navigation_attempt": navigation_attempt,
|
|
89
151
|
"viewer_instance_ids": [],
|
|
90
152
|
"phases": [],
|
|
91
153
|
"related_sids": None,
|
|
@@ -93,12 +155,20 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
|
|
|
93
155
|
"release_task": None,
|
|
94
156
|
"disconnect_release_grace_seconds": 30.0,
|
|
95
157
|
}
|
|
158
|
+
if navigation_key:
|
|
159
|
+
_session_mod.VIEWER_LAUNCH_ROUTES[navigation_key] = (
|
|
160
|
+
sid,
|
|
161
|
+
request_id,
|
|
162
|
+
)
|
|
96
163
|
return {
|
|
97
164
|
"sid": sid,
|
|
98
165
|
"request_id": request_id,
|
|
99
166
|
"window_id": window_id,
|
|
100
167
|
"server_id": _session_mod.SERVER_RUNTIME.instance_id,
|
|
101
168
|
"token": token,
|
|
169
|
+
"tab_key": tab_key,
|
|
170
|
+
"navigation_key": navigation_key,
|
|
171
|
+
"navigation_attempt": navigation_attempt,
|
|
102
172
|
"phases": [],
|
|
103
173
|
"viewer_instance_ids": [],
|
|
104
174
|
"related_sids": [],
|
|
@@ -10,6 +10,7 @@ import os
|
|
|
10
10
|
import time
|
|
11
11
|
import uuid
|
|
12
12
|
from dataclasses import dataclass, replace
|
|
13
|
+
from urllib.parse import urlencode
|
|
13
14
|
from fastapi import FastAPI, HTTPException, Request
|
|
14
15
|
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
|
15
16
|
from importlib.resources import files as _pkg_files
|
|
@@ -338,25 +339,45 @@ def status():
|
|
|
338
339
|
|
|
339
340
|
# ── Root UI Route ─────────────────────────────────────────────────
|
|
340
341
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
342
|
+
def _trace_page_request(
|
|
343
|
+
request: Request,
|
|
344
|
+
*,
|
|
345
|
+
request_id: str | None = None,
|
|
346
|
+
navigation_attempt: str | None = None,
|
|
347
|
+
) -> None:
|
|
348
|
+
"""Record that a viewer page fetch reached this backend.
|
|
349
|
+
|
|
350
|
+
A tunnel-delivered launch that never logs ``script-loaded`` is ambiguous
|
|
351
|
+
from the opener's side alone: the relay may have dropped the page request,
|
|
352
|
+
or the page may have arrived and the script stalled. This distinguishes
|
|
353
|
+
the two. Inert unless ``ARRAYVIEW_LAUNCH_TRACE`` is set.
|
|
354
|
+
"""
|
|
355
|
+
if not os.environ.get("ARRAYVIEW_LAUNCH_TRACE"):
|
|
356
|
+
return
|
|
357
|
+
try:
|
|
358
|
+
from arrayview._launch_trace import emit_launch_event
|
|
359
|
+
|
|
360
|
+
params = request.query_params
|
|
361
|
+
emit_launch_event(
|
|
362
|
+
"page.requested",
|
|
363
|
+
request_id=request_id or params.get("_av_launch_request_id"),
|
|
364
|
+
navigation_attempt=(
|
|
365
|
+
navigation_attempt
|
|
366
|
+
or params.get("_av_navigation_attempt")
|
|
367
|
+
or "0"
|
|
368
|
+
),
|
|
369
|
+
)
|
|
370
|
+
except Exception:
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _viewer_ui_response(
|
|
375
|
+
request: Request,
|
|
376
|
+
*,
|
|
377
|
+
sid: str | None,
|
|
378
|
+
query_val: str,
|
|
379
|
+
):
|
|
380
|
+
"""Render one viewer page with its launch query already chosen."""
|
|
360
381
|
_init_luts()
|
|
361
382
|
_cfg_colormaps = get_viewer_colormaps()
|
|
362
383
|
_valid_cfg_colormaps = (
|
|
@@ -390,7 +411,72 @@ def get_ui(request: Request, sid: str = None):
|
|
|
390
411
|
html,
|
|
391
412
|
request=request,
|
|
392
413
|
media_type="text/html; charset=utf-8",
|
|
393
|
-
# no-store stays: when the URL carries no ?sid=, the body embeds the
|
|
394
|
-
# latest session id, so a cached copy would resurrect a dead session.
|
|
395
414
|
headers={"Cache-Control": "no-store"},
|
|
396
415
|
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@app.get("/_av/{tab_key}/{navigation_key}")
|
|
419
|
+
def get_short_viewer_ui(
|
|
420
|
+
request: Request,
|
|
421
|
+
tab_key: str,
|
|
422
|
+
navigation_key: str,
|
|
423
|
+
):
|
|
424
|
+
"""Resolve a private integrated-browser launch without exposing its query."""
|
|
425
|
+
owner = _session_mod.VIEWER_LAUNCH_ROUTES.get(navigation_key)
|
|
426
|
+
if owner is None:
|
|
427
|
+
raise HTTPException(status_code=404, detail="Viewer launch route expired")
|
|
428
|
+
sid, request_id = owner
|
|
429
|
+
journal = (
|
|
430
|
+
_session_mod.VIEWER_PHASE_JOURNALS
|
|
431
|
+
.get(sid, {})
|
|
432
|
+
.get(request_id)
|
|
433
|
+
)
|
|
434
|
+
if (
|
|
435
|
+
journal is None
|
|
436
|
+
or journal.get("tab_key") != tab_key
|
|
437
|
+
or journal.get("navigation_key") != navigation_key
|
|
438
|
+
):
|
|
439
|
+
raise HTTPException(status_code=409, detail="Viewer launch route changed")
|
|
440
|
+
bootstrap_query = journal["viewer_query"] + "&" + urlencode(
|
|
441
|
+
{
|
|
442
|
+
"_av_integrated_browser": "1",
|
|
443
|
+
"_av_launch_request_id": request_id,
|
|
444
|
+
"_av_launch_server_id": _session_mod.SERVER_RUNTIME.instance_id,
|
|
445
|
+
"_av_launch_window_id": journal["window_id"],
|
|
446
|
+
"_av_launch_token": journal["token"],
|
|
447
|
+
"_av_navigation_attempt": journal["navigation_attempt"],
|
|
448
|
+
}
|
|
449
|
+
)
|
|
450
|
+
_trace_page_request(
|
|
451
|
+
request,
|
|
452
|
+
request_id=request_id,
|
|
453
|
+
navigation_attempt=str(journal["navigation_attempt"]),
|
|
454
|
+
)
|
|
455
|
+
return _viewer_ui_response(
|
|
456
|
+
request,
|
|
457
|
+
sid=sid,
|
|
458
|
+
query_val=json.dumps(bootstrap_query),
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@app.get("/")
|
|
463
|
+
def get_ui(request: Request, sid: str = None):
|
|
464
|
+
"""Viewer page."""
|
|
465
|
+
_trace_page_request(request)
|
|
466
|
+
# VS Code's asExternalUri() strips query parameters, so ?sid= is often lost
|
|
467
|
+
# before the page loads. Embed the SID directly in the HTML so the viewer
|
|
468
|
+
# JS can find it regardless of the URL.
|
|
469
|
+
if not sid:
|
|
470
|
+
# No sid in URL — VS Code strips the query string before loading the
|
|
471
|
+
# page, so ?sid= is lost. Inject the latest valid session
|
|
472
|
+
# server-side so the viewer JS can find it regardless of the URL.
|
|
473
|
+
if SESSIONS:
|
|
474
|
+
latest_sid = list(SESSIONS.keys())[-1]
|
|
475
|
+
query_val = json.dumps(f"?sid={latest_sid}")
|
|
476
|
+
else:
|
|
477
|
+
query_val = "null" # viewer will show "Session not found or expired"
|
|
478
|
+
else:
|
|
479
|
+
# sid is present in the URL (valid or not) — let the JS fetch /metadata/{sid}
|
|
480
|
+
# and handle errors itself (shows "Session not found or expired" on 404).
|
|
481
|
+
query_val = "null"
|
|
482
|
+
return _viewer_ui_response(request, sid=sid, query_val=query_val)
|
|
@@ -40,6 +40,9 @@ NATIVE_READY_REQUESTS: set[tuple[str, str]] = set()
|
|
|
40
40
|
# Launch readiness is control-plane state and must exist before a large array
|
|
41
41
|
# finishes loading. Keyed by SID, then request ID; release_session owns cleanup.
|
|
42
42
|
VIEWER_PHASE_JOURNALS: dict[str, dict[str, dict]] = {}
|
|
43
|
+
# Short integrated-browser navigation key -> (primary SID, request ID).
|
|
44
|
+
# The full launch query stays in the fenced journal instead of the visible URL.
|
|
45
|
+
VIEWER_LAUNCH_ROUTES: dict[str, tuple[str, str]] = {}
|
|
43
46
|
_window_process = None
|
|
44
47
|
PENDING_SESSIONS: set = set() # sids whose data is still loading in a background thread
|
|
45
48
|
PENDING_SESSION_EVENTS: dict[str, threading.Event] = {}
|
|
@@ -630,6 +633,7 @@ __all__ = [
|
|
|
630
633
|
"VIEWER_SIDS",
|
|
631
634
|
"VIEWER_CONNECTIONS_SEEN",
|
|
632
635
|
"VIEWER_PHASE_JOURNALS",
|
|
636
|
+
"VIEWER_LAUNCH_ROUTES",
|
|
633
637
|
"SHELL_SOCKETS",
|
|
634
638
|
"_window_process",
|
|
635
639
|
"PENDING_SESSIONS",
|