arrayview 0.33.0__tar.gz → 0.34.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 (107) hide show
  1. {arrayview-0.33.0 → arrayview-0.34.0}/PKG-INFO +1 -1
  2. {arrayview-0.33.0 → arrayview-0.34.0}/pyproject.toml +1 -1
  3. arrayview-0.34.0/src/arrayview/_config.py +352 -0
  4. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_instance_registry.py +3 -5
  5. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_launch_plan.py +8 -0
  6. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_launcher.py +6 -1
  7. arrayview-0.34.0/src/arrayview/_routes_preferences.py +114 -0
  8. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_server.py +20 -2
  9. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_viewer.html +353 -5
  10. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_vscode_extension.py +1 -1
  11. arrayview-0.34.0/src/arrayview/arrayview-opener.vsix +0 -0
  12. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_api.py +175 -0
  13. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_cli.py +1 -0
  14. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_config.py +72 -0
  15. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_instance_registry.py +4 -2
  16. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_launch_execution_ownership.py +3 -0
  17. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_launch_plan.py +38 -0
  18. arrayview-0.34.0/tests/test_preferences_ui.py +185 -0
  19. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_server_identity_status.py +1 -0
  20. arrayview-0.33.0/src/arrayview/_config.py +0 -135
  21. arrayview-0.33.0/src/arrayview/arrayview-opener.vsix +0 -0
  22. {arrayview-0.33.0 → arrayview-0.34.0}/.gitignore +0 -0
  23. {arrayview-0.33.0 → arrayview-0.34.0}/LICENSE +0 -0
  24. {arrayview-0.33.0 → arrayview-0.34.0}/README.md +0 -0
  25. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/ARCHITECTURE.md +0 -0
  26. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/__init__.py +0 -0
  27. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/__main__.py +0 -0
  28. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_analysis.py +0 -0
  29. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_app.py +0 -0
  30. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_codex_open.py +0 -0
  31. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_dicom.py +0 -0
  32. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_diff.py +0 -0
  33. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_icon.png +0 -0
  34. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_imaging.py +0 -0
  35. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_io.py +0 -0
  36. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_launch_trace.py +0 -0
  37. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_lifecycle.py +0 -0
  38. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_overlays.py +0 -0
  39. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_platform.py +0 -0
  40. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_render.py +0 -0
  41. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_analysis.py +0 -0
  42. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_drop.py +0 -0
  43. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_export.py +0 -0
  44. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_loading.py +0 -0
  45. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_persistence.py +0 -0
  46. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_preload.py +0 -0
  47. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_query.py +0 -0
  48. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_rendering.py +0 -0
  49. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_segmentation.py +0 -0
  50. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_state.py +0 -0
  51. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_vectorfield.py +0 -0
  52. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_websocket.py +0 -0
  53. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_segmentation.py +0 -0
  54. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_session.py +0 -0
  55. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_session_spec.py +0 -0
  56. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_shell.html +0 -0
  57. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_synthetic_mri.py +0 -0
  58. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_torch.py +0 -0
  59. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_vectorfield.py +0 -0
  60. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_vscode.py +0 -0
  61. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_vscode_browser.py +0 -0
  62. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_vscode_signal.py +0 -0
  63. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/gsap.min.js +0 -0
  64. {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/todo.md +0 -0
  65. {arrayview-0.33.0 → arrayview-0.34.0}/tests/capture_patient_loading.py +0 -0
  66. {arrayview-0.33.0 → arrayview-0.34.0}/tests/capture_v_animation.py +0 -0
  67. {arrayview-0.33.0 → arrayview-0.34.0}/tests/conftest.py +0 -0
  68. {arrayview-0.33.0 → arrayview-0.34.0}/tests/lifecycle_matrix.py +0 -0
  69. {arrayview-0.33.0 → arrayview-0.34.0}/tests/make_vectorfield_test_arrays.py +0 -0
  70. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_backend_shared.py +0 -0
  71. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_browser.py +0 -0
  72. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_codex_open.py +0 -0
  73. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_colorbar_hover_highlight.py +0 -0
  74. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_command_reachability.py +0 -0
  75. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_cross_mode_parametrized.py +0 -0
  76. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_dicom.py +0 -0
  77. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_display_opener_results.py +0 -0
  78. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_doctor_commands.py +0 -0
  79. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_drop_import.py +0 -0
  80. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_interactions.py +0 -0
  81. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_invocation_contract_matrix.py +0 -0
  82. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_io.py +0 -0
  83. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_large_arrays.py +0 -0
  84. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_launch_blackbox.py +0 -0
  85. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_launch_diagnostics.py +0 -0
  86. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_launch_trace.py +0 -0
  87. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_lifecycle_contract.py +0 -0
  88. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_loading_server.py +0 -0
  89. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_mode_consistency.py +0 -0
  90. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_mode_matrix.py +0 -0
  91. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_mode_roundtrip.py +0 -0
  92. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_nifti_meta.py +0 -0
  93. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_nifti_series.py +0 -0
  94. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_public_invocation_regressions.py +0 -0
  95. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_rgb_pixel_art.py +0 -0
  96. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_safe_server_stop.py +0 -0
  97. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_session_spec.py +0 -0
  98. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_spawn_ownership.py +0 -0
  99. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_torch.py +0 -0
  100. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_view_component_integration.py +0 -0
  101. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_view_component_unit.py +0 -0
  102. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_view_handle_lifecycle.py +0 -0
  103. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_view_launch_execution.py +0 -0
  104. {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_vscode_ack_protocol.py +0 -0
  105. {arrayview-0.33.0 → arrayview-0.34.0}/tests/ui_audit.py +0 -0
  106. {arrayview-0.33.0 → arrayview-0.34.0}/tests/v_anim_frames/.gitkeep +0 -0
  107. {arrayview-0.33.0 → arrayview-0.34.0}/tests/visual_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrayview
3
- Version: 0.33.0
3
+ Version: 0.34.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.33.0"
7
+ version = "0.34.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,352 @@
1
+ """Read/write ~/.arrayview/config.toml for persistent user preferences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as _datetime
6
+ import math
7
+ import os
8
+ import tempfile
9
+ import threading
10
+ from copy import deepcopy
11
+
12
+ CONFIG_PATH = os.path.expanduser("~/.arrayview/config.toml")
13
+
14
+ _VALID_WINDOW_MODES = {"browser", "vscode", "native", "inline", "none"}
15
+ _VALID_ENV_KEYS = {"default", "terminal", "vscode", "jupyter", "ssh", "julia"}
16
+ _VALID_THEMES = {"dark", "light"}
17
+ _VALID_ORTHO_LAYOUTS = {"horizontal", "big-left"}
18
+ _VALID_DIMBAR_MODES = {"compact", "extended"}
19
+ _CONFIG_LOCK = threading.Lock()
20
+
21
+ WINDOW_PREFERENCE_OPTIONS = {
22
+ "default": ["native", "browser", "none"],
23
+ "terminal": ["native", "browser", "none"],
24
+ "vscode": ["vscode", "browser", "native", "none"],
25
+ "jupyter": ["inline", "browser", "native", "none"],
26
+ "ssh": ["browser", "none"],
27
+ "julia": ["browser", "native", "none"],
28
+ }
29
+ BUILTIN_PREFERENCES = {
30
+ "window": {
31
+ "default": "native",
32
+ "terminal": "native",
33
+ "vscode": "vscode",
34
+ "jupyter": "inline",
35
+ "ssh": "browser",
36
+ "julia": "browser",
37
+ },
38
+ "viewer": {
39
+ "theme": "dark",
40
+ "rounded_panes": True,
41
+ "ortho_layout": None,
42
+ "dimbar_mode": "compact",
43
+ },
44
+ }
45
+ PREFERENCE_SCHEMA = {
46
+ "window": WINDOW_PREFERENCE_OPTIONS,
47
+ "viewer": {
48
+ "theme": sorted(_VALID_THEMES),
49
+ "rounded_panes": "boolean",
50
+ "colormaps": "non-empty list of names",
51
+ "ortho_layout": sorted(_VALID_ORTHO_LAYOUTS),
52
+ "dimbar_mode": sorted(_VALID_DIMBAR_MODES),
53
+ },
54
+ "nninteractive": {"url": "string"},
55
+ }
56
+
57
+
58
+ class MalformedConfigError(ValueError):
59
+ """Raised when an update would overwrite an unreadable config file."""
60
+
61
+
62
+ class InvalidPreferenceError(ValueError):
63
+ """Raised when an update contains an unknown or invalid preference."""
64
+
65
+
66
+ def _load_config_strict() -> dict:
67
+ if not os.path.isfile(CONFIG_PATH):
68
+ return {}
69
+ try:
70
+ with open(CONFIG_PATH, encoding="utf-8") as f:
71
+ parsed = _parse_toml(f.read())
72
+ except Exception as exc:
73
+ raise MalformedConfigError("The existing config.toml is malformed") from exc
74
+ if not isinstance(parsed, dict):
75
+ raise MalformedConfigError("The existing config.toml is not a TOML table")
76
+ return parsed
77
+
78
+
79
+ def load_config() -> dict:
80
+ """Load config from TOML file. Returns {} on missing/malformed file."""
81
+ try:
82
+ return _load_config_strict()
83
+ except MalformedConfigError:
84
+ return {}
85
+
86
+
87
+ def save_config(config: dict) -> None:
88
+ """Atomically write a config dict to its TOML file."""
89
+ folder = os.path.dirname(CONFIG_PATH)
90
+ if folder:
91
+ os.makedirs(folder, exist_ok=True)
92
+ payload = _dump_toml(config)
93
+ fd, temp_path = tempfile.mkstemp(prefix=".config.", suffix=".tmp", dir=folder or ".")
94
+ try:
95
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
96
+ f.write(payload)
97
+ f.flush()
98
+ os.fsync(f.fileno())
99
+ os.replace(temp_path, CONFIG_PATH)
100
+ except Exception:
101
+ try:
102
+ os.unlink(temp_path)
103
+ except FileNotFoundError:
104
+ pass
105
+ raise
106
+
107
+
108
+ def _normalized_string(value, allowed: set[str], label: str) -> str:
109
+ if not isinstance(value, str) or value.strip().lower() not in allowed:
110
+ raise InvalidPreferenceError(f"{label} must be one of: {', '.join(sorted(allowed))}")
111
+ return value.strip().lower()
112
+
113
+
114
+ def _validate_preference(section: str, key: str, value):
115
+ if section == "window" and key in _VALID_ENV_KEYS:
116
+ return _normalized_string(
117
+ value, set(WINDOW_PREFERENCE_OPTIONS[key]), f"window.{key}"
118
+ )
119
+ if section == "viewer" and key == "theme":
120
+ return _normalized_string(value, _VALID_THEMES, "viewer.theme")
121
+ if section == "viewer" and key == "rounded_panes":
122
+ if not isinstance(value, bool):
123
+ raise InvalidPreferenceError("viewer.rounded_panes must be a boolean")
124
+ return value
125
+ if section == "viewer" and key == "colormaps":
126
+ if (
127
+ not isinstance(value, list)
128
+ or not value
129
+ or not all(isinstance(item, str) and item.strip() for item in value)
130
+ ):
131
+ raise InvalidPreferenceError("viewer.colormaps must be a non-empty list of names")
132
+ normalized = [item.strip() for item in value]
133
+ if len(set(normalized)) != len(normalized):
134
+ raise InvalidPreferenceError("viewer.colormaps must not contain duplicates")
135
+ return normalized
136
+ if section == "viewer" and key == "ortho_layout":
137
+ return _normalized_string(value, _VALID_ORTHO_LAYOUTS, "viewer.ortho_layout")
138
+ if section == "viewer" and key == "dimbar_mode":
139
+ return _normalized_string(value, _VALID_DIMBAR_MODES, "viewer.dimbar_mode")
140
+ if section == "nninteractive" and key == "url":
141
+ if not isinstance(value, str) or not value.strip():
142
+ raise InvalidPreferenceError("nninteractive.url must be a non-empty string")
143
+ return value.strip()
144
+ raise InvalidPreferenceError(f"Unknown preference: {section}.{key}")
145
+
146
+
147
+ def update_preferences(changes: dict) -> dict:
148
+ """Validate and merge preferences into the latest config on disk.
149
+
150
+ ``None`` removes a known preference, restoring its built-in default.
151
+ Unknown config sections and keys already on disk are preserved.
152
+ """
153
+ if not isinstance(changes, dict) or not changes:
154
+ raise InvalidPreferenceError("Preference changes must be a non-empty object")
155
+ validated: dict[str, dict] = {}
156
+ for section, values in changes.items():
157
+ if section not in PREFERENCE_SCHEMA or not isinstance(values, dict):
158
+ raise InvalidPreferenceError(f"Unknown preference section: {section}")
159
+ if not values:
160
+ raise InvalidPreferenceError(f"Preference section is empty: {section}")
161
+ validated[section] = {}
162
+ for key, value in values.items():
163
+ if key not in PREFERENCE_SCHEMA[section]:
164
+ raise InvalidPreferenceError(f"Unknown preference: {section}.{key}")
165
+ validated[section][key] = None if value is None else _validate_preference(section, key, value)
166
+
167
+ with _CONFIG_LOCK:
168
+ config = _load_config_strict()
169
+ merged = deepcopy(config)
170
+ for section, values in validated.items():
171
+ target = merged.get(section)
172
+ if target is None:
173
+ target = {}
174
+ merged[section] = target
175
+ if not isinstance(target, dict):
176
+ raise MalformedConfigError(f"Config section [{section}] is not a table")
177
+ for key, value in values.items():
178
+ if value is None:
179
+ target.pop(key, None)
180
+ else:
181
+ target[key] = value
182
+ if not target:
183
+ merged.pop(section, None)
184
+ save_config(merged)
185
+ return merged
186
+
187
+
188
+ def get_preferences() -> dict:
189
+ """Return only supported preferences currently present in the config."""
190
+ config = load_config()
191
+ result: dict[str, dict] = {}
192
+ for section, keys in PREFERENCE_SCHEMA.items():
193
+ source = config.get(section)
194
+ if not isinstance(source, dict):
195
+ continue
196
+ selected = {key: source[key] for key in keys if key in source}
197
+ if selected:
198
+ result[section] = selected
199
+ return result
200
+
201
+
202
+ def get_viewer_colormaps() -> list[str] | None:
203
+ """Return user-configured colormap cycle list, or None if not configured."""
204
+ viewer_cfg = load_config().get("viewer", {})
205
+ if not isinstance(viewer_cfg, dict):
206
+ return None
207
+ colormaps = viewer_cfg.get("colormaps")
208
+ if isinstance(colormaps, list) and all(isinstance(c, str) for c in colormaps) and colormaps:
209
+ return colormaps
210
+ return None
211
+
212
+
213
+ def get_viewer_theme() -> str | None:
214
+ """Return user-configured default theme name, or None if not configured."""
215
+ viewer_cfg = load_config().get("viewer", {})
216
+ if not isinstance(viewer_cfg, dict):
217
+ return None
218
+ theme = viewer_cfg.get("theme")
219
+ if isinstance(theme, str) and theme.strip().lower() in _VALID_THEMES:
220
+ return theme.strip().lower()
221
+ return None
222
+
223
+
224
+ def get_viewer_rounded_panes() -> bool | None:
225
+ """Return user-configured default for rounded panes, or None if not configured."""
226
+ viewer_cfg = load_config().get("viewer", {})
227
+ if not isinstance(viewer_cfg, dict):
228
+ return None
229
+ val = viewer_cfg.get("rounded_panes")
230
+ if isinstance(val, bool):
231
+ return val
232
+ if isinstance(val, str):
233
+ s = val.strip().lower()
234
+ if s in ("true", "1", "yes", "on"):
235
+ return True
236
+ if s in ("false", "0", "no", "off"):
237
+ return False
238
+ return None
239
+
240
+
241
+ def get_viewer_ortho_layout() -> str | None:
242
+ """Return the preferred initial ortho-view layout."""
243
+ viewer_cfg = load_config().get("viewer", {})
244
+ if not isinstance(viewer_cfg, dict):
245
+ return None
246
+ value = viewer_cfg.get("ortho_layout")
247
+ return value.strip().lower() if isinstance(value, str) and value.strip().lower() in _VALID_ORTHO_LAYOUTS else None
248
+
249
+
250
+ def get_viewer_dimbar_mode() -> str | None:
251
+ """Return the preferred initial dimbar mode."""
252
+ viewer_cfg = load_config().get("viewer", {})
253
+ if not isinstance(viewer_cfg, dict):
254
+ return None
255
+ value = viewer_cfg.get("dimbar_mode")
256
+ return value.strip().lower() if isinstance(value, str) and value.strip().lower() in _VALID_DIMBAR_MODES else None
257
+
258
+
259
+ def get_nninteractive_url() -> str | None:
260
+ """Return configured nnInteractive server URL, or None."""
261
+ env_val = os.environ.get("ARRAYVIEW_NNINTERACTIVE_URL", "").strip()
262
+ if env_val:
263
+ return env_val
264
+ nn_cfg = load_config().get("nninteractive", {})
265
+ if isinstance(nn_cfg, dict):
266
+ url = nn_cfg.get("url")
267
+ if isinstance(url, str) and url.strip():
268
+ return url.strip()
269
+ return None
270
+
271
+
272
+ def get_window_default(environment: str) -> str | None:
273
+ """Return the user's preferred window mode for the given environment."""
274
+ env_val = os.environ.get("ARRAYVIEW_WINDOW", "").strip().lower()
275
+ if env_val:
276
+ return env_val if env_val in _VALID_WINDOW_MODES else None
277
+ window_cfg = load_config().get("window", {})
278
+ if not isinstance(window_cfg, dict):
279
+ return None
280
+ val = window_cfg.get(environment) or window_cfg.get("default")
281
+ if isinstance(val, str) and val.strip().lower() in _VALID_WINDOW_MODES:
282
+ return val.strip().lower()
283
+ return None
284
+
285
+
286
+ def _parse_toml(text: str) -> dict:
287
+ import tomllib
288
+
289
+ return tomllib.loads(text)
290
+
291
+
292
+ def _toml_key(value: str) -> str:
293
+ if value and all(
294
+ char in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
295
+ for char in value
296
+ ):
297
+ return value
298
+ return _toml_string(value)
299
+
300
+
301
+ def _toml_string(value: str) -> str:
302
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
303
+ escaped = escaped.replace("\b", "\\b").replace("\t", "\\t").replace("\n", "\\n")
304
+ escaped = escaped.replace("\f", "\\f").replace("\r", "\\r")
305
+ return f'"{escaped}"'
306
+
307
+
308
+ def _toml_value(value) -> str:
309
+ if isinstance(value, bool):
310
+ return "true" if value else "false"
311
+ if isinstance(value, str):
312
+ return _toml_string(value)
313
+ if isinstance(value, int):
314
+ return str(value)
315
+ if isinstance(value, float):
316
+ if not math.isfinite(value):
317
+ return str(value).lower()
318
+ return repr(value)
319
+ if isinstance(value, (_datetime.datetime, _datetime.date, _datetime.time)):
320
+ return value.isoformat()
321
+ if isinstance(value, list):
322
+ return "[" + ", ".join(_toml_value(item) for item in value) + "]"
323
+ raise TypeError(f"Unsupported TOML value type: {type(value).__name__}")
324
+
325
+
326
+ def _dump_toml(config: dict) -> str:
327
+ """Serialize nested dictionaries and scalar/list TOML values."""
328
+ lines: list[str] = []
329
+
330
+ def emit_table(path: tuple[str, ...], table: dict) -> None:
331
+ scalar_items = [(key, value) for key, value in table.items() if not isinstance(value, dict)]
332
+ child_items = [(key, value) for key, value in table.items() if isinstance(value, dict)]
333
+ if path:
334
+ lines.append("[" + ".".join(_toml_key(part) for part in path) + "]")
335
+ for key, value in scalar_items:
336
+ lines.append(f"{_toml_key(str(key))} = {_toml_value(value)}")
337
+ if path and (scalar_items or not child_items):
338
+ lines.append("")
339
+ for key, value in child_items:
340
+ emit_table((*path, str(key)), value)
341
+
342
+ if not isinstance(config, dict):
343
+ raise TypeError("Config must be a dictionary")
344
+ root_scalars = {key: value for key, value in config.items() if not isinstance(value, dict)}
345
+ root_tables = {key: value for key, value in config.items() if isinstance(value, dict)}
346
+ for key, value in root_scalars.items():
347
+ lines.append(f"{_toml_key(str(key))} = {_toml_value(value)}")
348
+ if root_scalars and root_tables:
349
+ lines.append("")
350
+ for key, value in root_tables.items():
351
+ emit_table((str(key),), value)
352
+ return "\n".join(lines).rstrip() + ("\n" if lines else "")
@@ -9,8 +9,6 @@ from __future__ import annotations
9
9
  from contextlib import contextmanager
10
10
  from dataclasses import asdict, dataclass
11
11
  import ctypes
12
- import getpass
13
- import hashlib
14
12
  import json
15
13
  import os
16
14
  from pathlib import Path
@@ -70,9 +68,9 @@ def runtime_directory() -> Path:
70
68
  base = os.environ.get("XDG_RUNTIME_DIR")
71
69
  if base:
72
70
  return Path(base) / "arrayview"
73
- user = getpass.getuser().encode("utf-8", "replace")
74
- suffix = hashlib.sha256(user).hexdigest()[:12]
75
- return Path(tempfile.gettempdir()) / f"arrayview-{suffix}"
71
+ if hasattr(os, "getuid"):
72
+ return Path(tempfile.gettempdir()) / f"arrayview-{os.getuid()}"
73
+ return Path(tempfile.gettempdir()) / f"arrayview-{os.getpid()}"
76
74
 
77
75
 
78
76
  def process_start_identity(pid: int) -> str | None:
@@ -132,6 +132,7 @@ class ServerSnapshot:
132
132
  server_process_start: str | None = None
133
133
  server_capabilities: tuple[str, ...] = ()
134
134
  server_protocol_version: str | None = None
135
+ server_uid: int | None = None
135
136
 
136
137
 
137
138
  @dataclass(frozen=True)
@@ -635,6 +636,7 @@ def _server_snapshot(port: int) -> ServerSnapshot:
635
636
  port_busy=_port_busy(port),
636
637
  arrayview_server_alive=payload is not None,
637
638
  server_pid=_int_or_none(payload.get("pid")) if payload else None,
639
+ server_uid=_int_or_none(payload.get("uid")) if payload else None,
638
640
  server_hostname=_str_or_none(payload.get("hostname")) if payload else None,
639
641
  server_instance_id=(
640
642
  _str_or_none(payload.get("instance_id")) if payload else None
@@ -658,8 +660,14 @@ def _server_snapshot(port: int) -> ServerSnapshot:
658
660
 
659
661
 
660
662
  def _server_reusable(server: ServerSnapshot) -> bool:
663
+ same_user = (
664
+ server.server_uid == os.geteuid()
665
+ if hasattr(os, "geteuid")
666
+ else True
667
+ )
661
668
  return (
662
669
  server.arrayview_server_alive
670
+ and same_user
663
671
  and server.server_instance_id is not None
664
672
  and server.server_protocol_version == "1"
665
673
  and "identity-fenced-load" in server.server_capabilities
@@ -111,8 +111,13 @@ def _register_server_runtime(port: int, owner_mode: str):
111
111
  from arrayview._instance_registry import InstanceRecord, InstanceRegistry
112
112
 
113
113
  registry = InstanceRegistry()
114
+ registry._prepare()
114
115
  log_directory = registry.directory / "logs"
115
- log_directory.mkdir(parents=True, exist_ok=True)
116
+ log_directory.mkdir(parents=True, exist_ok=True, mode=0o700)
117
+ try:
118
+ os.chmod(log_directory, 0o700)
119
+ except OSError:
120
+ pass
116
121
  record = InstanceRecord.create(
117
122
  port=port,
118
123
  protocol_version=_server_mod().SERVER_PROTOCOL_VERSION,
@@ -0,0 +1,114 @@
1
+ """Routes for reading and updating persistent user preferences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ import os
7
+
8
+ from fastapi import Request
9
+ from fastapi.responses import JSONResponse
10
+
11
+ from arrayview._config import (
12
+ BUILTIN_PREFERENCES,
13
+ InvalidPreferenceError,
14
+ MalformedConfigError,
15
+ PREFERENCE_SCHEMA,
16
+ get_preferences,
17
+ update_preferences,
18
+ )
19
+ import arrayview._session as _session_mod
20
+ from arrayview._session import SESSIONS
21
+
22
+
23
+ def _response_payload() -> dict:
24
+ defaults = deepcopy(BUILTIN_PREFERENCES)
25
+ schema = deepcopy(PREFERENCE_SCHEMA)
26
+ for key, options in schema["window"].items():
27
+ schema["window"][key] = [value for value in options if value != "none"]
28
+ schema["window"].pop("default", None)
29
+ try:
30
+ from arrayview._platform import _is_vscode_remote, _native_window_gui
31
+
32
+ native_available = _native_window_gui() is not None
33
+ vscode_remote = _is_vscode_remote()
34
+ except Exception:
35
+ native_available = False
36
+ vscode_remote = False
37
+ if not native_available:
38
+ defaults["window"]["default"] = "browser"
39
+ defaults["window"]["terminal"] = "browser"
40
+ for key in ("default", "terminal", "vscode", "jupyter", "julia"):
41
+ schema["window"][key] = [
42
+ value for value in schema["window"][key] if value != "native"
43
+ ]
44
+ if vscode_remote:
45
+ schema["window"]["vscode"] = ["vscode", "none"]
46
+
47
+ overrides = {}
48
+ if os.environ.get("ARRAYVIEW_WINDOW", "").strip():
49
+ overrides["window"] = "ARRAYVIEW_WINDOW"
50
+ if os.environ.get("ARRAYVIEW_NNINTERACTIVE_URL", "").strip():
51
+ overrides["nninteractive.url"] = "ARRAYVIEW_NNINTERACTIVE_URL"
52
+ return {
53
+ "preferences": get_preferences(),
54
+ "defaults": defaults,
55
+ "schema": schema,
56
+ "overrides": overrides,
57
+ "server_instance_id": _session_mod.SERVER_RUNTIME.instance_id,
58
+ }
59
+
60
+
61
+ def register_preferences_routes(app) -> None:
62
+ @app.get("/preferences/{sid}")
63
+ async def preferences_get(sid: str):
64
+ if SESSIONS.get(sid) is None:
65
+ return JSONResponse({"error": "session_not_found"}, status_code=404)
66
+ return _response_payload()
67
+
68
+ @app.patch("/preferences/{sid}")
69
+ async def preferences_patch(sid: str, request: Request):
70
+ if SESSIONS.get(sid) is None:
71
+ return JSONResponse({"error": "session_not_found"}, status_code=404)
72
+ content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
73
+ if content_type != "application/json":
74
+ return JSONResponse({"error": "json_content_type_required"}, status_code=415)
75
+ if request.headers.get("x-arrayview-preferences") != "1":
76
+ return JSONResponse({"error": "preferences_header_required"}, status_code=403)
77
+ try:
78
+ body = await request.json()
79
+ except Exception:
80
+ return JSONResponse({"error": "invalid_json"}, status_code=400)
81
+ if not isinstance(body, dict):
82
+ return JSONResponse({"error": "invalid_json"}, status_code=400)
83
+ if body.get("server_instance_id") != _session_mod.SERVER_RUNTIME.instance_id:
84
+ return JSONResponse({"error": "stale_server_instance"}, status_code=409)
85
+ changes = body.get("changes")
86
+ try:
87
+ viewer_changes = changes.get("viewer", {}) if isinstance(changes, dict) else {}
88
+ colormaps = viewer_changes.get("colormaps")
89
+ if colormaps is not None:
90
+ from arrayview._render import _ensure_lut
91
+
92
+ invalid = [
93
+ name for name in colormaps
94
+ if not isinstance(name, str) or not _ensure_lut(name)
95
+ ]
96
+ if invalid:
97
+ raise InvalidPreferenceError(f"Unknown colormap: {invalid[0]}")
98
+ update_preferences(changes)
99
+ except InvalidPreferenceError as exc:
100
+ return JSONResponse(
101
+ {"error": "invalid_preference", "detail": str(exc)},
102
+ status_code=422,
103
+ )
104
+ except MalformedConfigError as exc:
105
+ return JSONResponse(
106
+ {"error": "malformed_config", "detail": str(exc)},
107
+ status_code=409,
108
+ )
109
+ except OSError as exc:
110
+ return JSONResponse(
111
+ {"error": "config_write_failed", "detail": str(exc)},
112
+ status_code=500,
113
+ )
114
+ return _response_payload()
@@ -41,6 +41,7 @@ from arrayview._routes_analysis import register_analysis_routes
41
41
  from arrayview._routes_loading import register_loading_routes
42
42
  from arrayview._routes_drop import register_drop_routes
43
43
  from arrayview._routes_persistence import register_persistence_routes
44
+ from arrayview._routes_preferences import register_preferences_routes
44
45
  from arrayview._routes_export import register_export_routes
45
46
  from arrayview._routes_preload import register_preload_routes
46
47
  from arrayview._routes_query import register_query_routes
@@ -49,7 +50,13 @@ from arrayview._routes_segmentation import register_segmentation_routes
49
50
  from arrayview._routes_state import register_state_routes
50
51
  from arrayview._routes_vectorfield import register_vectorfield_routes
51
52
  from arrayview._routes_websocket import _notify_shells, register_websocket_routes
52
- from arrayview._config import get_viewer_colormaps, get_viewer_rounded_panes, get_viewer_theme
53
+ from arrayview._config import (
54
+ get_viewer_colormaps,
55
+ get_viewer_dimbar_mode,
56
+ get_viewer_ortho_layout,
57
+ get_viewer_rounded_panes,
58
+ get_viewer_theme,
59
+ )
53
60
 
54
61
 
55
62
  from arrayview._imaging import ensure_image as _pil_image, ensure_imageops as _pil_imageops
@@ -176,6 +183,7 @@ register_websocket_routes(app)
176
183
  register_loading_routes(app, notify_shells=_notify_shells, setup_rgb=_setup_rgb)
177
184
  register_drop_routes(app)
178
185
  register_persistence_routes(app)
186
+ register_preferences_routes(app)
179
187
  register_segmentation_routes(app, get_session_or_404)
180
188
  register_state_routes(app, get_session_or_404)
181
189
  register_export_routes(app, get_session_or_404=get_session_or_404, pil_image=_pil_image)
@@ -228,6 +236,7 @@ def ping():
228
236
  "ok": True,
229
237
  "service": "arrayview",
230
238
  "pid": os.getpid(),
239
+ "uid": os.geteuid() if hasattr(os, "geteuid") else None,
231
240
  "hostname": socket.gethostname(),
232
241
  "protocol_version": runtime.protocol_version,
233
242
  "package_version": _av_version,
@@ -286,12 +295,19 @@ def get_ui(sid: str = None):
286
295
  query_val = "null"
287
296
  _init_luts()
288
297
  _cfg_colormaps = get_viewer_colormaps()
289
- _active_colormaps = _cfg_colormaps if _cfg_colormaps is not None else COLORMAPS
298
+ _valid_cfg_colormaps = (
299
+ [name for name in _cfg_colormaps if _ensure_lut(name)]
300
+ if _cfg_colormaps is not None
301
+ else []
302
+ )
303
+ _active_colormaps = _valid_cfg_colormaps or COLORMAPS
290
304
  _theme_names = ["dark", "light"]
291
305
  _cfg_theme = get_viewer_theme()
292
306
  _default_theme_idx = _theme_names.index(_cfg_theme) if _cfg_theme in _theme_names else 0
293
307
  _cfg_rounded = get_viewer_rounded_panes()
294
308
  _default_rounded_panes = "false" if _cfg_rounded is False else "true"
309
+ _default_ortho_layout = json.dumps(get_viewer_ortho_layout())
310
+ _default_dimbar_mode = json.dumps(get_viewer_dimbar_mode())
295
311
  html = (
296
312
  _VIEWER_HTML_TEMPLATE.replace("__COLORMAPS__", str(_active_colormaps))
297
313
  .replace("__COLORMAP_GRADIENT_STOPS__", json.dumps(COLORMAP_GRADIENT_STOPS))
@@ -301,6 +317,8 @@ def get_ui(sid: str = None):
301
317
  .replace("__ARRAYVIEW_QUERY__", query_val)
302
318
  .replace("__DEFAULT_THEME_IDX__", str(_default_theme_idx))
303
319
  .replace("__DEFAULT_ROUNDED_PANES__", _default_rounded_panes)
320
+ .replace("__DEFAULT_ORTHO_LAYOUT__", _default_ortho_layout)
321
+ .replace("__DEFAULT_DIMBAR_MODE__", _default_dimbar_mode)
304
322
  .replace("__BODY_CLASS__", "av-loading" if sid else "")
305
323
  .replace("__ARRAYVIEW_VERSION__", _av_version)
306
324
  )