worldarray 0.8.0__tar.gz → 0.9.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.
- {worldarray-0.8.0 → worldarray-0.9.1}/CHANGELOG.md +27 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/PKG-INFO +11 -9
- {worldarray-0.8.0 → worldarray-0.9.1}/README.md +10 -8
- {worldarray-0.8.0 → worldarray-0.9.1}/docs/spec.md +43 -16
- {worldarray-0.8.0 → worldarray-0.9.1}/pyproject.toml +1 -1
- {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/__init__.py +1 -1
- {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/context.py +76 -26
- {worldarray-0.8.0 → worldarray-0.9.1}/tests/test_access.py +69 -1
- {worldarray-0.8.0 → worldarray-0.9.1}/tests/test_write.py +17 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/uv.lock +1 -1
- {worldarray-0.8.0 → worldarray-0.9.1}/.github/workflows/ci.yml +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/.github/workflows/docs.yml +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/.github/workflows/publish.yml +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/.gitignore +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/LICENSE +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/docs/changelog.md +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/docs/index.md +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/mkdocs.yml +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/array.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/geometry.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/grid.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/levels.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/placement.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/resample.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/retry.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/store.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/units.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/write.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/tests/conftest.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/tests/fixtures/metadata.json +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/tests/fixtures/write.json +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/tests/test_geometry.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/tests/test_grid.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/tests/test_lost_wakeup.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/tests/test_metadata.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/tests/test_read.py +0 -0
- {worldarray-0.8.0 → worldarray-0.9.1}/tests/test_resample.py +0 -0
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.1 (2026-10-26)
|
|
4
|
+
|
|
5
|
+
A fix for exit. No placement change, no unit table change, and no default changes.
|
|
6
|
+
|
|
7
|
+
- The watchdog thread stops at exit, before the interpreter finalizes. On Python up to
|
|
8
|
+
3.13 a process could abort once all its work was done ("terminate called without an
|
|
9
|
+
active exception", exit 134) when finalization began while the thread was inside a
|
|
10
|
+
tensorstore call; an LSF job that did this looked failed. Now an `atexit` handler,
|
|
11
|
+
which runs once the program's other threads are joined, ends the thread and waits
|
|
12
|
+
for it, and no new one starts after that.
|
|
13
|
+
|
|
14
|
+
## 0.9.0 (2026-10-23)
|
|
15
|
+
|
|
16
|
+
A job script can size each worker's threads. No placement change, no unit table change, and
|
|
17
|
+
no default changes: with `WORLDARRAY_THREADS` unset and no `file_io_floor`, every context
|
|
18
|
+
is what 0.8 made.
|
|
19
|
+
|
|
20
|
+
- `thread_budget()` reads `WORLDARRAY_THREADS` ahead of `OMP_NUM_THREADS` and the job's
|
|
21
|
+
slots, for a job script to give each dask worker its share of the job's cores where
|
|
22
|
+
`OMP_NUM_THREADS` stays 1 for numpy and `LSB_DJOB_NUMPROC` counts the whole job (for
|
|
23
|
+
example `max(1, ncpus // processes)`). A handle opened on the driver on the shared
|
|
24
|
+
context is sized in each worker by that worker's value. Anything but a positive integer
|
|
25
|
+
raises `ValueError`; blank is unset.
|
|
26
|
+
- `shared_context(file_io_floor=)` sets the least the file I/O pool takes under the thread
|
|
27
|
+
budget (default `FILE_IO_FLOOR`, 8); `1` keeps file I/O at the budget. A pickle carries
|
|
28
|
+
it, and with `threads=N` or `threads=0` it is ignored.
|
|
29
|
+
|
|
3
30
|
## 0.8.0 (2026-10-23)
|
|
4
31
|
|
|
5
32
|
Faster writes from the contexts the package makes, and the create open timed out. No
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: worldarray
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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/
|
|
@@ -146,11 +146,12 @@ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # place
|
|
|
146
146
|
interpolation anchored where you say. Adjoining block reads agree with one read across
|
|
147
147
|
both.
|
|
148
148
|
- **Behaves on a cluster**: one shared tensorstore context per process, its threads sized
|
|
149
|
-
from `
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
149
|
+
from `WORLDARRAY_THREADS` (a job script's share for each worker), `OMP_NUM_THREADS` or
|
|
150
|
+
the LSF job's slots instead of the node's cores, or a capped one per worker; reads and
|
|
151
|
+
writes retried with backoff and size-aware timeouts on transient store errors, a
|
|
152
|
+
cancelled future on timeout, and every context's thread pools nudged once a second
|
|
153
|
+
against a tensorstore bug that can leave a queued task with no thread, so a stuck
|
|
154
|
+
operation recovers or fails loudly instead of hanging the job.
|
|
154
155
|
- **Carries the geometry**: `Box` with `intersect`, `union`, `contains`, `grow`, `shift`,
|
|
155
156
|
`snap` to a grid, and the voxel indices a world box selects.
|
|
156
157
|
- **Writes placed arrays** (0.2): the OME-NGFF 0.4 or 0.5 document from voxel size, centre
|
|
@@ -209,10 +210,11 @@ wa.metadata(path, level=None, units=None, open=True, retries=None) -> ArrayMeta
|
|
|
209
210
|
wa.detect(path, missing_ok=False, retries=None) -> Node # format, array or group, raw attrs
|
|
210
211
|
wa.open_tensorstore(path, level=None, context=None, retries=None)
|
|
211
212
|
# retries= times out and retries document reads and the open, as it does data reads
|
|
212
|
-
wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none") ; wa.Retry(...)
|
|
213
|
+
wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none", file_io_floor=8) ; wa.Retry(...)
|
|
213
214
|
wa.capped_context(cache_bytes=0, concurrency=1, network=True, file_io=None, fsync=False, locking="none")
|
|
214
|
-
wa.thread_budget() # the shared context's decoding threads:
|
|
215
|
-
# file I/O gets at least 8;
|
|
215
|
+
wa.thread_budget() # the shared context's decoding threads: WORLDARRAY_THREADS, OMP_NUM_THREADS,
|
|
216
|
+
# the job's slots, or CPUs; file I/O gets at least file_io_floor (8);
|
|
217
|
+
# threads=N limits both to N, threads=0 leaves tensorstore's sizes
|
|
216
218
|
# contexts made here write without fsync or OS file locks; fsync=True, locking="os" restore them
|
|
217
219
|
wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
|
|
218
220
|
wa.Box ; wa.units.TABLE
|
|
@@ -87,11 +87,12 @@ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # place
|
|
|
87
87
|
interpolation anchored where you say. Adjoining block reads agree with one read across
|
|
88
88
|
both.
|
|
89
89
|
- **Behaves on a cluster**: one shared tensorstore context per process, its threads sized
|
|
90
|
-
from `
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
90
|
+
from `WORLDARRAY_THREADS` (a job script's share for each worker), `OMP_NUM_THREADS` or
|
|
91
|
+
the LSF job's slots instead of the node's cores, or a capped one per worker; reads and
|
|
92
|
+
writes retried with backoff and size-aware timeouts on transient store errors, a
|
|
93
|
+
cancelled future on timeout, and every context's thread pools nudged once a second
|
|
94
|
+
against a tensorstore bug that can leave a queued task with no thread, so a stuck
|
|
95
|
+
operation recovers or fails loudly instead of hanging the job.
|
|
95
96
|
- **Carries the geometry**: `Box` with `intersect`, `union`, `contains`, `grow`, `shift`,
|
|
96
97
|
`snap` to a grid, and the voxel indices a world box selects.
|
|
97
98
|
- **Writes placed arrays** (0.2): the OME-NGFF 0.4 or 0.5 document from voxel size, centre
|
|
@@ -150,10 +151,11 @@ wa.metadata(path, level=None, units=None, open=True, retries=None) -> ArrayMeta
|
|
|
150
151
|
wa.detect(path, missing_ok=False, retries=None) -> Node # format, array or group, raw attrs
|
|
151
152
|
wa.open_tensorstore(path, level=None, context=None, retries=None)
|
|
152
153
|
# retries= times out and retries document reads and the open, as it does data reads
|
|
153
|
-
wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none") ; wa.Retry(...)
|
|
154
|
+
wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none", file_io_floor=8) ; wa.Retry(...)
|
|
154
155
|
wa.capped_context(cache_bytes=0, concurrency=1, network=True, file_io=None, fsync=False, locking="none")
|
|
155
|
-
wa.thread_budget() # the shared context's decoding threads:
|
|
156
|
-
# file I/O gets at least 8;
|
|
156
|
+
wa.thread_budget() # the shared context's decoding threads: WORLDARRAY_THREADS, OMP_NUM_THREADS,
|
|
157
|
+
# the job's slots, or CPUs; file I/O gets at least file_io_floor (8);
|
|
158
|
+
# threads=N limits both to N, threads=0 leaves tensorstore's sizes
|
|
157
159
|
# contexts made here write without fsync or OS file locks; fsync=True, locking="os" restore them
|
|
158
160
|
wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
|
|
159
161
|
wa.Box ; wa.units.TABLE
|
|
@@ -185,11 +185,12 @@ wa.open_tensorstore(path, level=None, context=None, retries=None) -> tensorstore
|
|
|
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, threads=None, fsync=False, locking="none")
|
|
188
|
+
wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none", file_io_floor=8)
|
|
189
189
|
wa.capped_context(cache_bytes=0, concurrency=1, network=True, file_io=None, fsync=False, locking="none") ; wa.capped_context_spec(...)
|
|
190
|
-
# threads: None = thread_budget() for decoding and at least
|
|
191
|
-
# 0 = tensorstore's own pool sizes (0.6); fsync and locking:
|
|
192
|
-
# file_io_locking mode, off and "none" in every context the
|
|
190
|
+
# threads: None = thread_budget() for decoding and at least file_io_floor for file I/O (0.8; the
|
|
191
|
+
# argument 0.9), N = that many, 0 = tensorstore's own pool sizes (0.6); fsync and locking:
|
|
192
|
+
# tensorstore's file_io_sync and file_io_locking mode, off and "none" in every context the
|
|
193
|
+
# package makes (0.8, Behaviour 7)
|
|
193
194
|
# network=False caps decoding and local file I/O only, leaving tensorstore's request pools alone
|
|
194
195
|
wa.list_chunk_keys(path, separator=".", ndim=None) # chunk indices present under a path, from its keys alone: no
|
|
195
196
|
# metadata document needed, so a directory of chunk files with no .zarray lists too
|
|
@@ -236,8 +237,8 @@ where it also chooses the integer scale factor that makes the corner whole.
|
|
|
236
237
|
`shared_context(cache_bytes, threads=None)` is one `tensorstore.Context` per budget and
|
|
237
238
|
thread limit for the process, so the levels of a pyramid and repeat opens share one cache
|
|
238
239
|
pool; the default budget is 0. Its decoding pool is sized by `thread_budget()` (0.5) and
|
|
239
|
-
its file I/O pool by that or 8, whichever is larger (0.8
|
|
240
|
-
`0` leaving tensorstore's own sizes (0.6, Behaviour 7).
|
|
240
|
+
its file I/O pool by that or `file_io_floor` (8), whichever is larger (0.8; the argument
|
|
241
|
+
0.9), or both by `threads` when given, `0` leaving tensorstore's own sizes (0.6, Behaviour 7).
|
|
241
242
|
`capped_context` is mesh-n-bone's worker-process context (every tensorstore concurrency
|
|
242
243
|
resource limited, cache off); it returns a **new** context per call, as those workers
|
|
243
244
|
inline the spec into every open today, and `capped_context_spec` exports the dict for
|
|
@@ -366,8 +367,8 @@ wa.create(..., attrs={...}, exists="adopt") # an adopt also compares the keys
|
|
|
366
367
|
vol.write_index(begin, end, data, partial=False, retries=None)
|
|
367
368
|
vol.write(box, data, partial=False, retries=None) ; vol.write_chunk(index, data, retries=None)
|
|
368
369
|
# timed out and retried as reads are: `retries`, else the handle's (Behaviour 6)
|
|
369
|
-
wa.thread_budget() -> int # threads for tensorstore's decoding and file I/O in this process:
|
|
370
|
-
# LSB_DJOB_NUMPROC, else the CPUs it may run on (Behaviour 7)
|
|
370
|
+
wa.thread_budget() -> int # threads for tensorstore's decoding and file I/O in this process: WORLDARRAY_THREADS (0.9),
|
|
371
|
+
# else OMP_NUM_THREADS, else LSB_DJOB_NUMPROC, else the CPUs it may run on (Behaviour 7)
|
|
371
372
|
# every context the package makes or opens a store on is kicked once a second, whoever
|
|
372
373
|
# waits on it (Behaviour 6)
|
|
373
374
|
```
|
|
@@ -691,7 +692,15 @@ this document and that one differ, fix one of them.
|
|
|
691
692
|
copy and a local read of a key that does not exist; no store is touched, about 0.3 ms
|
|
692
693
|
of CPU). A stranded task then runs within about a second whoever waits on it: the
|
|
693
694
|
package, `vol.ts` directly, or `await`, at any concurrency. The retry stays the outer
|
|
694
|
-
net.
|
|
695
|
+
net. The thread stops at exit (0.9.1): an `atexit` handler, which Python runs once the
|
|
696
|
+
program's other threads are joined and before it finalizes, ends it and waits for it
|
|
697
|
+
(at most the longest one kick takes), and no new one starts after that. Python up to
|
|
698
|
+
3.13 ends a daemon thread that takes the GIL back during finalization with
|
|
699
|
+
`pthread_exit`, and inside a tensorstore call that aborts the process with "terminate
|
|
700
|
+
called without an active exception" and exit 134 after all the work is done (once in
|
|
701
|
+
six of mesh-n-bone's Linux CI runs on Python 3.12, 2026-10-26; every time when a
|
|
702
|
+
process exits while the thread kicks flat out). Open question 14 says when the
|
|
703
|
+
watchdog goes.
|
|
695
704
|
|
|
696
705
|
7. **Contexts.** Without `context`, every open in the process shares `shared_context(0)`:
|
|
697
706
|
tensorstore's cache is off and the levels of one pyramid share one context. Its
|
|
@@ -703,8 +712,20 @@ this document and that one differ, fix one of them.
|
|
|
703
712
|
workers in a twelve-slot job on a 96-core node could start 96 threads (0.5). A dask
|
|
704
713
|
worker therefore runs tensorstore on one thread of each pool, which Behaviour 6's
|
|
705
714
|
watchdog makes safe. SLURM's `SLURM_CPUS_PER_TASK` and SGE's `NSLOTS` stand in for
|
|
706
|
-
`LSB_DJOB_NUMPROC` elsewhere.
|
|
707
|
-
|
|
715
|
+
`LSB_DJOB_NUMPROC` elsewhere. Ahead of them all comes `WORLDARRAY_THREADS` (0.9), the
|
|
716
|
+
package's own variable, for a job script to give each worker its share of the job's
|
|
717
|
+
cores where `OMP_NUM_THREADS` stays 1 for numpy and `LSB_DJOB_NUMPROC` counts the whole
|
|
718
|
+
job: cellmap-analyze's prologue exports its CPUs over its worker processes, at least 1,
|
|
719
|
+
so a worker with spare cores uses them (in a two-slot job, pools of four against one
|
|
720
|
+
halved an 8-chunk block's time, below) and a worker per slot stays at one. A handle opened on
|
|
721
|
+
the driver before the cluster starts is then sized in each worker by that worker's
|
|
722
|
+
share, as a pickle carries `threads=None`, not a number (Behaviour 11). A value that is
|
|
723
|
+
not a positive integer raises `ValueError` rather than being skipped: nothing else sets
|
|
724
|
+
this variable, so a value worldarray cannot use is a mistake to report. Its
|
|
725
|
+
`file_io_concurrency` limit is that or `file_io_floor`, by default
|
|
726
|
+
`context.FILE_IO_FLOOR` (8), whichever is larger (0.8; the argument 0.9, `1` keeping
|
|
727
|
+
file I/O at the budget, cellmap-analyze's choice until eight is measured on /nrs at
|
|
728
|
+
scale): file I/O threads wait on storage
|
|
708
729
|
rather than compute, so they take no core from the job, and one of them serialises every
|
|
709
730
|
chunk file of a block on NFS's round trips. From one decoding thread, eight 64^3 label
|
|
710
731
|
chunks written to NFS took 166 to 176 ms with one file I/O thread and 83 to 89 ms with
|
|
@@ -720,10 +741,11 @@ this document and that one differ, fix one of them.
|
|
|
720
741
|
tensorstore's own sizes (decided 2026-10-23, over chunkmirage's objection, once the
|
|
721
742
|
watchdog had removed the hang risk; the argument is 0.6, at chunkmirage's request, so
|
|
722
743
|
its server keeps one watched context per cache budget for every store it opens
|
|
723
|
-
instead of building its own). Each
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
744
|
+
instead of building its own). Each set of arguments is one context for the process,
|
|
745
|
+
made on first use and watched; `threads=None` and the number `thread_budget()` returns
|
|
746
|
+
are two contexts, as a pickle carries `None` for the other process to size by its own
|
|
747
|
+
job and a number as that number. `file_io_floor` sizes only the budget's pool, so with
|
|
748
|
+
a thread count of its own it is ignored and no second context is made.
|
|
727
749
|
|
|
728
750
|
Writes (0.8). Every context the package makes (`shared_context`, `capped_context`)
|
|
729
751
|
sets tensorstore's `file_io_sync` to false and its `file_io_locking` mode to `"none"`,
|
|
@@ -1072,7 +1094,12 @@ shared context. 0.7: `metadata`, `open` and `detect` against a server
|
|
|
1072
1094
|
that accepts and never answers raising `TimeoutError` after the attempts. 0.8: the default
|
|
1073
1095
|
and capped contexts' file I/O limit, sync and locking, `fsync` and `locking` carried
|
|
1074
1096
|
through a pickle, and a create whose first attempt lands after its timeout adopting its own
|
|
1075
|
-
array under `exists="error"`.
|
|
1097
|
+
array under `exists="error"`. 0.9: `WORLDARRAY_THREADS` ahead of every other variable,
|
|
1098
|
+
blank as unset and a bad value refused; `file_io_floor` sizing file I/O under the budget,
|
|
1099
|
+
ignored with a thread count, and carried through a pickle; a handle pickled on the driver
|
|
1100
|
+
sized in a spawned worker by that worker's `WORLDARRAY_THREADS`. 0.9.1: a process whose
|
|
1101
|
+
finalization takes a while, exiting while the watchdog kicks flat out, exits cleanly.
|
|
1102
|
+
Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
|
|
1076
1103
|
`tests/test_lost_wakeup.py`): worker processes aim tasks at the moment a one-thread pool's
|
|
1077
1104
|
worker exits idle, so a plain context strands some for good (13 of about 7,000 on
|
|
1078
1105
|
tensorstore 0.1.78; 40 to 44 of 12,000 to 15,000 with 16 workers on 0.1.75 and 0.1.85)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "worldarray"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.9.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" }
|
|
@@ -5,9 +5,10 @@ thread limit for the whole process, so the levels of a pyramid and repeat opens
|
|
|
5
5
|
array draw on one cache pool. The default budget is 0: tensorstore's decoded-chunk cache
|
|
6
6
|
is off, because every consumer either reads each region once or caches decoded chunks
|
|
7
7
|
itself. By default its decoding pool is sized by ``thread_budget()``, from the variables a
|
|
8
|
-
cluster job sets
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
cluster job sets (``WORLDARRAY_THREADS`` first, a job script's share for each worker), where
|
|
9
|
+
tensorstore alone would size it to every CPU on the node, and its file I/O pool to at least
|
|
10
|
+
``file_io_floor`` threads (``FILE_IO_FLOOR``), which wait on storage rather than compute;
|
|
11
|
+
``threads=0`` leaves both at tensorstore's sizes (chunkmirage's server). Every
|
|
11
12
|
context made here writes without fsync and without OS file locks (``write_settings``).
|
|
12
13
|
Every context here, and every one a store is opened on, is watched for tensorstore's lost
|
|
13
14
|
wakeup (``watch``).
|
|
@@ -20,17 +21,17 @@ every open today; pass the same object twice to share pools (spec, Behaviour 7).
|
|
|
20
21
|
|
|
21
22
|
from __future__ import annotations
|
|
22
23
|
|
|
24
|
+
import atexit
|
|
23
25
|
import json
|
|
24
26
|
import os
|
|
25
27
|
import tempfile
|
|
26
28
|
import threading
|
|
27
|
-
import time
|
|
28
29
|
import weakref
|
|
29
30
|
from typing import Any
|
|
30
31
|
|
|
31
32
|
import tensorstore as ts
|
|
32
33
|
|
|
33
|
-
_shared: dict[tuple[int, int | None, bool, str], ts.Context] = {}
|
|
34
|
+
_shared: dict[tuple[int, int | None, bool, str, int], ts.Context] = {}
|
|
34
35
|
_rebuilt: dict[str, ts.Context] = {}
|
|
35
36
|
_lock = threading.Lock()
|
|
36
37
|
|
|
@@ -49,12 +50,19 @@ _lock = threading.Lock()
|
|
|
49
50
|
# `gh api repos/google/tensorstore/compare/ed51718085...vX.Y.Z` says "ahead" or "identical"
|
|
50
51
|
# for a release that has it, and `WORLDARRAY_STRESS=1 pytest tests/test_lost_wakeup.py -s`
|
|
51
52
|
# then strands nothing on a plain context.
|
|
53
|
+
#
|
|
54
|
+
# The thread stops at exit, before the interpreter finalizes. Python up to 3.13 ends a
|
|
55
|
+
# daemon thread that takes the GIL back during finalization with pthread_exit, and inside
|
|
56
|
+
# a tensorstore call that aborts the process: "terminate called without an active
|
|
57
|
+
# exception", exit 134, once all the work is done (mesh-n-bone's CI, 2026-10-26).
|
|
52
58
|
KICK_EVERY = 1.0
|
|
59
|
+
KICK_TIMEOUT = 10.0
|
|
53
60
|
_KICK_DIR = tempfile.gettempdir()
|
|
54
61
|
_KICK_KEY = "worldarray-kick-no-such-key"
|
|
55
62
|
_watched: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
|
|
56
63
|
_watch_lock = threading.Lock()
|
|
57
64
|
_watchdog: threading.Thread | None = None
|
|
65
|
+
_stop = threading.Event()
|
|
58
66
|
|
|
59
67
|
|
|
60
68
|
def _kickers(context: ts.Context) -> tuple:
|
|
@@ -62,7 +70,7 @@ def _kickers(context: ts.Context) -> tuple:
|
|
|
62
70
|
decoding pool, reading a missing key from the other runs on its file I/O pool."""
|
|
63
71
|
array = ts.open({"driver": "array", "array": [0], "dtype": "uint8"}, context=context)
|
|
64
72
|
kv = ts.KvStore.open({"driver": "file", "path": _KICK_DIR + "/"}, context=context)
|
|
65
|
-
return array.result(timeout=
|
|
73
|
+
return array.result(timeout=KICK_TIMEOUT), kv.result(timeout=KICK_TIMEOUT)
|
|
66
74
|
|
|
67
75
|
|
|
68
76
|
def kick(context: ts.Context) -> list:
|
|
@@ -85,14 +93,15 @@ def kick(context: ts.Context) -> list:
|
|
|
85
93
|
|
|
86
94
|
def watch(context: ts.Context | None) -> ts.Context | None:
|
|
87
95
|
"""Kick ``context``'s pools every ``KICK_EVERY`` seconds for as long as it lives, starting
|
|
88
|
-
the watchdog thread on first use. Returns
|
|
96
|
+
the watchdog thread on first use (not once it has stopped at exit). Returns
|
|
97
|
+
``context``."""
|
|
89
98
|
global _watchdog
|
|
90
99
|
if context is None:
|
|
91
100
|
return None
|
|
92
101
|
with _watch_lock:
|
|
93
102
|
if context not in _watched:
|
|
94
103
|
_watched[context] = None
|
|
95
|
-
if _watchdog is None or not _watchdog.is_alive():
|
|
104
|
+
if not _stop.is_set() and (_watchdog is None or not _watchdog.is_alive()):
|
|
96
105
|
_watchdog = threading.Thread(target=_watch, name="worldarray-kick", daemon=True)
|
|
97
106
|
_watchdog.start()
|
|
98
107
|
return context
|
|
@@ -100,16 +109,26 @@ def watch(context: ts.Context | None) -> ts.Context | None:
|
|
|
100
109
|
|
|
101
110
|
def _watch() -> None:
|
|
102
111
|
held: list = []
|
|
103
|
-
while
|
|
104
|
-
time.sleep(KICK_EVERY)
|
|
112
|
+
while not _stop.wait(KICK_EVERY):
|
|
105
113
|
with _watch_lock:
|
|
106
114
|
contexts = list(_watched.keys())
|
|
107
115
|
held = [f for f in held if not f.done()]
|
|
108
116
|
for context in contexts:
|
|
117
|
+
if _stop.is_set():
|
|
118
|
+
break
|
|
109
119
|
held += kick(context)
|
|
110
120
|
del contexts
|
|
111
121
|
|
|
112
122
|
|
|
123
|
+
def _stop_watchdog() -> None:
|
|
124
|
+
"""End every watchdog thread and wait for it, at exit: atexit runs once the program's
|
|
125
|
+
other threads are joined, so they are kicked to the end, and before finalization."""
|
|
126
|
+
_stop.set()
|
|
127
|
+
for thread in threading.enumerate():
|
|
128
|
+
if thread.name == "worldarray-kick" and thread is not threading.current_thread():
|
|
129
|
+
thread.join(2 * KICK_TIMEOUT) # the longest one kick takes: two opens
|
|
130
|
+
|
|
131
|
+
|
|
113
132
|
def _forget_watchdog() -> None:
|
|
114
133
|
global _watchdog
|
|
115
134
|
_watchdog = None # a forked child has no such thread; the next watch() starts one
|
|
@@ -117,16 +136,29 @@ def _forget_watchdog() -> None:
|
|
|
117
136
|
|
|
118
137
|
if hasattr(os, "register_at_fork"):
|
|
119
138
|
os.register_at_fork(after_in_child=_forget_watchdog)
|
|
139
|
+
atexit.register(_stop_watchdog)
|
|
120
140
|
|
|
121
141
|
|
|
142
|
+
THREADS_VAR = "WORLDARRAY_THREADS"
|
|
122
143
|
_SLOTS = ("OMP_NUM_THREADS", "LSB_DJOB_NUMPROC", "SLURM_CPUS_PER_TASK", "NSLOTS")
|
|
123
144
|
|
|
124
145
|
|
|
125
146
|
def thread_budget() -> int:
|
|
126
|
-
"""Threads for this process's tensorstore decoding and file I/O: ``
|
|
147
|
+
"""Threads for this process's tensorstore decoding and file I/O: ``WORLDARRAY_THREADS``
|
|
148
|
+
when set, a job script's share for each worker where ``OMP_NUM_THREADS`` must stay 1 for
|
|
149
|
+
numpy (a positive integer, anything else a ``ValueError``), else ``OMP_NUM_THREADS``
|
|
127
150
|
when set (dask sets 1 in every worker it spawns; a job script sets its slots), else the
|
|
128
151
|
job's slots (LSF's ``LSB_DJOB_NUMPROC``, SLURM's ``SLURM_CPUS_PER_TASK``, SGE's
|
|
129
152
|
``NSLOTS``), else the CPUs this process may run on."""
|
|
153
|
+
own = os.environ.get(THREADS_VAR, "").strip()
|
|
154
|
+
if own:
|
|
155
|
+
try:
|
|
156
|
+
n = int(own)
|
|
157
|
+
except ValueError:
|
|
158
|
+
n = 0
|
|
159
|
+
if n < 1:
|
|
160
|
+
raise ValueError(f"{THREADS_VAR} must be a positive integer, not {own!r}")
|
|
161
|
+
return n
|
|
130
162
|
for name in _SLOTS:
|
|
131
163
|
try:
|
|
132
164
|
n = int(os.environ.get(name, ""))
|
|
@@ -162,15 +194,27 @@ def shared_context(
|
|
|
162
194
|
threads: int | None = None,
|
|
163
195
|
fsync: bool = False,
|
|
164
196
|
locking: str = "none",
|
|
197
|
+
file_io_floor: int = FILE_IO_FLOOR,
|
|
165
198
|
) -> ts.Context:
|
|
166
199
|
"""The process's one context for these arguments, made on first use and watched.
|
|
167
200
|
``threads`` limits its decoding and file I/O pools: ``None`` decoding to
|
|
168
|
-
``thread_budget()
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
201
|
+
``thread_budget()``, read when the context is made, and file I/O to that or
|
|
202
|
+
``file_io_floor``, whichever is larger (``1`` keeps file I/O at the budget), a positive
|
|
203
|
+
number both to that many, ``0`` neither (tensorstore's own sizes, every CPU on the
|
|
204
|
+
node); ``file_io_floor`` applies to ``threads=None`` alone. ``fsync`` and ``locking`` go
|
|
205
|
+
to ``write_settings``."""
|
|
206
|
+
if threads is not None and int(threads) < 0:
|
|
173
207
|
raise ValueError(f"threads must be None, 0 or positive, not {threads!r}")
|
|
208
|
+
if int(file_io_floor) < 0:
|
|
209
|
+
raise ValueError(f"file_io_floor must be 0 or positive, not {file_io_floor!r}")
|
|
210
|
+
floor = max(int(file_io_floor), 1) if threads is None else FILE_IO_FLOOR
|
|
211
|
+
key = (
|
|
212
|
+
int(cache_bytes),
|
|
213
|
+
None if threads is None else int(threads),
|
|
214
|
+
bool(fsync),
|
|
215
|
+
str(locking),
|
|
216
|
+
floor,
|
|
217
|
+
)
|
|
174
218
|
with _lock:
|
|
175
219
|
ctx = _shared.get(key)
|
|
176
220
|
if ctx is None:
|
|
@@ -181,7 +225,7 @@ def shared_context(
|
|
|
181
225
|
if threads is None:
|
|
182
226
|
budget = thread_budget()
|
|
183
227
|
spec["data_copy_concurrency"] = {"limit": budget}
|
|
184
|
-
spec["file_io_concurrency"] = {"limit": max(budget,
|
|
228
|
+
spec["file_io_concurrency"] = {"limit": max(budget, floor)}
|
|
185
229
|
elif key[1]:
|
|
186
230
|
spec["data_copy_concurrency"] = spec["file_io_concurrency"] = {"limit": key[1]}
|
|
187
231
|
ctx = watch(ts.Context(spec))
|
|
@@ -238,16 +282,20 @@ def capped_context(
|
|
|
238
282
|
return watch(ts.Context(spec))
|
|
239
283
|
|
|
240
284
|
|
|
285
|
+
# shared_context's key with every argument at its default
|
|
286
|
+
_SHARED_DEFAULTS = (0, None, False, "none", FILE_IO_FLOOR)
|
|
287
|
+
|
|
288
|
+
|
|
241
289
|
def recipe_of(context: ts.Context) -> tuple[str, Any]:
|
|
242
290
|
"""How another process rebuilds ``context``: ``("shared", cache_bytes)`` for a shared
|
|
243
291
|
context with every other argument at its default (the other process takes its own
|
|
244
|
-
``thread_budget()``), ``("shared", (cache_bytes, threads, fsync, locking))``
|
|
245
|
-
else ``("spec", json)`` from the context's own spec, so a capped context
|
|
246
|
-
across a pickle (Behaviour 11, mesh-n-bone's workers)."""
|
|
292
|
+
``thread_budget()``), ``("shared", (cache_bytes, threads, fsync, locking, file_io_floor))``
|
|
293
|
+
otherwise, else ``("spec", json)`` from the context's own spec, so a capped context
|
|
294
|
+
stays capped across a pickle (Behaviour 11, mesh-n-bone's workers)."""
|
|
247
295
|
with _lock:
|
|
248
296
|
for key, ctx in _shared.items():
|
|
249
297
|
if ctx is context:
|
|
250
|
-
return ("shared", key[0] if key[1:] ==
|
|
298
|
+
return ("shared", key[0] if key[1:] == _SHARED_DEFAULTS[1:] else key)
|
|
251
299
|
spec = context.spec
|
|
252
300
|
return ("spec", json.dumps(spec.to_json() if spec is not None else {}, sort_keys=True))
|
|
253
301
|
|
|
@@ -259,12 +307,14 @@ def from_recipe(recipe: tuple[str, Any] | None) -> ts.Context | None:
|
|
|
259
307
|
return None
|
|
260
308
|
kind, value = recipe
|
|
261
309
|
if kind == "shared":
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
310
|
+
given = tuple(value) if isinstance(value, (tuple, list)) else (value,)
|
|
311
|
+
cache_bytes, threads, fsync, locking, floor = (
|
|
312
|
+
*given,
|
|
313
|
+
*_SHARED_DEFAULTS[len(given) :],
|
|
314
|
+
)[:5] # a 0.6 or 0.7 pickle carries two, a 0.8 one four
|
|
315
|
+
return shared_context(
|
|
316
|
+
int(cache_bytes), threads=threads, fsync=fsync, locking=locking, file_io_floor=floor
|
|
266
317
|
)
|
|
267
|
-
return shared_context(int(cache_bytes), threads=threads, fsync=fsync, locking=locking)
|
|
268
318
|
with _lock:
|
|
269
319
|
ctx = _rebuilt.get(value)
|
|
270
320
|
if ctx is None:
|
|
@@ -8,6 +8,9 @@ import http.server
|
|
|
8
8
|
import logging
|
|
9
9
|
import os
|
|
10
10
|
import socket
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import textwrap
|
|
11
14
|
import threading
|
|
12
15
|
import time
|
|
13
16
|
import weakref
|
|
@@ -137,6 +140,7 @@ def test_contexts_and_retry_timeouts():
|
|
|
137
140
|
|
|
138
141
|
|
|
139
142
|
def test_the_thread_budget_follows_the_job(monkeypatch):
|
|
143
|
+
monkeypatch.delenv("WORLDARRAY_THREADS", raising=False)
|
|
140
144
|
monkeypatch.delenv("OMP_NUM_THREADS", raising=False)
|
|
141
145
|
monkeypatch.delenv("LSB_DJOB_NUMPROC", raising=False)
|
|
142
146
|
allowed = len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else os.cpu_count()
|
|
@@ -161,6 +165,19 @@ def test_the_thread_budget_follows_the_job(monkeypatch):
|
|
|
161
165
|
assert spec["cache_pool"] == {"total_bytes_limit": 5}
|
|
162
166
|
|
|
163
167
|
|
|
168
|
+
def test_a_job_script_gives_each_worker_its_threads(monkeypatch):
|
|
169
|
+
monkeypatch.setenv("OMP_NUM_THREADS", "1") # what dask and a job prologue set, for numpy
|
|
170
|
+
monkeypatch.setenv("LSB_DJOB_NUMPROC", "24") # the whole job's slots, not one worker's
|
|
171
|
+
monkeypatch.setenv("WORLDARRAY_THREADS", "3") # the job's cpus over its worker processes
|
|
172
|
+
assert wa.thread_budget() == 3
|
|
173
|
+
monkeypatch.setenv("WORLDARRAY_THREADS", " ")
|
|
174
|
+
assert wa.thread_budget() == 1 # empty is unset
|
|
175
|
+
for bad in ("0", "-2", "x", "2.5"): # worldarray's own variable: a bad value is an error
|
|
176
|
+
monkeypatch.setenv("WORLDARRAY_THREADS", bad)
|
|
177
|
+
with pytest.raises(ValueError, match="WORLDARRAY_THREADS"):
|
|
178
|
+
wa.thread_budget()
|
|
179
|
+
|
|
180
|
+
|
|
164
181
|
def test_a_kick_runs_on_the_contexts_own_pools():
|
|
165
182
|
ctx = wa.capped_context(concurrency=1)
|
|
166
183
|
futures = wa.context.kick(ctx)
|
|
@@ -191,6 +208,32 @@ def test_every_context_the_package_touches_is_kicked(monkeypatch, tmp_path):
|
|
|
191
208
|
assert len(wa.context._watched) == n - 1 # a context that died is no longer watched
|
|
192
209
|
|
|
193
210
|
|
|
211
|
+
def test_a_process_exits_cleanly_while_the_watchdog_kicks(tmp_path):
|
|
212
|
+
# Python up to 3.13 ends a daemon thread that takes the GIL back during finalization
|
|
213
|
+
# with pthread_exit, which aborts inside a tensorstore call; the watchdog stops at exit
|
|
214
|
+
script = tmp_path / "exit.py"
|
|
215
|
+
script.write_text(
|
|
216
|
+
textwrap.dedent("""
|
|
217
|
+
import time
|
|
218
|
+
import tensorstore as ts
|
|
219
|
+
from worldarray import context
|
|
220
|
+
context.KICK_EVERY = 0.0 # kick flat out
|
|
221
|
+
limits = [{"cache_pool": {"total_bytes_limit": i}} for i in range(20)]
|
|
222
|
+
contexts = [context.watch(ts.Context(spec)) for spec in limits]
|
|
223
|
+
class SlowExit: # finalization takes a while, with the GIL free
|
|
224
|
+
def __del__(self):
|
|
225
|
+
time.sleep(0.5)
|
|
226
|
+
slow = SlowExit()
|
|
227
|
+
time.sleep(0.2)
|
|
228
|
+
""")
|
|
229
|
+
)
|
|
230
|
+
done = subprocess.run(
|
|
231
|
+
[sys.executable, str(script)], capture_output=True, text=True, timeout=120
|
|
232
|
+
)
|
|
233
|
+
assert done.returncode == 0, done.stderr
|
|
234
|
+
assert "terminate" not in done.stderr
|
|
235
|
+
|
|
236
|
+
|
|
194
237
|
class _StalledView:
|
|
195
238
|
"""A view whose first ``stalls`` reads never finish (a future nothing resolves, as a
|
|
196
239
|
read stuck on NFS looks from Python); later reads return ``0, 1, 2, 3``."""
|
|
@@ -326,6 +369,7 @@ def test_gce_guard_is_left_alone_on_a_google_machine(monkeypatch):
|
|
|
326
369
|
|
|
327
370
|
|
|
328
371
|
def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
|
|
372
|
+
monkeypatch.delenv("WORLDARRAY_THREADS", raising=False)
|
|
329
373
|
monkeypatch.setenv("OMP_NUM_THREADS", "1")
|
|
330
374
|
monkeypatch.setattr(wa.context, "_shared", {})
|
|
331
375
|
default = wa.shared_context(7)
|
|
@@ -342,7 +386,7 @@ def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
|
|
|
342
386
|
assert "data_copy_concurrency" not in spec and "file_io_concurrency" not in spec
|
|
343
387
|
assert spec["cache_pool"] == {"total_bytes_limit": 7}
|
|
344
388
|
assert machine in wa.context._watched
|
|
345
|
-
assert wa.context.recipe_of(machine) == ("shared", (7, 0, False, "none"))
|
|
389
|
+
assert wa.context.recipe_of(machine) == ("shared", (7, 0, False, "none", 8))
|
|
346
390
|
assert wa.context.from_recipe(("shared", (7, 0))) is machine # a 0.6 or 0.7 pickle
|
|
347
391
|
durable = wa.shared_context(7, fsync=True, locking="os")
|
|
348
392
|
spec = durable.spec.to_json()
|
|
@@ -354,6 +398,30 @@ def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
|
|
|
354
398
|
wa.shared_context(threads=-1)
|
|
355
399
|
|
|
356
400
|
|
|
401
|
+
def test_file_io_can_follow_the_thread_budget(monkeypatch):
|
|
402
|
+
monkeypatch.setenv("WORLDARRAY_THREADS", "3")
|
|
403
|
+
monkeypatch.setattr(wa.context, "_shared", {})
|
|
404
|
+
default = wa.shared_context(0)
|
|
405
|
+
assert default.spec.to_json()["file_io_concurrency"] == {"limit": 8}
|
|
406
|
+
level = wa.shared_context(0, file_io_floor=1)
|
|
407
|
+
spec = level.spec.to_json()
|
|
408
|
+
assert spec["data_copy_concurrency"] == spec["file_io_concurrency"] == {"limit": 3}
|
|
409
|
+
assert level is not default and wa.shared_context(0, file_io_floor=0) is level
|
|
410
|
+
assert wa.shared_context(0, file_io_floor=wa.context.FILE_IO_FLOOR) is default
|
|
411
|
+
wide = wa.shared_context(0, file_io_floor=16).spec.to_json()
|
|
412
|
+
assert wide["data_copy_concurrency"] == {"limit": 3}
|
|
413
|
+
assert wide["file_io_concurrency"] == {"limit": 16}
|
|
414
|
+
# the floor sizes the budget's pools alone: a thread count of its own ignores it
|
|
415
|
+
assert wa.shared_context(0, threads=4, file_io_floor=1) is wa.shared_context(0, threads=4)
|
|
416
|
+
assert wa.shared_context(0, threads=0, file_io_floor=1) is wa.shared_context(0, threads=0)
|
|
417
|
+
assert wa.context.recipe_of(level) == ("shared", (0, None, False, "none", 1))
|
|
418
|
+
assert wa.context.from_recipe(wa.context.recipe_of(level)) is level
|
|
419
|
+
assert wa.context.recipe_of(default) == ("shared", 0)
|
|
420
|
+
assert wa.context.from_recipe(("shared", (0, None, False, "none"))) is default # 0.8
|
|
421
|
+
with pytest.raises(ValueError):
|
|
422
|
+
wa.shared_context(file_io_floor=-1)
|
|
423
|
+
|
|
424
|
+
|
|
357
425
|
@pytest.fixture
|
|
358
426
|
def silent_server():
|
|
359
427
|
"""An http address whose server accepts every connection and never answers."""
|
|
@@ -830,6 +830,23 @@ def test_a_pickled_handle_keeps_its_context(tmp_path):
|
|
|
830
830
|
assert pickle.loads(pickle.dumps(wa.open(path, context=machine))).context is machine
|
|
831
831
|
|
|
832
832
|
|
|
833
|
+
def _worker_limits(handle):
|
|
834
|
+
spec = handle.context.spec.to_json()
|
|
835
|
+
return spec["data_copy_concurrency"]["limit"], spec["file_io_concurrency"]["limit"]
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def test_a_pickled_handle_takes_each_workers_thread_share(tmp_path, monkeypatch):
|
|
839
|
+
# a handle opened before the cluster starts is sized in each worker by that worker's job
|
|
840
|
+
# script, which exports WORLDARRAY_THREADS where OMP_NUM_THREADS stays 1 (cellmap-analyze)
|
|
841
|
+
path = str(tmp_path / "vol.zarr/s0")
|
|
842
|
+
wa.create(path, (8, 8, 8), "uint16", (4, 4, 4), (2, 2, 2), chunks=(4, 4, 4))
|
|
843
|
+
handles = [wa.open(path, context=wa.shared_context(0, file_io_floor=1)), wa.open(path)]
|
|
844
|
+
monkeypatch.setenv("OMP_NUM_THREADS", "1")
|
|
845
|
+
monkeypatch.setenv("WORLDARRAY_THREADS", "3")
|
|
846
|
+
with mp.get_context("spawn").Pool(1) as pool:
|
|
847
|
+
assert pool.map(_worker_limits, handles) == [(3, 3), (3, 8)]
|
|
848
|
+
|
|
849
|
+
|
|
833
850
|
def test_codecs_of_a_path_and_the_fill_write_that_removes_a_key(tmp_path):
|
|
834
851
|
path = str(tmp_path / "vol.zarr/s0")
|
|
835
852
|
vol = wa.create(
|
|
@@ -1247,7 +1247,7 @@ wheels = [
|
|
|
1247
1247
|
|
|
1248
1248
|
[[package]]
|
|
1249
1249
|
name = "worldarray"
|
|
1250
|
-
version = "0.
|
|
1250
|
+
version = "0.9.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
|
|
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
|