arrayview 0.49.0__tar.gz → 0.49.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.49.0 → arrayview-0.49.1}/PKG-INFO +1 -1
- {arrayview-0.49.0 → arrayview-0.49.1}/pyproject.toml +1 -1
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_instance_registry.py +4 -1
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_io.py +25 -16
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_launch_trace.py +2 -1
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_launcher.py +40 -4
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_export.py +9 -3
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_loading.py +12 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_websocket.py +30 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_viewer.html +432 -521
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_vscode_extension.py +14 -14
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_vscode_signal.py +57 -12
- arrayview-0.49.1/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_api.py +41 -3
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_browser.py +175 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_instance_registry.py +12 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_io.py +24 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_nifti_series.py +20 -0
- arrayview-0.49.0/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/.gitignore +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/LICENSE +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/README.md +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/ARCHITECTURE.md +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/__init__.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/__main__.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_analysis.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_app.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_bootstrap_app.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_config.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_diagnostics.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_dicom.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_diff.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_extra_ports.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_icon.png +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_imaging.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_launch_plan.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_lifecycle.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_overlays.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_platform.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_render.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_analysis.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_drop.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_persistence.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_preferences.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_preload.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_query.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_rendering.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_segmentation.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_state.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_routes_vectorfield.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_server.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_session.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_session_spec.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_shell.html +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_source_safety.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_synthetic_mri.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_torch.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_tutorial.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_vectorfield.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_view_memory.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_vscode_browser.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/_watch.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/src/arrayview/todo.md +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/capture_patient_loading.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/capture_v_animation.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/conftest.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_1d_arrays.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_backend_shared.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_cli.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_config.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_dicom.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_drop_import.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_large_arrays.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_launch_blackbox.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_mode_roundtrip.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_safe_server_stop.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_source_safety.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_torch.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_tutorial.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_view_memory.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/test_vsix_bundle.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/ui_audit.py +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/v_anim_frames/.gitkeep +0 -0
- {arrayview-0.49.0 → arrayview-0.49.1}/tests/visual_smoke.py +0 -0
|
@@ -70,7 +70,10 @@ def runtime_directory() -> Path:
|
|
|
70
70
|
return Path(base) / "arrayview"
|
|
71
71
|
if hasattr(os, "getuid"):
|
|
72
72
|
return Path(tempfile.gettempdir()) / f"arrayview-{os.getuid()}"
|
|
73
|
-
|
|
73
|
+
# Windows: no uid, but the temp folder is already per user. One shared
|
|
74
|
+
# name lets every command see the servers the others started.
|
|
75
|
+
user = "".join(c for c in os.environ.get("USERNAME", "") if c.isalnum() or c in "-_.")
|
|
76
|
+
return Path(tempfile.gettempdir()) / f"arrayview-{user or 'user'}"
|
|
74
77
|
|
|
75
78
|
|
|
76
79
|
def process_start_identity(pid: int) -> str | None:
|
|
@@ -503,7 +503,7 @@ def read_gzip_file_parallel(filepath):
|
|
|
503
503
|
"""
|
|
504
504
|
try:
|
|
505
505
|
size = os.path.getsize(filepath)
|
|
506
|
-
fd = os.open(filepath,
|
|
506
|
+
fd = os.open(filepath, _O_RDONLY_BINARY)
|
|
507
507
|
except OSError:
|
|
508
508
|
return None
|
|
509
509
|
buf = bytearray(size)
|
|
@@ -518,7 +518,7 @@ def read_gzip_file_parallel(filepath):
|
|
|
518
518
|
pos = start
|
|
519
519
|
try:
|
|
520
520
|
while pos < stop:
|
|
521
|
-
n =
|
|
521
|
+
n = _pread_into(fd, view[pos:stop], pos)
|
|
522
522
|
if n <= 0:
|
|
523
523
|
raise OSError("unexpected end of file")
|
|
524
524
|
pos += n
|
|
@@ -624,10 +624,21 @@ def _mapped_slice_is_scattered(view) -> bool:
|
|
|
624
624
|
return nbytes > 4 * view.nbytes and nbytes > _REGION_READ_CHUNK_BYTES
|
|
625
625
|
|
|
626
626
|
|
|
627
|
+
# Windows has neither pread nor preadv: reads there seek and read under one
|
|
628
|
+
# lock, so they are correct but no longer run in parallel.
|
|
629
|
+
_SEEK_READ_LOCK = threading.Lock()
|
|
630
|
+
_O_RDONLY_BINARY = os.O_RDONLY | getattr(os, "O_BINARY", 0)
|
|
631
|
+
|
|
632
|
+
|
|
627
633
|
def _pread_into(fd, buf, offset):
|
|
628
634
|
if hasattr(os, "preadv"):
|
|
629
635
|
return os.preadv(fd, [buf], offset)
|
|
630
|
-
|
|
636
|
+
if hasattr(os, "pread"):
|
|
637
|
+
data = os.pread(fd, len(buf), offset)
|
|
638
|
+
else:
|
|
639
|
+
with _SEEK_READ_LOCK:
|
|
640
|
+
os.lseek(fd, offset, os.SEEK_SET)
|
|
641
|
+
data = os.read(fd, len(buf))
|
|
631
642
|
buf[: len(data)] = data
|
|
632
643
|
return len(data)
|
|
633
644
|
|
|
@@ -646,7 +657,7 @@ def read_mapped_region(view, progress=None):
|
|
|
646
657
|
mv = memoryview(buf)
|
|
647
658
|
lock = threading.Lock()
|
|
648
659
|
done = [0]
|
|
649
|
-
fd = os.open(filename,
|
|
660
|
+
fd = os.open(filename, _O_RDONLY_BINARY)
|
|
650
661
|
try:
|
|
651
662
|
|
|
652
663
|
def _read(start):
|
|
@@ -681,9 +692,9 @@ class _ScatteredRegionMixin:
|
|
|
681
692
|
map of a 4D one. When a requested slice would be scattered across its
|
|
682
693
|
region, the region is read in one go (see ``read_mapped_region``) and kept;
|
|
683
694
|
every later slice of it, in any direction, is then served from memory.
|
|
684
|
-
|
|
685
|
-
the background, so the next step —
|
|
686
|
-
usually already there.
|
|
695
|
+
Whichever way the case is first viewed, its regions and the neighbouring
|
|
696
|
+
case's are then read in the background, so the next step — scrolling,
|
|
697
|
+
another map, a crossing view, the next patient — is usually already there.
|
|
687
698
|
"""
|
|
688
699
|
|
|
689
700
|
def _init_region_cache(self):
|
|
@@ -793,6 +804,9 @@ class _ScatteredRegionMixin:
|
|
|
793
804
|
if region is None:
|
|
794
805
|
try:
|
|
795
806
|
if not _mapped_slice_is_scattered(mapped[tuple(spatial_key)]):
|
|
807
|
+
# Cheap to read now, but the next scroll steps and any
|
|
808
|
+
# switch to a crossing view are not: load the case anyway.
|
|
809
|
+
self._schedule_region_warm(p_idx, m_idx, rest, spatial_key)
|
|
796
810
|
return None
|
|
797
811
|
view = self._region_view(mapped, rest)
|
|
798
812
|
if view.nbytes > self._region_cache_max_bytes // 2:
|
|
@@ -825,11 +839,6 @@ class _ScatteredRegionMixin:
|
|
|
825
839
|
else:
|
|
826
840
|
step = -1 if last is not None and last[0] > p_idx else 1
|
|
827
841
|
neighbours = [(p_idx + step, m_idx), (p_idx - step, m_idx)]
|
|
828
|
-
# Which axes are on screen is all that carries over to another case;
|
|
829
|
-
# the indices along the others may not fit a differently-sized one.
|
|
830
|
-
probe = tuple(
|
|
831
|
-
slice(None) if isinstance(k, slice) else 0 for k in spatial_key[: min(3, len(spatial_key))]
|
|
832
|
-
)
|
|
833
842
|
plan = [(p_idx, m_idx)] + [
|
|
834
843
|
(p, m) for p, m in neighbours if 0 <= p < n_p and 0 <= m < n_m
|
|
835
844
|
]
|
|
@@ -845,7 +854,7 @@ class _ScatteredRegionMixin:
|
|
|
845
854
|
if self._file_matrix[p][m] is None:
|
|
846
855
|
continue
|
|
847
856
|
mapped = self._mapped_volume(p, m, self._get_volume(p, m))
|
|
848
|
-
if mapped is None
|
|
857
|
+
if mapped is None:
|
|
849
858
|
continue
|
|
850
859
|
n_sp = min(3, mapped.ndim)
|
|
851
860
|
rests = list(np.ndindex(*mapped.shape[n_sp:])) or [()]
|
|
@@ -2213,7 +2222,7 @@ def prefetch_slice_pages(mapped, filepath, dims, index, progress=None) -> bool:
|
|
|
2213
2222
|
return False
|
|
2214
2223
|
|
|
2215
2224
|
try:
|
|
2216
|
-
fd = os.open(filepath,
|
|
2225
|
+
fd = os.open(filepath, _O_RDONLY_BINARY)
|
|
2217
2226
|
except OSError:
|
|
2218
2227
|
return False
|
|
2219
2228
|
try:
|
|
@@ -2323,13 +2332,13 @@ def read_npy_into_ram(mapped, progress=None):
|
|
|
2323
2332
|
lock = threading.Lock()
|
|
2324
2333
|
done = [0]
|
|
2325
2334
|
|
|
2326
|
-
fd = os.open(mapped.filename,
|
|
2335
|
+
fd = os.open(mapped.filename, _O_RDONLY_BINARY)
|
|
2327
2336
|
try:
|
|
2328
2337
|
def _read(start):
|
|
2329
2338
|
stop = min(start + _NPY_READ_CHUNK_BYTES, total)
|
|
2330
2339
|
pos = start
|
|
2331
2340
|
while pos < stop:
|
|
2332
|
-
n =
|
|
2341
|
+
n = _pread_into(fd, dst[pos:stop], offset + pos)
|
|
2333
2342
|
if n <= 0:
|
|
2334
2343
|
raise OSError(f"unexpected end of file reading {mapped.filename!r}")
|
|
2335
2344
|
pos += n
|
|
@@ -151,7 +151,8 @@ def emit_launch_event(event: str, **attrs: object) -> None:
|
|
|
151
151
|
encoded = (json.dumps(payload, separators=(",", ":")) + "\n").encode()
|
|
152
152
|
fd = os.open(_path, os.O_APPEND | os.O_CREAT | os.O_WRONLY, 0o600)
|
|
153
153
|
try:
|
|
154
|
-
os
|
|
154
|
+
if hasattr(os, "fchmod"): # Windows has none before Python 3.13
|
|
155
|
+
os.fchmod(fd, 0o600)
|
|
155
156
|
os.write(fd, encoded)
|
|
156
157
|
finally:
|
|
157
158
|
os.close(fd)
|
|
@@ -74,10 +74,15 @@ def _detached_child_kwargs() -> dict[str, object]:
|
|
|
74
74
|
control event raised for that group reaches *every* process attached to it,
|
|
75
75
|
so a child being shut down can deliver a KeyboardInterrupt to the process
|
|
76
76
|
that spawned it — under pytest, to the test runner itself. Both branches
|
|
77
|
-
state the same intent: the child gets its own group.
|
|
77
|
+
state the same intent: the child gets its own group. On Windows it also
|
|
78
|
+
gets no console of its own: started from a notebook or a GUI app, a
|
|
79
|
+
background server would otherwise pop up an empty black console window.
|
|
78
80
|
"""
|
|
79
81
|
if sys.platform == "win32":
|
|
80
|
-
return {
|
|
82
|
+
return {
|
|
83
|
+
"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP
|
|
84
|
+
| subprocess.CREATE_NO_WINDOW
|
|
85
|
+
}
|
|
81
86
|
return {"start_new_session": True}
|
|
82
87
|
|
|
83
88
|
|
|
@@ -391,6 +396,19 @@ def _build_inline_shell_html(url: str, shell_port: int) -> str | None:
|
|
|
391
396
|
return None
|
|
392
397
|
|
|
393
398
|
|
|
399
|
+
def _set_listen_reuse(sock: "socket.socket") -> None:
|
|
400
|
+
"""Let a restarted server rebind its port at once, without sharing it.
|
|
401
|
+
|
|
402
|
+
On Windows ``SO_REUSEADDR`` lets a second process bind a port another
|
|
403
|
+
listener already holds, so two servers could answer on one port; the
|
|
404
|
+
exclusive option there is what the POSIX flag means elsewhere.
|
|
405
|
+
"""
|
|
406
|
+
if sys.platform == "win32":
|
|
407
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
|
408
|
+
else:
|
|
409
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
410
|
+
|
|
411
|
+
|
|
394
412
|
def _make_loopback_socket(port: int) -> "socket.socket":
|
|
395
413
|
"""Bind a single TCP listener on the same loopback host the viewer URLs use."""
|
|
396
414
|
for family, socktype, proto, _, sockaddr in socket.getaddrinfo(
|
|
@@ -401,7 +419,7 @@ def _make_loopback_socket(port: int) -> "socket.socket":
|
|
|
401
419
|
sock = None
|
|
402
420
|
try:
|
|
403
421
|
sock = socket.socket(family, socktype, proto)
|
|
404
|
-
sock
|
|
422
|
+
_set_listen_reuse(sock)
|
|
405
423
|
sock.bind(sockaddr)
|
|
406
424
|
sock.listen(128)
|
|
407
425
|
sock.set_inheritable(True)
|
|
@@ -439,7 +457,7 @@ def _make_loopback_sockets(port: int) -> list["socket.socket"]:
|
|
|
439
457
|
sock = None
|
|
440
458
|
try:
|
|
441
459
|
sock = socket.socket(family, socktype, proto)
|
|
442
|
-
sock
|
|
460
|
+
_set_listen_reuse(sock)
|
|
443
461
|
# On Linux, binding ::1 first then 127.0.0.1 on the same port is
|
|
444
462
|
# fine (different addresses). IPV6_V6ONLY stays default (off on
|
|
445
463
|
# Linux) which is harmless since we bind explicit addresses.
|
|
@@ -5652,8 +5670,26 @@ def _handle_management_command(argv: list[str]) -> bool:
|
|
|
5652
5670
|
return True
|
|
5653
5671
|
|
|
5654
5672
|
|
|
5673
|
+
def _replace_unprintable_output() -> None:
|
|
5674
|
+
"""Keep messages with arrows or ticks from crashing a Windows console.
|
|
5675
|
+
|
|
5676
|
+
Output redirected to a file or pipe on Windows uses an old code page that
|
|
5677
|
+
cannot encode such characters, so printing them raised instead of showing
|
|
5678
|
+
the message. The servers this command starts inherit the same setting.
|
|
5679
|
+
"""
|
|
5680
|
+
if os.name != "nt":
|
|
5681
|
+
return
|
|
5682
|
+
os.environ.setdefault("PYTHONIOENCODING", ":replace")
|
|
5683
|
+
for stream in (sys.stdout, sys.stderr):
|
|
5684
|
+
try:
|
|
5685
|
+
stream.reconfigure(errors="replace")
|
|
5686
|
+
except (AttributeError, ValueError):
|
|
5687
|
+
pass
|
|
5688
|
+
|
|
5689
|
+
|
|
5655
5690
|
def arrayview():
|
|
5656
5691
|
"""Command Line Interface Entry Point."""
|
|
5692
|
+
_replace_unprintable_output()
|
|
5657
5693
|
# Handle "arrayview config ..." subcommand before argparse
|
|
5658
5694
|
if len(sys.argv) > 1 and sys.argv[1] == "config":
|
|
5659
5695
|
_handle_config_command(sys.argv[2:])
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import io
|
|
3
|
+
import re
|
|
3
4
|
|
|
4
5
|
import numpy as np
|
|
5
6
|
from fastapi import Body, Depends, Request, Response
|
|
@@ -8,6 +9,12 @@ from fastapi.responses import JSONResponse
|
|
|
8
9
|
from arrayview._render import render_rgba
|
|
9
10
|
|
|
10
11
|
|
|
12
|
+
def _safe_filename(name: str, default: str) -> str:
|
|
13
|
+
"""A bare file name every OS accepts: no folders, no reserved characters."""
|
|
14
|
+
name = re.sub(r'[\\/:*?"<>|\x00-\x1f\s]+', "_", str(name or "")).strip("._")
|
|
15
|
+
return name or default
|
|
16
|
+
|
|
17
|
+
|
|
11
18
|
def register_export_routes(app, *, get_session_or_404, pil_image) -> None:
|
|
12
19
|
@app.get("/export_array/{sid}")
|
|
13
20
|
def export_array(
|
|
@@ -20,8 +27,7 @@ def register_export_routes(app, *, get_session_or_404, pil_image) -> None:
|
|
|
20
27
|
buf = io.BytesIO()
|
|
21
28
|
np.save(buf, data)
|
|
22
29
|
buf.seek(0)
|
|
23
|
-
|
|
24
|
-
filename = f"{name_stem}.npy"
|
|
30
|
+
filename = f"{_safe_filename(session.name, 'array')}.npy"
|
|
25
31
|
if save_to_downloads:
|
|
26
32
|
import pathlib
|
|
27
33
|
|
|
@@ -39,7 +45,7 @@ def register_export_routes(app, *, get_session_or_404, pil_image) -> None:
|
|
|
39
45
|
async def save_file(request: Request):
|
|
40
46
|
"""Save a client-generated file to the Downloads folder."""
|
|
41
47
|
body = await request.json()
|
|
42
|
-
filename = body.get("filename", "arrayview_export")
|
|
48
|
+
filename = _safe_filename(body.get("filename"), "arrayview_export")
|
|
43
49
|
data_b64 = body.get("data", "")
|
|
44
50
|
if "," in data_b64:
|
|
45
51
|
data_b64 = data_b64.split(",", 1)[1]
|
|
@@ -618,6 +618,18 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
|
|
|
618
618
|
)
|
|
619
619
|
return {"sid": session.sid, "name": name, "notified": notified}
|
|
620
620
|
|
|
621
|
+
@app.post("/reveal/{sid}")
|
|
622
|
+
async def reveal_session_tab(sid: str):
|
|
623
|
+
"""Bring the VS Code browser tab already showing *sid* forward."""
|
|
624
|
+
from arrayview._routes_websocket import viewer_tab_reveal_target
|
|
625
|
+
from arrayview._vscode_signal import _request_vscode_tab_reveal
|
|
626
|
+
|
|
627
|
+
target = viewer_tab_reveal_target(sid)
|
|
628
|
+
if target is None:
|
|
629
|
+
return {"sid": sid, "revealed": False}
|
|
630
|
+
revealed = await asyncio.to_thread(_request_vscode_tab_reveal, *target)
|
|
631
|
+
return {"sid": sid, "revealed": revealed}
|
|
632
|
+
|
|
621
633
|
@app.post("/release/{sid}")
|
|
622
634
|
def release_viewer_session(sid: str, request: Request):
|
|
623
635
|
"""Release a session when its owning viewer tab/window closes."""
|
|
@@ -289,6 +289,32 @@ async def _send_json_quietly(ws, payload):
|
|
|
289
289
|
# a small socket budget, so linking must not open connections of its own.
|
|
290
290
|
_LINKED_VIEWER_SOCKETS: set = set()
|
|
291
291
|
|
|
292
|
+
# Where each live VS Code browser tab currently is: ws → (sid, path, window_id).
|
|
293
|
+
# The file picker uses it to bring an already-open array's tab forward.
|
|
294
|
+
_VIEWER_TAB_ADDRESSES: dict = {}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _handle_tab_address_message(ws, sid: str, msg: dict) -> None:
|
|
298
|
+
path = msg.get("path")
|
|
299
|
+
window_id = msg.get("window_id")
|
|
300
|
+
if not isinstance(path, str) or not path.startswith("/") or len(path) > 2048:
|
|
301
|
+
return
|
|
302
|
+
if not isinstance(window_id, str) or not window_id or len(window_id) > 128:
|
|
303
|
+
return
|
|
304
|
+
_VIEWER_TAB_ADDRESSES[ws] = (sid, path, window_id)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def viewer_tab_reveal_target(sid: str) -> tuple[str, str] | None:
|
|
308
|
+
"""(window_id, VS Code reuseUrlFilter) of a live tab showing *sid*."""
|
|
309
|
+
from urllib.parse import urlsplit
|
|
310
|
+
|
|
311
|
+
for tab_sid, path, window_id in reversed(list(_VIEWER_TAB_ADDRESSES.values())):
|
|
312
|
+
parts = urlsplit(path)
|
|
313
|
+
# A bare "/" is shared by every tab; only a query tells them apart.
|
|
314
|
+
if tab_sid == sid and parts.query:
|
|
315
|
+
return window_id, f"{parts.path}?{parts.query}"
|
|
316
|
+
return None
|
|
317
|
+
|
|
292
318
|
|
|
293
319
|
def _handle_link_message(ws, msg: dict) -> None:
|
|
294
320
|
action = msg.get("action")
|
|
@@ -531,6 +557,9 @@ def register_websocket_routes(app) -> None:
|
|
|
531
557
|
# displaces (or is displaced by) a pending render.
|
|
532
558
|
_handle_link_message(ws, msg)
|
|
533
559
|
continue
|
|
560
|
+
if isinstance(msg, dict) and msg.get("type") == "tab-address":
|
|
561
|
+
_handle_tab_address_message(ws, sid, msg)
|
|
562
|
+
continue
|
|
534
563
|
try:
|
|
535
564
|
_pending.get_nowait()
|
|
536
565
|
except asyncio.QueueEmpty:
|
|
@@ -933,6 +962,7 @@ def register_websocket_routes(app) -> None:
|
|
|
933
962
|
except ValueError:
|
|
934
963
|
pass
|
|
935
964
|
_LINKED_VIEWER_SOCKETS.discard(ws)
|
|
965
|
+
_VIEWER_TAB_ADDRESSES.pop(ws, None)
|
|
936
966
|
try:
|
|
937
967
|
from arrayview import _extra_ports
|
|
938
968
|
|