worldarray 0.1.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.
worldarray/store.py ADDED
@@ -0,0 +1,427 @@
1
+ """Where an array's files are, reading its metadata documents, and opening it.
2
+
3
+ Everything goes through tensorstore's kvstore: ``zarr.json``, ``.zarray``, ``.zattrs``,
4
+ ``.zgroup``, ``attributes.json`` and ``info`` are read as bytes and parsed here, so no
5
+ zarr-python is involved. Remote stores are read the way a public dataset most likely lets
6
+ one in, with credentials only when a public read is refused (spec, Conventions 5).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import json
13
+ import logging
14
+ import os
15
+ import re
16
+ import threading
17
+ from collections.abc import Callable, Sequence
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, TypeVar
20
+
21
+ import tensorstore as ts
22
+
23
+ logger = logging.getLogger(__name__)
24
+ T = TypeVar("T")
25
+
26
+ PRECOMPUTED_PREFIX = "precomputed://"
27
+ GCS_PUBLIC_URL = "https://storage.googleapis.com"
28
+ METADATA_RETRIES = {"max_retries": 5, "initial_delay": "0.2s", "max_delay": "2s"}
29
+ PROBE_KEYS = ("zarr.json", ".zarray", ".zgroup", "info", "attributes.json")
30
+
31
+ _SCHEME = re.compile(r"^(s3|gs|http|https|file|memory)://")
32
+ _REMOTE = ("s3://", "gs://", "http://", "https://")
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Paths
37
+
38
+
39
+ def normalize_path(path: str) -> str:
40
+ """``precomputed://`` dropped, trailing slashes dropped, a local path made absolute."""
41
+ path = str(path)
42
+ if path.startswith(PRECOMPUTED_PREFIX):
43
+ path = path[len(PRECOMPUTED_PREFIX) :]
44
+ path = path.rstrip("/") or "/"
45
+ if not _SCHEME.match(path):
46
+ path = os.path.abspath(path)
47
+ return path
48
+
49
+
50
+ def is_remote(path: str) -> bool:
51
+ return normalize_path(path).startswith(_REMOTE)
52
+
53
+
54
+ def parent(path: str) -> str | None:
55
+ """The containing directory or URL prefix; None at a root (``/``, ``s3://bucket``)."""
56
+ path = normalize_path(path)
57
+ m = _SCHEME.match(path)
58
+ head = m.group(0) if m else ""
59
+ rest = path[len(head) :]
60
+ if "/" not in rest.strip("/"):
61
+ return None if (head or rest == "/") else "/"
62
+ return head + rest.rsplit("/", 1)[0]
63
+
64
+
65
+ def is_root(path: str | None) -> bool:
66
+ """The filesystem root, a URL's host or bucket root, or nothing: never probed for documents."""
67
+ if path is None:
68
+ return True
69
+ path = normalize_path(path)
70
+ if path == "/":
71
+ return True
72
+ m = _SCHEME.match(path)
73
+ return bool(m) and "/" not in path[len(m.group(0)) :].strip("/")
74
+
75
+
76
+ GUARD_GCE_METADATA = True # see kvstore_specs
77
+
78
+
79
+ def basename(path: str) -> str:
80
+ return normalize_path(path).rsplit("/", 1)[-1]
81
+
82
+
83
+ def join(base: str, *parts: str) -> str:
84
+ return "/".join([normalize_path(base).rstrip("/"), *(p.strip("/") for p in parts)])
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Access
89
+
90
+
91
+ def kvstore_specs(root: str) -> list[dict]:
92
+ """The tensorstore kvstore specs that read the directory ``root``, in the order to try
93
+ them. ``s3://``: anonymously, then AWS's default credentials (S3 refuses even a public
94
+ read signed with a stale or foreign key). ``gs://``: Google's default credentials
95
+ (anonymous without any), then the bucket's public https URL for credentials that are
96
+ there but broken (``TENSORSTORE_GCS_HTTP_URL`` moves it). Anything else: one way. Each
97
+ root ends in ``/`` because tensorstore concatenates keys literally."""
98
+ root = normalize_path(root).rstrip("/") + "/" # "/" stays "/", never "//"
99
+ if not _SCHEME.match(root):
100
+ return [{"driver": "file", "path": root}]
101
+ spec = ts.KvStore.Spec(root).to_json()
102
+ if spec["driver"] == "s3":
103
+ extra: dict = {}
104
+ endpoint = os.environ.get("AWS_ENDPOINT_URL_S3") or os.environ.get("AWS_ENDPOINT_URL")
105
+ if endpoint: # an S3-compatible endpoint (MinIO, moto, a mirror), as the AWS tools read it
106
+ extra["endpoint"] = endpoint
107
+ region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
108
+ if region:
109
+ extra["aws_region"] = region
110
+ return [{**spec, **extra, "aws_credentials": {"type": t}} for t in ("anonymous", "default")]
111
+ if spec["driver"] == "gcs":
112
+ if GUARD_GCE_METADATA:
113
+ # off Google Cloud, tensorstore's probe of the GCE metadata server for credentials
114
+ # stalls every gs:// open for seconds; point it at a name that fails at once
115
+ os.environ.setdefault("GCE_METADATA_ROOT", "metadata.google.internal.invalid")
116
+ public = (os.environ.get("TENSORSTORE_GCS_HTTP_URL") or GCS_PUBLIC_URL).rstrip("/")
117
+ return [
118
+ spec,
119
+ {
120
+ "driver": "http",
121
+ "base_url": public,
122
+ "path": f"/{spec['bucket']}/{spec.get('path', '')}",
123
+ },
124
+ ]
125
+ return [spec]
126
+
127
+
128
+ class Refused(Exception):
129
+ """A read was refused for want of credentials (not a missing key, not a network error)."""
130
+
131
+
132
+ def is_refused(error: BaseException) -> bool:
133
+ return isinstance(error, Refused) or str(error).startswith(
134
+ ("PERMISSION_DENIED", "UNAUTHENTICATED")
135
+ )
136
+
137
+
138
+ _admitted: dict[tuple[str, str], int] = {} # (driver, bucket) -> index of the spec let in
139
+ _admitted_lock = threading.Lock()
140
+
141
+
142
+ def with_access(specs: Sequence[dict], attempt: Callable[[dict], T]) -> T:
143
+ """``attempt(spec)`` with each spec in turn, moving on only while access is refused; the
144
+ way that worked is tried first next time, for its bucket. Any other error, and the last
145
+ refusal, is raised."""
146
+ if len(specs) == 1:
147
+ return attempt(specs[0])
148
+ key = (specs[0]["driver"], specs[0].get("bucket", ""))
149
+ with _admitted_lock:
150
+ first = _admitted.get(key, 0)
151
+ order = [first] + [i for i in range(len(specs)) if i != first]
152
+ for i in order[:-1]:
153
+ try:
154
+ result = attempt(specs[i])
155
+ except Exception as e:
156
+ if not is_refused(e):
157
+ raise
158
+ logger.info(
159
+ "%s refused a read (%s); trying the next way in", key, str(e).splitlines()[0][:200]
160
+ )
161
+ continue
162
+ with _admitted_lock:
163
+ _admitted[key] = i
164
+ return result
165
+ result = attempt(specs[order[-1]])
166
+ with _admitted_lock:
167
+ _admitted[key] = order[-1]
168
+ return result
169
+
170
+
171
+ def metadata_spec(spec: dict) -> dict:
172
+ """``spec`` with its requests' retries capped, so a mistyped host fails in seconds."""
173
+ if spec["driver"] not in ("s3", "gcs", "http"):
174
+ return spec
175
+ return {**spec, "context": {f"{spec['driver']}_request_retries": METADATA_RETRIES}}
176
+
177
+
178
+ @functools.lru_cache(maxsize=512)
179
+ def _metadata_kvstore(spec_json: str, gcs_url: str | None) -> ts.KvStore:
180
+ """One opened kvstore per spec, and per ``TENSORSTORE_GCS_HTTP_URL``: tensorstore's gcs
181
+ driver reads that variable when the store is opened, so a cached store would keep
182
+ talking to the host the variable named then."""
183
+ return ts.KvStore.open(metadata_spec(json.loads(spec_json))).result()
184
+
185
+
186
+ def _read_key(spec: dict, key: str) -> bytes | None:
187
+ kv = _metadata_kvstore(
188
+ json.dumps(spec, sort_keys=True), os.environ.get("TENSORSTORE_GCS_HTTP_URL")
189
+ )
190
+ result = kv.read(key).result()
191
+ return None if result.state == "missing" else bytes(result.value)
192
+
193
+
194
+ class Location:
195
+ """A directory (local or a URL) and the ways in to it."""
196
+
197
+ def __init__(self, root: str):
198
+ self.root = normalize_path(root)
199
+ self.specs = kvstore_specs(self.root)
200
+
201
+ def read(self, key: str) -> bytes | None:
202
+ return with_access(self.specs, lambda spec: _read_key(spec, key))
203
+
204
+ def read_json(self, key: str) -> dict | None:
205
+ data = self.read(key)
206
+ return None if data is None else json.loads(data)
207
+
208
+ def read_many(self, keys: Sequence[str]) -> dict[str, bytes | None]:
209
+ """All of ``keys`` under one way in. A bucket with listing off answers a refusal for a
210
+ missing key, so a refusal on one key counts as missing when another key was read
211
+ under the same way; only when every key is refused is the next way tried."""
212
+
213
+ def attempt(spec: dict) -> dict[str, bytes | None]:
214
+ found: dict[str, bytes | None] = {}
215
+ refusals: dict[str, BaseException] = {}
216
+ for key in keys:
217
+ try:
218
+ found[key] = _read_key(spec, key)
219
+ except Exception as e:
220
+ if not is_refused(e):
221
+ raise
222
+ refusals[key] = e
223
+ if refusals and len(refusals) == len(keys):
224
+ raise Refused(next(iter(refusals.values())).args[0] if refusals else "refused")
225
+ for key in refusals:
226
+ found[key] = None
227
+ return found
228
+
229
+ return with_access(self.specs, attempt)
230
+
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # Detection
234
+
235
+
236
+ @dataclass(frozen=True)
237
+ class Node:
238
+ """What is at a path: its format, whether it is an array or a group, and the JSON
239
+ documents found there. For a precomputed scale named by its key, ``path`` is the
240
+ volume root and ``scale_key`` the key."""
241
+
242
+ format: str # zarr2 | zarr3 | n5 | precomputed
243
+ kind: str # array | group
244
+ path: str
245
+ docs: dict[str, Any] = field(default_factory=dict)
246
+ scale_key: str | None = None
247
+
248
+ @property
249
+ def location(self) -> Location:
250
+ return Location(self.path)
251
+
252
+ @property
253
+ def attrs(self) -> dict:
254
+ """The node's user attributes, raw: zarr v3 ``attributes``, zarr v2 ``.zattrs``, the N5
255
+ ``attributes.json``, a precomputed ``info``. ``{}`` for a group with no document."""
256
+ if self.format == "zarr3":
257
+ return dict(self.docs.get("zarr.json", {}).get("attributes", {}) or {})
258
+ if self.format == "zarr2":
259
+ return dict(self.location.read_json(".zattrs") or {})
260
+ if self.format == "n5":
261
+ return dict(self.docs.get("attributes.json", {}) or {})
262
+ return dict(self.docs.get("info", {}) or {})
263
+
264
+ @property
265
+ def ome_attrs(self) -> dict:
266
+ """``attrs`` with OME-NGFF 0.5's ``ome`` member unwrapped onto the top level, so
267
+ ``multiscales``, ``coordinateSystems``, ``coordinateTransformations`` and the rest
268
+ read the same for 0.4 and 0.5."""
269
+ attrs = self.attrs
270
+ ome = attrs.get("ome")
271
+ return {**attrs, **ome} if isinstance(ome, dict) else attrs
272
+
273
+ @property
274
+ def multiscales(self) -> list[dict]:
275
+ """The OME-NGFF ``multiscales`` list (0.5's ``ome`` nesting unwrapped), else ``[]``."""
276
+ return multiscales_of(self.attrs)
277
+
278
+
279
+ def multiscales_of(attrs: dict) -> list[dict]:
280
+ ome = attrs.get("ome")
281
+ if isinstance(ome, dict) and "multiscales" in ome:
282
+ return list(ome["multiscales"])
283
+ return list(attrs.get("multiscales", []) or [])
284
+
285
+
286
+ def _decode(docs: dict[str, bytes | None]) -> dict[str, Any]:
287
+ return {k: json.loads(v) for k, v in docs.items() if v is not None}
288
+
289
+
290
+ def detect(path: str, *, missing_ok: bool = False) -> Node | None:
291
+ """Probe ``zarr.json``, ``.zarray``, ``.zgroup``, ``info``, ``attributes.json`` in that
292
+ order; failing all, probe the parent for ``info`` and take the last path segment as a
293
+ precomputed scale key, then probe ``s0`` for a group that has no document of its own.
294
+ The ``.n5`` or ``.zarr`` suffix is never consulted. Nothing found raises
295
+ ``FileNotFoundError``, or returns None with ``missing_ok``."""
296
+ path = normalize_path(path)
297
+ docs = _decode(Location(path).read_many(PROBE_KEYS))
298
+ if "zarr.json" in docs:
299
+ node_type = docs["zarr.json"].get("node_type", "array")
300
+ return Node("zarr3", "array" if node_type == "array" else "group", path, docs)
301
+ if ".zarray" in docs:
302
+ return Node("zarr2", "array", path, docs)
303
+ if ".zgroup" in docs:
304
+ return Node("zarr2", "group", path, docs)
305
+ if "info" in docs:
306
+ return Node("precomputed", "group", path, docs)
307
+ if "attributes.json" in docs:
308
+ kind = "array" if "dimensions" in docs["attributes.json"] else "group"
309
+ return Node("n5", kind, path, docs)
310
+ up = parent(path)
311
+ if up is not None:
312
+ info = Location(up).read_json("info")
313
+ if info is not None:
314
+ return Node("precomputed", "group", up, {"info": info}, scale_key=basename(path))
315
+ child = _decode(
316
+ Location(join(path, "s0")).read_many(("zarr.json", ".zarray", "attributes.json"))
317
+ )
318
+ if child:
319
+ fmt = "zarr3" if "zarr.json" in child else "zarr2" if ".zarray" in child else "n5"
320
+ return Node(fmt, "group", path, {})
321
+ if missing_ok:
322
+ return None
323
+ raise FileNotFoundError(
324
+ f"{path}: none of {', '.join(PROBE_KEYS)} found, the parent holds no precomputed info, and there is no s0"
325
+ )
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # Opening
330
+
331
+
332
+ def precomputed_scale_index(info: dict, key: str | int) -> int:
333
+ """The index of a precomputed scale named by its key (``"8_8_8"``), by position
334
+ (``1``), or by cellmap-flow's ``s<N>`` convention when no scale is keyed that way."""
335
+ keys = [str(s.get("key", i)) for i, s in enumerate(info["scales"])]
336
+ if isinstance(key, int) and not isinstance(key, bool):
337
+ if not -len(keys) <= key < len(keys):
338
+ raise ValueError(f"no precomputed scale {key}; there are {len(keys)}: {keys}")
339
+ return key % len(keys)
340
+ if key in keys:
341
+ return keys.index(key)
342
+ m = re.fullmatch(r"s(\d+)", str(key))
343
+ if m and int(m.group(1)) < len(keys):
344
+ return int(m.group(1))
345
+ raise ValueError(f"no precomputed scale {key!r}; scales: {keys}")
346
+
347
+
348
+ _KNOWN_COMPRESSOR_MEMBERS = {
349
+ "zstd": {"id", "level"},
350
+ "blosc": {"id", "cname", "clevel", "shuffle", "blocksize"},
351
+ "gzip": {"id", "level"},
352
+ "zlib": {"id", "level"},
353
+ "bz2": {"id", "level"},
354
+ }
355
+
356
+
357
+ def cleaned_zarray(zarray: dict) -> dict:
358
+ """A zarr v2 ``.zarray`` with compressor members tensorstore rejects dropped, such as the
359
+ ``checksum`` newer numcodecs writes for zstd."""
360
+ compressor = zarray.get("compressor")
361
+ if isinstance(compressor, dict) and compressor.get("id") in _KNOWN_COMPRESSOR_MEMBERS:
362
+ keep = _KNOWN_COMPRESSOR_MEMBERS[compressor["id"]]
363
+ compressor = {k: v for k, v in compressor.items() if k in keep}
364
+ return {**zarray, "compressor": compressor}
365
+
366
+
367
+ _DRIVER = {"zarr2": "zarr", "zarr3": "zarr3", "n5": "n5", "precomputed": "neuroglancer_precomputed"}
368
+
369
+
370
+ def open_array(
371
+ node: Node,
372
+ *,
373
+ context: ts.Context,
374
+ scale_index: int | None = None,
375
+ squeeze_channel: bool = True,
376
+ ) -> ts.TensorStore:
377
+ """The tensorstore of an array node (or one scale of a precomputed node), in C order
378
+ with its origin at 0: N5 and precomputed are transposed, a precomputed ``voxel_offset``
379
+ is moved to 0 (and carried in the metadata as the translation), and a precomputed
380
+ single channel is dropped."""
381
+ if node.kind != "array" and node.format != "precomputed":
382
+ raise ValueError(f"{node.path} is a {node.format} group, not an array")
383
+
384
+ def attempt(kvstore: dict) -> ts.TensorStore:
385
+ spec: dict[str, Any] = {"driver": _DRIVER[node.format], "kvstore": kvstore}
386
+ if node.format == "zarr2":
387
+ spec["metadata"] = cleaned_zarray(node.docs[".zarray"])
388
+ spec["assume_metadata"] = True
389
+ if node.format == "n5":
390
+ # only the array members: tensorstore rejects attributes it validates and finds
391
+ # odd (a "units" string instead of a list); everything else is read here, not by it
392
+ core = node.docs["attributes.json"]
393
+ spec["metadata"] = {
394
+ k: core[k]
395
+ for k in ("dimensions", "blockSize", "dataType", "compression")
396
+ if k in core
397
+ }
398
+ spec["assume_metadata"] = True
399
+ if node.format == "precomputed" and scale_index is not None:
400
+ spec["scale_index"] = scale_index
401
+ return ts.open(spec, read=True, context=context).result()
402
+
403
+ store = with_access(kvstore_specs(node.path), attempt)
404
+ if node.format in ("n5", "precomputed"):
405
+ store = store.T
406
+ if node.format == "precomputed":
407
+ store = store[ts.d[:].translate_to[0]]
408
+ if squeeze_channel and store.shape[0] == 1:
409
+ store = store[0]
410
+ return store
411
+
412
+
413
+ def list_chunk_keys(
414
+ root: str, separator: str = ".", ndim: int | None = None
415
+ ) -> list[tuple[int, ...]]:
416
+ """The chunk indices present under ``root`` by listing its keys and parsing those that are
417
+ ``separator``-joined integers (``0.1.2``, ``0/1/2``), with no metadata document needed:
418
+ a directory of chunk files with no ``.zarray`` lists the same as one with it. ``ndim``
419
+ keeps only keys with that many parts. Any kvstore, URLs included."""
420
+ specs = kvstore_specs(root)
421
+ kv = with_access(specs, lambda spec: ts.KvStore.open(metadata_spec(spec)).result())
422
+ keys = [k.decode() if isinstance(k, bytes) else str(k) for k in kv.list().result()]
423
+ pattern = re.compile(r"^\d+(?:" + re.escape(separator) + r"\d+)*$")
424
+ found = {tuple(int(p) for p in k.split(separator)) for k in keys if pattern.match(k)}
425
+ if ndim is not None:
426
+ found = {f for f in found if len(f) == ndim}
427
+ return sorted(found)
worldarray/units.py ADDED
@@ -0,0 +1,77 @@
1
+ """Units: kept as stored, converted only on the axes a caller asks to convert.
2
+
3
+ One table, accepting American and British spellings, ``um``, ``µm`` (U+00B5) and ``μm``
4
+ (U+03BC), and the usual time units. A missing unit is the empty string. Nothing here is
5
+ consulted until a conversion is asked for, so an array with ``"degrees_north"`` axes is as
6
+ welcome as one in nanometres (spec, Conventions 3).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ NANOMETER = "nanometer"
12
+ SECOND = "second"
13
+
14
+ LENGTH_M = {
15
+ "picometer": 1e-12, "picometre": 1e-12, "pm": 1e-12,
16
+ "angstrom": 1e-10, "ångström": 1e-10, "å": 1e-10, "Å": 1e-10,
17
+ "nanometer": 1e-9, "nanometre": 1e-9, "nm": 1e-9,
18
+ "micrometer": 1e-6, "micrometre": 1e-6, "micron": 1e-6, "um": 1e-6, "µm": 1e-6, "μm": 1e-6,
19
+ "millimeter": 1e-3, "millimetre": 1e-3, "mm": 1e-3,
20
+ "centimeter": 1e-2, "centimetre": 1e-2, "cm": 1e-2,
21
+ "meter": 1.0, "metre": 1.0, "m": 1.0,
22
+ } # fmt: skip
23
+ TIME_S = {
24
+ "nanosecond": 1e-9, "ns": 1e-9,
25
+ "microsecond": 1e-6, "us": 1e-6, "µs": 1e-6, "μs": 1e-6,
26
+ "millisecond": 1e-3, "ms": 1e-3,
27
+ "second": 1.0, "sec": 1.0, "s": 1.0,
28
+ "minute": 60.0, "min": 60.0,
29
+ "hour": 3600.0, "h": 3600.0,
30
+ "day": 86400.0, "d": 86400.0,
31
+ } # fmt: skip
32
+
33
+
34
+ def _lookup(unit: str) -> tuple[str, float] | None:
35
+ """``(kind, size in the kind's base unit)`` of a unit string, or None when unknown.
36
+ Plurals and case are forgiven; the Greek letters are matched as they are."""
37
+ for key in (unit, unit.strip(), unit.strip().lower(), unit.strip().lower().rstrip("s")):
38
+ if key in LENGTH_M:
39
+ return "space", LENGTH_M[key]
40
+ if key in TIME_S:
41
+ return "time", TIME_S[key]
42
+ return None
43
+
44
+
45
+ def kind(unit: str) -> str:
46
+ """``"space"``, ``"time"`` or ``"unknown"`` for a unit string; ``""`` is unknown."""
47
+ found = _lookup(unit) if unit else None
48
+ return found[0] if found else "unknown"
49
+
50
+
51
+ TABLE = {"length": LENGTH_M, "time": TIME_S} # public: cellmap-flow exports it to the browser
52
+
53
+
54
+ def factor(
55
+ unit_from: str, unit_to: str, *, missing_as: str | None = None, lenient: bool = False
56
+ ) -> float:
57
+ """How many ``unit_to`` one ``unit_from`` is.
58
+
59
+ ``unit_from == ""`` (nothing stored) is read as ``missing_as`` when that is given, else
60
+ it is an error: the caller decides what an unlabelled axis means, not this table. An
61
+ unknown unit, or a length asked for in a time unit, is a ``ValueError``; with
62
+ ``lenient`` an unknown unit is a factor of 1 (the caller warns).
63
+ """
64
+ if unit_from == "":
65
+ if missing_as is None:
66
+ raise ValueError("no unit stored on this axis and no default given")
67
+ unit_from = missing_as
68
+ a, b = _lookup(unit_from), _lookup(unit_to)
69
+ if a is None:
70
+ if lenient: # "pixel", "voxel", a typo: the numbers are taken as already in unit_to
71
+ return 1.0
72
+ raise ValueError(f"unknown unit {unit_from!r}")
73
+ if b is None:
74
+ raise ValueError(f"unknown unit {unit_to!r}")
75
+ if a[0] != b[0]:
76
+ raise ValueError(f"cannot convert {unit_from!r} ({a[0]}) to {unit_to!r} ({b[0]})")
77
+ return a[1] / b[1]