worldarray 0.9.1__tar.gz → 0.10.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {worldarray-0.9.1 → worldarray-0.10.1}/CHANGELOG.md +31 -0
  2. {worldarray-0.9.1 → worldarray-0.10.1}/PKG-INFO +5 -2
  3. {worldarray-0.9.1 → worldarray-0.10.1}/README.md +4 -1
  4. {worldarray-0.9.1 → worldarray-0.10.1}/docs/spec.md +57 -12
  5. {worldarray-0.9.1 → worldarray-0.10.1}/pyproject.toml +1 -1
  6. {worldarray-0.9.1 → worldarray-0.10.1}/src/worldarray/__init__.py +1 -1
  7. {worldarray-0.9.1 → worldarray-0.10.1}/src/worldarray/array.py +113 -20
  8. {worldarray-0.9.1 → worldarray-0.10.1}/src/worldarray/placement.py +79 -5
  9. {worldarray-0.9.1 → worldarray-0.10.1}/src/worldarray/store.py +28 -8
  10. {worldarray-0.9.1 → worldarray-0.10.1}/tests/test_access.py +153 -0
  11. worldarray-0.10.1/tests/test_axes_order.py +197 -0
  12. {worldarray-0.9.1 → worldarray-0.10.1}/uv.lock +1 -1
  13. {worldarray-0.9.1 → worldarray-0.10.1}/.github/workflows/ci.yml +0 -0
  14. {worldarray-0.9.1 → worldarray-0.10.1}/.github/workflows/docs.yml +0 -0
  15. {worldarray-0.9.1 → worldarray-0.10.1}/.github/workflows/publish.yml +0 -0
  16. {worldarray-0.9.1 → worldarray-0.10.1}/.gitignore +0 -0
  17. {worldarray-0.9.1 → worldarray-0.10.1}/LICENSE +0 -0
  18. {worldarray-0.9.1 → worldarray-0.10.1}/docs/changelog.md +0 -0
  19. {worldarray-0.9.1 → worldarray-0.10.1}/docs/index.md +0 -0
  20. {worldarray-0.9.1 → worldarray-0.10.1}/mkdocs.yml +0 -0
  21. {worldarray-0.9.1 → worldarray-0.10.1}/src/worldarray/context.py +0 -0
  22. {worldarray-0.9.1 → worldarray-0.10.1}/src/worldarray/geometry.py +0 -0
  23. {worldarray-0.9.1 → worldarray-0.10.1}/src/worldarray/grid.py +0 -0
  24. {worldarray-0.9.1 → worldarray-0.10.1}/src/worldarray/levels.py +0 -0
  25. {worldarray-0.9.1 → worldarray-0.10.1}/src/worldarray/resample.py +0 -0
  26. {worldarray-0.9.1 → worldarray-0.10.1}/src/worldarray/retry.py +0 -0
  27. {worldarray-0.9.1 → worldarray-0.10.1}/src/worldarray/units.py +0 -0
  28. {worldarray-0.9.1 → worldarray-0.10.1}/src/worldarray/write.py +0 -0
  29. {worldarray-0.9.1 → worldarray-0.10.1}/tests/conftest.py +0 -0
  30. {worldarray-0.9.1 → worldarray-0.10.1}/tests/fixtures/metadata.json +0 -0
  31. {worldarray-0.9.1 → worldarray-0.10.1}/tests/fixtures/write.json +0 -0
  32. {worldarray-0.9.1 → worldarray-0.10.1}/tests/test_geometry.py +0 -0
  33. {worldarray-0.9.1 → worldarray-0.10.1}/tests/test_grid.py +0 -0
  34. {worldarray-0.9.1 → worldarray-0.10.1}/tests/test_lost_wakeup.py +0 -0
  35. {worldarray-0.9.1 → worldarray-0.10.1}/tests/test_metadata.py +0 -0
  36. {worldarray-0.9.1 → worldarray-0.10.1}/tests/test_read.py +0 -0
  37. {worldarray-0.9.1 → worldarray-0.10.1}/tests/test_resample.py +0 -0
  38. {worldarray-0.9.1 → worldarray-0.10.1}/tests/test_write.py +0 -0
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.1 (2026-11-13)
4
+
5
+ A fix for `gs://` with a stale Google login. No placement change, no unit table change,
6
+ and no default changes.
7
+
8
+ - A `gs://` open falls back to the bucket's public https URL when Google's default
9
+ credentials fail to refresh. An expired gcloud login (a Workspace reauth policy) is
10
+ refused with HTTP 400 `invalid_grant`, which tensorstore does not report as a refusal,
11
+ so the open raised instead of reading a public bucket. Any error from getting the
12
+ access token now counts as a refusal, and so does a token endpoint out of reach.
13
+ - When every way in fails after a refusal, the error raised carries the first refusal
14
+ as its cause, so a stale login stays in the traceback of a private bucket's 403.
15
+
16
+ ## 0.10.0 (2026-11-03)
17
+
18
+ Axes in another order, on request. No placement change, no unit table change, and no
19
+ default changes: without `axes_order`, every array opens as it did in 0.9.
20
+
21
+ - `open`, `open_pyramid` and `metadata` take `axes_order`: `"stored"` (the default, for
22
+ good), `"zyx"` (the axes named z, y and x last, in that order, the others first in
23
+ stored order) or a sequence naming each axis once. An OME-Zarr written x, y, z
24
+ (TensorSwitch's) then reads as z, y, x, and a channel-last array channel first. The
25
+ names move with the data, and shape, chunks, shards, units, voxel size and
26
+ translation with them, in `.ts`, `meta`, `grid`, reads, writes, `blocks`, a pyramid's
27
+ placements and `level_meta`, level choice by voxel size, `base_voxel_size`, `reopen`
28
+ and a pickle. `open_tensorstore` stays in the store's order.
29
+ - On such a handle, `chunk_key`, `parse_chunk_key` and `list_chunks` take and give
30
+ indices in the presented order (a selected axis in its stored place); the keys are
31
+ the store's. `add_level` takes its per-axis arguments in the pyramid's order and
32
+ creates the array in the group's.
33
+
3
34
  ## 0.9.1 (2026-10-26)
4
35
 
5
36
  A fix for exit. No placement change, no unit table change, and no default changes.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: worldarray
3
- Version: 0.9.1
3
+ Version: 0.10.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/
@@ -116,6 +116,7 @@ vol = wa.open(path, units=None)
116
116
  vol.meta # shape, dtype, chunk_shape, axes, units, voxel_size, translation, attrs, ...
117
117
  vol.ts # the tensorstore itself, C order, origin 0
118
118
  vol.read_index((0, 0, 0), (64, 64, 64), padding=0)
119
+ wa.open(path, axes_order="zyx") # an x, y, z store (TensorSwitch's) presented z, y, x, names and all
119
120
 
120
121
  pyr = wa.open_pyramid(path) # every level, opened on first use
121
122
  pyr.select((16, 16, 16), mode="floor") # the level to read at 16 nm
@@ -192,7 +193,9 @@ Dialects that store the corner are moved half a voxel on reading:
192
193
  | precomputed `voxel_offset` | corner | `(voxel_offset + 1/2) * resolution` |
193
194
  | nothing | a plain array | voxels of 1, corner at the origin |
194
195
 
195
- Axes are the store's C order; N5 and precomputed are transposed to it. Unlisted or
196
+ Axes are the store's C order; N5 and precomputed are transposed to it. `axes_order="zyx"`
197
+ presents the axes named z, y and x last, in that order, for a reader that indexes axes by
198
+ position; the store's order stays the default. Unlisted or
196
199
  conflicting attributes warn; a voxel size that disagrees with the OME scale raises, since
197
200
  that is how stale metadata is caught. Metadata the parser did not consume stays available
198
201
  raw. The full precedence per format, the half-voxel rules, and every read behaviour are
@@ -57,6 +57,7 @@ vol = wa.open(path, units=None)
57
57
  vol.meta # shape, dtype, chunk_shape, axes, units, voxel_size, translation, attrs, ...
58
58
  vol.ts # the tensorstore itself, C order, origin 0
59
59
  vol.read_index((0, 0, 0), (64, 64, 64), padding=0)
60
+ wa.open(path, axes_order="zyx") # an x, y, z store (TensorSwitch's) presented z, y, x, names and all
60
61
 
61
62
  pyr = wa.open_pyramid(path) # every level, opened on first use
62
63
  pyr.select((16, 16, 16), mode="floor") # the level to read at 16 nm
@@ -133,7 +134,9 @@ Dialects that store the corner are moved half a voxel on reading:
133
134
  | precomputed `voxel_offset` | corner | `(voxel_offset + 1/2) * resolution` |
134
135
  | nothing | a plain array | voxels of 1, corner at the origin |
135
136
 
136
- Axes are the store's C order; N5 and precomputed are transposed to it. Unlisted or
137
+ Axes are the store's C order; N5 and precomputed are transposed to it. `axes_order="zyx"`
138
+ presents the axes named z, y and x last, in that order, for a reader that indexes axes by
139
+ position; the store's order stays the default. Unlisted or
137
140
  conflicting attributes warn; a voxel size that disagrees with the OME scale raises, since
138
141
  that is how stale metadata is caught. Metadata the parser did not consume stays available
139
142
  raw. The full precedence per format, the half-voxel rules, and every read behaviour are
@@ -131,6 +131,9 @@ vol = wa.open(
131
131
  # part of a box outside the data
132
132
  method="nearest", # "nearest" | "linear": the resampling kernel; no dtype heuristic (Behaviour 5)
133
133
  select=None, # {"c": 0, "t": 0}: pin non-spatial axes to one index; else they are kept whole
134
+ axes_order="stored", # "stored": the store's order (Convention 2) | "zyx": the axes named z, y and x last, in
135
+ # that order, the others first in stored order | a sequence naming each axis left after
136
+ # `select` once (0.10); every per-axis field and the data move with their names
134
137
  units="nanometer", # what world coordinates are in on spatial axes; a dict per axis kind
135
138
  # ({"space": "micrometer", "time": "second"}); None keeps the store's own units
136
139
  context=None, # a tensorstore.Context; default shared_context(0)
@@ -144,7 +147,7 @@ vol = wa.open(
144
147
  strict_units=True, # False: an unknown unit ("pixel") is taken as the target unit, with a warning
145
148
  )
146
149
 
147
- vol.ts # tensorstore.TensorStore: C-order axes, origin 0, selected axes dropped
150
+ vol.ts # tensorstore.TensorStore: the axes in `axes_order`, origin 0, selected axes dropped
148
151
  vol.meta # ArrayMeta in the store's own units
149
152
  vol.grid # ArrayMeta in the requested units, every axis
150
153
  vol.shape, vol.dtype, vol.axes, vol.spatial, vol.level_name, vol.levels, vol.remote
@@ -168,7 +171,7 @@ vol.index_box(box) -> (begin, end) # the voxels a world box selects on this gri
168
171
  vol.chunks_covering(begin, end) # chunk indices a voxel box touches
169
172
  vol.reopen(context=None, **overrides) -> WorldArray # the same array on another context (a worker changing its concurrency)
170
173
 
171
- pyr = wa.open_pyramid(path, context=None, units="nanometer", select=None)
174
+ pyr = wa.open_pyramid(path, context=None, units="nanometer", select=None, axes_order="stored")
172
175
  pyr.names; pyr.levels; pyr.by_name("s1"); pyr.select(voxel_size, mode="floor")
173
176
  pyr.metas() ; pyr.level_meta("s1") # ArrayMeta per level from the documents alone: shape, dtype, chunks (a sharded
174
177
  # array's inner chunk), fill and placement, with no tensorstore opened
@@ -177,11 +180,13 @@ pyr.voxel_sizes() ; pyr.translations() # where it lists the levels (OME-NGFF
177
180
  # choosing or placing a level opens nothing, whether the pyramid was opened by its
178
181
  # group path or by one of its level paths; else by opening each level
179
182
 
180
- wa.metadata(path, level=None, units=None, open=True, retries=None) -> ArrayMeta # open=False: from the documents alone
183
+ wa.metadata(path, level=None, units=None, open=True, retries=None, axes_order="stored") -> ArrayMeta
184
+ # open=False: from the documents alone
181
185
  wa.detect(path, missing_ok=False, retries=None) -> Node | None # format, kind (array|group), path, docs, scale_key; .attrs (raw user
182
186
  # attributes), .multiscales (OME 0.5's `ome` unwrapped)
183
187
  wa.open_tensorstore(path, level=None, context=None, retries=None) -> tensorstore.TensorStore # reads no world metadata, so an
184
- # array with odd placement attributes (a coordinate array) still opens
188
+ # array with odd placement attributes (a coordinate array) still opens;
189
+ # always the store's order (Convention 2)
185
190
  wa.corner_to_centre(corner, voxel_size, axes=None) ; wa.default_axes(ndim) ; wa.index_range(begin, end, voxel_size, translation)
186
191
  wa.units.TABLE # the unit tables, public: {"length": {...metres per unit}, "time": {...seconds per unit}}
187
192
  wa.select_level(levels, voxel_size, mode) ; wa.Box ; wa.Retry(max_attempts=10, timeout=None, base_delay=1.0)
@@ -196,7 +201,7 @@ wa.list_chunk_keys(path, separator=".", ndim=None) # chunk indices present und
196
201
  # metadata document needed, so a directory of chunk files with no .zarray lists too
197
202
  ```
198
203
 
199
- `ArrayMeta` is a frozen dataclass with one entry per axis, in the array's C order, and
204
+ `ArrayMeta` is a frozen dataclass with one entry per axis, in the array's order (Convention 2), and
200
205
  deliberately the same field names as chunkmirage's `ArrayInfo`: `shape`, `dtype`,
201
206
  `chunk_shape` (tensorstore's read chunk), `axes` (as stored; `("t","c","z","y","x")[-ndim:]`
202
207
  when the store names none), `axes_named`, `units` (as stored, any string; `""` when none),
@@ -276,7 +281,8 @@ wa.ensure_group_entry(group_path, name, voxel_size, translation, axes, units="na
276
281
  # document and missing ancestors created, nothing written when the entry already says the same
277
282
  # (Behaviour 12). `create` and `add_level` call this
278
283
  wa.write.codecs_of(path_or_node) -> (codecs, shards) # an array's codecs in its format's dialect and its shard shape, for
279
- # a mirror created like it (cellmap-flow's sync)
284
+ # a mirror created like it (cellmap-flow's sync); the shards in the store's order, so a
285
+ # mirror of a handle opened with `axes_order` takes vol.meta.shard_shape (Grid.from_meta does)
280
286
 
281
287
  vol = wa.create(
282
288
  path, # ".../vol.zarr/s0": the last component is the level, its parent the multiscale group, made if absent
@@ -422,9 +428,21 @@ this document and that one differ, fix one of them.
422
428
 
423
429
  2. **Axis order is the store's C order; N5 and precomputed are transposed to it** on read,
424
430
  so `(z, y, x)` for a volume, `(c, z, y, x)` with channels, `(t, c, z, y, x)` for OME
425
- 0.5. Nothing is reordered beyond that transposition; `select` pins non-spatial axes. A
431
+ 0.5. Nothing is reordered beyond that transposition unless asked. `axes_order="zyx"`
432
+ (0.10, on `open`, `open_pyramid` and `metadata`) presents the axes named z, y and x last,
433
+ in that order, and the others first in their stored order, so an OME-Zarr written x, y, z
434
+ (TensorSwitch's) reads as z, y, x and a channel-last array channel first; an array naming
435
+ none of z, y and x is unchanged. A sequence of names presents exactly that order and must
436
+ name each axis left after `select` once. The names move with the data, and shape, chunks,
437
+ shards, units, voxel size and translation with them, in `.ts`, `meta`, `grid`, every read
438
+ and write, the pyramid's placements and `level_meta` (where a selected axis, still there,
439
+ keeps its stored place) and a pickle; `open_tensorstore` stays in the store's order. The
440
+ default is the store's order for good, not only until 1.0: an x, y, z store read
441
+ faithfully is x, y, z with its names, which a reader placing axes by name needs
442
+ (Neuroglancer, chunkmirage, a mirror), and a reader indexing axes by position asks for its
443
+ order or checks the names (David, 2026-11-03). `select` pins non-spatial axes. A
426
444
  non-zero stored origin (precomputed `voxel_offset`) is moved to 0 in `.ts` and carried in
427
- `translation`. \1 Sharded stores (zarr v3 `sharding_indexed`, sharded precomputed) read like any
445
+ `translation`. Sharded stores (zarr v3 `sharding_indexed`, sharded precomputed) read like any
428
446
  other through tensorstore; `chunk_shape` is the inner read chunk, not the shard. Unnamed axes are called `("t", "c", "z", "y", "x")[-ndim:]` (chunkmirage's
429
447
  default) with `axes_named` False, so a consumer can apply its own default instead.
430
448
 
@@ -521,11 +539,18 @@ this document and that one differ, fix one of them.
521
539
  changes it between opens reads the right host. An N5 `units` written once as a string
522
540
  is read as that unit on every axis; tensorstore is given only the array members of
523
541
  `attributes.json`, so attributes it would reject do not stop the open. The next way is tried only when the error
524
- starts `PERMISSION_DENIED` or `UNAUTHENTICATED`; the way that worked is remembered per
542
+ starts `PERMISSION_DENIED` or `UNAUTHENTICATED`, or came from getting an access token,
543
+ which tensorstore marks with its `internal/oauth2/` code among the error's source
544
+ locations (0.10.1): Google refuses an expired gcloud login with HTTP 400 `invalid_grant`,
545
+ which starts neither (cellmap-flow, on David's Mac), and a token endpoint out of reach
546
+ leaves no token either, so the public URL is worth trying. When the last way fails too
547
+ after a refusal, its error carries the first refusal as its cause, so a stale login is
548
+ still in the traceback of a private bucket's 403. The way that worked is remembered per
525
549
  (driver, bucket) for the process. Format probing runs all probe keys under one way before
526
550
  moving to the next: a bucket with listing off answers 403 for a missing key, so a 403 on
527
551
  one probe key counts as "missing" when another probe key was read under the same way,
528
- and only when every probe key is refused is the next way tried (see Open question 4).
552
+ and only when every probe key is refused is the next way tried (see Open question 4);
553
+ a failed login answers no key, so it moves on at once.
529
554
  Metadata reads retry for about five seconds in total (`max_retries 5, initial_delay
530
555
  0.2s, max_delay 2s`); data reads keep tensorstore's defaults plus `Retry`, and each
531
556
  document read and store open is also timed out under `Retry` (0.7, Behaviour 6).
@@ -910,6 +935,10 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
910
935
  name is replaced; the group's other content is untouched (Behaviour 12). It writes no
911
936
  voxel data: downsampling is the caller's, per block, through `write_index`, labels by
912
937
  mode and images by mean as each repo decides. `exists` means what it means for `create`.
938
+ On a pyramid opened with `axes_order` (0.10), the per-axis arguments are in the order it
939
+ presents (`factors`, `origin`, `voxel_size` and `translation` on its spatial axes, `shape`,
940
+ `chunks` and `shards` as `level_meta` lists them), the array is created in the group's own
941
+ order, and the level comes back in the pyramid's.
913
942
 
914
943
  15. **Chunk keys.** `chunk_key(index)` is the key, relative to the array, of the write unit
915
944
  at `index`: zarr v2 indices joined by the array's `dimension_separator` (`0.1.2`); zarr
@@ -921,7 +950,11 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
921
950
  (`zarr.json`, `.zarray`, `attributes.json`, `info`, a `c/` key of the wrong arity).
922
951
  `list_chunk_keys` and `list_chunks` use the same parser. The free functions take the
923
952
  format and the array's facts instead of an open array, for an array served from nowhere
924
- (chunkmirage's frontends) or about to be created.
953
+ (chunkmirage's frontends) or about to be created. On a handle opened with `axes_order`
954
+ (0.10), `chunk_key`, `parse_chunk_key` and `list_chunks` take and give indices in the
955
+ order the handle presents, a selected axis in its stored place, as an N5 index is in C
956
+ order and its key x first; the key is the store's. The free functions stay in the
957
+ store's order.
925
958
 
926
959
  16. **Write units.** `write_shape` is the shard shape of a sharded zarr v3 array, else the
927
960
  chunk shape; `ArrayMeta.shard_shape` is None when unsharded; `chunk_shape` stays the
@@ -1023,6 +1056,9 @@ chunkmirage derives from raw attributes carries a `layered` note beside the pack
1023
1056
  any other error raises at once, the way that worked is tried first next time for that
1024
1057
  bucket, and a 403 on one probe key with another probe key readable does not trigger the
1025
1058
  fallback; detection ignores the directory suffix and finds a precomputed scale key.
1059
+ 0.10.1: a failed login counts as a refusal and is the cause of a later failure; end to
1060
+ end, a `gs://` open whose token refresh is answered 400 `invalid_grant` (an https proxy
1061
+ playing www.googleapis.com under a test CA) reads through the public URL.
1026
1062
 
1027
1063
  Reads: every statement in Behaviour 1, 2, 3 and 9; a document-less group probed through
1028
1064
  `s0`, integer and `"first"` levels, `detect(missing_ok=True)`, `index_box`; funlib keys on
@@ -1098,7 +1134,16 @@ array under `exists="error"`. 0.9: `WORLDARRAY_THREADS` ahead of every other var
1098
1134
  blank as unset and a bad value refused; `file_io_floor` sizing file I/O under the budget,
1099
1135
  ignored with a thread count, and carried through a pickle; a handle pickled on the driver
1100
1136
  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.
1137
+ finalization takes a while, exiting while the watchdog kicks flat out, exits cleanly. 0.10:
1138
+ an x, y, z array and a c, x, y, z one opened with `axes_order="zyx"` reading as the
1139
+ transpose of the stored data with every per-axis field moved, a write through the
1140
+ reordered view landing in the stored array's order, its chunk keys unchanged and its
1141
+ indices presented; a sequence order, and a bad order refused; an N5 array and an
1142
+ unnamed one unchanged under "zyx"; a selected channel keeping its place in a key index;
1143
+ `metadata` with and without opening; a pyramid's placements and `level_meta` reordered, a
1144
+ level chosen by an anisotropic voxel size, `base_voxel_size` applied to the right axes, and
1145
+ `add_level` taking its factors in the presented order; the order carried through
1146
+ `reopen` and a pickle.
1102
1147
  Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
1103
1148
  `tests/test_lost_wakeup.py`): worker processes aim tasks at the moment a one-thread pool's
1104
1149
  worker exits idle, so a plain context strands some for good (13 of about 7,000 on
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "worldarray"
3
- version = "0.9.1"
3
+ version = "0.10.1"
4
4
  description = "Read zarr, N5 and Neuroglancer precomputed volumes in world coordinates through tensorstore: correct voxel placement, pyramid levels, padded region reads, resampling. numpy + tensorstore only."
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -28,7 +28,7 @@ from .write import (
28
28
  parse_chunk_key,
29
29
  )
30
30
 
31
- __version__ = "0.9.1"
31
+ __version__ = "0.10.1"
32
32
 
33
33
  __all__ = [
34
34
  "ArrayMeta",
@@ -29,11 +29,16 @@ from .geometry import Box, index_range, is_aligned
29
29
  from .levels import LevelRef, Mode, discover, discover_group, same_voxel_size, select_level
30
30
  from .placement import (
31
31
  ArrayMeta,
32
+ AxesOrder,
32
33
  _dims_from_docs,
34
+ axes_permutation,
35
+ check_axes_order,
33
36
  default_axes,
34
37
  light_levels,
35
38
  read_meta,
36
39
  read_meta_light,
40
+ spatial_axes,
41
+ spatial_order,
37
42
  )
38
43
  from .retry import Retry, read_with_retries, write_with_retries
39
44
  from .store import (
@@ -92,6 +97,7 @@ class WorldArray:
92
97
  strict_units: bool = True,
93
98
  mode: str = "r",
94
99
  store_fill: bool = False,
100
+ axes_order: AxesOrder = "stored",
95
101
  _ref: LevelRef | None = None,
96
102
  _node: Node | None = None,
97
103
  ):
@@ -112,6 +118,7 @@ class WorldArray:
112
118
  self.context = context if context is not None else shared_context(0)
113
119
  self.retries = retries if retries is not None else Retry()
114
120
  self._select = dict(select or {})
121
+ self._axes_order = check_axes_order(axes_order)
115
122
  self._opts = dict(
116
123
  level=level,
117
124
  voxel_size=voxel_size,
@@ -124,6 +131,7 @@ class WorldArray:
124
131
  strict_units=strict_units,
125
132
  mode=mode,
126
133
  store_fill=store_fill,
134
+ axes_order=self._axes_order,
127
135
  )
128
136
  self._anchor = tuple(float(a) for a in anchor) if anchor is not None else None
129
137
 
@@ -147,7 +155,21 @@ class WorldArray:
147
155
  self._meta = read_meta(
148
156
  node_for_level, self._store, scale_index=ref.scale_index, conflicts=metadata_conflicts
149
157
  )
158
+ stored_axes = self._meta.axes
150
159
  self._apply_select()
160
+ # the order the axes are presented in (Conventions 2), and the stored axis at each
161
+ # place of a chunk key's index: a selected axis keeps its stored place there
162
+ order = axes_permutation(self._meta.axes, self._axes_order)
163
+ if order != tuple(range(len(order))):
164
+ self._store = self._store[ts.d[order].transpose[:]]
165
+ self._meta = self._meta.permuted(order)
166
+ if node_for_level.format == "precomputed": # a precomputed key names the spatial chunk
167
+ sp = spatial_axes(stored_axes)
168
+ self._key_order = tuple(
169
+ sp.index(i) for i in spatial_order(stored_axes, self._meta.axes)
170
+ )
171
+ else:
172
+ self._key_order = axes_permutation(stored_axes, self._meta.axes, fixed=self._select)
151
173
  if base_voxel_size is not None:
152
174
  self._meta = self._rescaled_to_base(base_voxel_size, base_units)
153
175
  space, time = _unit_targets(units)
@@ -198,7 +220,12 @@ class WorldArray:
198
220
  warnings.warn(f"{self.path} is an array; level={level!r} is ignored", stacklevel=3)
199
221
  return LevelRef(node.path.rsplit("/", 1)[-1], node.path) # the array itself, always
200
222
  self._pyramid = Pyramid(
201
- self.path, context=self.context, units=self.units, select=self._select, _node=node
223
+ self.path,
224
+ context=self.context,
225
+ units=self.units,
226
+ select=self._select,
227
+ axes_order=self._axes_order,
228
+ _node=node,
202
229
  )
203
230
  if level is not None:
204
231
  return self._pyramid._ref(level)
@@ -227,7 +254,13 @@ class WorldArray:
227
254
  by the same per-axis factor, so stores whose metadata is in voxels of level 0 (as
228
255
  BigStitcher writes) or unitless (a BigDataViewer N5) can be placed correctly."""
229
256
  base = tuple(float(v) for v in base)
230
- native = Pyramid(self.path, context=self.context, units=None, select=self._select)
257
+ native = Pyramid(
258
+ self.path,
259
+ context=self.context,
260
+ units=None,
261
+ select=self._select,
262
+ axes_order=self._axes_order,
263
+ )
231
264
  level0 = native.levels[0].meta if len(native) > 1 else self._meta
232
265
  spatial = self._meta.spatial
233
266
  if len(base) != len(spatial):
@@ -394,7 +427,11 @@ class WorldArray:
394
427
  def levels_pyramid(self) -> Pyramid:
395
428
  if self._pyramid is None:
396
429
  self._pyramid = Pyramid(
397
- self._where, context=self.context, units=self.units, select=self._select
430
+ self._where,
431
+ context=self.context,
432
+ units=self.units,
433
+ select=self._select,
434
+ axes_order=self._axes_order,
398
435
  )
399
436
  return self._pyramid
400
437
 
@@ -507,19 +544,37 @@ class WorldArray:
507
544
  params["origin"] = tuple(int(o) for o in scale.get("voxel_offset", [0, 0, 0]))[::-1]
508
545
  return params
509
546
 
547
+ def _stored_index(self, index: Sequence[int]) -> tuple[int, ...]:
548
+ """A key index in the presented order, in the store's."""
549
+ index, order = tuple(index), self._key_order
550
+ if len(index) != len(order):
551
+ return index # the key function says what is wrong
552
+ stored = [0] * len(order)
553
+ for place, axis in enumerate(order):
554
+ stored[axis] = index[place]
555
+ return tuple(stored)
556
+
557
+ def _presented_index(self, index: tuple[int, ...] | None) -> tuple[int, ...] | None:
558
+ """A key index in the store's order, in the presented one."""
559
+ if index is None or len(index) != len(self._key_order):
560
+ return index
561
+ return tuple(index[axis] for axis in self._key_order)
562
+
510
563
  def chunk_key(self, index: Sequence[int]) -> str:
511
564
  """The storage key, relative to the array, of the write unit at ``index`` on the
512
- storage grid (Behaviour 15). A precomputed array's key names the spatial chunk only."""
513
- return _write.chunk_key(index, **self._key_params())
565
+ storage grid, in the order the axes are presented (Behaviour 15). A precomputed
566
+ array's key names the spatial chunk only."""
567
+ return _write.chunk_key(self._stored_index(index), **self._key_params())
514
568
 
515
569
  def parse_chunk_key(self, key: str) -> tuple[int, ...] | None:
516
570
  """``chunk_key`` inverted; None for a key that is not a chunk."""
517
- return _write.parse_chunk_key(key, **self._key_params())
571
+ return self._presented_index(_write.parse_chunk_key(key, **self._key_params()))
518
572
 
519
573
  def list_chunks(self) -> list[tuple[int, ...]]:
520
- """The chunk indices that exist in storage (C order, the storage grid), by listing the
521
- array's keys with ``parse_chunk_key``: zarr v2 and v3 (a sharded array lists its
522
- shards), N5, and unsharded precomputed. Works on any kvstore, URLs included."""
574
+ """The chunk indices that exist in storage (the storage grid, in the order the axes
575
+ are presented), by listing the array's keys with ``parse_chunk_key``: zarr v2 and v3
576
+ (a sharded array lists its shards), N5, and unsharded precomputed. Works on any
577
+ kvstore, URLs included."""
523
578
  params = self._key_params()
524
579
  kv = ts.KvStore.open(self._store.kvstore.spec()).result()
525
580
  keys = [k.decode() if isinstance(k, bytes) else str(k) for k in kv.list().result()]
@@ -529,7 +584,7 @@ class WorldArray:
529
584
  k.rsplit("/", 1)[-1] if params["format"] == "precomputed" else k, **params
530
585
  )
531
586
  if idx is not None:
532
- found.add(idx)
587
+ found.add(self._presented_index(idx))
533
588
  return sorted(found)
534
589
 
535
590
  # --- writing (Behaviour 13) ----------------------------------------------------
@@ -1013,6 +1068,7 @@ class Pyramid:
1013
1068
  units: Units = "nanometer",
1014
1069
  select: dict[str, int] | None = None,
1015
1070
  mode: str = "r",
1071
+ axes_order: AxesOrder = "stored",
1016
1072
  _node: Node | None = None,
1017
1073
  **kwargs,
1018
1074
  ):
@@ -1023,7 +1079,8 @@ class Pyramid:
1023
1079
  self.units = units
1024
1080
  self._select = select or {}
1025
1081
  self._mode = mode
1026
- self._kwargs = {**kwargs, "mode": mode}
1082
+ self._axes_order = check_axes_order(axes_order)
1083
+ self._kwargs = {**kwargs, "mode": mode, "axes_order": self._axes_order}
1027
1084
  self._node = _node if _node is not None else detect(path)
1028
1085
  self._group, self._refs = discover_group(self._node) # the node listing the levels
1029
1086
  if not self._refs:
@@ -1079,7 +1136,11 @@ class Pyramid:
1079
1136
  space, _ = _unit_targets(self.units)
1080
1137
  # the group the levels were discovered from, whichever path opened the pyramid
1081
1138
  group = self._group
1082
- light = light_levels(group, self.names, space) if group is not None else None
1139
+ light = (
1140
+ light_levels(group, self.names, space, self._axes_order)
1141
+ if group is not None
1142
+ else None
1143
+ )
1083
1144
  if light is None:
1084
1145
  light = [
1085
1146
  (lvl.level_name, lvl.source_voxel_size, lvl.translation) for lvl in self.levels
@@ -1090,12 +1151,15 @@ class Pyramid:
1090
1151
  def level_meta(self, level: str | int) -> ArrayMeta:
1091
1152
  """One level's ``ArrayMeta`` in native units from its documents alone, with no
1092
1153
  tensorstore opened (shape, dtype, chunks and placement; a sharded array's inner
1093
- chunk)."""
1154
+ chunk), every axis in the order the pyramid presents them, a selected one in its
1155
+ stored place."""
1094
1156
  ref = self._ref(level)
1095
1157
  conflicts = self._kwargs.get("metadata_conflicts", "error")
1096
1158
  if ref.scale_index is not None:
1097
- return read_meta_light(self._node, scale_index=ref.scale_index, conflicts=conflicts)
1098
- return read_meta_light(detect(ref.path), conflicts=conflicts)
1159
+ meta = read_meta_light(self._node, scale_index=ref.scale_index, conflicts=conflicts)
1160
+ else:
1161
+ meta = read_meta_light(detect(ref.path), conflicts=conflicts)
1162
+ return meta.permuted(axes_permutation(meta.axes, self._axes_order, fixed=self._select))
1099
1163
 
1100
1164
  def metas(self) -> list[ArrayMeta]:
1101
1165
  """``level_meta`` for every level, in order."""
@@ -1141,6 +1205,12 @@ class Pyramid:
1141
1205
  base_node = detect(base_ref.path)
1142
1206
  base = read_meta_light(base_node, conflicts=self._kwargs.get("metadata_conflicts", "error"))
1143
1207
  ndim, spatial = base.ndim, base.spatial
1208
+ # the arguments are in the order the pyramid presents the axes; the array is the group's
1209
+ order = axes_permutation(base.axes, self._axes_order, fixed=self._select)
1210
+ factors, origin, voxel_size, translation, shape, chunks, shards = (
1211
+ _in_stored_order(v, order, spatial)
1212
+ for v in (factors, origin, voxel_size, translation, shape, chunks, shards)
1213
+ )
1144
1214
  if voxel_size is None or translation is None:
1145
1215
  if factors is None:
1146
1216
  raise ValueError("pass factors (and origin), or voxel_size and translation")
@@ -1184,7 +1254,7 @@ class Pyramid:
1184
1254
  )
1185
1255
  self._group, self._refs = discover_group(detect(group.path))
1186
1256
  self._light, self._levels = None, {}
1187
- return vol
1257
+ return vol if self._axes_order == "stored" else vol.reopen(axes_order=self._axes_order)
1188
1258
 
1189
1259
  def _select_ref(self, voxel_size, mode: Mode = "floor") -> LevelRef:
1190
1260
  name, _ = select_level(self.voxel_sizes(), voxel_size, mode)
@@ -1337,6 +1407,27 @@ def create(
1337
1407
  )
1338
1408
 
1339
1409
 
1410
+ def _in_stored_order(values, order: tuple[int, ...], spatial: tuple[int, ...]):
1411
+ """Per-axis ``values`` given in a presented order (``order[k]`` is the stored axis at
1412
+ place ``k``), in the store's: one per axis, or one per spatial axis. A scalar, None or a
1413
+ list of another length passes through."""
1414
+ if values is None or np.isscalar(values):
1415
+ return values
1416
+ values = tuple(values)
1417
+ if len(values) == len(order):
1418
+ stored = [None] * len(order)
1419
+ for place, axis in enumerate(order):
1420
+ stored[axis] = values[place]
1421
+ return tuple(stored)
1422
+ presented = [axis for axis in order if axis in spatial]
1423
+ if len(values) == len(presented):
1424
+ stored = [None] * len(spatial)
1425
+ for place, axis in enumerate(presented):
1426
+ stored[spatial.index(axis)] = values[place]
1427
+ return tuple(stored)
1428
+ return values
1429
+
1430
+
1340
1431
  def _reopen(path: str, opts: dict, recipe=None) -> WorldArray:
1341
1432
  return WorldArray(path, context=from_recipe(recipe), **opts)
1342
1433
 
@@ -1357,13 +1448,14 @@ def metadata(
1357
1448
  units: Units = None,
1358
1449
  open: bool = True,
1359
1450
  retries: Retry | None = None,
1451
+ axes_order: AxesOrder = "stored",
1360
1452
  ) -> ArrayMeta:
1361
1453
  """The ``ArrayMeta`` of an array (or of ``level`` under a group), in native units unless
1362
- ``units`` says otherwise. ``open=False`` reads it from the documents alone, with no
1363
- tensorstore opened. ``retries`` times out and retries each document read and the open
1364
- (Behaviour 6)."""
1454
+ ``units`` says otherwise, its axes in ``axes_order`` (Conventions 2). ``open=False``
1455
+ reads it from the documents alone, with no tensorstore opened. ``retries`` times out and
1456
+ retries each document read and the open (Behaviour 6)."""
1365
1457
  if open:
1366
- vol = WorldArray(path, level=level, units=units, retries=retries)
1458
+ vol = WorldArray(path, level=level, units=units, retries=retries, axes_order=axes_order)
1367
1459
  return vol.meta if units is None else vol.grid
1368
1460
  node = detect(path)
1369
1461
  if level == "first":
@@ -1391,6 +1483,7 @@ def metadata(
1391
1483
  meta = read_meta_light(detect(ref.path))
1392
1484
  else:
1393
1485
  meta = read_meta_light(node)
1486
+ meta = meta.permuted(axes_permutation(meta.axes, axes_order))
1394
1487
  if units is None:
1395
1488
  return meta
1396
1489
  space, time = _unit_targets(units)
@@ -17,6 +17,7 @@ from __future__ import annotations
17
17
  import json
18
18
  import logging
19
19
  import re
20
+ from collections.abc import Sequence
20
21
  from dataclasses import dataclass, field, replace
21
22
 
22
23
  import numpy as np
@@ -143,12 +144,82 @@ class ArrayMeta:
143
144
  shard_shape=pick(self.shard_shape) if self.shard_shape is not None else None,
144
145
  )
145
146
 
147
+ def permuted(self, order: Sequence[int]) -> ArrayMeta:
148
+ """The same metadata with axis ``order[k]`` at place ``k``: every per-axis field moves
149
+ with its name."""
150
+ pick = lambda t: tuple(t[i] for i in order) # noqa: E731
151
+ return replace(
152
+ self,
153
+ shape=pick(self.shape),
154
+ chunk_shape=pick(self.chunk_shape),
155
+ axes=pick(self.axes),
156
+ units=pick(self.units),
157
+ voxel_size=pick(self.voxel_size),
158
+ translation=pick(self.translation),
159
+ shard_shape=pick(self.shard_shape) if self.shard_shape is not None else None,
160
+ )
161
+
146
162
  @property
147
163
  def write_shape(self) -> tuple[int, ...]:
148
164
  """The unit one writer may own: the shard shape when sharded, else the chunk shape."""
149
165
  return self.shard_shape if self.shard_shape is not None else self.chunk_shape
150
166
 
151
167
 
168
+ AxesOrder = str | Sequence[str]
169
+
170
+
171
+ def check_axes_order(order: AxesOrder) -> str | tuple[str, ...]:
172
+ """``"stored"``, ``"zyx"`` or a tuple of axis names (Conventions 2)."""
173
+ if isinstance(order, str):
174
+ if order not in ("stored", "zyx"):
175
+ raise ValueError(
176
+ f"axes_order is 'stored', 'zyx' or a sequence of axis names, not {order!r}"
177
+ )
178
+ return order
179
+ names = tuple(str(a) for a in order)
180
+ if len(set(names)) != len(names):
181
+ raise ValueError(f"axes_order {names} names an axis twice")
182
+ return names
183
+
184
+
185
+ def axes_permutation(
186
+ axes: Sequence[str], order: AxesOrder = "stored", fixed: Sequence[str] = ()
187
+ ) -> tuple[int, ...]:
188
+ """The axis of ``axes`` at each place once ``order`` presents them (Conventions 2):
189
+ ``"stored"`` keeps them; ``"zyx"`` moves the axes named z, y and x last, in that order,
190
+ the others first in stored order; a sequence of names is that order. Axes named in
191
+ ``fixed`` (selected ones, still in a key index or a pyramid's ``level_meta``) keep their
192
+ places, and the others fill the rest in order."""
193
+ axes = tuple(axes)
194
+ order = check_axes_order(order)
195
+ movable = [i for i, a in enumerate(axes) if a not in fixed]
196
+ if order == "stored":
197
+ ranked = movable
198
+ elif order == "zyx":
199
+ named = sorted(
200
+ (i for i in movable if axes[i] in SPATIAL_NAMES), key=lambda i: "zyx".index(axes[i])
201
+ )
202
+ ranked = [i for i in movable if axes[i] not in SPATIAL_NAMES] + named
203
+ else:
204
+ names = [axes[i] for i in movable]
205
+ if sorted(order) != sorted(names):
206
+ raise ValueError(f"axes_order {order} must name each of the axes {tuple(names)} once")
207
+ ranked = [movable[names.index(a)] for a in order]
208
+ places = iter(ranked)
209
+ return tuple(i if axes[i] in fixed else next(places) for i in range(len(axes)))
210
+
211
+
212
+ def spatial_order(axes: Sequence[str], order: AxesOrder = "stored") -> tuple[int, ...]:
213
+ """The spatial axes of ``axes`` in the order ``order`` presents them."""
214
+ axes = tuple(axes)
215
+ sp = spatial_axes(axes)
216
+ others = tuple(a for i, a in enumerate(axes) if i not in sp)
217
+ order = check_axes_order(order)
218
+ if not isinstance(order, str):
219
+ order = tuple(a for a in order if a in {axes[i] for i in sp})
220
+ return tuple(i for i in axes_permutation(axes, order, fixed=others) if i in sp)
221
+
222
+
152
223
  def spatial_axes(axes: tuple[str, ...]) -> tuple[int, ...]:
153
224
  named = tuple(i for i, a in enumerate(axes) if a in SPATIAL_NAMES)
154
225
  if named:
@@ -730,29 +801,32 @@ def _precomputed(info: dict, scale_index: int, ndim: int) -> _Resolved:
730
801
 
731
802
 
732
803
  def light_voxel_sizes(
733
- group: Node, names: list[str], space: str | None
804
+ group: Node, names: list[str], space: str | None, axes_order: AxesOrder = "stored"
734
805
  ) -> list[tuple[str, tuple[float, ...]]] | None:
735
- levels = light_levels(group, names, space)
806
+ levels = light_levels(group, names, space, axes_order)
736
807
  return None if levels is None else [(name, vs) for name, vs, _ in levels]
737
808
 
738
809
 
739
810
  def light_levels(
740
- group: Node, names: list[str], space: str | None
811
+ group: Node, names: list[str], space: str | None, axes_order: AxesOrder = "stored"
741
812
  ) -> list[tuple[str, tuple[float, ...], tuple[float, ...]]] | None:
742
813
  """``(name, spatial voxel size)`` for every level of a group, from the group's own
743
814
  document, in ``space`` units (native when None): OME-NGFF datasets, COSEM N5
744
815
  ``multiscales[].datasets[].transform`` and precomputed ``scales``. None when the
745
816
  document does not say (then the levels have to be opened), or when a unit cannot be
746
817
  converted. ``light_levels`` adds each level's spatial translation (the centre of its
747
- voxel 0, in the same units), so a pyramid can be placed without opening any level."""
818
+ voxel 0, in the same units), so a pyramid can be placed without opening any level.
819
+ Both on the spatial axes in the order ``axes_order`` presents them."""
748
820
  try:
749
821
  if group.format == "precomputed":
750
822
  f = U.factor("nm", space) if space else 1.0
823
+ order = spatial_order(("z", "y", "x"), axes_order)
751
824
  out = []
752
825
  for i, scale in enumerate(group.docs["info"]["scales"]):
753
826
  res = [float(v) * f for v in scale["resolution"]][::-1]
754
827
  vo = [float(v) for v in scale.get("voxel_offset", [0, 0, 0])][::-1]
755
828
  tr = [(o + 0.5) * r for o, r in zip(vo, res)]
829
+ res, tr = [res[k] for k in order], [tr[k] for k in order]
756
830
  out.append((str(scale.get("key", i)), tuple(res), tuple(tr)))
757
831
  return out
758
832
  multiscales = group.multiscales
@@ -788,7 +862,7 @@ def light_levels(
788
862
  axes = found.axes or default_axes(ndim)
789
863
  units = found.units or ("",) * ndim
790
864
  sizes, trs = [], []
791
- for i in spatial_axes(axes):
865
+ for i in spatial_order(axes, axes_order):
792
866
  f = U.factor(units[i], space, missing_as=U.NANOMETER) if space else 1.0
793
867
  sizes.append(found.voxel_size[i] * f)
794
868
  trs.append((found.translation[i] if found.translation is not None else 0.0) * f)
@@ -158,9 +158,19 @@ class Refused(Exception):
158
158
  """A read was refused for want of credentials (not a missing key, not a network error)."""
159
159
 
160
160
 
161
+ def login_failed(error: BaseException) -> bool:
162
+ """``error`` came from getting an access token, before any request reached the store: a
163
+ stale Google login (HTTP 400 ``invalid_grant``, which starts neither refusal code), a
164
+ rejected client, or a token endpoint out of reach. tensorstore names its oauth2 code
165
+ among the error's source locations."""
166
+ return "tensorstore/internal/oauth2/" in str(error)
167
+
168
+
161
169
  def is_refused(error: BaseException) -> bool:
162
- return isinstance(error, Refused) or str(error).startswith(
163
- ("PERMISSION_DENIED", "UNAUTHENTICATED")
170
+ return (
171
+ isinstance(error, Refused)
172
+ or str(error).startswith(("PERMISSION_DENIED", "UNAUTHENTICATED"))
173
+ or login_failed(error)
164
174
  )
165
175
 
166
176
 
@@ -171,19 +181,23 @@ _admitted_lock = threading.Lock()
171
181
  def with_access(specs: Sequence[dict], attempt: Callable[[dict], T]) -> T:
172
182
  """``attempt(spec)`` with each spec in turn, moving on only while access is refused; the
173
183
  way that worked is tried first next time, for its bucket. Any other error, and the last
174
- refusal, is raised."""
184
+ refusal, is raised; after a refusal, with the first refusal as its cause, since that
185
+ can be the reason the read failed (a stale login passed over for a public URL that
186
+ refuses too)."""
175
187
  if len(specs) == 1:
176
188
  return attempt(specs[0])
177
189
  key = (specs[0]["driver"], specs[0].get("bucket", ""))
178
190
  with _admitted_lock:
179
191
  first = _admitted.get(key, 0)
180
192
  order = [first] + [i for i in range(len(specs)) if i != first]
193
+ refusal: BaseException | None = None
181
194
  for i in order[:-1]:
182
195
  try:
183
196
  result = attempt(specs[i])
184
197
  except Exception as e:
185
198
  if not is_refused(e):
186
199
  raise
200
+ refusal = refusal or e
187
201
  logger.info(
188
202
  "%s refused a read (%s); trying the next way in", key, str(e).splitlines()[0][:200]
189
203
  )
@@ -191,7 +205,12 @@ def with_access(specs: Sequence[dict], attempt: Callable[[dict], T]) -> T:
191
205
  with _admitted_lock:
192
206
  _admitted[key] = i
193
207
  return result
194
- result = attempt(specs[order[-1]])
208
+ try:
209
+ result = attempt(specs[order[-1]])
210
+ except Exception as e:
211
+ if refusal is None or e.__cause__ is not None:
212
+ raise
213
+ raise e from refusal
195
214
  with _admitted_lock:
196
215
  _admitted[key] = order[-1]
197
216
  return result
@@ -264,7 +283,8 @@ def _read_keys(spec: dict, keys: Sequence[str]) -> dict[str, bytes | None | Base
264
283
  """``keys`` under one way in, their reads begun at once: one round trip, not one per key
265
284
  (an http open probes five). Each value is the document, None when it is missing, or the
266
285
  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."""
286
+ left out, so a server that never answers costs one key's attempts, not five, and so
287
+ does a failed login, which every key would meet."""
268
288
  kv = _metadata_kv(spec)
269
289
  started = {key: kv.read(key) for key in keys}
270
290
  out: dict[str, bytes | None | BaseException] = {}
@@ -278,7 +298,7 @@ def _read_keys(spec: dict, keys: Sequence[str]) -> dict[str, bytes | None | Base
278
298
  result = _wait(start, spec, _where(spec, key), "read")
279
299
  except Exception as e: # noqa: BLE001 the caller decides what a failed key means
280
300
  out[key] = e
281
- if not is_refused(e):
301
+ if not is_refused(e) or login_failed(e):
282
302
  for future in started.values():
283
303
  future.cancel()
284
304
  return out
@@ -325,14 +345,14 @@ class Location:
325
345
  """All of ``keys`` under one way in, read at once (``_read_keys``). A bucket with
326
346
  listing off answers a refusal for a missing key, so a refusal on one key counts as
327
347
  missing when another key was read under the same way; only when every key is refused
328
- is the next way tried."""
348
+ is the next way tried, and at once after a failed login, which answers no key."""
329
349
 
330
350
  def attempt(spec: dict) -> dict[str, bytes | None]:
331
351
  found: dict[str, bytes | None] = {}
332
352
  refusals: dict[str, BaseException] = {}
333
353
  for key, value in _read_keys(spec, keys).items():
334
354
  if isinstance(value, BaseException):
335
- if not is_refused(value):
355
+ if not is_refused(value) or login_failed(value):
336
356
  raise value
337
357
  refusals[key] = value
338
358
  else:
@@ -5,9 +5,13 @@ from __future__ import annotations
5
5
  import functools
6
6
  import gc
7
7
  import http.server
8
+ import json
8
9
  import logging
9
10
  import os
11
+ import shutil
10
12
  import socket
13
+ import socketserver
14
+ import ssl
11
15
  import subprocess
12
16
  import sys
13
17
  import textwrap
@@ -104,6 +108,40 @@ def test_all_probe_keys_refused_falls_back(monkeypatch):
104
108
  assert found[".zarray"] == b"{}" and found["zarr.json"] is None
105
109
 
106
110
 
111
+ _STALE_LOGIN = ( # tensorstore's error for gcloud's login after a reauth policy expired it
112
+ 'Unknown [HTTP code 400] [http_response_body=\'{"error": "invalid_grant"}\'] '
113
+ "[source locations='tensorstore/internal/oauth2/oauth2_auth_provider.cc:83\n"
114
+ "tensorstore/internal/oauth2/auth_provider.cc:30']"
115
+ )
116
+
117
+
118
+ def test_a_failed_login_is_a_refusal_and_the_cause_of_a_later_one():
119
+ unreachable = (
120
+ "UNAVAILABLE: CURL error Could not resolve host [source locations='"
121
+ "tensorstore/internal/curl/curl_transport.cc:475\n"
122
+ "tensorstore/internal/oauth2/oauth2_auth_provider.cc:81']"
123
+ )
124
+ assert store.is_refused(ValueError(_STALE_LOGIN)) and store.is_refused(ValueError(unreachable))
125
+ assert not store.is_refused(ValueError("Unknown [HTTP code 400] [source locations='x.cc:1']"))
126
+ specs = [
127
+ {"driver": "gcs", "bucket": "b"},
128
+ {"driver": "http", "base_url": "https://storage.googleapis.com", "path": "/b/"},
129
+ ]
130
+
131
+ def attempt(spec, public=True):
132
+ if spec["driver"] == "gcs":
133
+ raise ValueError(_STALE_LOGIN)
134
+ if public:
135
+ return "ok"
136
+ raise ValueError("PERMISSION_DENIED: Forbidden [http_response_code='403']")
137
+
138
+ assert store.with_access(specs, attempt) == "ok"
139
+ store._admitted.clear()
140
+ with pytest.raises(ValueError, match="PERMISSION_DENIED") as raised:
141
+ store.with_access(specs, functools.partial(attempt, public=False))
142
+ assert "invalid_grant" in str(raised.value.__cause__) # the stale login is in the traceback
143
+
144
+
107
145
  def test_detection_ignores_the_suffix_and_finds_precomputed_scale_keys(tmp_path):
108
146
  misnamed = tmp_path / "actually_zarr.n5"
109
147
  write_zarr2_group(misnamed)
@@ -481,6 +519,121 @@ def test_an_http_open_probes_its_keys_at_once(tmp_path):
481
519
  server.shutdown()
482
520
 
483
521
 
522
+ def _google_tls(tmp_path):
523
+ """A test CA's certificate, and a TLS context serving a certificate it signed for
524
+ Google's token hosts, with the extensions macOS requires of a TLS server's."""
525
+ (tmp_path / "test.cnf").write_text(
526
+ textwrap.dedent("""
527
+ [req]
528
+ distinguished_name = dn
529
+ [dn]
530
+ [ca]
531
+ basicConstraints = critical, CA:TRUE
532
+ keyUsage = critical, keyCertSign, cRLSign
533
+ [leaf]
534
+ basicConstraints = critical, CA:FALSE
535
+ keyUsage = critical, digitalSignature, keyEncipherment
536
+ extendedKeyUsage = serverAuth
537
+ subjectAltName = DNS:www.googleapis.com, DNS:oauth2.googleapis.com
538
+ """)
539
+ )
540
+ for args in (
541
+ "req -x509 -config test.cnf -extensions ca -newkey rsa:2048 -nodes -keyout ca.key"
542
+ " -out ca.pem -subj /CN=ca -days 1 -sha256",
543
+ "req -new -config test.cnf -newkey rsa:2048 -nodes -keyout leaf.key -out leaf.csr"
544
+ " -subj /CN=www.googleapis.com -sha256",
545
+ "x509 -req -in leaf.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out leaf.pem -days 1"
546
+ " -sha256 -extfile test.cnf -extensions leaf",
547
+ ):
548
+ subprocess.run(["openssl", *args.split()], cwd=tmp_path, check=True, capture_output=True)
549
+ tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
550
+ tls.load_cert_chain(tmp_path / "leaf.pem", tmp_path / "leaf.key")
551
+ return tmp_path / "ca.pem", tls
552
+
553
+
554
+ @pytest.mark.skipif(shutil.which("openssl") is None, reason="the test CA is made with openssl")
555
+ def test_a_stale_google_login_falls_back_to_the_public_url(tmp_path):
556
+ """Google answers the token refresh of an expired gcloud login 400 invalid_grant, which
557
+ starts neither refusal code (cellmap-flow, on David's Mac). An https proxy plays
558
+ www.googleapis.com, under a CA tensorstore is told to trust, and a local http server is
559
+ the bucket's public URL."""
560
+ public = tmp_path / "public"
561
+ write_zarr2(public / "bucket" / "vol.zarr" / "s0", pattern((4, 4)))
562
+ ca, tls = _google_tls(tmp_path)
563
+ refreshes = []
564
+
565
+ class Google(socketserver.StreamRequestHandler):
566
+ def handle(self):
567
+ target = self.rfile.readline().split()[1]
568
+ while self.rfile.readline() not in (b"\r\n", b""):
569
+ pass
570
+ if target != b"www.googleapis.com:443":
571
+ self.wfile.write(b"HTTP/1.1 502 Bad Gateway\r\n\r\n")
572
+ return
573
+ self.wfile.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
574
+ with tls.wrap_socket(self.connection, server_side=True) as conn:
575
+ request = conn.makefile("rb")
576
+ headers = []
577
+ while (line := request.readline()) not in (b"\r\n", b""):
578
+ headers.append(line.decode())
579
+ sizes = [h.split(":")[1] for h in headers if h.lower().startswith("content-length")]
580
+ request.read(int(sizes[0]) if sizes else 0)
581
+ refreshes.append(headers[0].split()[1])
582
+ body = b'{"error": "invalid_grant", "error_description": "reauth related error"}'
583
+ conn.sendall(
584
+ b"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\n"
585
+ b"Connection: close\r\nContent-Length: %d\r\n\r\n%s" % (len(body), body)
586
+ )
587
+
588
+ class Files(http.server.SimpleHTTPRequestHandler):
589
+ def log_message(self, *args):
590
+ pass
591
+
592
+ proxy = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Google)
593
+ proxy.daemon_threads = True
594
+ files = http.server.ThreadingHTTPServer(
595
+ ("127.0.0.1", 0), functools.partial(Files, directory=str(public))
596
+ )
597
+ for server in (proxy, files):
598
+ threading.Thread(target=server.serve_forever, daemon=True).start()
599
+ login = tmp_path / "adc.json" # gcloud's application default credentials, for a user
600
+ login.write_text(
601
+ json.dumps(
602
+ {
603
+ "type": "authorized_user",
604
+ "client_id": "c",
605
+ "client_secret": "s",
606
+ "refresh_token": "r",
607
+ }
608
+ )
609
+ )
610
+ env = {k: v for k, v in os.environ.items() if "proxy" not in k.lower()}
611
+ env.update(
612
+ HTTPS_PROXY=f"http://127.0.0.1:{proxy.server_address[1]}",
613
+ NO_PROXY="127.0.0.1",
614
+ TENSORSTORE_CA_BUNDLE=str(ca),
615
+ GOOGLE_APPLICATION_CREDENTIALS=str(login),
616
+ TENSORSTORE_GCS_HTTP_URL=f"http://127.0.0.1:{files.server_address[1]}",
617
+ TENSORSTORE_CURL_VERBOSE="1", # why a handshake with the proxy failed, if one does
618
+ )
619
+ read = (
620
+ "import json, worldarray as wa; "
621
+ "vol = wa.open('gs://bucket/vol.zarr/s0', units=None); "
622
+ "print(json.dumps(vol.ts.read().result().tolist()))"
623
+ )
624
+ try:
625
+ done = subprocess.run(
626
+ [sys.executable, "-c", read], env=env, capture_output=True, text=True, timeout=120
627
+ )
628
+ finally:
629
+ proxy.shutdown()
630
+ files.shutdown()
631
+ assert done.returncode == 0, done.stderr
632
+ assert json.loads(done.stdout) == pattern((4, 4)).tolist()
633
+ # the login was tried, and refused
634
+ assert refreshes and set(refreshes) == {"/oauth2/v3/token"}, done.stderr[-4000:]
635
+
636
+
484
637
  def test_capped_contexts_size_file_io_apart_and_write_without_sync_or_locks():
485
638
  spec = wa.capped_context_spec(concurrency=1, network=False)
486
639
  assert spec["data_copy_concurrency"] == spec["file_io_concurrency"] == {"limit": 1}
@@ -0,0 +1,197 @@
1
+ """Presenting the axes in another order: ``axes_order`` (spec, Conventions 2, Behaviours 14
2
+ and 15)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import pickle
7
+
8
+ import numpy as np
9
+ import pytest
10
+
11
+ import worldarray as wa
12
+ from conftest import pattern, write_n5, write_zarr2
13
+
14
+ XYZ = ("x", "y", "z")
15
+
16
+
17
+ def _xyz(tmp_path, shape=(8, 6, 4), **kw) -> tuple[str, np.ndarray]:
18
+ """An x, y, z array (TensorSwitch's order) at 16, 16, 45 nm, filled with a pattern."""
19
+ path = str(tmp_path / "xyz.zarr/s0")
20
+ vol = wa.create(path, shape, "uint16", (16, 16, 45), (4, 4, 0), axes=XYZ, **kw)
21
+ data = pattern(shape)
22
+ vol.write_index((0,) * len(shape), shape, data)
23
+ return path, data
24
+
25
+
26
+ def test_zyx_presents_an_xyz_array_as_its_transpose(tmp_path):
27
+ path, data = _xyz(tmp_path, chunks=(4, 3, 2))
28
+ stored = wa.open(path)
29
+ assert stored.axes == XYZ and stored.shape == (8, 6, 4) # the default is the store's order
30
+ vol = wa.open(path, axes_order="zyx")
31
+ assert vol.axes == ("z", "y", "x") and vol.shape == (4, 6, 8) and vol.ts.shape == (4, 6, 8)
32
+ assert vol.voxel_size == (45, 16, 16) and vol.translation == (0, 4, 4)
33
+ assert vol.meta.chunk_shape == (2, 3, 4) and vol.write_shape == (2, 3, 4)
34
+ assert vol.meta.units == ("nanometer",) * 3
35
+ np.testing.assert_array_equal(vol.read_index((0, 0, 0), (4, 6, 8)), data.transpose(2, 1, 0))
36
+ np.testing.assert_array_equal(vol.read(vol.box), data.transpose(2, 1, 0))
37
+ part = vol.read_index((1, 2, 3), (3, 5, 7))
38
+ np.testing.assert_array_equal(part, data[3:7, 2:5, 1:3].transpose(2, 1, 0))
39
+ assert wa.Grid.from_meta(vol.meta).axes == ("z", "y", "x")
40
+
41
+
42
+ def test_a_channel_axis_comes_first_and_select_still_pins_it(tmp_path):
43
+ path = str(tmp_path / "cxyz.zarr/s0")
44
+ data = pattern((2, 8, 6, 4))
45
+ wa.create(
46
+ path, data.shape, "uint16", (16, 16, 45), (4, 4, 0), axes=("c", *XYZ), chunks=(1, 4, 3, 2)
47
+ )
48
+ wa.open(path, mode="r+").write_index((0,) * 4, data.shape, data)
49
+ vol = wa.open(path, axes_order="zyx")
50
+ assert vol.axes == ("c", "z", "y", "x") and vol.meta.chunk_shape == (1, 2, 3, 4)
51
+ np.testing.assert_array_equal(vol.read_index((0,) * 4, vol.shape), data.transpose(0, 3, 2, 1))
52
+ one = wa.open(path, axes_order="zyx", select={"c": 1})
53
+ assert one.axes == ("z", "y", "x")
54
+ np.testing.assert_array_equal(one.read_index((0,) * 3, one.shape), data[1].transpose(2, 1, 0))
55
+ # a channel-last array comes out channel first
56
+ last = str(tmp_path / "xyzc.zarr/s0")
57
+ wa.create(
58
+ last, (8, 6, 4, 2), "uint16", (16, 16, 45), (4, 4, 0), axes=(*XYZ, "c"), chunks=(4, 3, 2, 1)
59
+ )
60
+ assert wa.open(last, axes_order="zyx").axes == ("c", "z", "y", "x")
61
+
62
+
63
+ def test_a_write_through_the_reordered_view_lands_in_the_stores_order(tmp_path):
64
+ path = str(tmp_path / "w.zarr/s0")
65
+ wa.create(
66
+ path,
67
+ (8, 6, 4),
68
+ "uint16",
69
+ (16, 16, 45),
70
+ (4, 4, 0),
71
+ axes=XYZ,
72
+ chunks=(2, 3, 2),
73
+ shards=(4, 6, 2),
74
+ )
75
+ vol = wa.open(path, mode="r+", axes_order="zyx")
76
+ assert vol.meta.shard_shape == (2, 6, 4) and vol.write_shape == (2, 6, 4)
77
+ zyx = pattern((4, 6, 8))
78
+ vol.write_index((0, 0, 0), (2, 6, 4), zyx[:2, :, :4]) # one shard: z 0-2, x 0-4
79
+ vol.write_chunk((1, 0, 1), zyx[2:, :, 4:])
80
+ stored = wa.open(path)
81
+ np.testing.assert_array_equal(
82
+ stored.read_index((0, 0, 0), (4, 6, 2)), zyx[:2, :, :4].transpose(2, 1, 0)
83
+ )
84
+ np.testing.assert_array_equal(
85
+ stored.read_index((4, 0, 2), (8, 6, 4)), zyx[2:, :, 4:].transpose(2, 1, 0)
86
+ )
87
+ with pytest.raises(ValueError, match="whole write units"):
88
+ vol.write_index((0, 0, 0), (1, 6, 4), zyx[:1, :, :4])
89
+ # keys are the store's; the indices are in the presented order
90
+ assert vol.chunk_key((1, 0, 1)) == stored.chunk_key((1, 0, 1)[::-1]) == "c/1/0/1"
91
+ assert vol.chunk_key((0, 0, 1)) == "c/1/0/0"
92
+ assert vol.parse_chunk_key("c/1/0/0") == (0, 0, 1)
93
+ assert vol.list_chunks() == [(0, 0, 0), (1, 0, 1)]
94
+ assert stored.list_chunks() == [(0, 0, 0), (1, 0, 1)]
95
+ for b in vol.blocks():
96
+ assert vol.parse_chunk_key(vol.chunk_key(b.index)) == b.index
97
+
98
+
99
+ def test_a_selected_axis_keeps_its_stored_place_in_a_key_index(tmp_path):
100
+ path = str(tmp_path / "k.zarr/s0")
101
+ wa.create(
102
+ path, (8, 6, 4, 2), "uint16", (16, 16, 45), (4, 4, 0), axes=(*XYZ, "c"), chunks=(4, 3, 2, 1)
103
+ )
104
+ vol = wa.open(path, axes_order="zyx", select={"c": 1})
105
+ assert vol.axes == ("z", "y", "x")
106
+ # stored (x, y, z, c); the key index is (z, y, x, c): c stays last, where it is stored
107
+ assert vol.chunk_key((1, 0, 1, 1)) == "c/1/0/1/1"
108
+ assert vol.chunk_key((0, 1, 1, 0)) == "c/1/1/0/0"
109
+ assert vol.parse_chunk_key("c/1/1/0/0") == (0, 1, 1, 0)
110
+
111
+
112
+ def test_a_sequence_names_the_order_and_a_bad_order_is_refused(tmp_path):
113
+ path, data = _xyz(tmp_path, chunks=(4, 3, 2))
114
+ vol = wa.open(path, axes_order=("y", "z", "x"))
115
+ assert vol.axes == ("y", "z", "x") and vol.voxel_size == (16, 45, 16)
116
+ np.testing.assert_array_equal(vol.read_index((0, 0, 0), vol.shape), data.transpose(1, 2, 0))
117
+ with pytest.raises(ValueError, match="axes_order is"):
118
+ wa.open(path, axes_order="xyz")
119
+ with pytest.raises(ValueError, match="must name each of the axes"):
120
+ wa.open(path, axes_order=("z", "y"))
121
+ with pytest.raises(ValueError, match="names an axis twice"):
122
+ wa.open(path, axes_order=("z", "z", "x"))
123
+
124
+
125
+ def test_n5_and_unnamed_arrays_are_unchanged_under_zyx(tmp_path):
126
+ data = pattern((4, 6, 8))
127
+ write_n5(tmp_path / "a.n5", data, attrs={"resolution": [16, 16, 45], "axes": ["x", "y", "z"]})
128
+ n5 = wa.open(str(tmp_path / "a.n5"), axes_order="zyx", units=None)
129
+ assert n5.axes == ("z", "y", "x") and n5.voxel_size == (45, 16, 16)
130
+ np.testing.assert_array_equal(n5.read_index((0, 0, 0), n5.shape), data)
131
+ write_zarr2(tmp_path / "u.zarr", data, chunks=(2, 3, 4))
132
+ unnamed = wa.open(str(tmp_path / "u.zarr"), axes_order="zyx", units=None)
133
+ assert unnamed.axes == ("z", "y", "x") and not unnamed.meta.axes_named
134
+ np.testing.assert_array_equal(unnamed.read_index((0, 0, 0), unnamed.shape), data)
135
+
136
+
137
+ def test_metadata_with_and_without_opening(tmp_path):
138
+ path, _ = _xyz(tmp_path, chunks=(4, 3, 2))
139
+ for opened in (True, False):
140
+ meta = wa.metadata(path, axes_order="zyx", open=opened)
141
+ assert meta.axes == ("z", "y", "x") and meta.shape == (4, 6, 8)
142
+ assert meta.chunk_shape == (2, 3, 4) and meta.voxel_size == (45, 16, 16)
143
+ assert meta.translation == (0, 4, 4)
144
+ assert wa.metadata(path, open=False).axes == XYZ
145
+
146
+
147
+ def _xyz_pyramid(tmp_path) -> str:
148
+ """s0 at 16, 16, 45 nm and s1 at 32, 32, 45 (x and y halved), stored x, y, z."""
149
+ _xyz(tmp_path, chunks=(4, 3, 2))
150
+ group = str(tmp_path / "xyz.zarr")
151
+ wa.open_pyramid(group, mode="r+").add_level("s1", factors=(2, 2, 1))
152
+ return group
153
+
154
+
155
+ def test_a_pyramid_presents_its_placements_and_level_meta_in_the_order(tmp_path):
156
+ group = _xyz_pyramid(tmp_path)
157
+ pyr = wa.open_pyramid(group, axes_order="zyx")
158
+ assert pyr.placements() == [
159
+ ("s0", (45.0, 16.0, 16.0), (0.0, 4.0, 4.0)),
160
+ ("s1", (45.0, 32.0, 32.0), (0.0, 12.0, 12.0)),
161
+ ]
162
+ assert pyr.level_meta("s1").shape == (4, 3, 4) and pyr.level_meta("s1").axes == ("z", "y", "x")
163
+ assert pyr.by_name("s1").shape == (4, 3, 4) and pyr.by_name("s1").axes == ("z", "y", "x")
164
+ # a level chosen by an anisotropic voxel size, given in the presented order
165
+ vol = wa.open(group, voxel_size=(45, 32, 32), axes_order="zyx")
166
+ assert vol.level_name == "s1" and vol.voxel_size == (45, 32, 32)
167
+ with pytest.raises(ValueError, match="no level at voxel size"):
168
+ wa.open(group, voxel_size=(45, 32, 32)) # read as x, y, z: no such level
169
+
170
+
171
+ def test_base_voxel_size_lands_on_the_presented_axes(tmp_path):
172
+ group = _xyz_pyramid(tmp_path)
173
+ vol = wa.open(group, level="s1", axes_order="zyx", base_voxel_size=(450, 160, 160))
174
+ assert vol.voxel_size == (450, 320, 320)
175
+
176
+
177
+ def test_add_level_takes_its_arguments_in_the_presented_order(tmp_path):
178
+ group = _xyz_pyramid(tmp_path)
179
+ pyr = wa.open_pyramid(group, mode="r+", axes_order="zyx")
180
+ s2 = pyr.add_level("s2", factors=(2, 2, 2), from_level="s1")
181
+ s3 = pyr.add_level("s3", factors=(1, 2, 2), from_level="s1", chunks=(2, 3, 4))
182
+ assert s2.axes == s3.axes == ("z", "y", "x")
183
+ assert s2.voxel_size == (90, 64, 64) and s3.voxel_size == (45, 64, 64)
184
+ stored = wa.open(f"{group}/s3")
185
+ assert stored.axes == XYZ and stored.voxel_size == (64, 64, 45)
186
+ assert stored.shape == (2, 2, 4) and stored.meta.chunk_shape == (4, 3, 2)
187
+
188
+
189
+ def test_the_order_survives_reopen_and_a_pickle(tmp_path):
190
+ path, data = _xyz(tmp_path, chunks=(4, 3, 2))
191
+ vol = wa.open(path, axes_order="zyx")
192
+ for again in (vol.reopen(), pickle.loads(pickle.dumps(vol))):
193
+ assert again.axes == ("z", "y", "x")
194
+ np.testing.assert_array_equal(
195
+ again.read_index((0, 0, 0), again.shape), data.transpose(2, 1, 0)
196
+ )
197
+ assert vol.reopen(axes_order="stored").axes == XYZ
@@ -1247,7 +1247,7 @@ wheels = [
1247
1247
 
1248
1248
  [[package]]
1249
1249
  name = "worldarray"
1250
- version = "0.9.1"
1250
+ version = "0.10.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