worldarray 0.10.0__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.10.0 → worldarray-0.10.1}/CHANGELOG.md +13 -0
  2. {worldarray-0.10.0 → worldarray-0.10.1}/PKG-INFO +1 -1
  3. {worldarray-0.10.0 → worldarray-0.10.1}/docs/spec.md +12 -2
  4. {worldarray-0.10.0 → worldarray-0.10.1}/pyproject.toml +1 -1
  5. {worldarray-0.10.0 → worldarray-0.10.1}/src/worldarray/__init__.py +1 -1
  6. {worldarray-0.10.0 → worldarray-0.10.1}/src/worldarray/store.py +28 -8
  7. {worldarray-0.10.0 → worldarray-0.10.1}/tests/test_access.py +153 -0
  8. {worldarray-0.10.0 → worldarray-0.10.1}/uv.lock +1 -1
  9. {worldarray-0.10.0 → worldarray-0.10.1}/.github/workflows/ci.yml +0 -0
  10. {worldarray-0.10.0 → worldarray-0.10.1}/.github/workflows/docs.yml +0 -0
  11. {worldarray-0.10.0 → worldarray-0.10.1}/.github/workflows/publish.yml +0 -0
  12. {worldarray-0.10.0 → worldarray-0.10.1}/.gitignore +0 -0
  13. {worldarray-0.10.0 → worldarray-0.10.1}/LICENSE +0 -0
  14. {worldarray-0.10.0 → worldarray-0.10.1}/README.md +0 -0
  15. {worldarray-0.10.0 → worldarray-0.10.1}/docs/changelog.md +0 -0
  16. {worldarray-0.10.0 → worldarray-0.10.1}/docs/index.md +0 -0
  17. {worldarray-0.10.0 → worldarray-0.10.1}/mkdocs.yml +0 -0
  18. {worldarray-0.10.0 → worldarray-0.10.1}/src/worldarray/array.py +0 -0
  19. {worldarray-0.10.0 → worldarray-0.10.1}/src/worldarray/context.py +0 -0
  20. {worldarray-0.10.0 → worldarray-0.10.1}/src/worldarray/geometry.py +0 -0
  21. {worldarray-0.10.0 → worldarray-0.10.1}/src/worldarray/grid.py +0 -0
  22. {worldarray-0.10.0 → worldarray-0.10.1}/src/worldarray/levels.py +0 -0
  23. {worldarray-0.10.0 → worldarray-0.10.1}/src/worldarray/placement.py +0 -0
  24. {worldarray-0.10.0 → worldarray-0.10.1}/src/worldarray/resample.py +0 -0
  25. {worldarray-0.10.0 → worldarray-0.10.1}/src/worldarray/retry.py +0 -0
  26. {worldarray-0.10.0 → worldarray-0.10.1}/src/worldarray/units.py +0 -0
  27. {worldarray-0.10.0 → worldarray-0.10.1}/src/worldarray/write.py +0 -0
  28. {worldarray-0.10.0 → worldarray-0.10.1}/tests/conftest.py +0 -0
  29. {worldarray-0.10.0 → worldarray-0.10.1}/tests/fixtures/metadata.json +0 -0
  30. {worldarray-0.10.0 → worldarray-0.10.1}/tests/fixtures/write.json +0 -0
  31. {worldarray-0.10.0 → worldarray-0.10.1}/tests/test_axes_order.py +0 -0
  32. {worldarray-0.10.0 → worldarray-0.10.1}/tests/test_geometry.py +0 -0
  33. {worldarray-0.10.0 → worldarray-0.10.1}/tests/test_grid.py +0 -0
  34. {worldarray-0.10.0 → worldarray-0.10.1}/tests/test_lost_wakeup.py +0 -0
  35. {worldarray-0.10.0 → worldarray-0.10.1}/tests/test_metadata.py +0 -0
  36. {worldarray-0.10.0 → worldarray-0.10.1}/tests/test_read.py +0 -0
  37. {worldarray-0.10.0 → worldarray-0.10.1}/tests/test_resample.py +0 -0
  38. {worldarray-0.10.0 → worldarray-0.10.1}/tests/test_write.py +0 -0
@@ -1,5 +1,18 @@
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
+
3
16
  ## 0.10.0 (2026-11-03)
4
17
 
5
18
  Axes in another order, on request. No placement change, no unit table change, and no
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: worldarray
3
- Version: 0.10.0
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/
@@ -539,11 +539,18 @@ this document and that one differ, fix one of them.
539
539
  changes it between opens reads the right host. An N5 `units` written once as a string
540
540
  is read as that unit on every axis; tensorstore is given only the array members of
541
541
  `attributes.json`, so attributes it would reject do not stop the open. The next way is tried only when the error
542
- 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
543
549
  (driver, bucket) for the process. Format probing runs all probe keys under one way before
544
550
  moving to the next: a bucket with listing off answers 403 for a missing key, so a 403 on
545
551
  one probe key counts as "missing" when another probe key was read under the same way,
546
- 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.
547
554
  Metadata reads retry for about five seconds in total (`max_retries 5, initial_delay
548
555
  0.2s, max_delay 2s`); data reads keep tensorstore's defaults plus `Retry`, and each
549
556
  document read and store open is also timed out under `Retry` (0.7, Behaviour 6).
@@ -1049,6 +1056,9 @@ chunkmirage derives from raw attributes carries a `layered` note beside the pack
1049
1056
  any other error raises at once, the way that worked is tried first next time for that
1050
1057
  bucket, and a 403 on one probe key with another probe key readable does not trigger the
1051
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.
1052
1062
 
1053
1063
  Reads: every statement in Behaviour 1, 2, 3 and 9; a document-less group probed through
1054
1064
  `s0`, integer and `"first"` levels, `detect(missing_ok=True)`, `index_box`; funlib keys on
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "worldarray"
3
- version = "0.10.0"
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.10.0"
31
+ __version__ = "0.10.1"
32
32
 
33
33
  __all__ = [
34
34
  "ArrayMeta",
@@ -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}
@@ -1247,7 +1247,7 @@ wheels = [
1247
1247
 
1248
1248
  [[package]]
1249
1249
  name = "worldarray"
1250
- version = "0.10.0"
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
File without changes