arrayview 0.44.0__tar.gz → 0.45.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.
Files changed (111) hide show
  1. {arrayview-0.44.0 → arrayview-0.45.0}/PKG-INFO +1 -1
  2. {arrayview-0.44.0 → arrayview-0.45.0}/pyproject.toml +1 -1
  3. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_analysis.py +6 -0
  4. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_io.py +46 -5
  5. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_routes_loading.py +144 -130
  6. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_viewer.html +1074 -325
  7. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_browser.py +1137 -24
  8. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_mode_consistency.py +43 -0
  9. {arrayview-0.44.0 → arrayview-0.45.0}/.gitignore +0 -0
  10. {arrayview-0.44.0 → arrayview-0.45.0}/LICENSE +0 -0
  11. {arrayview-0.44.0 → arrayview-0.45.0}/README.md +0 -0
  12. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/ARCHITECTURE.md +0 -0
  13. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/__init__.py +0 -0
  14. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/__main__.py +0 -0
  15. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_app.py +0 -0
  16. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_config.py +0 -0
  17. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_diagnostics.py +0 -0
  18. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_dicom.py +0 -0
  19. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_diff.py +0 -0
  20. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_extra_ports.py +0 -0
  21. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_icon.png +0 -0
  22. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_imaging.py +0 -0
  23. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_instance_registry.py +0 -0
  24. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_launch_plan.py +0 -0
  25. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_launch_trace.py +0 -0
  26. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_launcher.py +0 -0
  27. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_lifecycle.py +0 -0
  28. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_overlays.py +0 -0
  29. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_platform.py +0 -0
  30. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_render.py +0 -0
  31. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_routes_analysis.py +0 -0
  32. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_routes_drop.py +0 -0
  33. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_routes_export.py +0 -0
  34. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_routes_persistence.py +0 -0
  35. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_routes_preferences.py +0 -0
  36. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_routes_preload.py +0 -0
  37. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_routes_query.py +0 -0
  38. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_routes_rendering.py +0 -0
  39. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_routes_segmentation.py +0 -0
  40. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_routes_state.py +0 -0
  41. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_routes_vectorfield.py +0 -0
  42. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_routes_websocket.py +0 -0
  43. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_segmentation.py +0 -0
  44. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_server.py +0 -0
  45. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_session.py +0 -0
  46. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_session_spec.py +0 -0
  47. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_shell.html +0 -0
  48. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_source_safety.py +0 -0
  49. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_synthetic_mri.py +0 -0
  50. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_torch.py +0 -0
  51. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_tutorial.py +0 -0
  52. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_vectorfield.py +0 -0
  53. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_vscode.py +0 -0
  54. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_vscode_browser.py +0 -0
  55. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_vscode_extension.py +0 -0
  56. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_vscode_signal.py +0 -0
  57. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/_watch.py +0 -0
  58. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/arrayview-opener.vsix +0 -0
  59. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/gsap.min.js +0 -0
  60. {arrayview-0.44.0 → arrayview-0.45.0}/src/arrayview/todo.md +0 -0
  61. {arrayview-0.44.0 → arrayview-0.45.0}/tests/capture_patient_loading.py +0 -0
  62. {arrayview-0.44.0 → arrayview-0.45.0}/tests/capture_v_animation.py +0 -0
  63. {arrayview-0.44.0 → arrayview-0.45.0}/tests/conftest.py +0 -0
  64. {arrayview-0.44.0 → arrayview-0.45.0}/tests/lifecycle_matrix.py +0 -0
  65. {arrayview-0.44.0 → arrayview-0.45.0}/tests/make_vectorfield_test_arrays.py +0 -0
  66. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_1d_arrays.py +0 -0
  67. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_api.py +0 -0
  68. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_backend_shared.py +0 -0
  69. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_cli.py +0 -0
  70. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_colorbar_hover_highlight.py +0 -0
  71. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_config.py +0 -0
  72. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_dicom.py +0 -0
  73. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_display_opener_results.py +0 -0
  74. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_doctor_commands.py +0 -0
  75. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_drop_import.py +0 -0
  76. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_instance_registry.py +0 -0
  77. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_invocation_contract_matrix.py +0 -0
  78. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_io.py +0 -0
  79. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_large_arrays.py +0 -0
  80. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_launch_blackbox.py +0 -0
  81. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_launch_diagnostics.py +0 -0
  82. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_launch_execution_ownership.py +0 -0
  83. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_launch_plan.py +0 -0
  84. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_launch_trace.py +0 -0
  85. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_lifecycle_contract.py +0 -0
  86. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_loading_server.py +0 -0
  87. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_mode_matrix.py +0 -0
  88. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_mode_roundtrip.py +0 -0
  89. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_nifti_meta.py +0 -0
  90. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_nifti_series.py +0 -0
  91. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_preferences_ui.py +0 -0
  92. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_public_invocation_regressions.py +0 -0
  93. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_render_failure_reporting.py +0 -0
  94. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_render_progress.py +0 -0
  95. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_rgb_pixel_art.py +0 -0
  96. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_safe_server_stop.py +0 -0
  97. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_server_identity_status.py +0 -0
  98. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_session_spec.py +0 -0
  99. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_source_safety.py +0 -0
  100. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_spawn_ownership.py +0 -0
  101. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_torch.py +0 -0
  102. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_tutorial.py +0 -0
  103. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_tutorial_browser.py +0 -0
  104. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_value_formatting.py +0 -0
  105. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_view_handle_lifecycle.py +0 -0
  106. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_view_launch_execution.py +0 -0
  107. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_vscode_ack_protocol.py +0 -0
  108. {arrayview-0.44.0 → arrayview-0.45.0}/tests/test_window_level_preview.py +0 -0
  109. {arrayview-0.44.0 → arrayview-0.45.0}/tests/ui_audit.py +0 -0
  110. {arrayview-0.44.0 → arrayview-0.45.0}/tests/v_anim_frames/.gitkeep +0 -0
  111. {arrayview-0.44.0 → arrayview-0.45.0}/tests/visual_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.44.0
3
+ Version: 0.45.0
4
4
  Summary: Fast multi-dimensional array viewer
5
5
  Project-URL: Home, https://github.com/oscarvanderheide/arrayview
6
6
  Project-URL: Source, https://github.com/oscarvanderheide/arrayview
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arrayview"
7
- version = "0.44.0"
7
+ version = "0.45.0"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -70,6 +70,12 @@ def _build_metadata(session) -> dict:
70
70
  [list(shape) for shape in row]
71
71
  for row in session.data.ragged_spatial_shapes
72
72
  ]
73
+ case_voxel_spacings = getattr(session.data, "case_voxel_spacings", None)
74
+ if case_voxel_spacings:
75
+ meta["case_voxel_spacings"] = [
76
+ [list(spacing) for spacing in row]
77
+ for row in case_voxel_spacings
78
+ ]
73
79
  return meta
74
80
 
75
81
 
@@ -283,6 +283,11 @@ class _ProgressiveNiftiJob:
283
283
  if self.future is not None and self.future.cancel():
284
284
  self._finish(cancelled=True)
285
285
 
286
+ def has_plane(self, z_index) -> bool:
287
+ """True if *z_index* is already decoded (a non-blocking peek)."""
288
+ with self._condition:
289
+ return bool(self._decoded[z_index]) or self._done
290
+
286
291
  def plane(self, z_index):
287
292
  with self._condition:
288
293
  while not self._decoded[z_index] and not self._done:
@@ -442,7 +447,8 @@ class _ProgressiveNiftiSeriesMixin:
442
447
  return None
443
448
  with self._progressive_lock:
444
449
  job = self._progressive_jobs.get(cache_key)
445
- if job is None:
450
+ is_new_job = job is None
451
+ if is_new_job:
446
452
  spec = _progressive_nifti_spec(filepath)
447
453
  if spec is None:
448
454
  return None
@@ -471,7 +477,18 @@ class _ProgressiveNiftiSeriesMixin:
471
477
  overlay_prefetch=overlay_prefetch
472
478
  ).submit(job.run)
473
479
  try:
474
- return job.plane(z_index)
480
+ # The request that starts the job waits on just the one plane, so
481
+ # the first frame of a fresh file shows up as soon as the decoder
482
+ # reaches it rather than waiting for the whole thing. Every later
483
+ # request for this same file instead waits for the whole volume
484
+ # (unless its plane is already decoded): scrolling through every
485
+ # plane that way finishes in less total time than paying a small
486
+ # wait on each individual plane (~200ms vs ~315ms measured on a
487
+ # 75-plane volume), so it also keeps later frames from feeling
488
+ # slower than the ones on the other two axes.
489
+ if is_new_job or job.has_plane(z_index):
490
+ return job.plane(z_index)
491
+ return job.volume()[:, :, z_index]
475
492
  except _ProgressiveLoadCancelled:
476
493
  raise
477
494
  except Exception:
@@ -491,11 +508,12 @@ class _NiftiSeries(_ProgressiveNiftiSeriesMixin):
491
508
 
492
509
  _av_lazy = True
493
510
 
494
- def __init__(self, file_matrix, vol_shape, dtype, spatial_meta=None):
511
+ def __init__(self, file_matrix, vol_shape, dtype, spatial_meta=None, spacing_matrix=None):
495
512
  self._file_matrix = file_matrix
496
513
  self._vol_shape = tuple(vol_shape)
497
514
  self._dtype = np.dtype(dtype)
498
515
  self._spatial_meta = spatial_meta
516
+ self._spacing_matrix = spacing_matrix
499
517
  n_patients = len(file_matrix)
500
518
  n_modalities = len(file_matrix[0]) if file_matrix else 0
501
519
  if n_modalities <= 1:
@@ -518,6 +536,10 @@ class _NiftiSeries(_ProgressiveNiftiSeriesMixin):
518
536
  def dtype(self):
519
537
  return self._dtype
520
538
 
539
+ @property
540
+ def case_voxel_spacings(self):
541
+ return self._spacing_matrix
542
+
521
543
  def _get_volume(self, p_idx, m_idx):
522
544
  cache_key = (p_idx, m_idx)
523
545
  with self._cache_lock:
@@ -711,7 +733,10 @@ class _RaggedFileSeries(_ProgressiveNiftiSeriesMixin):
711
733
  _av_lazy = True
712
734
  _av_ragged = True
713
735
 
714
- def __init__(self, file_matrix, spatial_shapes, dtype, spatial_meta=None, all_nifti=False):
736
+ def __init__(
737
+ self, file_matrix, spatial_shapes, dtype, spatial_meta=None, all_nifti=False,
738
+ spacing_matrix=None,
739
+ ):
715
740
  self._file_matrix = file_matrix
716
741
  self._spatial_shapes = [
717
742
  [tuple(int(s) for s in shape) for shape in row]
@@ -719,6 +744,7 @@ class _RaggedFileSeries(_ProgressiveNiftiSeriesMixin):
719
744
  ]
720
745
  self._dtype = np.dtype(dtype)
721
746
  self._spatial_meta = spatial_meta
747
+ self._spacing_matrix = spacing_matrix
722
748
  self._all_nifti = bool(all_nifti)
723
749
  self._spatial_ndim = len(self._spatial_shapes[0][0])
724
750
  self._vol_shape = tuple(
@@ -755,6 +781,10 @@ class _RaggedFileSeries(_ProgressiveNiftiSeriesMixin):
755
781
  def ragged_spatial_shapes(self):
756
782
  return self._spatial_shapes
757
783
 
784
+ @property
785
+ def case_voxel_spacings(self):
786
+ return self._spacing_matrix
787
+
758
788
  def _normalize_collection_indices(self, idx_list):
759
789
  n_p = len(self._file_matrix)
760
790
  n_m = len(self._file_matrix[0]) if self._file_matrix else 0
@@ -992,12 +1022,14 @@ def _series_from_file_matrix(
992
1022
 
993
1023
  if all_nifti:
994
1024
  spatial_shapes = []
1025
+ voxel_sizes_list = []
995
1026
  for fpath, img, item_meta in _iter_nifti_headers(
996
1027
  all_paths, scan_progress=scan_progress, scan_label=scan_label
997
1028
  ):
998
1029
  shape = tuple(item_meta["canonical_shape"])
999
1030
  dtype = img.get_data_dtype()
1000
1031
  spatial_shapes.append(shape)
1032
+ voxel_sizes_list.append(tuple(item_meta["voxel_sizes"]))
1001
1033
  if ref_shape is None:
1002
1034
  ref_shape = shape
1003
1035
  ref_dtype = dtype
@@ -1013,16 +1045,21 @@ def _series_from_file_matrix(
1013
1045
  f"{dtype}, expected {ref_dtype}."
1014
1046
  )
1015
1047
  shape_matrix = []
1048
+ spacing_matrix = []
1016
1049
  pos = 0
1017
1050
  for row in file_matrix:
1018
1051
  row_shapes = []
1052
+ row_spacings = []
1019
1053
  for path in row:
1020
1054
  if path is None:
1021
1055
  row_shapes.append(ref_shape)
1056
+ row_spacings.append(voxel_sizes_list[0] if voxel_sizes_list else (1.0, 1.0, 1.0))
1022
1057
  else:
1023
1058
  row_shapes.append(spatial_shapes[pos])
1059
+ row_spacings.append(voxel_sizes_list[pos])
1024
1060
  pos += 1
1025
1061
  shape_matrix.append(row_shapes)
1062
+ spacing_matrix.append(row_spacings)
1026
1063
  shapes_differ = any(shape != ref_shape for shape in spatial_shapes)
1027
1064
  if stack == "dense" and shapes_differ:
1028
1065
  raise ValueError("Dense stacking requires every file to have the same shape.")
@@ -1033,9 +1070,13 @@ def _series_from_file_matrix(
1033
1070
  ref_dtype,
1034
1071
  spatial_meta=spatial_meta,
1035
1072
  all_nifti=True,
1073
+ spacing_matrix=spacing_matrix,
1036
1074
  )
1037
1075
  else:
1038
- series = _NiftiSeries(file_matrix, ref_shape, ref_dtype, spatial_meta)
1076
+ series = _NiftiSeries(
1077
+ file_matrix, ref_shape, ref_dtype, spatial_meta,
1078
+ spacing_matrix=spacing_matrix,
1079
+ )
1039
1080
  if load == "eager":
1040
1081
  for p, row in enumerate(file_matrix):
1041
1082
  for m, path in enumerate(row):
@@ -401,148 +401,162 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
401
401
  "pending": True,
402
402
  }
403
403
  signature_before_load = file_signature(abs_path) if not dir_patterns else None
404
+ # A collection scan+load runs synchronously on this request (no
405
+ # "background" fast path — see the `not dir_patterns` guard above),
406
+ # but the caller already navigates the browser to `requested_sid`
407
+ # before this returns. Without marking the sid pending here, the
408
+ # viewer's very first phase report can 404 (sid known to neither
409
+ # SESSIONS nor PENDING_SESSIONS yet) and the opener misreports launch
410
+ # as failed even though the load goes on to succeed.
411
+ dir_pending_sid = requested_sid if (dir_patterns and requested_sid) else None
412
+ if dir_pending_sid:
413
+ PENDING_SESSIONS.add(dir_pending_sid)
404
414
  try:
405
- from ._io import load_data_with_meta, load_dir_collection, list_array_keys
406
-
407
- # Multi-array .npz/.mat: if no key provided, return the key list so
408
- # the client can show a picker instead of blocking on terminal input.
409
- _key = body.get("key")
410
- _array_keys = None
411
- if dir_patterns:
412
- data, spatial_meta, dir_overlay_items, summary = await asyncio.to_thread(
413
- load_dir_collection,
414
- list(dir_patterns),
415
- overlays=[tuple(item) for item in body.get("dir_overlay_specs", [])],
416
- case_regex=body.get("dir_case_regex"),
417
- exclude_cases=body.get("dir_exclude_cases", []),
418
- load=body.get("load", "lazy"),
419
- stack=body.get("stack", "auto"),
420
- )
421
- elif filepath.endswith(".npz") or filepath.endswith(".mat"):
422
- _array_keys = await asyncio.to_thread(list_array_keys, filepath)
423
- if len(_array_keys) > 1 and not _key:
424
- return {"array_keys": _array_keys, "filepath": filepath}
425
- if len(_array_keys) == 1 and not _key:
426
- _key = _array_keys[0]["key"]
427
-
428
- data, spatial_meta = await asyncio.to_thread(
429
- load_data_with_meta,
430
- filepath,
431
- key=_key,
432
- select=body.get("select"),
433
- ) if not dir_patterns else (data, spatial_meta)
434
- except Exception as e:
435
- if staging_dir:
436
- cleanup_staging_directory(staging_dir)
437
- return {"error": str(e)}
438
- try:
439
- session = await asyncio.to_thread(
440
- Session, data, filepath=None if dir_patterns else filepath, name=name
441
- )
442
- except Exception as e:
443
- if staging_dir:
444
- cleanup_staging_directory(staging_dir)
445
- return {"error": str(e)}
446
- if requested_sid:
447
- session.sid = requested_sid
448
- session.release_on_disconnect = bool(
449
- body.get("release_on_disconnect", False)
450
- )
451
- session.related_release_sids = [
452
- str(value) for value in body.get("related_sids", [])
453
- ]
454
- if staging_dir:
455
- session._source_staging_dirs = [str(staging_dir)]
456
- if not dir_patterns:
457
415
  try:
458
- signature_after_load = file_signature(abs_path)
416
+ from ._io import load_data_with_meta, load_dir_collection, list_array_keys
417
+
418
+ # Multi-array .npz/.mat: if no key provided, return the key list so
419
+ # the client can show a picker instead of blocking on terminal input.
420
+ _key = body.get("key")
421
+ _array_keys = None
422
+ if dir_patterns:
423
+ data, spatial_meta, dir_overlay_items, summary = await asyncio.to_thread(
424
+ load_dir_collection,
425
+ list(dir_patterns),
426
+ overlays=[tuple(item) for item in body.get("dir_overlay_specs", [])],
427
+ case_regex=body.get("dir_case_regex"),
428
+ exclude_cases=body.get("dir_exclude_cases", []),
429
+ load=body.get("load", "lazy"),
430
+ stack=body.get("stack", "auto"),
431
+ )
432
+ elif filepath.endswith(".npz") or filepath.endswith(".mat"):
433
+ _array_keys = await asyncio.to_thread(list_array_keys, filepath)
434
+ if len(_array_keys) > 1 and not _key:
435
+ return {"array_keys": _array_keys, "filepath": filepath}
436
+ if len(_array_keys) == 1 and not _key:
437
+ _key = _array_keys[0]["key"]
438
+
439
+ data, spatial_meta = await asyncio.to_thread(
440
+ load_data_with_meta,
441
+ filepath,
442
+ key=_key,
443
+ select=body.get("select"),
444
+ ) if not dir_patterns else (data, spatial_meta)
459
445
  except Exception as e:
460
446
  if staging_dir:
461
447
  cleanup_staging_directory(staging_dir)
462
448
  return {"error": str(e)}
463
- if signature_before_load == signature_after_load:
464
- session.file_signature = signature_after_load
465
- if dir_patterns:
466
- session.collection_spatial_ndim = len(summary["spatial_shape"])
467
- session.collection_identity = collection_identity
468
- if spatial_meta is not None:
469
- session.spatial_meta = spatial_meta
470
- session.original_volume = data
471
- if _array_keys and len(_array_keys) > 1:
472
- session.array_keys = _array_keys
473
- session.array_filepath = filepath
474
- if body.get("rgb"):
475
449
  try:
476
- await asyncio.to_thread(setup_rgb, session)
477
- except ValueError as e:
478
- if staging_dir:
479
- cleanup_staging_directory(staging_dir)
480
- return {"error": str(e)}
481
- overlay_sids = []
482
- overlay_names = []
483
- overlay_sessions = []
484
- for item in dir_overlay_items if dir_patterns else []:
485
- try:
486
- overlay_session = await asyncio.to_thread(
487
- Session, item["data"], filepath=None, name=item["name"]
450
+ session = await asyncio.to_thread(
451
+ Session, data, filepath=None if dir_patterns else filepath, name=name
488
452
  )
489
453
  except Exception as e:
490
454
  if staging_dir:
491
455
  cleanup_staging_directory(staging_dir)
492
456
  return {"error": str(e)}
493
- overlay_sessions.append(overlay_session)
494
- overlay_sids.append(overlay_session.sid)
495
- overlay_names.append(item["name"])
496
- if dir_patterns:
497
- session.collection_overlay_sids = list(overlay_sids)
498
- session.collection_overlay_names = list(overlay_names)
499
- if requested_sid:
500
- if not commit_session_group_unless_cancelled(
501
- session.sid,
502
- [session, *overlay_sessions],
503
- ):
504
- CANCELLED_PENDING_SESSIONS.discard(session.sid)
505
- if staging_dir:
506
- cleanup_staging_directory(staging_dir)
507
- return {"error": "load request was cancelled"}
508
- else:
509
- SESSIONS[session.sid] = session
510
- for overlay_session in overlay_sessions:
511
- SESSIONS[overlay_session.sid] = overlay_session
512
- if body.get("watch"):
513
- import arrayview._session as _session_mod
514
- from ._watch import attach_file_watch
515
-
516
- attach_file_watch(session, filepath, _session_mod.SERVER_PORT or 8000)
517
- notified = False
518
- if notify:
519
- tab_url = None
520
- if body.get("compare_sids"):
521
- tab_url = (
522
- f"/?sid={session.sid}"
523
- f"&compare_sid={body['compare_sid']}"
524
- f"&compare_sids={body['compare_sids']}"
525
- )
526
- if overlay_sids:
527
- tab_url = (
528
- f"/?sid={session.sid}"
529
- f"&overlay_sid={','.join(overlay_sids)}"
530
- f"&overlay_names={urllib.parse.quote(','.join(overlay_names))}"
531
- )
532
- if native_request_id:
533
- tab_url = tab_url or f"/?sid={session.sid}"
534
- tab_url += (
535
- "&native_request_id="
536
- + urllib.parse.quote(native_request_id)
537
- )
538
- notified = await notify_shells(session.sid, name, url=tab_url, wait=False)
539
- return {
540
- "sid": session.sid,
541
- "name": name,
542
- "notified": notified,
543
- "overlay_sids": overlay_sids,
544
- "overlay_names": overlay_names,
545
- }
457
+ if requested_sid:
458
+ session.sid = requested_sid
459
+ session.release_on_disconnect = bool(
460
+ body.get("release_on_disconnect", False)
461
+ )
462
+ session.related_release_sids = [
463
+ str(value) for value in body.get("related_sids", [])
464
+ ]
465
+ if staging_dir:
466
+ session._source_staging_dirs = [str(staging_dir)]
467
+ if not dir_patterns:
468
+ try:
469
+ signature_after_load = file_signature(abs_path)
470
+ except Exception as e:
471
+ if staging_dir:
472
+ cleanup_staging_directory(staging_dir)
473
+ return {"error": str(e)}
474
+ if signature_before_load == signature_after_load:
475
+ session.file_signature = signature_after_load
476
+ if dir_patterns:
477
+ session.collection_spatial_ndim = len(summary["spatial_shape"])
478
+ session.collection_identity = collection_identity
479
+ if spatial_meta is not None:
480
+ session.spatial_meta = spatial_meta
481
+ session.original_volume = data
482
+ if _array_keys and len(_array_keys) > 1:
483
+ session.array_keys = _array_keys
484
+ session.array_filepath = filepath
485
+ if body.get("rgb"):
486
+ try:
487
+ await asyncio.to_thread(setup_rgb, session)
488
+ except ValueError as e:
489
+ if staging_dir:
490
+ cleanup_staging_directory(staging_dir)
491
+ return {"error": str(e)}
492
+ overlay_sids = []
493
+ overlay_names = []
494
+ overlay_sessions = []
495
+ for item in dir_overlay_items if dir_patterns else []:
496
+ try:
497
+ overlay_session = await asyncio.to_thread(
498
+ Session, item["data"], filepath=None, name=item["name"]
499
+ )
500
+ except Exception as e:
501
+ if staging_dir:
502
+ cleanup_staging_directory(staging_dir)
503
+ return {"error": str(e)}
504
+ overlay_sessions.append(overlay_session)
505
+ overlay_sids.append(overlay_session.sid)
506
+ overlay_names.append(item["name"])
507
+ if dir_patterns:
508
+ session.collection_overlay_sids = list(overlay_sids)
509
+ session.collection_overlay_names = list(overlay_names)
510
+ if requested_sid:
511
+ if not commit_session_group_unless_cancelled(
512
+ session.sid,
513
+ [session, *overlay_sessions],
514
+ ):
515
+ CANCELLED_PENDING_SESSIONS.discard(session.sid)
516
+ if staging_dir:
517
+ cleanup_staging_directory(staging_dir)
518
+ return {"error": "load request was cancelled"}
519
+ else:
520
+ SESSIONS[session.sid] = session
521
+ for overlay_session in overlay_sessions:
522
+ SESSIONS[overlay_session.sid] = overlay_session
523
+ if body.get("watch"):
524
+ import arrayview._session as _session_mod
525
+ from ._watch import attach_file_watch
526
+
527
+ attach_file_watch(session, filepath, _session_mod.SERVER_PORT or 8000)
528
+ notified = False
529
+ if notify:
530
+ tab_url = None
531
+ if body.get("compare_sids"):
532
+ tab_url = (
533
+ f"/?sid={session.sid}"
534
+ f"&compare_sid={body['compare_sid']}"
535
+ f"&compare_sids={body['compare_sids']}"
536
+ )
537
+ if overlay_sids:
538
+ tab_url = (
539
+ f"/?sid={session.sid}"
540
+ f"&overlay_sid={','.join(overlay_sids)}"
541
+ f"&overlay_names={urllib.parse.quote(','.join(overlay_names))}"
542
+ )
543
+ if native_request_id:
544
+ tab_url = tab_url or f"/?sid={session.sid}"
545
+ tab_url += (
546
+ "&native_request_id="
547
+ + urllib.parse.quote(native_request_id)
548
+ )
549
+ notified = await notify_shells(session.sid, name, url=tab_url, wait=False)
550
+ return {
551
+ "sid": session.sid,
552
+ "name": name,
553
+ "notified": notified,
554
+ "overlay_sids": overlay_sids,
555
+ "overlay_names": overlay_names,
556
+ }
557
+ finally:
558
+ if dir_pending_sid:
559
+ PENDING_SESSIONS.discard(dir_pending_sid)
546
560
 
547
561
  @app.post("/notify/{sid}")
548
562
  async def notify_existing_session(sid: str, request: Request):