arrayview 0.35.3__tar.gz → 0.36.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.35.3 → arrayview-0.36.0}/PKG-INFO +1 -1
- {arrayview-0.35.3 → arrayview-0.36.0}/pyproject.toml +1 -1
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_launcher.py +44 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_loading.py +7 -3
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_query.py +8 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_session.py +5 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_viewer.html +64 -15
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_vscode_extension.py +46 -2
- arrayview-0.36.0/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_api.py +1 -1
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_cli.py +9 -1
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_instance_registry.py +3 -1
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_invocation_contract_matrix.py +2 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_launch_diagnostics.py +3 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_launch_execution_ownership.py +4 -2
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_launch_plan.py +5 -2
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_lifecycle_contract.py +15 -5
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_public_invocation_regressions.py +1 -0
- arrayview-0.36.0/tests/test_value_formatting.py +126 -0
- arrayview-0.35.3/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/.gitignore +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/LICENSE +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/README.md +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/ARCHITECTURE.md +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/__init__.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/__main__.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_analysis.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_app.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_codex_open.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_config.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_dicom.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_diff.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_icon.png +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_imaging.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_instance_registry.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_io.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_launch_plan.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_launch_trace.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_lifecycle.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_overlays.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_platform.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_render.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_analysis.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_drop.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_export.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_persistence.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_preferences.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_preload.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_rendering.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_segmentation.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_state.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_vectorfield.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_routes_websocket.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_server.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_session_spec.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_shell.html +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_synthetic_mri.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_torch.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_vectorfield.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_vscode_browser.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/_vscode_signal.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/src/arrayview/todo.md +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/capture_patient_loading.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/capture_v_animation.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/conftest.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/lifecycle_matrix.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_backend_shared.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_browser.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_codex_open.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_colorbar_hover_highlight.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_command_reachability.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_config.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_cross_mode_parametrized.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_dicom.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_display_opener_results.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_doctor_commands.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_drop_import.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_interactions.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_io.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_large_arrays.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_launch_blackbox.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_launch_trace.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_loading_server.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_mode_consistency.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_mode_matrix.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_mode_roundtrip.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_nifti_series.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_preferences_ui.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_safe_server_stop.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_server_identity_status.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_session_spec.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_spawn_ownership.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_torch.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_view_component_integration.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_view_component_unit.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_view_handle_lifecycle.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_view_launch_execution.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/test_vscode_ack_protocol.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/ui_audit.py +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/v_anim_frames/.gitkeep +0 -0
- {arrayview-0.35.3 → arrayview-0.36.0}/tests/visual_smoke.py +0 -0
|
@@ -2590,6 +2590,11 @@ _PERSIST_DAEMON_CONNECT_TIMEOUT_SECONDS = float(
|
|
|
2590
2590
|
_PERSIST_DAEMON_IDLE_SECONDS = float(
|
|
2591
2591
|
os.environ.get("ARRAYVIEW_PERSIST_IDLE_SECONDS", "1800")
|
|
2592
2592
|
)
|
|
2593
|
+
# How long a daemon whose initial load failed keeps serving so the opener can
|
|
2594
|
+
# read the reason from /metadata/<sid> before the port is released.
|
|
2595
|
+
_FAILED_LOAD_EXIT_GRACE_SECONDS = float(
|
|
2596
|
+
os.environ.get("ARRAYVIEW_FAILED_LOAD_EXIT_GRACE_SECONDS", "30")
|
|
2597
|
+
)
|
|
2593
2598
|
|
|
2594
2599
|
|
|
2595
2600
|
def _join_query_values(values: _CSVValues) -> str:
|
|
@@ -4169,6 +4174,7 @@ def _serve_daemon(
|
|
|
4169
4174
|
owner="persistent" if persist else "transient",
|
|
4170
4175
|
)
|
|
4171
4176
|
# Register sid as pending so /metadata can poll while data loads.
|
|
4177
|
+
_initial_load_failed = threading.Event()
|
|
4172
4178
|
_session_mod.PENDING_SESSIONS.add(sid)
|
|
4173
4179
|
_pending_event = threading.Event()
|
|
4174
4180
|
_session_mod.PENDING_SESSION_EVENTS[sid] = _pending_event
|
|
@@ -4349,6 +4355,18 @@ def _serve_daemon(
|
|
|
4349
4355
|
sid_tag=sid_tag,
|
|
4350
4356
|
error_type=type(exc).__name__,
|
|
4351
4357
|
)
|
|
4358
|
+
# This runs on a daemon thread, so re-raising only kills the thread
|
|
4359
|
+
# and writes to a stderr nobody reads. Record why the sid will never
|
|
4360
|
+
# become ready so /metadata can report it instead of returning 404
|
|
4361
|
+
# until the opener gives up.
|
|
4362
|
+
_session_mod.FAILED_PENDING_SESSIONS[sid] = (
|
|
4363
|
+
str(exc) or type(exc).__name__
|
|
4364
|
+
)
|
|
4365
|
+
_vprint(
|
|
4366
|
+
f"[ArrayView] Failed to load {filepath}: {exc}",
|
|
4367
|
+
flush=True,
|
|
4368
|
+
)
|
|
4369
|
+
_initial_load_failed.set()
|
|
4352
4370
|
raise
|
|
4353
4371
|
finally:
|
|
4354
4372
|
_session_mod.PENDING_SESSIONS.discard(sid)
|
|
@@ -4357,6 +4375,32 @@ def _serve_daemon(
|
|
|
4357
4375
|
|
|
4358
4376
|
threading.Thread(target=_load, daemon=True).start()
|
|
4359
4377
|
|
|
4378
|
+
def _exit_if_initial_load_failed() -> None:
|
|
4379
|
+
"""Release the port when the file this daemon exists to serve won't load.
|
|
4380
|
+
|
|
4381
|
+
uvicorn starts before the load finishes, so a failed load otherwise
|
|
4382
|
+
leaves a server with no session holding the port until the connect
|
|
4383
|
+
timeout expires minutes later — and answering /ping the whole time. The
|
|
4384
|
+
grace period is only so the opener can read the failure from
|
|
4385
|
+
/metadata/<sid> before the port goes away.
|
|
4386
|
+
"""
|
|
4387
|
+
_initial_load_failed.wait()
|
|
4388
|
+
time.sleep(_FAILED_LOAD_EXIT_GRACE_SECONDS)
|
|
4389
|
+
if _session_mod.SESSIONS or _session_mod.VIEWER_SOCKETS:
|
|
4390
|
+
return # something else is using this server; leave it alone
|
|
4391
|
+
_trace_launch_event(
|
|
4392
|
+
"daemon.exiting",
|
|
4393
|
+
reason="initial_load_failed",
|
|
4394
|
+
instance_tag=_launch_trace_tag(record.instance_id),
|
|
4395
|
+
)
|
|
4396
|
+
try:
|
|
4397
|
+
registry.remove(record.instance_id)
|
|
4398
|
+
except Exception:
|
|
4399
|
+
pass
|
|
4400
|
+
os._exit(0)
|
|
4401
|
+
|
|
4402
|
+
threading.Thread(target=_exit_if_initial_load_failed, daemon=True).start()
|
|
4403
|
+
|
|
4360
4404
|
if persist:
|
|
4361
4405
|
_wait_for_viewer_close(
|
|
4362
4406
|
idle_seconds=_PERSIST_DAEMON_IDLE_SECONDS,
|
|
@@ -18,6 +18,7 @@ from arrayview._lifecycle import (
|
|
|
18
18
|
)
|
|
19
19
|
from arrayview._session import (
|
|
20
20
|
CANCELLED_PENDING_SESSIONS,
|
|
21
|
+
FAILED_PENDING_SESSIONS,
|
|
21
22
|
PENDING_SESSION_EVENTS,
|
|
22
23
|
PENDING_SESSIONS,
|
|
23
24
|
SESSIONS,
|
|
@@ -263,6 +264,7 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
|
|
|
263
264
|
# the real Session instead of seeing a transient 404.
|
|
264
265
|
sid = requested_sid or uuid.uuid4().hex
|
|
265
266
|
pending_event = threading.Event()
|
|
267
|
+
FAILED_PENDING_SESSIONS.pop(sid, None)
|
|
266
268
|
PENDING_SESSIONS.add(sid)
|
|
267
269
|
PENDING_SESSION_EVENTS[sid] = pending_event
|
|
268
270
|
signature_before_load = file_signature(abs_path)
|
|
@@ -293,12 +295,14 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
|
|
|
293
295
|
]
|
|
294
296
|
commit_pending_session(sid, session)
|
|
295
297
|
except Exception as exc:
|
|
296
|
-
# Keep the server alive and
|
|
297
|
-
#
|
|
298
|
-
#
|
|
298
|
+
# Keep the server alive, and record why this sid will never
|
|
299
|
+
# become ready. Without the record the opener cannot tell a
|
|
300
|
+
# failed load from a slow one and burns its whole deadline
|
|
301
|
+
# on a file that raised immediately.
|
|
299
302
|
import traceback
|
|
300
303
|
|
|
301
304
|
traceback.print_exc()
|
|
305
|
+
FAILED_PENDING_SESSIONS[sid] = str(exc) or exc.__class__.__name__
|
|
302
306
|
finally:
|
|
303
307
|
PENDING_SESSIONS.discard(sid)
|
|
304
308
|
CANCELLED_PENDING_SESSIONS.discard(sid)
|
|
@@ -211,6 +211,14 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
|
|
|
211
211
|
# request; the opener retries until the pending Session is published.
|
|
212
212
|
if sid in _session_mod.PENDING_SESSIONS:
|
|
213
213
|
return Response(status_code=404, headers={"Retry-After": "1"})
|
|
214
|
+
# A load that raised is terminal. Say so with a distinct status and the
|
|
215
|
+
# real reason, so the caller stops polling and can show the user why
|
|
216
|
+
# this file will never open instead of waiting out its deadline.
|
|
217
|
+
load_error = _session_mod.FAILED_PENDING_SESSIONS.get(sid)
|
|
218
|
+
if load_error is not None:
|
|
219
|
+
return Response(
|
|
220
|
+
status_code=422, content=load_error.encode(), media_type="text/plain"
|
|
221
|
+
)
|
|
214
222
|
session = await wait_for_session_ready(sid)
|
|
215
223
|
if not session:
|
|
216
224
|
return Response(status_code=404)
|
|
@@ -41,6 +41,10 @@ _window_process = None
|
|
|
41
41
|
PENDING_SESSIONS: set = set() # sids whose data is still loading in a background thread
|
|
42
42
|
PENDING_SESSION_EVENTS: dict[str, threading.Event] = {}
|
|
43
43
|
CANCELLED_PENDING_SESSIONS: set[str] = set()
|
|
44
|
+
# sid -> why its background load failed. A load that raises must be reportable:
|
|
45
|
+
# without this the opener cannot tell "still loading" from "will never load" and
|
|
46
|
+
# waits out its whole deadline on a file that failed in the first second.
|
|
47
|
+
FAILED_PENDING_SESSIONS: dict[str, str] = {}
|
|
44
48
|
|
|
45
49
|
|
|
46
50
|
def file_signature(filepath: str):
|
|
@@ -575,6 +579,7 @@ __all__ = [
|
|
|
575
579
|
"_window_process",
|
|
576
580
|
"PENDING_SESSIONS",
|
|
577
581
|
"PENDING_SESSION_EVENTS",
|
|
582
|
+
"FAILED_PENDING_SESSIONS",
|
|
578
583
|
"wait_for_session_ready",
|
|
579
584
|
# Render thread pool
|
|
580
585
|
"_RENDER_QUEUE",
|
|
@@ -9278,7 +9278,20 @@
|
|
|
9278
9278
|
function formatValueGroup(values, options = {}) {
|
|
9279
9279
|
const finite = (Array.isArray(values) ? values : [values]).filter(Number.isFinite);
|
|
9280
9280
|
const cap = Math.max(0, Math.min(8, Math.trunc(options.maxDecimals ?? 7)));
|
|
9281
|
-
|
|
9281
|
+
// Decimals worth showing depend on magnitude. Two was applied at
|
|
9282
|
+
// every scale, so a range topping out near 1890 read as "1890.04"
|
|
9283
|
+
// when the hundredths carry no information. The floor is only ever
|
|
9284
|
+
// lowered for large values, never raised, so small ranges keep the
|
|
9285
|
+
// precision they had. The search below can still add decimals when
|
|
9286
|
+
// values would otherwise collide.
|
|
9287
|
+
const _defaultMinDecimals = (vals) => {
|
|
9288
|
+
const m = Math.max(...vals.filter(Number.isFinite).map(v => Math.abs(v)));
|
|
9289
|
+
if (!(m > 0) || !Number.isFinite(m)) return 2;
|
|
9290
|
+
return Math.max(0, Math.min(2, 3 - Math.floor(Math.log10(m))));
|
|
9291
|
+
};
|
|
9292
|
+
const minDecimals = Math.max(0, Math.min(cap, Math.trunc(
|
|
9293
|
+
options.minDecimals ?? _defaultMinDecimals(finite)
|
|
9294
|
+
)));
|
|
9282
9295
|
const ordinaryLo = options.ordinaryLo ?? 1e-4;
|
|
9283
9296
|
const ordinaryHi = options.ordinaryHi ?? 1e6;
|
|
9284
9297
|
const fallback = {
|
|
@@ -14242,6 +14255,41 @@
|
|
|
14242
14255
|
|
|
14243
14256
|
/* ── JS: Initialization and Metadata Fetch ────────────────── */
|
|
14244
14257
|
|
|
14258
|
+
// The backend serves this page as soon as its port is up, which can be
|
|
14259
|
+
// seconds before a large array has finished loading. While the session
|
|
14260
|
+
// is still loading /metadata answers 404 with a Retry-After header; an
|
|
14261
|
+
// unknown or released sid answers 404 without one, and a load that
|
|
14262
|
+
// failed answers 422 with the reason. Keep waiting only for the first.
|
|
14263
|
+
async function fetchMetadataWithRetry(sid, timeoutMs = 180000) {
|
|
14264
|
+
const deadline = Date.now() + timeoutMs;
|
|
14265
|
+
for (;;) {
|
|
14266
|
+
const res = await proxyFetch(`/metadata/${sid}`);
|
|
14267
|
+
if (res.ok) return { data: await res.json() };
|
|
14268
|
+
if (res.status === 422) {
|
|
14269
|
+
let reason = '';
|
|
14270
|
+
try { reason = (await res.text()).trim(); } catch (_e) {}
|
|
14271
|
+
return { error: reason || 'This file could not be loaded.', status: 422 };
|
|
14272
|
+
}
|
|
14273
|
+
const pending = res.status === 404 && res.headers.get('Retry-After');
|
|
14274
|
+
if (!pending || Date.now() >= deadline) {
|
|
14275
|
+
return { error: null, status: res.status };
|
|
14276
|
+
}
|
|
14277
|
+
await new Promise(r => setTimeout(r, 300));
|
|
14278
|
+
}
|
|
14279
|
+
}
|
|
14280
|
+
|
|
14281
|
+
function showMetadataError(result) {
|
|
14282
|
+
const msg = result.error
|
|
14283
|
+
? result.error
|
|
14284
|
+
: (result.status === 404
|
|
14285
|
+
? 'Session not found or expired.'
|
|
14286
|
+
: `Server error ${result.status} — check Python console for details.`);
|
|
14287
|
+
document.getElementById('loading-overlay').innerHTML =
|
|
14288
|
+
`<div style="color:#c55;font-size:16px;font-family:monospace;max-width:60ch;text-align:center;line-height:1.5;padding:0 2em">${
|
|
14289
|
+
msg.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
14290
|
+
}</div>`;
|
|
14291
|
+
}
|
|
14292
|
+
|
|
14245
14293
|
async function init() {
|
|
14246
14294
|
// Loading spinner is in static HTML — body.av-loading set by server template.
|
|
14247
14295
|
if (!sid) { hideLoadingOverlay(); drawPlasmaDemo(); return; }
|
|
@@ -14260,26 +14308,24 @@
|
|
|
14260
14308
|
);
|
|
14261
14309
|
data = await Promise.race([_metadataFromWs, httpFallback]);
|
|
14262
14310
|
if (!data) {
|
|
14263
|
-
// Both failed —
|
|
14264
|
-
const
|
|
14265
|
-
if (!
|
|
14266
|
-
reportParent('metadata-http-error', { status:
|
|
14267
|
-
|
|
14268
|
-
document.getElementById('loading-overlay').innerHTML = `<div style="color:#c55;font-size:18px;font-family:monospace">${msg}</div>`;
|
|
14311
|
+
// Both failed — wait out a still-loading session.
|
|
14312
|
+
const result = await fetchMetadataWithRetry(sid);
|
|
14313
|
+
if (!result.data) {
|
|
14314
|
+
reportParent('metadata-http-error', { status: result.status, sid });
|
|
14315
|
+
showMetadataError(result);
|
|
14269
14316
|
return;
|
|
14270
14317
|
}
|
|
14271
|
-
data =
|
|
14318
|
+
data = result.data;
|
|
14272
14319
|
}
|
|
14273
14320
|
} else {
|
|
14274
14321
|
// HTTP transport mode (VS Code) — use traditional fetch
|
|
14275
|
-
const
|
|
14276
|
-
if (!
|
|
14277
|
-
reportParent('metadata-http-error', { status:
|
|
14278
|
-
|
|
14279
|
-
document.getElementById('loading-overlay').innerHTML = `<div style="color:#c55;font-size:18px;font-family:monospace">${msg}</div>`;
|
|
14322
|
+
const result = await fetchMetadataWithRetry(sid);
|
|
14323
|
+
if (!result.data) {
|
|
14324
|
+
reportParent('metadata-http-error', { status: result.status, sid });
|
|
14325
|
+
showMetadataError(result);
|
|
14280
14326
|
return;
|
|
14281
14327
|
}
|
|
14282
|
-
data =
|
|
14328
|
+
data = result.data;
|
|
14283
14329
|
}
|
|
14284
14330
|
|
|
14285
14331
|
await _loadOverlayLabelValues();
|
|
@@ -35919,9 +35965,12 @@
|
|
|
35919
35965
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
35920
35966
|
}).join(' ');
|
|
35921
35967
|
const distLabel = distance.toFixed(1) + ' px';
|
|
35968
|
+
// Share the colorbar's formatter so the same value does not read
|
|
35969
|
+
// differently here than it does on the colorbar.
|
|
35970
|
+
const _lpFmt = formatValueGroup([vMin, vMax]);
|
|
35922
35971
|
const fmtVal = v => {
|
|
35923
35972
|
if (v === null || !isFinite(v)) return '?';
|
|
35924
|
-
return
|
|
35973
|
+
return _lpFmt.format(v);
|
|
35925
35974
|
};
|
|
35926
35975
|
_lineProfileOverlay.innerHTML = `
|
|
35927
35976
|
<svg width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg">
|
|
@@ -54,7 +54,7 @@ _VSCODE_EXT_INSTALLED = False # cached so we only check once per process
|
|
|
54
54
|
_VSCODE_EXT_FRESH_INSTALL = False # True if we just installed it this session
|
|
55
55
|
_VSCODE_EXT_RELOAD_REQUIRED = False # installed files are newer than the live host
|
|
56
56
|
_VSCODE_EXT_INSTALL_FAILED = False # automatic install could not complete safely
|
|
57
|
-
_VSCODE_EXT_VERSION = "0.14.
|
|
57
|
+
_VSCODE_EXT_VERSION = "0.14.87" # current bundled extension version
|
|
58
58
|
_VSCODE_CONFIGURED_PORTS: set[int] = set()
|
|
59
59
|
|
|
60
60
|
def _bundled_vscode_vsix_version(vsix_path: str) -> str | None:
|
|
@@ -633,6 +633,47 @@ def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
|
|
|
633
633
|
# ---------------------------------------------------------------------------
|
|
634
634
|
|
|
635
635
|
|
|
636
|
+
def _port_has_listener(port: int) -> bool:
|
|
637
|
+
"""Return True when something is listening on ``port`` on loopback."""
|
|
638
|
+
import socket
|
|
639
|
+
|
|
640
|
+
for family, addr in ((socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")):
|
|
641
|
+
sock = socket.socket(family, socket.SOCK_STREAM)
|
|
642
|
+
try:
|
|
643
|
+
sock.settimeout(0.15)
|
|
644
|
+
if sock.connect_ex((addr, port)) == 0:
|
|
645
|
+
return True
|
|
646
|
+
except OSError:
|
|
647
|
+
continue
|
|
648
|
+
finally:
|
|
649
|
+
sock.close()
|
|
650
|
+
return False
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _stale_arrayview_port_keys(attrs: dict, keep_port: int) -> list[str]:
|
|
654
|
+
"""Return ArrayView port entries that no longer have a live server.
|
|
655
|
+
|
|
656
|
+
Every launch on a fresh port used to leave its entry behind for good. On a
|
|
657
|
+
tunnel those entries keep telling VS Code to forward ports nothing serves,
|
|
658
|
+
which competes for a limited per-tunnel forwarding budget. Only entries
|
|
659
|
+
ArrayView wrote are considered, and only when their port is dead, so a
|
|
660
|
+
second concurrent ArrayView server is never disturbed.
|
|
661
|
+
"""
|
|
662
|
+
stale: list[str] = []
|
|
663
|
+
for key, value in list(attrs.items()):
|
|
664
|
+
if not isinstance(value, dict) or value.get("label") != "ArrayView":
|
|
665
|
+
continue
|
|
666
|
+
if key == str(keep_port):
|
|
667
|
+
continue
|
|
668
|
+
try:
|
|
669
|
+
candidate = int(key)
|
|
670
|
+
except (TypeError, ValueError):
|
|
671
|
+
continue
|
|
672
|
+
if not _port_has_listener(candidate):
|
|
673
|
+
stale.append(key)
|
|
674
|
+
return stale
|
|
675
|
+
|
|
676
|
+
|
|
636
677
|
def _configure_vscode_port_preview(
|
|
637
678
|
port: int,
|
|
638
679
|
*,
|
|
@@ -686,8 +727,11 @@ def _configure_vscode_port_preview(
|
|
|
686
727
|
}
|
|
687
728
|
current = attrs.get(str(port))
|
|
688
729
|
updated = {**current, **desired} if isinstance(current, dict) else desired
|
|
689
|
-
|
|
730
|
+
stale = _stale_arrayview_port_keys(attrs, port)
|
|
731
|
+
if current == updated and not stale:
|
|
690
732
|
return
|
|
733
|
+
for key in stale:
|
|
734
|
+
attrs.pop(key, None)
|
|
691
735
|
attrs[str(port)] = updated
|
|
692
736
|
with open(path, "w") as f:
|
|
693
737
|
json.dump(settings, f, indent=2)
|
|
Binary file
|
|
@@ -6754,7 +6754,7 @@ def _isolate_view_planner(
|
|
|
6754
6754
|
if port in alive_ports
|
|
6755
6755
|
else (),
|
|
6756
6756
|
"1" if port in alive_ports else None,
|
|
6757
|
-
server_uid=os.geteuid() if port in alive_ports else None,
|
|
6757
|
+
server_uid=os.geteuid() if hasattr(os, "geteuid") and port in alive_ports else None,
|
|
6758
6758
|
),
|
|
6759
6759
|
)
|
|
6760
6760
|
if alive_ports:
|
|
@@ -13,6 +13,7 @@ import pytest
|
|
|
13
13
|
import arrayview._app as appmod
|
|
14
14
|
import arrayview._launch_plan as _launch_plan_mod
|
|
15
15
|
import arrayview._launcher as _launcher_mod
|
|
16
|
+
import arrayview._platform as _platform_mod
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
class _DummyResponse:
|
|
@@ -60,7 +61,7 @@ def _mock_launch_server_snapshot(monkeypatch, *, alive: bool, busy: bool | None
|
|
|
60
61
|
None,
|
|
61
62
|
("identity-fenced-load", "identity-fenced-mutations") if alive else (),
|
|
62
63
|
"1" if alive else None,
|
|
63
|
-
server_uid=os.geteuid() if alive else None,
|
|
64
|
+
server_uid=os.geteuid() if hasattr(os, "geteuid") and alive else None,
|
|
64
65
|
),
|
|
65
66
|
)
|
|
66
67
|
if alive:
|
|
@@ -263,6 +264,7 @@ def test_cli_stack_existing_server_has_remote_flag_before_register(monkeypatch,
|
|
|
263
264
|
assert calls[0]["dir_patterns"] == [os.path.abspath(base_pattern)]
|
|
264
265
|
|
|
265
266
|
|
|
267
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="VSCode terminal readiness test is Unix-specific")
|
|
266
268
|
def test_cli_vscode_terminal_requires_extension_readiness_ack(tmp_path):
|
|
267
269
|
arr_path = tmp_path / "base.npy"
|
|
268
270
|
np.save(arr_path, np.zeros((8, 8), dtype=np.float32))
|
|
@@ -690,6 +692,8 @@ def test_cli_existing_server_native_injection_skips_browser(monkeypatch, tmp_pat
|
|
|
690
692
|
_mock_launch_server_snapshot(monkeypatch, alive=True)
|
|
691
693
|
monkeypatch.setattr(_launch_plan_mod, "_native_window_gui", lambda: "webview")
|
|
692
694
|
monkeypatch.setattr(_launcher_mod, "_is_vscode_remote", lambda: False)
|
|
695
|
+
monkeypatch.setattr(_platform_mod, "_is_vscode_remote", lambda: False)
|
|
696
|
+
monkeypatch.setattr(_platform_mod, "_in_vscode_terminal", lambda: False)
|
|
693
697
|
monkeypatch.setattr(
|
|
694
698
|
_launcher_mod,
|
|
695
699
|
"_open_browser",
|
|
@@ -737,6 +741,8 @@ def test_cli_existing_server_native_shell_connection_skips_browser(
|
|
|
737
741
|
_mock_launch_server_snapshot(monkeypatch, alive=True)
|
|
738
742
|
monkeypatch.setattr(_launch_plan_mod, "_native_window_gui", lambda: "webview")
|
|
739
743
|
monkeypatch.setattr(_launcher_mod, "_is_vscode_remote", lambda: False)
|
|
744
|
+
monkeypatch.setattr(_platform_mod, "_is_vscode_remote", lambda: False)
|
|
745
|
+
monkeypatch.setattr(_platform_mod, "_in_vscode_terminal", lambda: False)
|
|
740
746
|
monkeypatch.setattr(
|
|
741
747
|
_launcher_mod,
|
|
742
748
|
"_open_webview_cli_tracked",
|
|
@@ -805,6 +811,8 @@ def test_cli_spawn_daemon_opens_viewer_url(monkeypatch, tmp_path):
|
|
|
805
811
|
lambda port: ("spawned-server", "process-start", 43210),
|
|
806
812
|
)
|
|
807
813
|
monkeypatch.setattr(_launcher_mod, "_is_vscode_remote", lambda: False)
|
|
814
|
+
monkeypatch.setattr(_platform_mod, "_is_vscode_remote", lambda: False)
|
|
815
|
+
monkeypatch.setattr(_platform_mod, "_in_vscode_terminal", lambda: False)
|
|
808
816
|
monkeypatch.setattr(
|
|
809
817
|
_launcher_mod.subprocess,
|
|
810
818
|
"Popen",
|
|
@@ -4,6 +4,7 @@ import os
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
import subprocess
|
|
6
6
|
import sys
|
|
7
|
+
import sys
|
|
7
8
|
import time
|
|
8
9
|
|
|
9
10
|
import pytest
|
|
@@ -164,11 +165,12 @@ def test_runtime_directory_override(monkeypatch, tmp_path):
|
|
|
164
165
|
assert runtime_directory() == tmp_path / "custom"
|
|
165
166
|
|
|
166
167
|
|
|
168
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="UID-based directory resolution is Unix-only")
|
|
167
169
|
def test_runtime_directory_is_per_uid_without_override(monkeypatch, tmp_path):
|
|
168
170
|
monkeypatch.delenv("ARRAYVIEW_RUNTIME_DIR", raising=False)
|
|
169
171
|
monkeypatch.delenv("XDG_RUNTIME_DIR", raising=False)
|
|
170
172
|
monkeypatch.setattr("arrayview._instance_registry.tempfile.gettempdir", lambda: str(tmp_path))
|
|
171
|
-
current_uid = os.getuid()
|
|
173
|
+
current_uid = os.getuid() if hasattr(os, "getuid") else 1000
|
|
172
174
|
first = runtime_directory()
|
|
173
175
|
monkeypatch.setattr("arrayview._instance_registry.os.getuid", lambda: current_uid + 1)
|
|
174
176
|
second = runtime_directory()
|
|
@@ -7,6 +7,7 @@ remote extension host and therefore belong in the tunnel handoff, not mocks.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import os
|
|
10
11
|
from dataclasses import dataclass
|
|
11
12
|
|
|
12
13
|
import pytest
|
|
@@ -147,6 +148,7 @@ def test_compatible_server_reuse_is_identical_for_every_adapter(case, platform):
|
|
|
147
148
|
"contract-process-start",
|
|
148
149
|
("identity-fenced-load", "identity-fenced-mutations"),
|
|
149
150
|
"1",
|
|
151
|
+
server_uid=os.geteuid() if hasattr(os, "geteuid") else None,
|
|
150
152
|
)
|
|
151
153
|
|
|
152
154
|
plan = plan_launch(
|
|
@@ -3,6 +3,7 @@ import socket
|
|
|
3
3
|
import sys
|
|
4
4
|
|
|
5
5
|
import arrayview._launcher as launcher
|
|
6
|
+
import arrayview._platform as _platform_mod
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
def _free_port() -> int:
|
|
@@ -18,6 +19,8 @@ def test_diagnose_serializes_shared_snapshot_and_plan(monkeypatch, capsys):
|
|
|
18
19
|
"argv",
|
|
19
20
|
["arrayview", "--diagnose", "--port", str(port), "--window", "browser"],
|
|
20
21
|
)
|
|
22
|
+
monkeypatch.setattr(_platform_mod, "_is_vscode_remote", lambda: False)
|
|
23
|
+
monkeypatch.setattr(_platform_mod, "_in_vscode_terminal", lambda: False)
|
|
21
24
|
|
|
22
25
|
launcher.arrayview()
|
|
23
26
|
|
|
@@ -168,7 +168,8 @@ def test_http_load_plan_fails_closed_when_selected_server_changes(
|
|
|
168
168
|
"planned-process-start",
|
|
169
169
|
("identity-fenced-load", "identity-fenced-mutations"),
|
|
170
170
|
"1",
|
|
171
|
-
server_uid=os.geteuid(),
|
|
171
|
+
server_uid=os.geteuid() if hasattr(os, "geteuid") else None,
|
|
172
|
+
|
|
172
173
|
),
|
|
173
174
|
)
|
|
174
175
|
monkeypatch.setattr(
|
|
@@ -224,7 +225,8 @@ def test_display_error_after_http_registration_is_not_relabelled(monkeypatch):
|
|
|
224
225
|
"planned-process-start",
|
|
225
226
|
("identity-fenced-load", "identity-fenced-mutations"),
|
|
226
227
|
"1",
|
|
227
|
-
server_uid=os.geteuid(),
|
|
228
|
+
server_uid=os.geteuid() if hasattr(os, "geteuid") else None,
|
|
229
|
+
|
|
228
230
|
),
|
|
229
231
|
)
|
|
230
232
|
monkeypatch.setattr(
|
|
@@ -586,7 +586,8 @@ def test_remote_python_reuses_healthy_cli_default_server():
|
|
|
586
586
|
"remote-process-start",
|
|
587
587
|
("identity-fenced-load", "identity-fenced-mutations"),
|
|
588
588
|
"1",
|
|
589
|
-
server_uid=os.geteuid(),
|
|
589
|
+
server_uid=os.geteuid() if hasattr(os, "geteuid") else None,
|
|
590
|
+
|
|
590
591
|
),
|
|
591
592
|
)
|
|
592
593
|
plan = plan_launch(LaunchIntent(Invocation.PYTHON, 8123), facts)
|
|
@@ -662,7 +663,8 @@ def test_existing_server_is_reused_through_http():
|
|
|
662
663
|
"process-start",
|
|
663
664
|
("identity-fenced-load", "identity-fenced-mutations"),
|
|
664
665
|
"1",
|
|
665
|
-
server_uid=os.geteuid(),
|
|
666
|
+
server_uid=os.geteuid() if hasattr(os, "geteuid") else None,
|
|
667
|
+
|
|
666
668
|
)
|
|
667
669
|
)
|
|
668
670
|
plan = plan_launch(LaunchIntent(Invocation.CLI, 8123), facts)
|
|
@@ -671,6 +673,7 @@ def test_existing_server_is_reused_through_http():
|
|
|
671
673
|
assert plan.registration.value == "http_load"
|
|
672
674
|
|
|
673
675
|
|
|
676
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="UID checks are Unix-only")
|
|
674
677
|
@pytest.mark.parametrize(
|
|
675
678
|
"server_uid",
|
|
676
679
|
[None, os.geteuid() + 1 if hasattr(os, "geteuid") else -1],
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
import os
|
|
3
|
+
import sys
|
|
3
4
|
from threading import Thread as RealThread
|
|
4
5
|
|
|
5
6
|
import numpy as np
|
|
@@ -388,6 +389,7 @@ def test_vscode_tunnel_open_request_is_remote_only(monkeypatch, tmp_path):
|
|
|
388
389
|
assert captured[0]["remoteOnly"] is True
|
|
389
390
|
|
|
390
391
|
|
|
392
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific VS Code extension lifecycle")
|
|
391
393
|
def test_vscode_extension_disk_check_is_scoped_to_remote_host(monkeypatch, tmp_path):
|
|
392
394
|
import arrayview._vscode_extension as extension
|
|
393
395
|
|
|
@@ -405,6 +407,7 @@ def test_vscode_extension_disk_check_is_scoped_to_remote_host(monkeypatch, tmp_p
|
|
|
405
407
|
assert extension._extension_on_disk("1.2.3", remote=True) is True
|
|
406
408
|
|
|
407
409
|
|
|
410
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific VS Code extension lifecycle")
|
|
408
411
|
@pytest.mark.parametrize("remote", [False, True])
|
|
409
412
|
def test_vscode_extension_missing_hash_verifies_content_without_reinstall(
|
|
410
413
|
monkeypatch, tmp_path, remote
|
|
@@ -503,6 +506,7 @@ def test_vscode_port_settings_preserve_unreadable_jsonc(monkeypatch, tmp_path):
|
|
|
503
506
|
assert settings_path.read_text() == original
|
|
504
507
|
|
|
505
508
|
|
|
509
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific VS Code extension lifecycle")
|
|
506
510
|
def test_vscode_port_settings_are_idempotent_and_preserve_extra_keys(
|
|
507
511
|
monkeypatch, tmp_path
|
|
508
512
|
):
|
|
@@ -535,6 +539,7 @@ def test_vscode_port_settings_are_idempotent_and_preserve_extra_keys(
|
|
|
535
539
|
assert settings_path.read_text() == first
|
|
536
540
|
|
|
537
541
|
|
|
542
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific VS Code extension lifecycle")
|
|
538
543
|
def test_vscode_extension_cleanup_never_removes_newer_version(monkeypatch, tmp_path):
|
|
539
544
|
import arrayview._vscode_extension as extension
|
|
540
545
|
|
|
@@ -554,14 +559,14 @@ def test_vscode_extension_cleanup_never_removes_newer_version(monkeypatch, tmp_p
|
|
|
554
559
|
def test_vscode_extension_keeps_newer_install_without_downgrading(monkeypatch, tmp_path):
|
|
555
560
|
import arrayview._vscode_extension as extension
|
|
556
561
|
|
|
557
|
-
home = tmp_path / "home"
|
|
558
562
|
newer_version = "99.0.0"
|
|
559
|
-
(home / ".vscode" / "extensions" / f"arrayview.arrayview-opener-{newer_version}").mkdir(
|
|
560
|
-
parents=True
|
|
561
|
-
)
|
|
562
|
-
monkeypatch.setenv("HOME", str(home))
|
|
563
563
|
monkeypatch.setattr(extension, "_is_vscode_remote", lambda: False)
|
|
564
564
|
monkeypatch.setattr(extension, "_active_extension_version", lambda: newer_version)
|
|
565
|
+
monkeypatch.setattr(extension, "_active_extension_registration", lambda: None)
|
|
566
|
+
monkeypatch.setattr(extension, "_extension_on_disk", lambda *args, **kwargs: False)
|
|
567
|
+
monkeypatch.setattr(
|
|
568
|
+
extension, "_newer_extension_on_disk", lambda *args, **kwargs: newer_version
|
|
569
|
+
)
|
|
565
570
|
monkeypatch.setattr(
|
|
566
571
|
extension.subprocess,
|
|
567
572
|
"run",
|
|
@@ -600,6 +605,7 @@ def test_vscode_extension_install_timeout_fails_closed_for_stale_host(monkeypatc
|
|
|
600
605
|
assert extension._VSCODE_EXT_RELOAD_REQUIRED is True
|
|
601
606
|
|
|
602
607
|
|
|
608
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific VS Code extension lifecycle")
|
|
603
609
|
def test_remote_vscode_launch_automatically_runs_exact_installer(monkeypatch, tmp_path):
|
|
604
610
|
import subprocess
|
|
605
611
|
import arrayview._vscode_extension as extension
|
|
@@ -634,6 +640,7 @@ def test_remote_vscode_launch_automatically_runs_exact_installer(monkeypatch, tm
|
|
|
634
640
|
assert extension._VSCODE_EXT_RELOAD_REQUIRED is False
|
|
635
641
|
|
|
636
642
|
|
|
643
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific VS Code extension lifecycle")
|
|
637
644
|
def test_active_extension_version_prefers_live_ipc_over_stale_terminal_env(
|
|
638
645
|
monkeypatch, tmp_path
|
|
639
646
|
):
|
|
@@ -660,6 +667,7 @@ def test_active_extension_version_prefers_live_ipc_over_stale_terminal_env(
|
|
|
660
667
|
assert extension._active_extension_version() == "0.14.70"
|
|
661
668
|
|
|
662
669
|
|
|
670
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific VS Code extension lifecycle")
|
|
663
671
|
def test_active_extension_version_recovers_revived_terminal_from_exact_server(
|
|
664
672
|
monkeypatch, tmp_path
|
|
665
673
|
):
|
|
@@ -772,6 +780,7 @@ def test_extension_activation_wait_requires_fresh_exact_registration(monkeypatch
|
|
|
772
780
|
)
|
|
773
781
|
|
|
774
782
|
|
|
783
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific VS Code extension lifecycle")
|
|
775
784
|
def test_remote_vscode_setup_touches_only_active_extension_base(
|
|
776
785
|
monkeypatch, tmp_path
|
|
777
786
|
):
|
|
@@ -811,6 +820,7 @@ def test_remote_vscode_setup_touches_only_active_extension_base(
|
|
|
811
820
|
assert (remote_base / f"arrayview.arrayview-opener-{old_version}").is_dir()
|
|
812
821
|
|
|
813
822
|
|
|
823
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific VS Code extension lifecycle")
|
|
814
824
|
def test_remote_vscode_install_requires_exact_activation_before_launch(
|
|
815
825
|
monkeypatch, tmp_path
|
|
816
826
|
):
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Behavioural coverage for the viewer's shared vmin/vmax formatter.
|
|
2
|
+
|
|
3
|
+
`formatValueGroup` decides how many decimals every colorbar label, range
|
|
4
|
+
readout and line-profile axis shows. The rule is numeric, so asserting on the
|
|
5
|
+
source text would not catch a change in what the user actually sees. These
|
|
6
|
+
tests extract the function and execute it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import textwrap
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
VIEWER_HTML = Path(__file__).parent.parent / "src" / "arrayview" / "_viewer.html"
|
|
18
|
+
|
|
19
|
+
pytestmark = pytest.mark.skipif(
|
|
20
|
+
shutil.which("node") is None, reason="node is required to execute viewer JS"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _extract_function(source: str, name: str) -> str:
|
|
25
|
+
"""Return the full text of a top-level `function <name>(...) { ... }`.
|
|
26
|
+
|
|
27
|
+
Brace counting has to start after the parameter list: a default such as
|
|
28
|
+
`options = {}` would otherwise close the body before it opened.
|
|
29
|
+
"""
|
|
30
|
+
start = source.index(f"function {name}(")
|
|
31
|
+
paren_depth = 0
|
|
32
|
+
body_start = None
|
|
33
|
+
for index in range(start, len(source)):
|
|
34
|
+
char = source[index]
|
|
35
|
+
if char == "(":
|
|
36
|
+
paren_depth += 1
|
|
37
|
+
elif char == ")":
|
|
38
|
+
paren_depth -= 1
|
|
39
|
+
if paren_depth == 0:
|
|
40
|
+
body_start = source.index("{", index)
|
|
41
|
+
break
|
|
42
|
+
assert body_start is not None, f"could not find parameter list of {name}"
|
|
43
|
+
|
|
44
|
+
depth = 0
|
|
45
|
+
for index in range(body_start, len(source)):
|
|
46
|
+
char = source[index]
|
|
47
|
+
if char == "{":
|
|
48
|
+
depth += 1
|
|
49
|
+
elif char == "}":
|
|
50
|
+
depth -= 1
|
|
51
|
+
if depth == 0:
|
|
52
|
+
return source[start : index + 1]
|
|
53
|
+
raise AssertionError(f"unbalanced braces while extracting {name}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _format_pairs(pairs):
|
|
57
|
+
"""Format each [lo, hi] pair through the real viewer implementation."""
|
|
58
|
+
fn = _extract_function(VIEWER_HTML.read_text(), "formatValueGroup")
|
|
59
|
+
script = textwrap.dedent(
|
|
60
|
+
"""
|
|
61
|
+
const _escapeHtml = (s) => String(s);
|
|
62
|
+
__FN__
|
|
63
|
+
const pairs = __PAIRS__;
|
|
64
|
+
const out = pairs.map((pair) => {
|
|
65
|
+
const [values, options] = pair;
|
|
66
|
+
const fmt = formatValueGroup(values, options || {});
|
|
67
|
+
return values.map((v) => fmt.format(v));
|
|
68
|
+
});
|
|
69
|
+
process.stdout.write(JSON.stringify(out));
|
|
70
|
+
"""
|
|
71
|
+
).replace("__FN__", fn).replace("__PAIRS__", json.dumps(pairs))
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
["node", "-e", script], capture_output=True, text=True, timeout=60
|
|
74
|
+
)
|
|
75
|
+
assert result.returncode == 0, result.stderr
|
|
76
|
+
return json.loads(result.stdout)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_large_ranges_drop_meaningless_decimals():
|
|
80
|
+
"""A range topping out near 1890 should not advertise hundredths."""
|
|
81
|
+
(near_two_thousand,), = _format_pairs([[[0, 1890.04], None]]),
|
|
82
|
+
assert near_two_thousand == ["0", "1890"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_small_ranges_keep_their_precision():
|
|
86
|
+
"""Lowering the floor for large values must not coarsen small ones."""
|
|
87
|
+
results = _format_pairs(
|
|
88
|
+
[
|
|
89
|
+
[[0, 0.5], None],
|
|
90
|
+
[[1.23456, 9.87654], None],
|
|
91
|
+
[[0, 3.14159265], None],
|
|
92
|
+
[[0, 0.00123], None],
|
|
93
|
+
]
|
|
94
|
+
)
|
|
95
|
+
assert results[0] == ["0.00", "0.50"]
|
|
96
|
+
assert results[1] == ["1.23", "9.88"]
|
|
97
|
+
assert results[2] == ["0.00", "3.14"]
|
|
98
|
+
assert results[3] == ["0.000", "0.001"]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_vmin_and_vmax_share_a_decimal_count():
|
|
102
|
+
"""The pair must never read as e.g. "0.1" beside "1890.0421"."""
|
|
103
|
+
for values in ([0, 1890.04], [0.5, 18.9], [100.5, 100.6], [-1890.04, 1890.04]):
|
|
104
|
+
low, high = _format_pairs([[values, None]])[0]
|
|
105
|
+
assert low.partition(".")[2].__len__() == high.partition(".")[2].__len__(), (
|
|
106
|
+
f"{values} rendered as {low} / {high}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_close_values_stay_distinguishable():
|
|
111
|
+
"""Rounding must never collapse two different values into one label."""
|
|
112
|
+
for values in ([100.5, 100.6], [1000000, 1000001], [-0.001, 0.001]):
|
|
113
|
+
low, high = _format_pairs([[values, None]])[0]
|
|
114
|
+
assert low != high, f"{values} both rendered as {low}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_non_finite_and_degenerate_inputs_are_safe():
|
|
118
|
+
results = _format_pairs([[[0, 0], None]])
|
|
119
|
+
assert results[0] == ["0", "0"]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_explicit_min_decimals_still_wins():
|
|
123
|
+
"""The pixel readout asks for three decimals; that override must hold."""
|
|
124
|
+
low, high = _format_pairs([[[0, 1890.04], {"minDecimals": 3, "maxDecimals": 7}]])[0]
|
|
125
|
+
assert high == "1890.040"
|
|
126
|
+
assert low == "0.000"
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|