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