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 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