worldarray 0.6.0__tar.gz → 0.7.1__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 (37) hide show
  1. {worldarray-0.6.0 → worldarray-0.7.1}/CHANGELOG.md +23 -0
  2. {worldarray-0.6.0 → worldarray-0.7.1}/PKG-INFO +5 -4
  3. {worldarray-0.6.0 → worldarray-0.7.1}/README.md +4 -3
  4. {worldarray-0.6.0 → worldarray-0.7.1}/docs/spec.md +40 -17
  5. {worldarray-0.6.0 → worldarray-0.7.1}/pyproject.toml +1 -1
  6. {worldarray-0.6.0 → worldarray-0.7.1}/src/worldarray/__init__.py +1 -1
  7. {worldarray-0.6.0 → worldarray-0.7.1}/src/worldarray/array.py +23 -7
  8. {worldarray-0.6.0 → worldarray-0.7.1}/src/worldarray/store.py +110 -22
  9. {worldarray-0.6.0 → worldarray-0.7.1}/tests/test_access.py +80 -2
  10. {worldarray-0.6.0 → worldarray-0.7.1}/uv.lock +1 -1
  11. {worldarray-0.6.0 → worldarray-0.7.1}/.github/workflows/ci.yml +0 -0
  12. {worldarray-0.6.0 → worldarray-0.7.1}/.github/workflows/docs.yml +0 -0
  13. {worldarray-0.6.0 → worldarray-0.7.1}/.github/workflows/publish.yml +0 -0
  14. {worldarray-0.6.0 → worldarray-0.7.1}/.gitignore +0 -0
  15. {worldarray-0.6.0 → worldarray-0.7.1}/LICENSE +0 -0
  16. {worldarray-0.6.0 → worldarray-0.7.1}/docs/changelog.md +0 -0
  17. {worldarray-0.6.0 → worldarray-0.7.1}/docs/index.md +0 -0
  18. {worldarray-0.6.0 → worldarray-0.7.1}/mkdocs.yml +0 -0
  19. {worldarray-0.6.0 → worldarray-0.7.1}/src/worldarray/context.py +0 -0
  20. {worldarray-0.6.0 → worldarray-0.7.1}/src/worldarray/geometry.py +0 -0
  21. {worldarray-0.6.0 → worldarray-0.7.1}/src/worldarray/grid.py +0 -0
  22. {worldarray-0.6.0 → worldarray-0.7.1}/src/worldarray/levels.py +0 -0
  23. {worldarray-0.6.0 → worldarray-0.7.1}/src/worldarray/placement.py +0 -0
  24. {worldarray-0.6.0 → worldarray-0.7.1}/src/worldarray/resample.py +0 -0
  25. {worldarray-0.6.0 → worldarray-0.7.1}/src/worldarray/retry.py +0 -0
  26. {worldarray-0.6.0 → worldarray-0.7.1}/src/worldarray/units.py +0 -0
  27. {worldarray-0.6.0 → worldarray-0.7.1}/src/worldarray/write.py +0 -0
  28. {worldarray-0.6.0 → worldarray-0.7.1}/tests/conftest.py +0 -0
  29. {worldarray-0.6.0 → worldarray-0.7.1}/tests/fixtures/metadata.json +0 -0
  30. {worldarray-0.6.0 → worldarray-0.7.1}/tests/fixtures/write.json +0 -0
  31. {worldarray-0.6.0 → worldarray-0.7.1}/tests/test_geometry.py +0 -0
  32. {worldarray-0.6.0 → worldarray-0.7.1}/tests/test_grid.py +0 -0
  33. {worldarray-0.6.0 → worldarray-0.7.1}/tests/test_lost_wakeup.py +0 -0
  34. {worldarray-0.6.0 → worldarray-0.7.1}/tests/test_metadata.py +0 -0
  35. {worldarray-0.6.0 → worldarray-0.7.1}/tests/test_read.py +0 -0
  36. {worldarray-0.6.0 → worldarray-0.7.1}/tests/test_resample.py +0 -0
  37. {worldarray-0.6.0 → worldarray-0.7.1}/tests/test_write.py +0 -0
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.1 (2026-10-23)
4
+
5
+ Faster opens over http. No placement change, no unit table change, no API change.
6
+
7
+ - The documents a call needs from one directory are read at once instead of in turn. An
8
+ http open of a zarr v2 level made its eight requests one after another (five probe keys,
9
+ the level's `.zattrs`, the parent's `zarr.json` and `.zattrs`); now they go in three
10
+ rounds. At 10 ms an answer, `wa.open` took 98 ms and takes 52 (cellmap-flow's
11
+ measurement). A server that never answers still costs one key's attempts, not five.
12
+
13
+ ## 0.7.0 (2026-10-23)
14
+
15
+ Opens time out. No placement change and no unit table change.
16
+
17
+ - Every metadata document read or written and every store opened is timed out and
18
+ retried under `Retry`, as data reads are (Behaviour 6): by default 5 s times the attempt,
19
+ ten attempts. A server that accepts a connection and never answers held `wa.open` and
20
+ `wa.metadata` forever before; it now raises `TimeoutError` naming the URL and the
21
+ attempts (cellmap-flow, 2026-10-23). The policy is the call's `retries`, which
22
+ `wa.metadata`, `wa.detect` and `wa.open_tensorstore` now take as `wa.open`,
23
+ `wa.open_pyramid` and `wa.create` already did. Listings and the open that creates an
24
+ array are not timed out.
25
+
3
26
  ## 0.6.0 (2026-10-23)
4
27
 
5
28
  One new argument, at chunkmirage's request. No placement change and no unit table change;
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: worldarray
3
- Version: 0.6.0
3
+ Version: 0.7.1
4
4
  Summary: Read zarr, N5 and Neuroglancer precomputed volumes in world coordinates through tensorstore: correct voxel placement, pyramid levels, padded region reads, resampling. numpy + tensorstore only.
5
5
  Project-URL: Homepage, https://janeliascicomp.github.io/worldarray/
6
6
  Project-URL: Documentation, https://janeliascicomp.github.io/worldarray/
@@ -205,9 +205,10 @@ wa.open(path, *, level=None, voxel_size=None, level_mode="floor", on_mismatch="e
205
205
  metadata_conflicts="error", base_voxel_size=None, base_units=None, anchor=None,
206
206
  transform=None, strict_units=True) -> WorldArray
207
207
  wa.open_pyramid(path, ...) -> Pyramid # .names .levels .by_name() .select() .placements() .metas(): no opens needed
208
- wa.metadata(path, level=None, units=None, open=True) -> ArrayMeta # open=False: from documents alone
209
- wa.detect(path, missing_ok=False) -> Node # format, array or group, raw attrs
210
- wa.open_tensorstore(path, level=None, context=None)
208
+ wa.metadata(path, level=None, units=None, open=True, retries=None) -> ArrayMeta # open=False: from documents alone
209
+ wa.detect(path, missing_ok=False, retries=None) -> Node # format, array or group, raw attrs
210
+ wa.open_tensorstore(path, level=None, context=None, retries=None)
211
+ # retries= times out and retries document reads and the open, as it does data reads
211
212
  wa.shared_context(cache_bytes=0, threads=None) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
212
213
  wa.thread_budget() # the shared context's default thread limit: OMP_NUM_THREADS, the job's slots, or CPUs;
213
214
  # threads=N limits it to N, threads=0 leaves tensorstore's own pool sizes
@@ -146,9 +146,10 @@ wa.open(path, *, level=None, voxel_size=None, level_mode="floor", on_mismatch="e
146
146
  metadata_conflicts="error", base_voxel_size=None, base_units=None, anchor=None,
147
147
  transform=None, strict_units=True) -> WorldArray
148
148
  wa.open_pyramid(path, ...) -> Pyramid # .names .levels .by_name() .select() .placements() .metas(): no opens needed
149
- wa.metadata(path, level=None, units=None, open=True) -> ArrayMeta # open=False: from documents alone
150
- wa.detect(path, missing_ok=False) -> Node # format, array or group, raw attrs
151
- wa.open_tensorstore(path, level=None, context=None)
149
+ wa.metadata(path, level=None, units=None, open=True, retries=None) -> ArrayMeta # open=False: from documents alone
150
+ wa.detect(path, missing_ok=False, retries=None) -> Node # format, array or group, raw attrs
151
+ wa.open_tensorstore(path, level=None, context=None, retries=None)
152
+ # retries= times out and retries document reads and the open, as it does data reads
152
153
  wa.shared_context(cache_bytes=0, threads=None) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
153
154
  wa.thread_budget() # the shared context's default thread limit: OMP_NUM_THREADS, the job's slots, or CPUs;
154
155
  # threads=N limits it to N, threads=0 leaves tensorstore's own pool sizes
@@ -177,10 +177,10 @@ pyr.voxel_sizes() ; pyr.translations() # where it lists the levels (OME-NGFF
177
177
  # choosing or placing a level opens nothing, whether the pyramid was opened by its
178
178
  # group path or by one of its level paths; else by opening each level
179
179
 
180
- wa.metadata(path, level=None, units=None, open=True) -> ArrayMeta # open=False: from the documents alone
181
- wa.detect(path, missing_ok=False) -> Node | None # format, kind (array|group), path, docs, scale_key; .attrs (raw user
180
+ wa.metadata(path, level=None, units=None, open=True, retries=None) -> ArrayMeta # open=False: from the documents alone
181
+ wa.detect(path, missing_ok=False, retries=None) -> Node | None # format, kind (array|group), path, docs, scale_key; .attrs (raw user
182
182
  # attributes), .multiscales (OME 0.5's `ome` unwrapped)
183
- wa.open_tensorstore(path, level=None, context=None) -> tensorstore.TensorStore # reads no world metadata, so an
183
+ wa.open_tensorstore(path, level=None, context=None, retries=None) -> tensorstore.TensorStore # reads no world metadata, so an
184
184
  # array with odd placement attributes (a coordinate array) still opens
185
185
  wa.corner_to_centre(corner, voxel_size, axes=None) ; wa.default_axes(ndim) ; wa.index_range(begin, end, voxel_size, translation)
186
186
  wa.units.TABLE # the unit tables, public: {"length": {...metres per unit}, "time": {...seconds per unit}}
@@ -490,7 +490,9 @@ this document and that one differ, fix one of them.
490
490
 
491
491
  5. **Stores are read through tensorstore's kvstore only**, never zarr-python: `zarr.json`,
492
492
  `.zarray`, `.zattrs`, `.zgroup`, `attributes.json`, `info`. Format detection probes keys
493
- in this order: `zarr.json`, `.zarray`, `.zgroup`, `info`, `attributes.json`; when none is
493
+ in this order: `zarr.json`, `.zarray`, `.zgroup`, `info`, `attributes.json`, the reads
494
+ begun all at once and the order deciding which document wins (0.7.1: one round trip
495
+ where an http open made five in a row, cellmap-flow's measurement); when none is
494
496
  found, the parent is probed for `info` and the last path segment is taken as a
495
497
  precomputed scale key (mesh-n-bone's case), then `s0` is probed so a group with no
496
498
  document of its own (an N5 directory holding `s0, s1, ...`) is still a group. The
@@ -521,7 +523,8 @@ this document and that one differ, fix one of them.
521
523
  one probe key counts as "missing" when another probe key was read under the same way,
522
524
  and only when every probe key is refused is the next way tried (see Open question 4).
523
525
  Metadata reads retry for about five seconds in total (`max_retries 5, initial_delay
524
- 0.2s, max_delay 2s`); data reads keep tensorstore's defaults plus `Retry`.
526
+ 0.2s, max_delay 2s`); data reads keep tensorstore's defaults plus `Retry`, and each
527
+ document read and store open is also timed out under `Retry` (0.7, Behaviour 6).
525
528
 
526
529
  6. **Writing is reading inverted, in one format.** The package writes OME-NGFF zarr only:
527
530
  zarr v2 with OME 0.4 (`.zgroup`, and `.zattrs` holding `multiscales`, each entry with
@@ -649,9 +652,21 @@ this document and that one differ, fix one of them.
649
652
  still land after its cancel, with the same bytes the next attempt writes. Each retry is
650
653
  logged as a warning naming the error's first line, or its type when the message is
651
654
  empty (tensorstore's timeout is `TimeoutError()`); when every attempt times out the
652
- `TimeoutError` names the operation, the path and the attempts. Metadata documents are not
653
- retried this way (Convention 5). The policy is a per-handle attribute and a per-read and
654
- per-write argument, never only constructor state.
655
+ `TimeoutError` names the operation, the path and the attempts. The policy is a
656
+ per-handle attribute and a per-read and per-write argument, never only constructor
657
+ state.
658
+
659
+ Documents and opens (0.7): every metadata document read or written and every store
660
+ opened is timed out and retried the same way, as an operation of no bytes (by default
661
+ 5 s times the attempt, ten attempts), with tensorstore's own request retries
662
+ (Convention 5) running inside each attempt. A server that accepts a connection and
663
+ never answers used to hold `wa.open` and `wa.metadata` forever: cellmap-flow's picker
664
+ kept a 5 s probe of its own in front of them (2026-10-23). The policy is the `retries`
665
+ of the public call doing the reading: `open`, `open_pyramid`, `create`, `metadata`,
666
+ `detect` and `open_tensorstore`, which all take it; a document read later outside such
667
+ a call (a zarr v2 `.zattrs` read on demand, a pyramid placing its levels) waits under
668
+ `Retry()`. Listings are not timed out, their size being unknown, and neither is the
669
+ open that creates an array, which a second attempt would find already made.
655
670
 
656
671
  The watchdog (0.5): tensorstore up to at least 0.1.85 can strand a queued task. A pool
657
672
  worker idle for 20 ms exits, and a task queued as it exits gets no thread while the pool
@@ -1019,7 +1034,8 @@ kicked by the watchdog, a dead context no longer watched, and a kick landing on
1019
1034
  context's own pools; `thread_budget` from each variable, a bad value skipped, and the
1020
1035
  shared context's limits. 0.6: `shared_context(threads=)` at `None`, `N` and `0`, one
1021
1036
  context per pair, a negative count refused, and a pickled handle coming back on the same
1022
- shared context. Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
1037
+ shared context. 0.7: `metadata`, `open` and `detect` against a server
1038
+ that accepts and never answers raising `TimeoutError` after the attempts. Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
1023
1039
  `tests/test_lost_wakeup.py`): worker processes aim tasks at the moment a one-thread pool's
1024
1040
  worker exits idle, so a plain context strands some for good (13 of about 7,000 on
1025
1041
  tensorstore 0.1.78; 40 to 44 of 12,000 to 15,000 with 16 workers on 0.1.75 and 0.1.85)
@@ -1160,13 +1176,20 @@ over funlib geometry, by this spec's own assignment.
1160
1176
  a real bucket with listing disabled. One network test before 0.1.0 may change the rule.
1161
1177
  4. **The network test** against a non-integer-voxel OpenOrganelle volume (Tests) is not yet
1162
1178
  written; it needs the dataset chosen from the catalog.
1163
- 5. **Legacy attributes on new outputs.** cellmap-analyze writes funlib `voxel_size` and
1164
- `offset` (holding the centre) on every output and nothing in the five repos reads them;
1165
- dacapo's checkout has nine `open_ds` users, and cellmap-data and fibsem-tools were not
1166
- checked. Does any live consumer of cellmap-analyze or cellmap-flow outputs read funlib
1167
- attributes? If so, should the package offer `funlib_attrs(voxel_size, translation, axes,
1168
- units)`, writing `offset` as the corner, funlib's meaning, for `create(attrs=)`, or do
1169
- the repos keep writing their own?
1179
+ 5. **Legacy attributes on new outputs.** Resolved 2026-10-23: nobody needs them, so there
1180
+ is no `funlib_attrs`. cellmap-analyze stopped writing funlib `voxel_size` and `offset`
1181
+ (which held the centre) in 0.6.0, whose outputs carry only the OME documents `create`
1182
+ writes. cellmap-flow writes them through funlib's own `prepare_ds`, `offset` as the
1183
+ corner beside an agreeing OME 0.4 translation, and is their only reader. dacapo reads
1184
+ funlib attributes through `open_ds` (`ome_metadata=False` by default), but nothing feeds
1185
+ it these outputs, and on a zarr v3 one funlib 0.7 silently reads voxel size 1 and
1186
+ offset 0. cellmap-data and fibsem-tools read OME only, and both pin `zarr<3`. funlib
1187
+ 0.6 and 0.7 also require integer offsets and voxel sizes, so a float helper here would
1188
+ either refuse placements or write numbers funlib truncates; a repo that wants the keys
1189
+ passes them through `create(attrs=)`. Found on the way, outside this package:
1190
+ cellmap-data (main 356c3ac, `image.py`) takes an OME translation as voxel 0's corner,
1191
+ half a voxel off on every OME-correct store, and funlib's `open_ome_ds` puts the
1192
+ translation straight into its corner `offset`.
1170
1193
  6. **The block iterator's shape** (Behaviour 17). Resolved 2026-10-22: both sessions
1171
1194
  confirmed it. mesh-n-bone asked that `box` may reach past the array and that `clip`
1172
1195
  clips read boxes only, and derives its per-level boxes from one grid itself;
@@ -1197,7 +1220,7 @@ over funlib geometry, by this spec's own assignment.
1197
1220
  unlisted has its placement nowhere until it is listed, so a `create(listed=False)`
1198
1221
  handle, and its pickles, would carry the placement from its arguments alone. Decide
1199
1222
  whether that is worth having, or whether an unlisted level should describe itself on
1200
- the array (open question 5's funlib attributes, or an OME-shaped array attribute).
1223
+ the array (an OME-shaped array attribute; funlib keys are out, open question 5).
1201
1224
  12. **Per-open object-store credentials.** cellmap-flow's MinIO writers open with a
1202
1225
  per-session key, secret and endpoint while the same process reads public S3
1203
1226
  anonymously. tensorstore 0.1.85's `aws_credentials` takes no inline key and secret, and
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "worldarray"
3
- version = "0.6.0"
3
+ version = "0.7.1"
4
4
  description = "Read zarr, N5 and Neuroglancer precomputed volumes in world coordinates through tensorstore: correct voxel placement, pyramid levels, padded region reads, resampling. numpy + tensorstore only."
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -28,7 +28,7 @@ from .write import (
28
28
  parse_chunk_key,
29
29
  )
30
30
 
31
- __version__ = "0.6.0"
31
+ __version__ = "0.7.1"
32
32
 
33
33
  __all__ = [
34
34
  "ArrayMeta",
@@ -44,6 +44,7 @@ from .store import (
44
44
  normalize_path,
45
45
  open_array,
46
46
  precomputed_scale_index,
47
+ retried_documents,
47
48
  )
48
49
 
49
50
  logger = logging.getLogger(__name__)
@@ -68,6 +69,7 @@ class WorldArray:
68
69
  ``voxel_size`` picks the level; a group with neither raises, listing its levels).
69
70
  """
70
71
 
72
+ @retried_documents
71
73
  def __init__(
72
74
  self,
73
75
  path: str,
@@ -1002,6 +1004,7 @@ class Pyramid:
1002
1004
  own metadata where it lists the levels' scales (OME-NGFF, COSEM N5, precomputed), and
1003
1005
  opens the levels only when it does not."""
1004
1006
 
1007
+ @retried_documents
1005
1008
  def __init__(
1006
1009
  self,
1007
1010
  path: str,
@@ -1201,6 +1204,7 @@ class Pyramid:
1201
1204
  # Functions
1202
1205
 
1203
1206
 
1207
+ @retried_documents
1204
1208
  def create(
1205
1209
  path: str,
1206
1210
  shape: Sequence[int] | None = None,
@@ -1345,14 +1349,21 @@ def open_pyramid(path: str, **kwargs) -> Pyramid:
1345
1349
  return Pyramid(path, **kwargs)
1346
1350
 
1347
1351
 
1352
+ @retried_documents
1348
1353
  def metadata(
1349
- path: str, *, level: str | int | None = None, units: Units = None, open: bool = True
1354
+ path: str,
1355
+ *,
1356
+ level: str | int | None = None,
1357
+ units: Units = None,
1358
+ open: bool = True,
1359
+ retries: Retry | None = None,
1350
1360
  ) -> ArrayMeta:
1351
1361
  """The ``ArrayMeta`` of an array (or of ``level`` under a group), in native units unless
1352
1362
  ``units`` says otherwise. ``open=False`` reads it from the documents alone, with no
1353
- tensorstore opened."""
1363
+ tensorstore opened. ``retries`` times out and retries each document read and the open
1364
+ (Behaviour 6)."""
1354
1365
  if open:
1355
- vol = WorldArray(path, level=level, units=units)
1366
+ vol = WorldArray(path, level=level, units=units, retries=retries)
1356
1367
  return vol.meta if units is None else vol.grid
1357
1368
  node = detect(path)
1358
1369
  if level == "first":
@@ -1386,13 +1397,18 @@ def metadata(
1386
1397
  return meta.in_units(space=space, time=time)
1387
1398
 
1388
1399
 
1400
+ @retried_documents
1389
1401
  def open_tensorstore(
1390
- path: str, *, level: str | int | None = None, context: ts.Context | None = None
1402
+ path: str,
1403
+ *,
1404
+ level: str | int | None = None,
1405
+ context: ts.Context | None = None,
1406
+ retries: Retry | None = None,
1391
1407
  ) -> ts.TensorStore:
1392
1408
  """The raw tensorstore of an array or level: C order, origin 0, a precomputed single
1393
- channel dropped. The access fallbacks and shared context apply. No world metadata is
1394
- read, so an array whose placement attributes are odd (a coordinate array, a field) still
1395
- opens."""
1409
+ channel dropped. The access fallbacks and shared context apply, and ``retries`` to each
1410
+ document read and the open (Behaviour 6). No world metadata is read, so an array whose
1411
+ placement attributes are odd (a coordinate array, a field) still opens."""
1396
1412
  context = context if context is not None else shared_context(0)
1397
1413
  node = detect(path)
1398
1414
  if level == "first":
@@ -8,6 +8,8 @@ one in, with credentials only when a public read is refused (spec, Conventions 5
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import contextlib
12
+ import contextvars
11
13
  import functools
12
14
  import json
13
15
  import logging
@@ -21,6 +23,7 @@ from typing import Any, TypeVar
21
23
  import tensorstore as ts
22
24
 
23
25
  from .context import watch
26
+ from .retry import Retry, with_retries
24
27
 
25
28
  logger = logging.getLogger(__name__)
26
29
  T = TypeVar("T")
@@ -33,6 +36,11 @@ PROBE_KEYS = ("zarr.json", ".zarray", ".zgroup", "info", "attributes.json")
33
36
  _SCHEME = re.compile(r"^(s3|gs|http|https|file|memory)://")
34
37
  _REMOTE = ("s3://", "gs://", "http://", "https://")
35
38
 
39
+ # the Retry that documents and opens wait under: the policy of the public call they serve
40
+ _documents_retry: contextvars.ContextVar[Retry | None] = contextvars.ContextVar(
41
+ "worldarray_documents_retry", default=None
42
+ )
43
+
36
44
 
37
45
  # ---------------------------------------------------------------------------
38
46
  # Paths
@@ -189,6 +197,47 @@ def with_access(specs: Sequence[dict], attempt: Callable[[dict], T]) -> T:
189
197
  return result
190
198
 
191
199
 
200
+ @contextlib.contextmanager
201
+ def documents_retried(retry: Retry | None):
202
+ """Metadata documents read or written, and stores opened, inside this block are timed
203
+ out and retried per ``retry`` as reads of no bytes; ``None`` keeps the enclosing
204
+ policy, else ``Retry()`` (spec, Behaviour 6)."""
205
+ token = _documents_retry.set(retry) if retry is not None else None
206
+ try:
207
+ yield
208
+ finally:
209
+ if token is not None:
210
+ _documents_retry.reset(token)
211
+
212
+
213
+ def retried_documents(fn: Callable[..., T]) -> Callable[..., T]:
214
+ """``fn`` run under ``documents_retried(retries)``, ``retries`` being its keyword."""
215
+
216
+ @functools.wraps(fn)
217
+ def run(*args, **kwargs):
218
+ with documents_retried(kwargs.get("retries")):
219
+ return fn(*args, **kwargs)
220
+
221
+ return run
222
+
223
+
224
+ def _where(spec: dict, key: str = "") -> str:
225
+ if spec["driver"] == "file":
226
+ return spec["path"] + key
227
+ if spec["driver"] == "http":
228
+ return spec["base_url"].rstrip("/") + "/" + spec.get("path", "").lstrip("/") + key
229
+ return f"{spec['driver']}://{spec.get('bucket', '')}/{spec.get('path', '')}{key}"
230
+
231
+
232
+ def _wait(start: Callable[[], Any], spec: dict, what: str, verb: str) -> Any:
233
+ """``start().result()`` for a document or an open: a server that accepts and never
234
+ answers would otherwise hold it forever (tensorstore's own request retries run inside
235
+ each attempt)."""
236
+ retry = _documents_retry.get() or Retry()
237
+ remote = spec["driver"] in ("http", "gcs", "s3")
238
+ return with_retries(start, 0, retry, remote=remote, what=what, verb=verb)
239
+
240
+
192
241
  def metadata_spec(spec: dict) -> dict:
193
242
  """``spec`` with its requests' retries capped, so a mistyped host fails in seconds."""
194
243
  if spec["driver"] not in ("s3", "gcs", "http"):
@@ -201,22 +250,53 @@ def _metadata_kvstore(spec_json: str, gcs_url: str | None) -> ts.KvStore:
201
250
  """One opened kvstore per spec, and per ``TENSORSTORE_GCS_HTTP_URL``: tensorstore's gcs
202
251
  driver reads that variable when the store is opened, so a cached store would keep
203
252
  talking to the host the variable named then."""
204
- return ts.KvStore.open(metadata_spec(json.loads(spec_json))).result()
253
+ spec = json.loads(spec_json)
254
+ return _wait(lambda: ts.KvStore.open(metadata_spec(spec)), spec, _where(spec), "open")
205
255
 
206
256
 
207
- def _read_key(spec: dict, key: str) -> bytes | None:
208
- kv = _metadata_kvstore(
257
+ def _metadata_kv(spec: dict) -> ts.KvStore:
258
+ return _metadata_kvstore(
209
259
  json.dumps(spec, sort_keys=True), os.environ.get("TENSORSTORE_GCS_HTTP_URL")
210
260
  )
211
- result = kv.read(key).result()
212
- return None if result.state == "missing" else bytes(result.value)
261
+
262
+
263
+ def _read_keys(spec: dict, keys: Sequence[str]) -> dict[str, bytes | None | BaseException]:
264
+ """``keys`` under one way in, their reads begun at once: one round trip, not one per key
265
+ (an http open probes five). Each value is the document, None when it is missing, or the
266
+ error its read raised; after an error other than a refusal the rest are cancelled and
267
+ left out, so a server that never answers costs one key's attempts, not five."""
268
+ kv = _metadata_kv(spec)
269
+ started = {key: kv.read(key) for key in keys}
270
+ out: dict[str, bytes | None | BaseException] = {}
271
+ for key in keys:
272
+ first = [started[key]]
273
+
274
+ def start(key=key, first=first):
275
+ return first.pop() if first else kv.read(key)
276
+
277
+ try:
278
+ result = _wait(start, spec, _where(spec, key), "read")
279
+ except Exception as e: # noqa: BLE001 the caller decides what a failed key means
280
+ out[key] = e
281
+ if not is_refused(e):
282
+ for future in started.values():
283
+ future.cancel()
284
+ return out
285
+ continue
286
+ out[key] = None if result.state == "missing" else bytes(result.value)
287
+ return out
288
+
289
+
290
+ def _read_key(spec: dict, key: str) -> bytes | None:
291
+ value = _read_keys(spec, [key])[key]
292
+ if isinstance(value, BaseException):
293
+ raise value
294
+ return value
213
295
 
214
296
 
215
297
  def _write_key(spec: dict, key: str, data: bytes) -> None:
216
- kv = _metadata_kvstore(
217
- json.dumps(spec, sort_keys=True), os.environ.get("TENSORSTORE_GCS_HTTP_URL")
218
- )
219
- kv.write(key, data).result()
298
+ kv = _metadata_kv(spec)
299
+ _wait(lambda: kv.write(key, data), spec, _where(spec, key), "write")
220
300
 
221
301
 
222
302
  class Location:
@@ -242,20 +322,21 @@ class Location:
242
322
  self.write(key, (json.dumps(doc, indent=2) + "\n").encode())
243
323
 
244
324
  def read_many(self, keys: Sequence[str]) -> dict[str, bytes | None]:
245
- """All of ``keys`` under one way in. A bucket with listing off answers a refusal for a
246
- missing key, so a refusal on one key counts as missing when another key was read
247
- under the same way; only when every key is refused is the next way tried."""
325
+ """All of ``keys`` under one way in, read at once (``_read_keys``). A bucket with
326
+ listing off answers a refusal for a missing key, so a refusal on one key counts as
327
+ missing when another key was read under the same way; only when every key is refused
328
+ is the next way tried."""
248
329
 
249
330
  def attempt(spec: dict) -> dict[str, bytes | None]:
250
331
  found: dict[str, bytes | None] = {}
251
332
  refusals: dict[str, BaseException] = {}
252
- for key in keys:
253
- try:
254
- found[key] = _read_key(spec, key)
255
- except Exception as e:
256
- if not is_refused(e):
257
- raise
258
- refusals[key] = e
333
+ for key, value in _read_keys(spec, keys).items():
334
+ if isinstance(value, BaseException):
335
+ if not is_refused(value):
336
+ raise value
337
+ refusals[key] = value
338
+ else:
339
+ found[key] = value
259
340
  if refusals and len(refusals) == len(keys):
260
341
  raise Refused(next(iter(refusals.values())).args[0] if refusals else "refused")
261
342
  for key in refusals:
@@ -323,12 +404,14 @@ def _decode(docs: dict[str, bytes | None]) -> dict[str, Any]:
323
404
  return {k: json.loads(v) for k, v in docs.items() if v is not None}
324
405
 
325
406
 
326
- def detect(path: str, *, missing_ok: bool = False) -> Node | None:
407
+ @retried_documents
408
+ def detect(path: str, *, missing_ok: bool = False, retries: Retry | None = None) -> Node | None:
327
409
  """Probe ``zarr.json``, ``.zarray``, ``.zgroup``, ``info``, ``attributes.json`` in that
328
410
  order; failing all, probe the parent for ``info`` and take the last path segment as a
329
411
  precomputed scale key, then probe ``s0`` for a group that has no document of its own.
330
412
  The ``.n5`` or ``.zarr`` suffix is never consulted. Nothing found raises
331
- ``FileNotFoundError``, or returns None with ``missing_ok``."""
413
+ ``FileNotFoundError``, or returns None with ``missing_ok``. ``retries`` times out and
414
+ retries each document read (Behaviour 6)."""
332
415
  path = normalize_path(path)
333
416
  docs = _decode(Location(path).read_many(PROBE_KEYS))
334
417
  if "zarr.json" in docs:
@@ -440,7 +523,12 @@ def open_array(
440
523
  spec["assume_metadata"] = True
441
524
  if node.format == "precomputed" and scale_index is not None:
442
525
  spec["scale_index"] = scale_index
443
- return ts.open(spec, read=True, write=write, context=watch(context)).result()
526
+ return _wait(
527
+ lambda: ts.open(spec, read=True, write=write, context=watch(context)),
528
+ kvstore,
529
+ node.path,
530
+ "open",
531
+ )
444
532
 
445
533
  store = with_access(kvstore_specs(node.path), attempt)
446
534
  if node.format in ("n5", "precomputed"):
@@ -2,9 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import functools
5
6
  import gc
7
+ import http.server
6
8
  import logging
7
9
  import os
10
+ import socket
11
+ import threading
8
12
  import time
9
13
  import weakref
10
14
 
@@ -54,6 +58,21 @@ def test_other_errors_raise_at_once():
54
58
  store.with_access(specs, attempt)
55
59
 
56
60
 
61
+ def _per_key(read_key):
62
+ """A ``store._read_keys`` stand-in built from a one-key fake."""
63
+
64
+ def read_keys(spec, keys):
65
+ out = {}
66
+ for key in keys:
67
+ try:
68
+ out[key] = read_key(spec, key)
69
+ except Exception as e: # noqa: BLE001
70
+ out[key] = e
71
+ return out
72
+
73
+ return read_keys
74
+
75
+
57
76
  def test_a_refused_probe_key_counts_as_missing_when_another_key_was_read(monkeypatch):
58
77
  present = {"zarr.json": b'{"node_type": "array"}'}
59
78
 
@@ -64,7 +83,7 @@ def test_a_refused_probe_key_counts_as_missing_when_another_key_was_read(monkeyp
64
83
  return present[key]
65
84
  raise RuntimeError("PERMISSION_DENIED: listing is off, so a missing key is a 403")
66
85
 
67
- monkeypatch.setattr(store, "_read_key", fake_read_key)
86
+ monkeypatch.setattr(store, "_read_keys", _per_key(fake_read_key))
68
87
  loc = store.Location("s3://bucket/vol.zarr/s0")
69
88
  found = loc.read_many(store.PROBE_KEYS)
70
89
  assert found["zarr.json"] == present["zarr.json"]
@@ -77,7 +96,7 @@ def test_all_probe_keys_refused_falls_back(monkeypatch):
77
96
  raise RuntimeError("PERMISSION_DENIED")
78
97
  return b"{}" if key == ".zarray" else None
79
98
 
80
- monkeypatch.setattr(store, "_read_key", fake_read_key)
99
+ monkeypatch.setattr(store, "_read_keys", _per_key(fake_read_key))
81
100
  found = store.Location("s3://bucket/vol.zarr/s0").read_many(store.PROBE_KEYS)
82
101
  assert found[".zarray"] == b"{}" and found["zarr.json"] is None
83
102
 
@@ -321,3 +340,62 @@ def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
321
340
  assert wa.context.from_recipe(("shared", 7)) is default
322
341
  with pytest.raises(ValueError):
323
342
  wa.shared_context(threads=-1)
343
+
344
+
345
+ @pytest.fixture
346
+ def silent_server():
347
+ """An http address whose server accepts every connection and never answers."""
348
+ srv = socket.socket()
349
+ srv.bind(("127.0.0.1", 0))
350
+ srv.listen(16)
351
+ held = []
352
+
353
+ def accept():
354
+ while True:
355
+ try:
356
+ held.append(srv.accept()[0])
357
+ except OSError:
358
+ return
359
+
360
+ threading.Thread(target=accept, daemon=True).start()
361
+ yield f"http://127.0.0.1:{srv.getsockname()[1]}"
362
+ srv.close()
363
+ for c in held:
364
+ c.close()
365
+
366
+
367
+ def test_a_server_that_never_answers_times_out_the_open(silent_server):
368
+ quick = wa.Retry(timeout=0.3, max_attempts=2, base_delay=0.0)
369
+ for open_it in (
370
+ lambda url: wa.metadata(url, open=False, retries=quick),
371
+ lambda url: wa.open(url, retries=quick),
372
+ lambda url: wa.detect(url, retries=quick),
373
+ ):
374
+ start = time.monotonic()
375
+ with pytest.raises(TimeoutError, match="127.0.0.1.*2 attempts"):
376
+ open_it(f"{silent_server}/vol.zarr/s0")
377
+ assert time.monotonic() - start < 10
378
+
379
+
380
+ def test_an_http_open_probes_its_keys_at_once(tmp_path):
381
+ """Five probe keys, one round trip: each answer here takes 0.3 s."""
382
+ write_zarr2(tmp_path / "vol.zarr" / "s0", pattern((4, 4, 4)))
383
+
384
+ class Slow(http.server.SimpleHTTPRequestHandler):
385
+ def do_GET(self):
386
+ time.sleep(0.3)
387
+ super().do_GET()
388
+
389
+ def log_message(self, *args):
390
+ pass
391
+
392
+ handler = functools.partial(Slow, directory=str(tmp_path))
393
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
394
+ threading.Thread(target=server.serve_forever, daemon=True).start()
395
+ try:
396
+ start = time.monotonic()
397
+ node = wa.detect(f"http://127.0.0.1:{server.server_address[1]}/vol.zarr/s0")
398
+ assert node.format == "zarr2" and node.kind == "array"
399
+ assert time.monotonic() - start < 1.0 # in turn: 1.5 s
400
+ finally:
401
+ server.shutdown()
@@ -1247,7 +1247,7 @@ wheels = [
1247
1247
 
1248
1248
  [[package]]
1249
1249
  name = "worldarray"
1250
- version = "0.6.0"
1250
+ version = "0.7.1"
1251
1251
  source = { editable = "." }
1252
1252
  dependencies = [
1253
1253
  { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes