arrayview 0.39.0__tar.gz → 0.40.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 (114) hide show
  1. {arrayview-0.39.0 → arrayview-0.40.0}/PKG-INFO +5 -2
  2. {arrayview-0.39.0 → arrayview-0.40.0}/README.md +4 -1
  3. {arrayview-0.39.0 → arrayview-0.40.0}/pyproject.toml +1 -1
  4. arrayview-0.40.0/src/arrayview/_diagnostics.py +211 -0
  5. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_io.py +65 -16
  6. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_launcher.py +167 -38
  7. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_lifecycle.py +16 -0
  8. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_render.py +76 -11
  9. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_routes_query.py +14 -7
  10. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_routes_rendering.py +5 -1
  11. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_routes_websocket.py +89 -5
  12. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_server.py +76 -6
  13. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_session.py +12 -0
  14. arrayview-0.40.0/src/arrayview/_tutorial.py +106 -0
  15. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_viewer.html +1966 -216
  16. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_vscode_extension.py +10 -1
  17. arrayview-0.40.0/src/arrayview/arrayview-opener.vsix +0 -0
  18. arrayview-0.40.0/tests/test_1d_arrays.py +55 -0
  19. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_api.py +82 -0
  20. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_browser.py +883 -23
  21. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_cli.py +6 -2
  22. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_dicom.py +74 -0
  23. arrayview-0.40.0/tests/test_launch_diagnostics.py +122 -0
  24. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_lifecycle_contract.py +101 -4
  25. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_nifti_series.py +20 -3
  26. arrayview-0.40.0/tests/test_render_failure_reporting.py +74 -0
  27. arrayview-0.40.0/tests/test_render_progress.py +275 -0
  28. arrayview-0.40.0/tests/test_tutorial.py +60 -0
  29. arrayview-0.40.0/tests/test_tutorial_browser.py +197 -0
  30. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_view_component_integration.py +29 -0
  31. arrayview-0.40.0/tests/test_window_level_preview.py +101 -0
  32. {arrayview-0.39.0 → arrayview-0.40.0}/tests/ui_audit.py +3 -3
  33. {arrayview-0.39.0 → arrayview-0.40.0}/tests/visual_smoke.py +53 -47
  34. arrayview-0.39.0/src/arrayview/arrayview-opener.vsix +0 -0
  35. arrayview-0.39.0/tests/test_launch_diagnostics.py +0 -47
  36. {arrayview-0.39.0 → arrayview-0.40.0}/.gitignore +0 -0
  37. {arrayview-0.39.0 → arrayview-0.40.0}/LICENSE +0 -0
  38. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/ARCHITECTURE.md +0 -0
  39. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/__init__.py +0 -0
  40. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/__main__.py +0 -0
  41. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_analysis.py +0 -0
  42. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_app.py +0 -0
  43. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_config.py +0 -0
  44. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_dicom.py +0 -0
  45. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_diff.py +0 -0
  46. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_icon.png +0 -0
  47. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_imaging.py +0 -0
  48. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_instance_registry.py +0 -0
  49. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_launch_plan.py +0 -0
  50. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_launch_trace.py +0 -0
  51. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_overlays.py +0 -0
  52. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_platform.py +0 -0
  53. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_routes_analysis.py +0 -0
  54. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_routes_drop.py +0 -0
  55. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_routes_export.py +0 -0
  56. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_routes_loading.py +0 -0
  57. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_routes_persistence.py +0 -0
  58. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_routes_preferences.py +0 -0
  59. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_routes_preload.py +0 -0
  60. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_routes_segmentation.py +0 -0
  61. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_routes_state.py +0 -0
  62. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_routes_vectorfield.py +0 -0
  63. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_segmentation.py +0 -0
  64. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_session_spec.py +0 -0
  65. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_shell.html +0 -0
  66. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_synthetic_mri.py +0 -0
  67. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_torch.py +0 -0
  68. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_vectorfield.py +0 -0
  69. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_vscode.py +0 -0
  70. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_vscode_browser.py +0 -0
  71. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/_vscode_signal.py +0 -0
  72. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/gsap.min.js +0 -0
  73. {arrayview-0.39.0 → arrayview-0.40.0}/src/arrayview/todo.md +0 -0
  74. {arrayview-0.39.0 → arrayview-0.40.0}/tests/capture_patient_loading.py +0 -0
  75. {arrayview-0.39.0 → arrayview-0.40.0}/tests/capture_v_animation.py +0 -0
  76. {arrayview-0.39.0 → arrayview-0.40.0}/tests/conftest.py +0 -0
  77. {arrayview-0.39.0 → arrayview-0.40.0}/tests/lifecycle_matrix.py +0 -0
  78. {arrayview-0.39.0 → arrayview-0.40.0}/tests/make_vectorfield_test_arrays.py +0 -0
  79. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_backend_shared.py +0 -0
  80. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_colorbar_hover_highlight.py +0 -0
  81. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_command_reachability.py +0 -0
  82. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_config.py +0 -0
  83. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_cross_mode_parametrized.py +0 -0
  84. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_display_opener_results.py +0 -0
  85. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_doctor_commands.py +0 -0
  86. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_drop_import.py +0 -0
  87. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_instance_registry.py +0 -0
  88. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_interactions.py +0 -0
  89. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_invocation_contract_matrix.py +0 -0
  90. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_io.py +0 -0
  91. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_large_arrays.py +0 -0
  92. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_launch_blackbox.py +0 -0
  93. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_launch_execution_ownership.py +0 -0
  94. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_launch_plan.py +0 -0
  95. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_launch_trace.py +0 -0
  96. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_loading_server.py +0 -0
  97. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_mode_consistency.py +0 -0
  98. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_mode_matrix.py +0 -0
  99. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_mode_roundtrip.py +0 -0
  100. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_nifti_meta.py +0 -0
  101. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_preferences_ui.py +0 -0
  102. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_public_invocation_regressions.py +0 -0
  103. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_rgb_pixel_art.py +0 -0
  104. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_safe_server_stop.py +0 -0
  105. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_server_identity_status.py +0 -0
  106. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_session_spec.py +0 -0
  107. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_spawn_ownership.py +0 -0
  108. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_torch.py +0 -0
  109. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_value_formatting.py +0 -0
  110. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_view_component_unit.py +0 -0
  111. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_view_handle_lifecycle.py +0 -0
  112. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_view_launch_execution.py +0 -0
  113. {arrayview-0.39.0 → arrayview-0.40.0}/tests/test_vscode_ack_protocol.py +0 -0
  114. {arrayview-0.39.0 → arrayview-0.40.0}/tests/v_anim_frames/.gitkeep +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrayview
3
- Version: 0.39.0
3
+ Version: 0.40.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
@@ -43,7 +43,10 @@ Arrayview lets you scroll through multi-dimensional arrays.
43
43
 
44
44
  Open it from the shell, from Python, Julia, or Matlab, or inside a Jupyter notebook. Use it locally or over SSH.
45
45
 
46
- If you work in VS Code, you can open arrays directly from the explorer; with Remote SSH, it works the same way.
46
+ If you work in VS Code, you can open arrays directly from the explorer — click an
47
+ array file, or right-click a folder and choose **Open Folder in ArrayView** to
48
+ review a DICOM series or a whole directory of arrays as one stack. With Remote
49
+ SSH or a tunnel, it works the same way.
47
50
 
48
51
  It is meant to feel simple but there's more to it than meets the eye.
49
52
 
@@ -6,7 +6,10 @@ Arrayview lets you scroll through multi-dimensional arrays.
6
6
 
7
7
  Open it from the shell, from Python, Julia, or Matlab, or inside a Jupyter notebook. Use it locally or over SSH.
8
8
 
9
- If you work in VS Code, you can open arrays directly from the explorer; with Remote SSH, it works the same way.
9
+ If you work in VS Code, you can open arrays directly from the explorer — click an
10
+ array file, or right-click a folder and choose **Open Folder in ArrayView** to
11
+ review a DICOM series or a whole directory of arrays as one stack. With Remote
12
+ SSH or a tunnel, it works the same way.
10
13
 
11
14
  It is meant to feel simple but there's more to it than meets the eye.
12
15
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arrayview"
7
- version = "0.39.0"
7
+ version = "0.40.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"
@@ -0,0 +1,211 @@
1
+ """Turn a launch failure into a concise, actionable terminal message.
2
+
3
+ One line saying what went wrong, one line saying what to do about it. The
4
+ severity colour is the signal that matters:
5
+
6
+ orange something you can clear yourself right now — reload the window,
7
+ run it again, kill a stale server
8
+ red something that needs a real change — the file, the arrays, or a
9
+ bug worth a traceback
10
+
11
+ `SETUP` is the exception to all of that: the run before it *worked*, and the
12
+ only thing left is a step the user has to take by hand. Nothing went wrong, so
13
+ it is not written as though something did — no "failed to open" headline, no
14
+ traceback hint, and none of the raw internal text.
15
+
16
+ The patterns below are matched against the message text the opener and the
17
+ backend actually produce; they are taken from observed failures rather than
18
+ invented, so a new failure string falls through to the red catch-all with a
19
+ `--trace` hint rather than being mislabelled as harmless.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import re
26
+ import sys
27
+
28
+ ACTION = "action" # orange — user can clear it
29
+ FATAL = "fatal" # red — needs a real fix
30
+ SETUP = "setup" # orange — nothing failed; one manual step is outstanding
31
+
32
+ _RESET = "\033[0m"
33
+ _RED = "\033[1;31m"
34
+ _ORANGE = "\033[1;38;5;208m"
35
+ _DIM = "\033[2m"
36
+
37
+ _RELOAD = 'Reload the VS Code window (Ctrl/Cmd+Shift+P → "Developer: Reload Window").'
38
+ _RETRY = "Run the same command again."
39
+
40
+ # (pattern, severity, what happened, what to do)
41
+ _DIAGNOSES: list[tuple[str, str, str, str]] = [
42
+ # ── First run after the opener is installed — not a failure ───────────
43
+ # Must stay ahead of the stale-opener pattern below: the local wording
44
+ # ("reload this VS Code window once, then retry") matches both, and a
45
+ # just-installed opener is not a stale one.
46
+ (r"opener was installed|installed its vs ?code opener|"
47
+ r"updated its vs ?code opener", SETUP,
48
+ "The VS Code opener is installed.",
49
+ 'Reload this VS Code window once (Ctrl/Cmd+Shift+P → "Developer: Reload '
50
+ 'Window"), then run the same command again.'),
51
+
52
+ # ── Opener/extension state — all clearable ────────────────────────────
53
+ (r"stale arrayview opener|reload this vs ?code window", ACTION,
54
+ "This VS Code window is running an older ArrayView opener than the one "
55
+ "installed.",
56
+ _RELOAD),
57
+ (r"another arrayview backend now owns this port", ACTION,
58
+ "A newer launch took over the port, so the session this tab wanted is "
59
+ "gone.",
60
+ _RETRY),
61
+ (r"required extension|extension version|opener v\d", ACTION,
62
+ "The installed opener and the Python package disagree on version.",
63
+ _RELOAD + " If it persists: uv tool install --reinstall arrayview"),
64
+
65
+ # ── Transport — the route, not the data ───────────────────────────────
66
+ (r"backend stopped answering", ACTION,
67
+ "The route to the backend stopped answering before the viewer was ready "
68
+ "(usually a cold tunnel, not a dead server).",
69
+ _RETRY + " If it repeats, reload the VS Code window."),
70
+ (r"could not connect to the arrayview backend|ws-open", ACTION,
71
+ "The viewer loaded but its WebSocket never connected.",
72
+ _RELOAD + " If it persists, the port is blocked or the tunnel is down."),
73
+ (r"viewer route stopped answering|route .*not answering", ACTION,
74
+ "The forwarded route stopped answering; the backend itself is likely "
75
+ "fine.",
76
+ _RETRY),
77
+
78
+ # ── Timing — slow, not broken ─────────────────────────────────────────
79
+ (r"did not render a frame|did not become ready|did not start the viewer",
80
+ ACTION,
81
+ "The viewer opened but showed no frame before the deadline — usually a "
82
+ "slow first load.",
83
+ _RETRY + " If the same file always does this, it may be too large to "
84
+ "render in one go."),
85
+ (r"panel closed before|closed before its first frame", ACTION,
86
+ "The viewer tab was closed (or replaced by a preview tab) before it "
87
+ "rendered.",
88
+ _RETRY + " Leave the tab open while it loads."),
89
+ (r"signal expired|expired before a panel|hard timeout after", ACTION,
90
+ "The opener was still busy with an earlier request and this one aged "
91
+ "out.",
92
+ _RETRY),
93
+
94
+ # ── Ports and stale processes ─────────────────────────────────────────
95
+ (r"could not find a free port|no free arrayview port|address already in use",
96
+ ACTION,
97
+ "No free port was available — earlier ArrayView servers are probably "
98
+ "still running.",
99
+ "See them with `arrayview instances`, clear them with `arrayview stop`, "
100
+ "then retry."),
101
+ (r"could not register the session|registration", ACTION,
102
+ "The server started but the session could not be registered.",
103
+ _RETRY + " If it repeats: arrayview stop, then try again."),
104
+
105
+ # ── The data itself — a real change is needed ─────────────────────────
106
+ (r"pickled \(object\) data|allow_pickle", FATAL,
107
+ "This .npy holds pickled Python objects, not a plain array.",
108
+ "Load it yourself and pass the array, or re-save with "
109
+ "np.save(..., arr) on a numeric array."),
110
+ (r"could not open this file|no data left in file|failed to load|"
111
+ r"cannot load|unsupported file", FATAL,
112
+ "The file could not be read as an array.",
113
+ "Check the path and that the file is complete and not truncated."),
114
+ (r"dtype mismatch", FATAL,
115
+ "The arrays you passed have different dtypes.",
116
+ "Compare needs matching dtypes — convert one before loading."),
117
+ (r"shape mismatch|expected \(|spatial shape|shapes? .*differ", FATAL,
118
+ "The arrays you passed have incompatible shapes.",
119
+ "Compare needs matching spatial shapes — check both files."),
120
+ (r"cannot start server", FATAL,
121
+ "The backend server could not start at all.",
122
+ "Re-run with --trace for the underlying error."),
123
+
124
+ # ── Bad arguments, caught before anything starts ──────────────────────
125
+ (r"file not found", FATAL,
126
+ "That file does not exist.",
127
+ "Check the path — and that the share it lives on is mounted."),
128
+ (r"not a directory", FATAL,
129
+ "That path is not a directory.",
130
+ "Point --stack at a directory of arrays."),
131
+ (r"invalid vector ?field", FATAL,
132
+ "The vector field file could not be used with this array.",
133
+ "It needs the array's spatial shape plus a trailing components axis."),
134
+ (r"could not discover masks|could not match collection", FATAL,
135
+ "The pattern matched no files.",
136
+ "Check the pattern and run with --dry-run to see what it resolves to."),
137
+ (r"invalid launch request", FATAL,
138
+ "The launch arguments did not form a valid request.",
139
+ "Re-run with --diagnose to see how the launch was planned."),
140
+ ]
141
+
142
+ _COMPILED = [(re.compile(p, re.I), sev, what, fix) for p, sev, what, fix in _DIAGNOSES]
143
+
144
+
145
+ def _colour_enabled(stream) -> bool:
146
+ if os.environ.get("NO_COLOR"):
147
+ return False
148
+ if os.environ.get("ARRAYVIEW_FORCE_COLOR"):
149
+ return True
150
+ return bool(getattr(stream, "isatty", lambda: False)())
151
+
152
+
153
+ def diagnose(message: str) -> tuple[str, str, str]:
154
+ """Classify a failure message → (severity, what happened, what to do)."""
155
+ text = (message or "").strip()
156
+ for pattern, severity, what, fix in _COMPILED:
157
+ if pattern.search(text):
158
+ return severity, what, fix
159
+ return (
160
+ FATAL,
161
+ "ArrayView failed to open the display.",
162
+ "Re-run with --trace (or ARRAYVIEW_TRACE=1) for the full traceback.",
163
+ )
164
+
165
+
166
+ def format_failure(message: str, *, colour: bool = True) -> str:
167
+ """Render the diagnosis as the block printed to stderr."""
168
+ severity, what, fix = diagnose(message)
169
+ detail = (message or "").strip().removeprefix("[ArrayView] ").rstrip()
170
+ head = (_RED if severity == FATAL else _ORANGE) if colour else ""
171
+ dim = _DIM if colour else ""
172
+ end = _RESET if colour else ""
173
+ lines = [
174
+ f"{head}[ArrayView] {what}{end}",
175
+ f"{head} → {fix}{end}",
176
+ ]
177
+ # A pending setup step is not a failure report: the instruction is the
178
+ # whole message. Echoing the internal wording underneath it is what made
179
+ # "the opener is installed, reload once" read like something broke.
180
+ if severity == SETUP:
181
+ return "\n".join(lines)
182
+ # Keep the raw text when it says more than the headline does.
183
+ if detail and detail.lower() not in what.lower():
184
+ lines.append(f"{dim} ({detail}){end}")
185
+ return "\n".join(lines)
186
+
187
+
188
+ def print_failure(message: str, stream=None) -> None:
189
+ stream = stream if stream is not None else sys.stderr
190
+ print(format_failure(message, colour=_colour_enabled(stream)), file=stream)
191
+
192
+
193
+ def format_notice(message: str, *, colour: bool = True) -> str:
194
+ """Render a one-line ACTION notice.
195
+
196
+ Same orange as an ACTION failure, because it carries the same meaning —
197
+ something the user asked for is not happening — but the launch continues,
198
+ so there is no cause/fix block to print.
199
+ """
200
+ head = _ORANGE if colour else ""
201
+ end = _RESET if colour else ""
202
+ return f"{head}[ArrayView] {message}{end}"
203
+
204
+
205
+ def print_notice(message: str, stream=None) -> None:
206
+ stream = stream if stream is not None else sys.stdout
207
+ print(
208
+ format_notice(message, colour=_colour_enabled(stream)),
209
+ file=stream,
210
+ flush=True,
211
+ )
@@ -1290,12 +1290,47 @@ def load_dir_collection(
1290
1290
  return data, spatial_meta, overlay_items, summary
1291
1291
 
1292
1292
 
1293
+ def _dicom_series_collection(path, *, load="lazy", stack="auto"):
1294
+ """Stack a directory holding several DICOM series, one case per series.
1295
+
1296
+ Right-clicking a study folder in the VS Code Explorer hands over the parent
1297
+ of a set of per-case DICOM folders. There is no single series to show, and
1298
+ ``--series`` is unreachable from that gesture, so the load failed with
1299
+ "Multiple DICOM series found ... Select one with --series INDEX". Build the
1300
+ collection instead: one stack element per series, which is what "open this
1301
+ folder" means for that layout.
1302
+
1303
+ Grouping by series rather than by subfolder is what the DICOM headers
1304
+ actually say. It gives the same answer for the usual one-series-per-folder
1305
+ layout and a better one when a folder holds two series or a series is split
1306
+ across folders.
1307
+
1308
+ Returns ``None`` when the directory holds a single series: the ordinary
1309
+ one-series load handles that and keeps its spatial metadata, which a
1310
+ stacked collection cannot carry.
1311
+ """
1312
+ from ._dicom import discover_dicom_series
1313
+
1314
+ series = discover_dicom_series(path)
1315
+ if len(series) < 2:
1316
+ return None
1317
+ # One member per series is enough to name it: loading any slice of a DICOM
1318
+ # series loads the whole series it belongs to.
1319
+ return _series_from_file_matrix(
1320
+ [[item["headers"][0].path] for item in series], load=load, stack=stack
1321
+ )
1322
+
1323
+
1293
1324
  def _load_file_series(path, *, load="lazy", stack="auto"):
1294
1325
  """Build a lazy series from a directory of supported array files.
1295
1326
 
1296
- Walks *path* recursively, groups files of any supported format by
1297
- immediate parent folder (= patient). Requires exactly one file per
1298
- patient → 4D ``(*vol, P)``.
1327
+ Walks *path* recursively and groups files of any supported format by
1328
+ immediate parent folder (= patient).
1329
+
1330
+ One file per patient folder → 4D ``(*vol, P)``. When a folder holds
1331
+ several arrays side by side there is no per-folder case to key on, so every
1332
+ file becomes its own case — the same collection ``--stack`` builds for that
1333
+ directory.
1299
1334
 
1300
1335
  If every file is NIfTI (.nii/.nii.gz), delegates to
1301
1336
  ``_load_nifti_series`` for canonical reorientation. Otherwise builds
@@ -1337,24 +1372,24 @@ def _load_file_series(path, *, load="lazy", stack="auto"):
1337
1372
  f"Supported: {', '.join(sorted(_SUPPORTED_EXTS))}"
1338
1373
  )
1339
1374
 
1375
+ # A folder holding several arrays side by side is a flat collection: one
1376
+ # case per file. This used to raise and tell the user to retype the command
1377
+ # with --stack, which made "open this folder" unreachable from any caller
1378
+ # that can only hand over a path (VS Code Explorer, drag-and-drop, the
1379
+ # /load route). Route it through the same builder --stack uses so both
1380
+ # spellings produce the same session instead of one of them failing.
1381
+ if any(len(files) != 1 for files in patients.values()):
1382
+ flat = sorted(fpath for files in patients.values() for fpath in files)
1383
+ return _series_from_file_matrix(
1384
+ [[fpath] for fpath in flat], load=load, stack=stack
1385
+ )
1386
+
1340
1387
  # NIfTI-only → preserve canonical reorientation + lazy dataobj slicing
1341
1388
  if all_nifti:
1342
1389
  return _load_nifti_series(path, load=load, stack=stack)
1343
1390
 
1344
1391
  # ── build file matrix ──────────────────────────────────────────
1345
- patient_dirs = sorted(patients.keys())
1346
- file_matrix: list[list[str]] = []
1347
-
1348
- for pdir in patient_dirs:
1349
- files = patients[pdir]
1350
- if len(files) == 1:
1351
- file_matrix.append([files[0]])
1352
- else:
1353
- raise ValueError(
1354
- f"Folder {pdir!r} contains {len(files)} files: "
1355
- f"{[os.path.basename(f) for f in files]}. "
1356
- "Use --stack with explicit file patterns for multi-file folders."
1357
- )
1392
+ file_matrix = [[patients[pdir][0]] for pdir in sorted(patients)]
1358
1393
 
1359
1394
  return _series_from_file_matrix(file_matrix, load=load, stack=stack)
1360
1395
 
@@ -1494,6 +1529,10 @@ def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto", select
1494
1529
  if (
1495
1530
  (os.path.isdir(filepath) and not is_zarr_dir) or filepath.lower().endswith(".dcm")
1496
1531
  ) and is_dicom_source(filepath):
1532
+ if os.path.isdir(filepath) and select is None:
1533
+ collection = _dicom_series_collection(filepath, load=load, stack=stack)
1534
+ if collection is not None:
1535
+ return collection
1497
1536
  return load_dicom_series(filepath, select=select)
1498
1537
  if os.path.isdir(filepath) and not is_zarr_dir:
1499
1538
  return _load_file_series(filepath, load=load, stack=stack)
@@ -1609,6 +1648,16 @@ def load_data(filepath, key=None):
1609
1648
  "Select one in the viewer or pass a key."
1610
1649
  )
1611
1650
  else:
1651
+ # DICOM files routinely carry no extension at all. Every path that
1652
+ # reaches here by extension has already been ruled out, so a header
1653
+ # read is cheap and is the only thing that can still recognise one —
1654
+ # this is how a per-series representative path from
1655
+ # `_dicom_series_collection` resolves back to its series.
1656
+ from ._dicom import is_dicom_source, load_dicom_series
1657
+
1658
+ if os.path.isfile(filepath) and is_dicom_source(filepath):
1659
+ data, _meta = load_dicom_series(filepath)
1660
+ return data
1612
1661
  raise ValueError(
1613
1662
  "Unsupported format. Supported: .npy, .npz, .nii/.nii.gz, .zarr, "
1614
1663
  ".pt/.pth, .h5/.hdf5, .tif/.tiff, .mat"