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.
- {arrayview-0.33.0 → arrayview-0.34.0}/PKG-INFO +1 -1
- {arrayview-0.33.0 → arrayview-0.34.0}/pyproject.toml +1 -1
- arrayview-0.34.0/src/arrayview/_config.py +352 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_instance_registry.py +3 -5
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_launch_plan.py +8 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_launcher.py +6 -1
- arrayview-0.34.0/src/arrayview/_routes_preferences.py +114 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_server.py +20 -2
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_viewer.html +353 -5
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_vscode_extension.py +1 -1
- arrayview-0.34.0/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_api.py +175 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_cli.py +1 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_config.py +72 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_instance_registry.py +4 -2
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_launch_execution_ownership.py +3 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_launch_plan.py +38 -0
- arrayview-0.34.0/tests/test_preferences_ui.py +185 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_server_identity_status.py +1 -0
- arrayview-0.33.0/src/arrayview/_config.py +0 -135
- arrayview-0.33.0/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/.gitignore +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/LICENSE +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/README.md +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/ARCHITECTURE.md +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/__init__.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/__main__.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_analysis.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_app.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_codex_open.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_dicom.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_diff.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_icon.png +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_imaging.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_io.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_launch_trace.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_lifecycle.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_overlays.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_platform.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_render.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_analysis.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_drop.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_export.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_loading.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_persistence.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_preload.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_query.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_rendering.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_segmentation.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_state.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_vectorfield.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_routes_websocket.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_session.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_session_spec.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_shell.html +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_synthetic_mri.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_torch.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_vectorfield.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_vscode_browser.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/_vscode_signal.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/src/arrayview/todo.md +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/capture_patient_loading.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/capture_v_animation.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/conftest.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/lifecycle_matrix.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_backend_shared.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_browser.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_codex_open.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_colorbar_hover_highlight.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_command_reachability.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_cross_mode_parametrized.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_dicom.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_display_opener_results.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_doctor_commands.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_drop_import.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_interactions.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_invocation_contract_matrix.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_io.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_large_arrays.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_launch_blackbox.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_launch_diagnostics.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_launch_trace.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_lifecycle_contract.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_loading_server.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_mode_consistency.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_mode_matrix.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_mode_roundtrip.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_nifti_series.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_public_invocation_regressions.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_safe_server_stop.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_session_spec.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_spawn_ownership.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_torch.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_view_component_integration.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_view_component_unit.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_view_handle_lifecycle.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_view_launch_execution.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/test_vscode_ack_protocol.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/ui_audit.py +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/v_anim_frames/.gitkeep +0 -0
- {arrayview-0.33.0 → arrayview-0.34.0}/tests/visual_smoke.py +0 -0
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
return Path(tempfile.gettempdir()) / f"arrayview-{
|
|
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
|
|
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
|
-
|
|
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
|
)
|