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.
- {worldarray-0.5.0 → worldarray-0.7.0}/CHANGELOG.md +30 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/PKG-INFO +8 -6
- {worldarray-0.5.0 → worldarray-0.7.0}/README.md +7 -5
- {worldarray-0.5.0 → worldarray-0.7.0}/docs/spec.md +65 -25
- {worldarray-0.5.0 → worldarray-0.7.0}/pyproject.toml +1 -1
- {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/__init__.py +1 -1
- {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/array.py +23 -7
- {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/context.py +36 -26
- {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/store.py +63 -6
- {worldarray-0.5.0 → worldarray-0.7.0}/tests/test_access.py +57 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/tests/test_write.py +2 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/uv.lock +1 -1
- {worldarray-0.5.0 → worldarray-0.7.0}/.github/workflows/ci.yml +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/.github/workflows/docs.yml +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/.github/workflows/publish.yml +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/.gitignore +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/LICENSE +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/docs/changelog.md +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/docs/index.md +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/mkdocs.yml +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/geometry.py +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/grid.py +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/levels.py +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/placement.py +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/resample.py +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/retry.py +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/units.py +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/src/worldarray/write.py +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/tests/conftest.py +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/tests/fixtures/metadata.json +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/tests/fixtures/write.json +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/tests/test_geometry.py +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/tests/test_grid.py +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/tests/test_lost_wakeup.py +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/tests/test_metadata.py +0 -0
- {worldarray-0.5.0 → worldarray-0.7.0}/tests/test_read.py +0 -0
- {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.
|
|
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
|
-
|
|
212
|
-
wa.
|
|
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
|
-
|
|
153
|
-
wa.
|
|
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
|
|
233
|
-
the levels of a pyramid and repeat opens share one cache
|
|
234
|
-
decoding and file I/O pools are sized by
|
|
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.
|
|
650
|
-
|
|
651
|
-
|
|
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
|
|
688
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.**
|
|
1145
|
-
`
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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 (
|
|
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.
|
|
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" }
|
|
@@ -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,
|
|
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,
|
|
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
|
|
1394
|
-
read
|
|
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
|
|
4
|
-
process, so the levels of a pyramid and repeat opens of an
|
|
5
|
-
The default budget is 0: tensorstore's decoded-chunk cache
|
|
6
|
-
either reads each region once or caches decoded chunks
|
|
7
|
-
pools are sized by ``thread_budget()``, from
|
|
8
|
-
tensorstore alone would size them to every CPU on
|
|
9
|
-
|
|
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(
|
|
150
|
+
ctx = _shared.get(key)
|
|
142
151
|
if ctx is None:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|