arrayview 0.29.2__py3-none-any.whl → 0.31.0__py3-none-any.whl
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/__init__.py +2 -1
- arrayview/_analysis.py +12 -0
- arrayview/_instance_registry.py +253 -0
- arrayview/_io.py +638 -98
- arrayview/_launch_plan.py +243 -0
- arrayview/_launcher.py +1093 -305
- arrayview/_lifecycle.py +29 -4
- arrayview/_overlays.py +14 -1
- arrayview/_render.py +23 -1
- arrayview/_routes_loading.py +103 -11
- arrayview/_routes_query.py +13 -1
- arrayview/_routes_rendering.py +4 -0
- arrayview/_routes_websocket.py +23 -0
- arrayview/_server.py +86 -0
- arrayview/_session.py +15 -2
- arrayview/_session_spec.py +199 -0
- arrayview/_viewer.html +766 -89
- arrayview/_vscode_browser.py +124 -11
- arrayview/_vscode_extension.py +1 -1
- arrayview/_vscode_signal.py +239 -10
- arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.29.2.dist-info → arrayview-0.31.0.dist-info}/METADATA +1 -1
- arrayview-0.31.0.dist-info/RECORD +49 -0
- arrayview-0.29.2.dist-info/RECORD +0 -47
- {arrayview-0.29.2.dist-info → arrayview-0.31.0.dist-info}/WHEEL +0 -0
- {arrayview-0.29.2.dist-info → arrayview-0.31.0.dist-info}/entry_points.txt +0 -0
- {arrayview-0.29.2.dist-info → arrayview-0.31.0.dist-info}/licenses/LICENSE +0 -0
arrayview/__init__.py
CHANGED
|
@@ -8,12 +8,13 @@ __all__ = [
|
|
|
8
8
|
"view",
|
|
9
9
|
"view_batch",
|
|
10
10
|
"view_dir",
|
|
11
|
+
"view_dir_patterns",
|
|
11
12
|
"zarr_chunk_preset",
|
|
12
13
|
]
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
def __getattr__(name: str):
|
|
16
|
-
if name in {"arrayview", "view", "view_dir", "ViewHandle"}:
|
|
17
|
+
if name in {"arrayview", "view", "view_dir", "view_dir_patterns", "ViewHandle"}:
|
|
17
18
|
from arrayview import _launcher
|
|
18
19
|
|
|
19
20
|
return getattr(_launcher, name)
|
arrayview/_analysis.py
CHANGED
|
@@ -21,6 +21,10 @@ def _visible_shape(session) -> list[int]:
|
|
|
21
21
|
def _build_metadata(session) -> dict:
|
|
22
22
|
"""Build metadata shared by HTTP routes and WebSocket startup."""
|
|
23
23
|
target_shape = session.spatial_shape if session.rgb_axis is not None else session.shape
|
|
24
|
+
if getattr(session.data, "_av_ragged", False):
|
|
25
|
+
spatial_shapes = session.data.ragged_spatial_shapes
|
|
26
|
+
spatial_ndim = len(spatial_shapes[0][0])
|
|
27
|
+
target_shape = tuple(spatial_shapes[0][0]) + tuple(session.shape[spatial_ndim:])
|
|
24
28
|
meta = {
|
|
25
29
|
"shape": [int(s) for s in target_shape],
|
|
26
30
|
"is_complex": bool(np.iscomplexobj(session.data)),
|
|
@@ -45,6 +49,14 @@ def _build_metadata(session) -> dict:
|
|
|
45
49
|
)
|
|
46
50
|
if getattr(session, "array_keys", None):
|
|
47
51
|
meta["array_keys"] = session.array_keys
|
|
52
|
+
collection_spatial_ndim = getattr(session, "collection_spatial_ndim", None)
|
|
53
|
+
if collection_spatial_ndim is not None:
|
|
54
|
+
meta["collection_spatial_ndim"] = int(collection_spatial_ndim)
|
|
55
|
+
if getattr(session.data, "_av_ragged", False):
|
|
56
|
+
meta["ragged_spatial_shapes"] = [
|
|
57
|
+
[list(shape) for shape in row]
|
|
58
|
+
for row in session.data.ragged_spatial_shapes
|
|
59
|
+
]
|
|
48
60
|
return meta
|
|
49
61
|
|
|
50
62
|
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Small, dependency-free registry for ArrayView server processes.
|
|
2
|
+
|
|
3
|
+
This module deliberately contains no server or launcher integration. It is safe
|
|
4
|
+
to import on CLI fast paths.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
import ctypes
|
|
12
|
+
import getpass
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
import subprocess
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
from typing import Iterator
|
|
21
|
+
import uuid
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
REGISTRY_SCHEMA = 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class InstanceRecord:
|
|
29
|
+
instance_id: str
|
|
30
|
+
pid: int
|
|
31
|
+
process_start: str
|
|
32
|
+
port: int
|
|
33
|
+
protocol_version: str
|
|
34
|
+
package_version: str
|
|
35
|
+
owner_mode: str
|
|
36
|
+
started_at: float
|
|
37
|
+
last_seen_at: float
|
|
38
|
+
control_token: str
|
|
39
|
+
log_path: str
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def create(
|
|
43
|
+
cls, *, port: int, protocol_version: str, package_version: str,
|
|
44
|
+
owner_mode: str, log_path: str, pid: int | None = None,
|
|
45
|
+
) -> "InstanceRecord":
|
|
46
|
+
pid = os.getpid() if pid is None else pid
|
|
47
|
+
identity = process_start_identity(pid)
|
|
48
|
+
if identity is None:
|
|
49
|
+
raise ProcessLookupError(pid)
|
|
50
|
+
now = time.time()
|
|
51
|
+
return cls(str(uuid.uuid4()), pid, identity, port, protocol_version,
|
|
52
|
+
package_version, owner_mode, now, now,
|
|
53
|
+
uuid.uuid4().hex + uuid.uuid4().hex, log_path)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_dict(cls, value: dict[str, object]) -> "InstanceRecord":
|
|
57
|
+
if value.get("schema") != REGISTRY_SCHEMA:
|
|
58
|
+
raise ValueError("unsupported instance registry schema")
|
|
59
|
+
fields = {key: value[key] for key in cls.__dataclass_fields__}
|
|
60
|
+
return cls(**fields) # type: ignore[arg-type]
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, object]:
|
|
63
|
+
return {"schema": REGISTRY_SCHEMA, **asdict(self)}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def runtime_directory() -> Path:
|
|
67
|
+
override = os.environ.get("ARRAYVIEW_RUNTIME_DIR")
|
|
68
|
+
if override:
|
|
69
|
+
return Path(override).expanduser()
|
|
70
|
+
base = os.environ.get("XDG_RUNTIME_DIR")
|
|
71
|
+
if base:
|
|
72
|
+
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}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def process_start_identity(pid: int) -> str | None:
|
|
79
|
+
"""Return an OS process birth identity, or ``None`` when PID is not alive."""
|
|
80
|
+
if pid <= 0:
|
|
81
|
+
return None
|
|
82
|
+
if os.name == "nt":
|
|
83
|
+
return _windows_process_start(pid)
|
|
84
|
+
proc_stat = Path(f"/proc/{pid}/stat")
|
|
85
|
+
try:
|
|
86
|
+
# comm may contain spaces and parentheses; fields after its final ')' are stable.
|
|
87
|
+
return "linux:" + proc_stat.read_text().rsplit(")", 1)[1].split()[19]
|
|
88
|
+
except (FileNotFoundError, PermissionError, IndexError, OSError):
|
|
89
|
+
pass
|
|
90
|
+
if sys_platform() == "darwin":
|
|
91
|
+
try:
|
|
92
|
+
out = subprocess.run(
|
|
93
|
+
["ps", "-p", str(pid), "-o", "lstart="], capture_output=True,
|
|
94
|
+
text=True, timeout=2, check=False,
|
|
95
|
+
).stdout.strip()
|
|
96
|
+
return "darwin:" + out if out else None
|
|
97
|
+
except (OSError, subprocess.SubprocessError):
|
|
98
|
+
return None
|
|
99
|
+
try:
|
|
100
|
+
os.kill(pid, 0)
|
|
101
|
+
except PermissionError:
|
|
102
|
+
return f"pid-only:{pid}"
|
|
103
|
+
except (ProcessLookupError, OSError):
|
|
104
|
+
return None
|
|
105
|
+
return f"pid-only:{pid}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def sys_platform() -> str:
|
|
109
|
+
import sys
|
|
110
|
+
return sys.platform
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _windows_process_start(pid: int) -> str | None:
|
|
114
|
+
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
115
|
+
handle = kernel32.OpenProcess(0x1000, False, pid)
|
|
116
|
+
if not handle:
|
|
117
|
+
return None
|
|
118
|
+
creation = ctypes.c_ulonglong()
|
|
119
|
+
exit_time = ctypes.c_ulonglong()
|
|
120
|
+
kernel = ctypes.c_ulonglong()
|
|
121
|
+
user = ctypes.c_ulonglong()
|
|
122
|
+
try:
|
|
123
|
+
ok = kernel32.GetProcessTimes(handle, ctypes.byref(creation),
|
|
124
|
+
ctypes.byref(exit_time), ctypes.byref(kernel),
|
|
125
|
+
ctypes.byref(user))
|
|
126
|
+
return f"windows:{creation.value}" if ok else None
|
|
127
|
+
finally:
|
|
128
|
+
kernel32.CloseHandle(handle)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_CURRENT_PROCESS_START = process_start_identity(os.getpid())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class InstanceRegistry:
|
|
135
|
+
def __init__(self, directory: Path | str | None = None):
|
|
136
|
+
self.directory = Path(directory) if directory is not None else runtime_directory()
|
|
137
|
+
self.records = self.directory / "instances"
|
|
138
|
+
self.lock_path = self.directory / "startup.lock"
|
|
139
|
+
|
|
140
|
+
def _prepare(self) -> None:
|
|
141
|
+
self.records.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
142
|
+
try:
|
|
143
|
+
os.chmod(self.directory, 0o700)
|
|
144
|
+
os.chmod(self.records, 0o700)
|
|
145
|
+
except OSError:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
def write(self, record: InstanceRecord) -> Path:
|
|
149
|
+
self._prepare()
|
|
150
|
+
destination = self.records / f"{record.instance_id}.json"
|
|
151
|
+
fd, temporary = tempfile.mkstemp(prefix=".record-", dir=self.records)
|
|
152
|
+
try:
|
|
153
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
154
|
+
json.dump(record.to_dict(), stream, separators=(",", ":"), sort_keys=True)
|
|
155
|
+
stream.flush()
|
|
156
|
+
os.fsync(stream.fileno())
|
|
157
|
+
os.replace(temporary, destination)
|
|
158
|
+
finally:
|
|
159
|
+
try:
|
|
160
|
+
os.unlink(temporary)
|
|
161
|
+
except FileNotFoundError:
|
|
162
|
+
pass
|
|
163
|
+
return destination
|
|
164
|
+
|
|
165
|
+
def remove(self, instance_id: str) -> bool:
|
|
166
|
+
try:
|
|
167
|
+
(self.records / f"{instance_id}.json").unlink()
|
|
168
|
+
return True
|
|
169
|
+
except FileNotFoundError:
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
def discover(self, *, clean_stale: bool = False) -> list[InstanceRecord]:
|
|
173
|
+
found: list[InstanceRecord] = []
|
|
174
|
+
if not self.records.is_dir():
|
|
175
|
+
return found
|
|
176
|
+
for path in self.records.glob("*.json"):
|
|
177
|
+
try:
|
|
178
|
+
record = InstanceRecord.from_dict(json.loads(path.read_text()))
|
|
179
|
+
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
|
|
180
|
+
if clean_stale:
|
|
181
|
+
path.unlink(missing_ok=True)
|
|
182
|
+
continue
|
|
183
|
+
if is_stale(record):
|
|
184
|
+
if clean_stale:
|
|
185
|
+
path.unlink(missing_ok=True)
|
|
186
|
+
continue
|
|
187
|
+
found.append(record)
|
|
188
|
+
return sorted(found, key=lambda item: item.started_at)
|
|
189
|
+
|
|
190
|
+
@contextmanager
|
|
191
|
+
def startup_lock(self, *, timeout: float = 10.0,
|
|
192
|
+
poll_interval: float = 0.02) -> Iterator[None]:
|
|
193
|
+
self._prepare()
|
|
194
|
+
deadline = time.monotonic() + timeout
|
|
195
|
+
identity = (
|
|
196
|
+
_CURRENT_PROCESS_START
|
|
197
|
+
or process_start_identity(os.getpid())
|
|
198
|
+
or "unknown"
|
|
199
|
+
)
|
|
200
|
+
lock_token = uuid.uuid4().hex
|
|
201
|
+
payload = json.dumps(
|
|
202
|
+
{
|
|
203
|
+
"pid": os.getpid(),
|
|
204
|
+
"process_start": identity,
|
|
205
|
+
"token": lock_token,
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
while True:
|
|
209
|
+
try:
|
|
210
|
+
self.lock_path.mkdir()
|
|
211
|
+
(self.lock_path / "owner.json").write_text(payload)
|
|
212
|
+
break
|
|
213
|
+
except FileExistsError:
|
|
214
|
+
if self._break_stale_lock():
|
|
215
|
+
continue
|
|
216
|
+
if time.monotonic() >= deadline:
|
|
217
|
+
raise TimeoutError("timed out waiting for ArrayView startup lock")
|
|
218
|
+
time.sleep(poll_interval)
|
|
219
|
+
try:
|
|
220
|
+
yield
|
|
221
|
+
finally:
|
|
222
|
+
try:
|
|
223
|
+
owner_path = self.lock_path / "owner.json"
|
|
224
|
+
owner = json.loads(owner_path.read_text())
|
|
225
|
+
if owner.get("token") == lock_token:
|
|
226
|
+
owner_path.unlink(missing_ok=True)
|
|
227
|
+
self.lock_path.rmdir()
|
|
228
|
+
except (OSError, json.JSONDecodeError):
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
def _break_stale_lock(self) -> bool:
|
|
232
|
+
try:
|
|
233
|
+
owner = json.loads((self.lock_path / "owner.json").read_text())
|
|
234
|
+
live = process_start_identity(int(owner["pid"]))
|
|
235
|
+
stale = live != owner["process_start"]
|
|
236
|
+
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
|
|
237
|
+
# A creator may not yet have written owner.json. Only reap an old lock.
|
|
238
|
+
try:
|
|
239
|
+
stale = time.time() - self.lock_path.stat().st_mtime > 5
|
|
240
|
+
except OSError:
|
|
241
|
+
return True
|
|
242
|
+
if not stale:
|
|
243
|
+
return False
|
|
244
|
+
try:
|
|
245
|
+
(self.lock_path / "owner.json").unlink(missing_ok=True)
|
|
246
|
+
self.lock_path.rmdir()
|
|
247
|
+
return True
|
|
248
|
+
except OSError:
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def is_stale(record: InstanceRecord) -> bool:
|
|
253
|
+
return process_start_identity(record.pid) != record.process_start
|