worldarray 0.5.0__tar.gz → 0.7.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.
Files changed (37) hide show
  1. {worldarray-0.5.0 → worldarray-0.7.0}/CHANGELOG.md +30 -0
  2. {worldarray-0.5.0 → worldarray-0.7.0}/PKG-INFO +8 -6
  3. {worldarray-0.5.0 → worldarray-0.7.0}/README.md +7 -5
  4. {worldarray-0.5.0 → worldarray-0.7.0}/docs/spec.md +65 -25
  5. {worldarray-0.5.0 → worldarray-0.7.0}/pyproject.toml +1 -1
  6. {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/__init__.py +1 -1
  7. {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/array.py +23 -7
  8. {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/context.py +36 -26
  9. {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/store.py +63 -6
  10. {worldarray-0.5.0 → worldarray-0.7.0}/tests/test_access.py +57 -0
  11. {worldarray-0.5.0 → worldarray-0.7.0}/tests/test_write.py +2 -0
  12. {worldarray-0.5.0 → worldarray-0.7.0}/uv.lock +1 -1
  13. {worldarray-0.5.0 → worldarray-0.7.0}/.github/workflows/ci.yml +0 -0
  14. {worldarray-0.5.0 → worldarray-0.7.0}/.github/workflows/docs.yml +0 -0
  15. {worldarray-0.5.0 → worldarray-0.7.0}/.github/workflows/publish.yml +0 -0
  16. {worldarray-0.5.0 → worldarray-0.7.0}/.gitignore +0 -0
  17. {worldarray-0.5.0 → worldarray-0.7.0}/LICENSE +0 -0
  18. {worldarray-0.5.0 → worldarray-0.7.0}/docs/changelog.md +0 -0
  19. {worldarray-0.5.0 → worldarray-0.7.0}/docs/index.md +0 -0
  20. {worldarray-0.5.0 → worldarray-0.7.0}/mkdocs.yml +0 -0
  21. {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/geometry.py +0 -0
  22. {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/grid.py +0 -0
  23. {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/levels.py +0 -0
  24. {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/placement.py +0 -0
  25. {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/resample.py +0 -0
  26. {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/retry.py +0 -0
  27. {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/units.py +0 -0
  28. {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/write.py +0 -0
  29. {worldarray-0.5.0 → worldarray-0.7.0}/tests/conftest.py +0 -0
  30. {worldarray-0.5.0 → worldarray-0.7.0}/tests/fixtures/metadata.json +0 -0
  31. {worldarray-0.5.0 → worldarray-0.7.0}/tests/fixtures/write.json +0 -0
  32. {worldarray-0.5.0 → worldarray-0.7.0}/tests/test_geometry.py +0 -0
  33. {worldarray-0.5.0 → worldarray-0.7.0}/tests/test_grid.py +0 -0
  34. {worldarray-0.5.0 → worldarray-0.7.0}/tests/test_lost_wakeup.py +0 -0
  35. {worldarray-0.5.0 → worldarray-0.7.0}/tests/test_metadata.py +0 -0
  36. {worldarray-0.5.0 → worldarray-0.7.0}/tests/test_read.py +0 -0
  37. {worldarray-0.5.0 → worldarray-0.7.0}/tests/test_resample.py +0 -0
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 (2026-10-23)
4
+
5
+ Opens time out. No placement change and no unit table change.
6
+
7
+ - Every metadata document read or written and every store opened is timed out and
8
+ retried under `Retry`, as data reads are (Behaviour 6): by default 5 s times the attempt,
9
+ ten attempts. A server that accepts a connection and never answers held `wa.open` and
10
+ `wa.metadata` forever before; it now raises `TimeoutError` naming the URL and the
11
+ attempts (cellmap-flow, 2026-10-23). The policy is the call's `retries`, which
12
+ `wa.metadata`, `wa.detect` and `wa.open_tensorstore` now take as `wa.open`,
13
+ `wa.open_pyramid` and `wa.create` already did. Listings and the open that creates an
14
+ array are not timed out.
15
+
16
+ ## 0.6.0 (2026-10-23)
17
+
18
+ One new argument, at chunkmirage's request. No placement change and no unit table change;
19
+ nothing changes for a caller that does not pass it.
20
+
21
+ - `wa.shared_context(cache_bytes, threads=)`: `None` (the default) limits the decoding and
22
+ file I/O pools to `thread_budget()` as in 0.5, a positive number limits them to that many,
23
+ and `0` writes no limit, so tensorstore sizes them itself (every CPU on the node). Each
24
+ `(cache_bytes, threads)` is one watched context for the process, so a server can share
25
+ one cache pool across every store it opens at the thread count it chose, without building
26
+ and watching its own context. A pickled handle on such a context comes back on the same
27
+ one; `threads=None` still lets the other process size by its own job.
28
+ - Documented what a pool of one thread costs (spec Behaviour 7, `capped_context_spec`):
29
+ nothing on one-chunk blocks; on 8-chunk blocks, half the speed of 4 in a two-slot job
30
+ but only 7 to 19% slower in a one-slot one (c-elegans on /nrs), so a worker per slot
31
+ loses little at the default.
32
+
3
33
  ## 0.5.0 (2026-10-23)
4
34
 
5
35
  Two bugs that hung or crashed cellmap-analyze 0.6 on /nrs (salamander's and c-elegans'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: worldarray
3
- Version: 0.5.0
3
+ Version: 0.7.0
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,11 +205,13 @@ 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)
211
- wa.shared_context(cache_bytes=0) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
212
- wa.thread_budget() # the shared context's thread limit: OMP_NUM_THREADS, the job's slots, or CPUs
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
212
+ wa.shared_context(cache_bytes=0, threads=None) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
213
+ wa.thread_budget() # the shared context's default thread limit: OMP_NUM_THREADS, the job's slots, or CPUs;
214
+ # threads=N limits it to N, threads=0 leaves tensorstore's own pool sizes
213
215
  wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
214
216
  wa.Box ; wa.units.TABLE
215
217
 
@@ -146,11 +146,13 @@ 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)
152
- wa.shared_context(cache_bytes=0) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
153
- wa.thread_budget() # the shared context's thread limit: OMP_NUM_THREADS, the job's slots, or CPUs
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
153
+ wa.shared_context(cache_bytes=0, threads=None) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
154
+ wa.thread_budget() # the shared context's default thread limit: OMP_NUM_THREADS, the job's slots, or CPUs;
155
+ # threads=N limits it to N, threads=0 leaves tensorstore's own pool sizes
154
156
  wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
155
157
  wa.Box ; wa.units.TABLE
156
158
 
@@ -177,15 +177,16 @@ 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}}
187
187
  wa.select_level(levels, voxel_size, mode) ; wa.Box ; wa.Retry(max_attempts=10, timeout=None, base_delay=1.0)
188
- wa.shared_context(cache_bytes=0) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.capped_context_spec(...)
188
+ wa.shared_context(cache_bytes=0, threads=None) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.capped_context_spec(...)
189
+ # threads: None = thread_budget(), N = that many, 0 = tensorstore's own pool sizes (0.6)
189
190
  # network=False caps decoding and local file I/O only, leaving tensorstore's request pools alone
190
191
  wa.list_chunk_keys(path, separator=".", ndim=None) # chunk indices present under a path, from its keys alone: no
191
192
  # metadata document needed, so a directory of chunk files with no .zarray lists too
@@ -229,9 +230,11 @@ returned one would raise on inputs cellmap-analyze reads every day. A consumer t
229
230
  in integer Rois builds them from `corner`, `voxel_size` and `shape` in its own subclass,
230
231
  where it also chooses the integer scale factor that makes the corner whole.
231
232
 
232
- `shared_context(cache_bytes)` is one `tensorstore.Context` per budget for the process, so
233
- the levels of a pyramid and repeat opens share one cache pool; the default budget is 0. Its
234
- decoding and file I/O pools are sized by `thread_budget()` (0.5, Behaviour 7).
233
+ `shared_context(cache_bytes, threads=None)` is one `tensorstore.Context` per budget and
234
+ thread limit for the process, so the levels of a pyramid and repeat opens share one cache
235
+ pool; the default budget is 0. Its decoding and file I/O pools are sized by
236
+ `thread_budget()` (0.5), or by `threads` when given, `0` leaving tensorstore's own sizes
237
+ (0.6, Behaviour 7).
235
238
  `capped_context` is mesh-n-bone's worker-process context (every tensorstore concurrency
236
239
  resource limited, cache off); it returns a **new** context per call, as those workers
237
240
  inline the spec into every open today, and `capped_context_spec` exports the dict for
@@ -518,7 +521,8 @@ this document and that one differ, fix one of them.
518
521
  one probe key counts as "missing" when another probe key was read under the same way,
519
522
  and only when every probe key is refused is the next way tried (see Open question 4).
520
523
  Metadata reads retry for about five seconds in total (`max_retries 5, initial_delay
521
- 0.2s, max_delay 2s`); data reads keep tensorstore's defaults plus `Retry`.
524
+ 0.2s, max_delay 2s`); data reads keep tensorstore's defaults plus `Retry`, and each
525
+ document read and store open is also timed out under `Retry` (0.7, Behaviour 6).
522
526
 
523
527
  6. **Writing is reading inverted, in one format.** The package writes OME-NGFF zarr only:
524
528
  zarr v2 with OME 0.4 (`.zgroup`, and `.zattrs` holding `multiscales`, each entry with
@@ -646,9 +650,21 @@ this document and that one differ, fix one of them.
646
650
  still land after its cancel, with the same bytes the next attempt writes. Each retry is
647
651
  logged as a warning naming the error's first line, or its type when the message is
648
652
  empty (tensorstore's timeout is `TimeoutError()`); when every attempt times out the
649
- `TimeoutError` names the operation, the path and the attempts. Metadata documents are not
650
- retried this way (Convention 5). The policy is a per-handle attribute and a per-read and
651
- per-write argument, never only constructor state.
653
+ `TimeoutError` names the operation, the path and the attempts. The policy is a
654
+ per-handle attribute and a per-read and per-write argument, never only constructor
655
+ state.
656
+
657
+ Documents and opens (0.7): every metadata document read or written and every store
658
+ opened is timed out and retried the same way, as an operation of no bytes (by default
659
+ 5 s times the attempt, ten attempts), with tensorstore's own request retries
660
+ (Convention 5) running inside each attempt. A server that accepts a connection and
661
+ never answers used to hold `wa.open` and `wa.metadata` forever: cellmap-flow's picker
662
+ kept a 5 s probe of its own in front of them (2026-10-23). The policy is the `retries`
663
+ of the public call doing the reading: `open`, `open_pyramid`, `create`, `metadata`,
664
+ `detect` and `open_tensorstore`, which all take it; a document read later outside such
665
+ a call (a zarr v2 `.zattrs` read on demand, a pyramid placing its levels) waits under
666
+ `Retry()`. Listings are not timed out, their size being unknown, and neither is the
667
+ open that creates an array, which a second attempt would find already made.
652
668
 
653
669
  The watchdog (0.5): tensorstore up to at least 0.1.85 can strand a queued task. A pool
654
670
  worker idle for 20 ms exits, and a task queued as it exits gets no thread while the pool
@@ -684,13 +700,27 @@ this document and that one differ, fix one of them.
684
700
  task (a 256^3 block write took 2.1 s against 1.3 s uncapped) but not a job of many
685
701
  workers, whose throughput its cores bound either way (four writers: 1.8 s against
686
702
  1.9 s). A process that wants more threads than its job has slots, a server under
687
- `bsub -n 1` say, asks for more slots or passes its own context (decided 2026-10-23,
688
- over chunkmirage's objection, once the watchdog had removed the hang risk).
703
+ `bsub -n 1` say, asks for more slots or says so: `shared_context(cache_bytes,
704
+ threads=N)` limits both pools to `N`, and `threads=0` writes no limit, leaving
705
+ tensorstore's own sizes (decided 2026-10-23, over chunkmirage's objection, once the
706
+ watchdog had removed the hang risk; the argument is 0.6, at chunkmirage's request, so
707
+ its server keeps one watched context per cache budget for every store it opens
708
+ instead of building its own). Each `(cache_bytes, threads)` is one context for the
709
+ process, made on first use and watched; `threads=None` and the number
710
+ `thread_budget()` returns are two contexts, as a pickle carries `None` for the other
711
+ process to size by its own job and a number as that number.
689
712
  `capped_context` sets `data_copy_concurrency`, `file_io_concurrency`,
690
713
  `gcs_request_concurrency`, `s3_request_concurrency` and `http_request_concurrency` to
691
714
  `concurrency` and the cache to `cache_bytes`, and returns a new context each call, so two
692
715
  handles never share one capped pool unless the caller passes the same object twice; a
693
- `concurrency` of 1 is safe under Behaviour 6's watchdog. Both are plain tensorstore contexts; a caller may pass its own, at construction or through
716
+ `concurrency` of 1 is safe under Behaviour 6's watchdog, but a read or write spanning
717
+ several chunks then handles them one at a time. On /nrs, from one process alternating
718
+ limits op by op (c-elegans, 2026-10-23): a one-chunk block (128^3 uint64) read in 18 to
719
+ 19 ms and wrote in 51 ms at both 1 and 4. An 8-chunk block (256^3, what a downsampling
720
+ step reads) read in 157 ms against 73 and wrote in 312 ms against 158 in a two-slot
721
+ job, but in a one-slot job read in 147 ms against 119 and wrote in 325 ms against 302
722
+ (and that job still used 1.3 cores, LSF not pinning it). Most of the gain is a core the
723
+ worker was not given, so a worker per slot, as a dask job runs, loses little at 1. Both are plain tensorstore contexts; a caller may pass its own, at construction or through
694
724
  `reopen(context=)` when a long-lived worker changes its concurrency (cellmap-analyze
695
725
  reopens tensorstore on every read today for that reason).
696
726
 
@@ -769,8 +799,8 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
769
799
  unchanged, and a handle pickles by its arguments and its context's spec, so each worker
770
800
  process opens its own cheaply (a local path goes resolved against the working directory
771
801
  at open, so a worker that changes directory before unpickling, as cellmap-analyze's dask
772
- workers do, opens the same store; `reopen` uses it too), on the shared context when the handle had one, else on
773
- a context rebuilt once per process from the same spec, so a capped context stays
802
+ workers do, opens the same store; `reopen` uses it too), on the shared context when the handle had one (the same
803
+ `threads`, and the same cache budget), else on a context rebuilt once per process from the same spec, so a capped context stays
774
804
  capped (mesh-n-bone's per-process cache and thread caps).
775
805
 
776
806
  12. **Group attributes belong to everyone.** The package rewrites only the `multiscales`
@@ -1000,7 +1030,10 @@ nothing resolves) cancelled and retried, the warning naming `TimeoutError`, and
1000
1030
  after the last attempt; a capped context and a caller's context passed to an open both
1001
1031
  kicked by the watchdog, a dead context no longer watched, and a kick landing on a capped
1002
1032
  context's own pools; `thread_budget` from each variable, a bad value skipped, and the
1003
- shared context's limits. Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
1033
+ shared context's limits. 0.6: `shared_context(threads=)` at `None`, `N` and `0`, one
1034
+ context per pair, a negative count refused, and a pickled handle coming back on the same
1035
+ shared context. 0.7: `metadata`, `open` and `detect` against a server
1036
+ that accepts and never answers raising `TimeoutError` after the attempts. Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
1004
1037
  `tests/test_lost_wakeup.py`): worker processes aim tasks at the moment a one-thread pool's
1005
1038
  worker exits idle, so a plain context strands some for good (13 of about 7,000 on
1006
1039
  tensorstore 0.1.78; 40 to 44 of 12,000 to 15,000 with 16 workers on 0.1.75 and 0.1.85)
@@ -1141,13 +1174,20 @@ over funlib geometry, by this spec's own assignment.
1141
1174
  a real bucket with listing disabled. One network test before 0.1.0 may change the rule.
1142
1175
  4. **The network test** against a non-integer-voxel OpenOrganelle volume (Tests) is not yet
1143
1176
  written; it needs the dataset chosen from the catalog.
1144
- 5. **Legacy attributes on new outputs.** cellmap-analyze writes funlib `voxel_size` and
1145
- `offset` (holding the centre) on every output and nothing in the five repos reads them;
1146
- dacapo's checkout has nine `open_ds` users, and cellmap-data and fibsem-tools were not
1147
- checked. Does any live consumer of cellmap-analyze or cellmap-flow outputs read funlib
1148
- attributes? If so, should the package offer `funlib_attrs(voxel_size, translation, axes,
1149
- units)`, writing `offset` as the corner, funlib's meaning, for `create(attrs=)`, or do
1150
- the repos keep writing their own?
1177
+ 5. **Legacy attributes on new outputs.** Resolved 2026-10-23: nobody needs them, so there
1178
+ is no `funlib_attrs`. cellmap-analyze stopped writing funlib `voxel_size` and `offset`
1179
+ (which held the centre) in 0.6.0, whose outputs carry only the OME documents `create`
1180
+ writes. cellmap-flow writes them through funlib's own `prepare_ds`, `offset` as the
1181
+ corner beside an agreeing OME 0.4 translation, and is their only reader. dacapo reads
1182
+ funlib attributes through `open_ds` (`ome_metadata=False` by default), but nothing feeds
1183
+ it these outputs, and on a zarr v3 one funlib 0.7 silently reads voxel size 1 and
1184
+ offset 0. cellmap-data and fibsem-tools read OME only, and both pin `zarr<3`. funlib
1185
+ 0.6 and 0.7 also require integer offsets and voxel sizes, so a float helper here would
1186
+ either refuse placements or write numbers funlib truncates; a repo that wants the keys
1187
+ passes them through `create(attrs=)`. Found on the way, outside this package:
1188
+ cellmap-data (main 356c3ac, `image.py`) takes an OME translation as voxel 0's corner,
1189
+ half a voxel off on every OME-correct store, and funlib's `open_ome_ds` puts the
1190
+ translation straight into its corner `offset`.
1151
1191
  6. **The block iterator's shape** (Behaviour 17). Resolved 2026-10-22: both sessions
1152
1192
  confirmed it. mesh-n-bone asked that `box` may reach past the array and that `clip`
1153
1193
  clips read boxes only, and derives its per-level boxes from one grid itself;
@@ -1178,7 +1218,7 @@ over funlib geometry, by this spec's own assignment.
1178
1218
  unlisted has its placement nowhere until it is listed, so a `create(listed=False)`
1179
1219
  handle, and its pickles, would carry the placement from its arguments alone. Decide
1180
1220
  whether that is worth having, or whether an unlisted level should describe itself on
1181
- the array (open question 5's funlib attributes, or an OME-shaped array attribute).
1221
+ the array (an OME-shaped array attribute; funlib keys are out, open question 5).
1182
1222
  12. **Per-open object-store credentials.** cellmap-flow's MinIO writers open with a
1183
1223
  per-session key, secret and endpoint while the same process reads public S3
1184
1224
  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.5.0"
3
+ version = "0.7.0"
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.5.0"
31
+ __version__ = "0.7.0"
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":
@@ -1,12 +1,14 @@
1
1
  """Tensorstore contexts: one shared per cache budget, or a capped one per call.
2
2
 
3
- ``shared_context(cache_bytes)`` is one ``tensorstore.Context`` per budget for the whole
4
- process, so the levels of a pyramid and repeat opens of an array draw on one cache pool.
5
- The default budget is 0: tensorstore's decoded-chunk cache is off, because every consumer
6
- either reads each region once or caches decoded chunks itself. Its decoding and file I/O
7
- pools are sized by ``thread_budget()``, from the variables a cluster job sets, where
8
- tensorstore alone would size them to every CPU on the node. Every context here, and every
9
- one a store is opened on, is watched for tensorstore's lost wakeup (``watch``).
3
+ ``shared_context(cache_bytes, threads=)`` is one ``tensorstore.Context`` per budget and
4
+ thread limit for the whole process, so the levels of a pyramid and repeat opens of an
5
+ array draw on one cache pool. The default budget is 0: tensorstore's decoded-chunk cache
6
+ is off, because every consumer either reads each region once or caches decoded chunks
7
+ itself. By default its decoding and file I/O pools are sized by ``thread_budget()``, from
8
+ the variables a cluster job sets, where tensorstore alone would size them to every CPU on
9
+ the node; ``threads=0`` leaves them at tensorstore's sizes (chunkmirage's server). Every
10
+ context here, and every one a store is opened on, is watched for tensorstore's lost wakeup
11
+ (``watch``).
10
12
 
11
13
  ``capped_context`` is mesh-n-bone's worker-process context: every tensorstore concurrency
12
14
  resource limited, so dozens of worker processes on one host do not multiply into thousands
@@ -26,7 +28,7 @@ from typing import Any
26
28
 
27
29
  import tensorstore as ts
28
30
 
29
- _shared: dict[int, ts.Context] = {}
31
+ _shared: dict[tuple[int, int | None], ts.Context] = {}
30
32
  _rebuilt: dict[str, ts.Context] = {}
31
33
  _lock = threading.Lock()
32
34
 
@@ -136,20 +138,23 @@ def thread_budget() -> int:
136
138
  return os.cpu_count() or 1
137
139
 
138
140
 
139
- def shared_context(cache_bytes: int = 0) -> ts.Context:
141
+ def shared_context(cache_bytes: int = 0, *, threads: int | None = None) -> ts.Context:
142
+ """The process's one context for ``cache_bytes`` and ``threads``, made on first use and
143
+ watched. ``threads`` limits its decoding and file I/O pools: ``None`` to
144
+ ``thread_budget()``, a positive number to that many, ``0`` not at all (tensorstore's own
145
+ sizes, every CPU on the node)."""
146
+ key = (int(cache_bytes), None if threads is None else int(threads))
147
+ if key[1] is not None and key[1] < 0:
148
+ raise ValueError(f"threads must be None, 0 or positive, not {threads!r}")
140
149
  with _lock:
141
- ctx = _shared.get(int(cache_bytes))
150
+ ctx = _shared.get(key)
142
151
  if ctx is None:
143
- limit = {"limit": thread_budget()}
144
- ctx = ts.Context(
145
- {
146
- "cache_pool": {"total_bytes_limit": int(cache_bytes)},
147
- "data_copy_concurrency": limit,
148
- "file_io_concurrency": limit,
149
- }
150
- )
151
- watch(ctx)
152
- _shared[int(cache_bytes)] = ctx
152
+ spec: dict = {"cache_pool": {"total_bytes_limit": key[0]}}
153
+ limit = thread_budget() if threads is None else key[1]
154
+ if limit:
155
+ spec["data_copy_concurrency"] = spec["file_io_concurrency"] = {"limit": limit}
156
+ ctx = watch(ts.Context(spec))
157
+ _shared[key] = ctx
153
158
  return ctx
154
159
 
155
160
 
@@ -157,7 +162,9 @@ def capped_context_spec(cache_bytes: int = 0, concurrency: int = 1, network: boo
157
162
  """The spec dict behind ``capped_context``, for callers building their own opens.
158
163
  ``network=False`` caps only decoding and local file I/O, leaving tensorstore's request
159
164
  pools alone (cellmap-analyze's choice for remote reads). A ``concurrency`` of 1 is safe:
160
- the context is watched (see ``KICK_EVERY``)."""
165
+ the context is watched (see ``KICK_EVERY``). It is not free: a read or write spanning
166
+ several chunks then decodes and stores them one at a time, half the speed of 4 on
167
+ 8-chunk blocks in a two-slot job, 7 to 19% slower in a one-slot one (spec, Behaviour 7)."""
161
168
  limit = {"limit": int(concurrency)}
162
169
  spec = {
163
170
  "data_copy_concurrency": limit,
@@ -179,12 +186,14 @@ def capped_context(cache_bytes: int = 0, concurrency: int = 1, network: bool = T
179
186
 
180
187
  def recipe_of(context: ts.Context) -> tuple[str, Any]:
181
188
  """How another process rebuilds ``context``: ``("shared", cache_bytes)`` for a shared
182
- context, else ``("spec", json)`` from the context's own spec, so a capped context stays
183
- capped across a pickle (Behaviour 11, mesh-n-bone's workers)."""
189
+ context at the default limit (the other process takes its own ``thread_budget()``),
190
+ ``("shared", (cache_bytes, threads))`` for one with ``threads`` given, else
191
+ ``("spec", json)`` from the context's own spec, so a capped context stays capped across
192
+ a pickle (Behaviour 11, mesh-n-bone's workers)."""
184
193
  with _lock:
185
- for cache_bytes, ctx in _shared.items():
194
+ for (cache_bytes, threads), ctx in _shared.items():
186
195
  if ctx is context:
187
- return ("shared", cache_bytes)
196
+ return ("shared", cache_bytes if threads is None else (cache_bytes, threads))
188
197
  spec = context.spec
189
198
  return ("spec", json.dumps(spec.to_json() if spec is not None else {}, sort_keys=True))
190
199
 
@@ -196,7 +205,8 @@ def from_recipe(recipe: tuple[str, Any] | None) -> ts.Context | None:
196
205
  return None
197
206
  kind, value = recipe
198
207
  if kind == "shared":
199
- return shared_context(int(value))
208
+ cache_bytes, threads = value if isinstance(value, (tuple, list)) else (value, None)
209
+ return shared_context(int(cache_bytes), threads=threads)
200
210
  with _lock:
201
211
  ctx = _rebuilt.get(value)
202
212
  if ctx is None:
@@ -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,14 +250,15 @@ 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
257
  def _read_key(spec: dict, key: str) -> bytes | None:
208
258
  kv = _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()
261
+ result = _wait(lambda: kv.read(key), spec, _where(spec, key), "read")
212
262
  return None if result.state == "missing" else bytes(result.value)
213
263
 
214
264
 
@@ -216,7 +266,7 @@ def _write_key(spec: dict, key: str, data: bytes) -> None:
216
266
  kv = _metadata_kvstore(
217
267
  json.dumps(spec, sort_keys=True), os.environ.get("TENSORSTORE_GCS_HTTP_URL")
218
268
  )
219
- kv.write(key, data).result()
269
+ _wait(lambda: kv.write(key, data), spec, _where(spec, key), "write")
220
270
 
221
271
 
222
272
  class Location:
@@ -323,12 +373,14 @@ def _decode(docs: dict[str, bytes | None]) -> dict[str, Any]:
323
373
  return {k: json.loads(v) for k, v in docs.items() if v is not None}
324
374
 
325
375
 
326
- def detect(path: str, *, missing_ok: bool = False) -> Node | None:
376
+ @retried_documents
377
+ def detect(path: str, *, missing_ok: bool = False, retries: Retry | None = None) -> Node | None:
327
378
  """Probe ``zarr.json``, ``.zarray``, ``.zgroup``, ``info``, ``attributes.json`` in that
328
379
  order; failing all, probe the parent for ``info`` and take the last path segment as a
329
380
  precomputed scale key, then probe ``s0`` for a group that has no document of its own.
330
381
  The ``.n5`` or ``.zarr`` suffix is never consulted. Nothing found raises
331
- ``FileNotFoundError``, or returns None with ``missing_ok``."""
382
+ ``FileNotFoundError``, or returns None with ``missing_ok``. ``retries`` times out and
383
+ retries each document read (Behaviour 6)."""
332
384
  path = normalize_path(path)
333
385
  docs = _decode(Location(path).read_many(PROBE_KEYS))
334
386
  if "zarr.json" in docs:
@@ -440,7 +492,12 @@ def open_array(
440
492
  spec["assume_metadata"] = True
441
493
  if node.format == "precomputed" and scale_index is not None:
442
494
  spec["scale_index"] = scale_index
443
- return ts.open(spec, read=True, write=write, context=watch(context)).result()
495
+ return _wait(
496
+ lambda: ts.open(spec, read=True, write=write, context=watch(context)),
497
+ kvstore,
498
+ node.path,
499
+ "open",
500
+ )
444
501
 
445
502
  store = with_access(kvstore_specs(node.path), attempt)
446
503
  if node.format in ("n5", "precomputed"):
@@ -5,6 +5,8 @@ from __future__ import annotations
5
5
  import gc
6
6
  import logging
7
7
  import os
8
+ import socket
9
+ import threading
8
10
  import time
9
11
  import weakref
10
12
 
@@ -301,3 +303,58 @@ def test_gce_guard_is_left_alone_on_a_google_machine(monkeypatch):
301
303
  monkeypatch.setattr(store, "_on_google_cloud", lambda: False)
302
304
  store.kvstore_specs("gs://bucket/vol")
303
305
  assert os.environ["GCE_METADATA_ROOT"] == "metadata.google.internal.invalid"
306
+
307
+
308
+ def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
309
+ monkeypatch.setenv("OMP_NUM_THREADS", "1")
310
+ monkeypatch.setattr(wa.context, "_shared", {})
311
+ default = wa.shared_context(7)
312
+ assert default.spec.to_json()["data_copy_concurrency"] == {"limit": 1}
313
+ four = wa.shared_context(7, threads=4).spec.to_json()
314
+ assert four["data_copy_concurrency"] == four["file_io_concurrency"] == {"limit": 4}
315
+ machine = wa.shared_context(7, threads=0)
316
+ assert machine is wa.shared_context(7, threads=0) and machine is not default
317
+ spec = machine.spec.to_json()
318
+ assert "data_copy_concurrency" not in spec and "file_io_concurrency" not in spec
319
+ assert spec["cache_pool"] == {"total_bytes_limit": 7}
320
+ assert machine in wa.context._watched
321
+ assert wa.context.recipe_of(machine) == ("shared", (7, 0))
322
+ assert wa.context.from_recipe(("shared", (7, 0))) is machine
323
+ assert wa.context.from_recipe(("shared", 7)) is default
324
+ with pytest.raises(ValueError):
325
+ wa.shared_context(threads=-1)
326
+
327
+
328
+ @pytest.fixture
329
+ def silent_server():
330
+ """An http address whose server accepts every connection and never answers."""
331
+ srv = socket.socket()
332
+ srv.bind(("127.0.0.1", 0))
333
+ srv.listen(16)
334
+ held = []
335
+
336
+ def accept():
337
+ while True:
338
+ try:
339
+ held.append(srv.accept()[0])
340
+ except OSError:
341
+ return
342
+
343
+ threading.Thread(target=accept, daemon=True).start()
344
+ yield f"http://127.0.0.1:{srv.getsockname()[1]}"
345
+ srv.close()
346
+ for c in held:
347
+ c.close()
348
+
349
+
350
+ def test_a_server_that_never_answers_times_out_the_open(silent_server):
351
+ quick = wa.Retry(timeout=0.3, max_attempts=2, base_delay=0.0)
352
+ for open_it in (
353
+ lambda url: wa.metadata(url, open=False, retries=quick),
354
+ lambda url: wa.open(url, retries=quick),
355
+ lambda url: wa.detect(url, retries=quick),
356
+ ):
357
+ start = time.monotonic()
358
+ with pytest.raises(TimeoutError, match="127.0.0.1.*2 attempts"):
359
+ open_it(f"{silent_server}/vol.zarr/s0")
360
+ assert time.monotonic() - start < 10
@@ -826,6 +826,8 @@ def test_a_pickled_handle_keeps_its_context(tmp_path):
826
826
  ) # one rebuilt context per spec
827
827
  shared = pickle.loads(pickle.dumps(wa.open(path)))
828
828
  assert shared.context is wa.shared_context(0)
829
+ machine = wa.shared_context(0, threads=0)
830
+ assert pickle.loads(pickle.dumps(wa.open(path, context=machine))).context is machine
829
831
 
830
832
 
831
833
  def test_codecs_of_a_path_and_the_fill_write_that_removes_a_key(tmp_path):
@@ -1247,7 +1247,7 @@ wheels = [
1247
1247
 
1248
1248
  [[package]]
1249
1249
  name = "worldarray"
1250
- version = "0.5.0"
1250
+ version = "0.7.0"
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