worldarray 0.10.0__tar.gz → 0.10.2__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.2}/.github/workflows/ci.yml +16 -0
  2. {worldarray-0.10.0 → worldarray-0.10.2}/CHANGELOG.md +27 -0
  3. {worldarray-0.10.0 → worldarray-0.10.2}/PKG-INFO +1 -1
  4. {worldarray-0.10.0 → worldarray-0.10.2}/docs/spec.md +18 -2
  5. {worldarray-0.10.0 → worldarray-0.10.2}/pyproject.toml +1 -1
  6. {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/__init__.py +1 -1
  7. {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/store.py +38 -8
  8. {worldarray-0.10.0 → worldarray-0.10.2}/tests/test_access.py +157 -0
  9. {worldarray-0.10.0 → worldarray-0.10.2}/uv.lock +1 -1
  10. {worldarray-0.10.0 → worldarray-0.10.2}/.github/workflows/docs.yml +0 -0
  11. {worldarray-0.10.0 → worldarray-0.10.2}/.github/workflows/publish.yml +0 -0
  12. {worldarray-0.10.0 → worldarray-0.10.2}/.gitignore +0 -0
  13. {worldarray-0.10.0 → worldarray-0.10.2}/LICENSE +0 -0
  14. {worldarray-0.10.0 → worldarray-0.10.2}/README.md +0 -0
  15. {worldarray-0.10.0 → worldarray-0.10.2}/docs/changelog.md +0 -0
  16. {worldarray-0.10.0 → worldarray-0.10.2}/docs/index.md +0 -0
  17. {worldarray-0.10.0 → worldarray-0.10.2}/mkdocs.yml +0 -0
  18. {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/array.py +0 -0
  19. {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/context.py +0 -0
  20. {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/geometry.py +0 -0
  21. {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/grid.py +0 -0
  22. {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/levels.py +0 -0
  23. {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/placement.py +0 -0
  24. {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/resample.py +0 -0
  25. {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/retry.py +0 -0
  26. {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/units.py +0 -0
  27. {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/write.py +0 -0
  28. {worldarray-0.10.0 → worldarray-0.10.2}/tests/conftest.py +0 -0
  29. {worldarray-0.10.0 → worldarray-0.10.2}/tests/fixtures/metadata.json +0 -0
  30. {worldarray-0.10.0 → worldarray-0.10.2}/tests/fixtures/write.json +0 -0
  31. {worldarray-0.10.0 → worldarray-0.10.2}/tests/test_axes_order.py +0 -0
  32. {worldarray-0.10.0 → worldarray-0.10.2}/tests/test_geometry.py +0 -0
  33. {worldarray-0.10.0 → worldarray-0.10.2}/tests/test_grid.py +0 -0
  34. {worldarray-0.10.0 → worldarray-0.10.2}/tests/test_lost_wakeup.py +0 -0
  35. {worldarray-0.10.0 → worldarray-0.10.2}/tests/test_metadata.py +0 -0
  36. {worldarray-0.10.0 → worldarray-0.10.2}/tests/test_read.py +0 -0
  37. {worldarray-0.10.0 → worldarray-0.10.2}/tests/test_resample.py +0 -0
  38. {worldarray-0.10.0 → worldarray-0.10.2}/tests/test_write.py +0 -0
@@ -3,6 +3,8 @@ on:
3
3
  push:
4
4
  branches: [main]
5
5
  pull_request:
6
+ schedule:
7
+ - cron: "0 6 * * 1" # weekly, so a new dependency release is tried without a push
6
8
  jobs:
7
9
  test:
8
10
  strategy:
@@ -20,3 +22,17 @@ jobs:
20
22
  - run: uv run ruff format --check src tests
21
23
  - run: uv run pytest -q
22
24
  - run: uv run mkdocs build --strict
25
+ # what a fresh install gets: the newest release of every dependency, not the lock's
26
+ newest:
27
+ strategy:
28
+ matrix:
29
+ os: [ubuntu-latest, macos-latest]
30
+ runs-on: ${{ matrix.os }}
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+ - uses: astral-sh/setup-uv@v5
34
+ with:
35
+ python-version: "3.12"
36
+ - run: uv sync --group dev --upgrade
37
+ - run: uv pip list | grep -E "^(tensorstore|numpy) "
38
+ - run: uv run pytest -q
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.2 (2026-11-13)
4
+
5
+ 0.10.1's fix, on tensorstore 0.1.86. No placement change, no unit table change, and no
6
+ default changes.
7
+
8
+ - A `gs://` open with a stale Google login falls back to the public https URL on
9
+ tensorstore 0.1.86 too. 0.10.1 knew the failed login by the source locations in
10
+ tensorstore's error, which 0.1.86 leaves out, so with the newest tensorstore (what a
11
+ fresh install gets) the open still raised. The token endpoint's own error code
12
+ (`invalid_grant`, and the other OAuth 2.0 codes) is now matched as well. On 0.1.86 a
13
+ token endpoint out of reach is raised as any unreachable host is, not passed over.
14
+ - CI also runs the tests on the newest release of every dependency, weekly and on every
15
+ pull request; 0.10.1 passed on the lock's tensorstore 0.1.78.
16
+
17
+ ## 0.10.1 (2026-11-13)
18
+
19
+ A fix for `gs://` with a stale Google login. No placement change, no unit table change,
20
+ and no default changes.
21
+
22
+ - A `gs://` open falls back to the bucket's public https URL when Google's default
23
+ credentials fail to refresh. An expired gcloud login (a Workspace reauth policy) is
24
+ refused with HTTP 400 `invalid_grant`, which tensorstore does not report as a refusal,
25
+ so the open raised instead of reading a public bucket. Any error from getting the
26
+ access token now counts as a refusal, and so does a token endpoint out of reach.
27
+ - When every way in fails after a refusal, the error raised carries the first refusal
28
+ as its cause, so a stale login stays in the traceback of a private bucket's 403.
29
+
3
30
  ## 0.10.0 (2026-11-03)
4
31
 
5
32
  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.2
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,21 @@ 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 up to 0.1.85 marks with its `internal/oauth2/` code among the error's
544
+ source locations (0.10.1), and which is otherwise known by the token endpoint's own error
545
+ code, an OAuth 2.0 one (RFC 6749 §5.2) as the body's string `error`, where a store's error
546
+ body holds an object (0.10.2: tensorstore 0.1.86 leaves source locations out): Google refuses an expired gcloud login with HTTP 400 `invalid_grant`,
547
+ which starts neither (cellmap-flow, on David's Mac), and a token endpoint out of reach
548
+ leaves no token either, so the public URL is worth trying; from 0.1.86 an unreachable
549
+ token endpoint carries no such mark and is raised as any unreachable host is. When the last way fails too
550
+ after a refusal, its error carries the first refusal as its cause, so a stale login is
551
+ still in the traceback of a private bucket's 403. The way that worked is remembered per
543
552
  (driver, bucket) for the process. Format probing runs all probe keys under one way before
544
553
  moving to the next: a bucket with listing off answers 403 for a missing key, so a 403 on
545
554
  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).
555
+ and only when every probe key is refused is the next way tried (see Open question 4);
556
+ a failed login answers no key, so it moves on at once.
547
557
  Metadata reads retry for about five seconds in total (`max_retries 5, initial_delay
548
558
  0.2s, max_delay 2s`); data reads keep tensorstore's defaults plus `Retry`, and each
549
559
  document read and store open is also timed out under `Retry` (0.7, Behaviour 6).
@@ -1049,6 +1059,12 @@ chunkmirage derives from raw attributes carries a `layered` note beside the pack
1049
1059
  any other error raises at once, the way that worked is tried first next time for that
1050
1060
  bucket, and a 403 on one probe key with another probe key readable does not trigger the
1051
1061
  fallback; detection ignores the directory suffix and finds a precomputed scale key.
1062
+ 0.10.1: a failed login counts as a refusal and is the cause of a later failure; end to
1063
+ end, a `gs://` open whose token refresh is answered 400 `invalid_grant` (an https proxy
1064
+ playing www.googleapis.com under a test CA) reads through the public URL. 0.10.2: the
1065
+ same on tensorstore 0.1.86's messages, and CI runs the suite on the newest release of
1066
+ every dependency as well as the lock's, weekly and on every pull request: 0.10.1 passed
1067
+ on the lock's tensorstore 0.1.78 and failed on 0.1.86, which a fresh install got.
1052
1068
 
1053
1069
  Reads: every statement in Behaviour 1, 2, 3 and 9; a document-less group probed through
1054
1070
  `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.2"
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.2"
32
32
 
33
33
  __all__ = [
34
34
  "ArrayMeta",
@@ -158,9 +158,29 @@ 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
+ # An OAuth 2.0 token endpoint's error codes (RFC 6749, 5.2), the string "error" member of the
162
+ # body it answers; a store's own error bodies hold an object there. Quotes may come escaped.
163
+ _TOKEN_ERROR = re.compile(
164
+ r'error\\*"\s*:\s*\\*"(?:invalid_grant|invalid_client|invalid_request|invalid_scope'
165
+ r'|unauthorized_client|unsupported_grant_type)\\*"'
166
+ )
167
+
168
+
169
+ def login_failed(error: BaseException) -> bool:
170
+ """``error`` came from getting an access token, before any request reached the store: a
171
+ stale Google login (HTTP 400 ``invalid_grant``, which starts neither refusal code), a
172
+ rejected client, or (up to tensorstore 0.1.85) a token endpoint out of reach. Up to
173
+ 0.1.85 tensorstore names its oauth2 code among the error's source locations; 0.1.86
174
+ leaves source locations out, so the token endpoint's own error code is matched too."""
175
+ message = str(error)
176
+ return "tensorstore/internal/oauth2/" in message or bool(_TOKEN_ERROR.search(message))
177
+
178
+
161
179
  def is_refused(error: BaseException) -> bool:
162
- return isinstance(error, Refused) or str(error).startswith(
163
- ("PERMISSION_DENIED", "UNAUTHENTICATED")
180
+ return (
181
+ isinstance(error, Refused)
182
+ or str(error).startswith(("PERMISSION_DENIED", "UNAUTHENTICATED"))
183
+ or login_failed(error)
164
184
  )
165
185
 
166
186
 
@@ -171,19 +191,23 @@ _admitted_lock = threading.Lock()
171
191
  def with_access(specs: Sequence[dict], attempt: Callable[[dict], T]) -> T:
172
192
  """``attempt(spec)`` with each spec in turn, moving on only while access is refused; the
173
193
  way that worked is tried first next time, for its bucket. Any other error, and the last
174
- refusal, is raised."""
194
+ refusal, is raised; after a refusal, with the first refusal as its cause, since that
195
+ can be the reason the read failed (a stale login passed over for a public URL that
196
+ refuses too)."""
175
197
  if len(specs) == 1:
176
198
  return attempt(specs[0])
177
199
  key = (specs[0]["driver"], specs[0].get("bucket", ""))
178
200
  with _admitted_lock:
179
201
  first = _admitted.get(key, 0)
180
202
  order = [first] + [i for i in range(len(specs)) if i != first]
203
+ refusal: BaseException | None = None
181
204
  for i in order[:-1]:
182
205
  try:
183
206
  result = attempt(specs[i])
184
207
  except Exception as e:
185
208
  if not is_refused(e):
186
209
  raise
210
+ refusal = refusal or e
187
211
  logger.info(
188
212
  "%s refused a read (%s); trying the next way in", key, str(e).splitlines()[0][:200]
189
213
  )
@@ -191,7 +215,12 @@ def with_access(specs: Sequence[dict], attempt: Callable[[dict], T]) -> T:
191
215
  with _admitted_lock:
192
216
  _admitted[key] = i
193
217
  return result
194
- result = attempt(specs[order[-1]])
218
+ try:
219
+ result = attempt(specs[order[-1]])
220
+ except Exception as e:
221
+ if refusal is None or e.__cause__ is not None:
222
+ raise
223
+ raise e from refusal
195
224
  with _admitted_lock:
196
225
  _admitted[key] = order[-1]
197
226
  return result
@@ -264,7 +293,8 @@ def _read_keys(spec: dict, keys: Sequence[str]) -> dict[str, bytes | None | Base
264
293
  """``keys`` under one way in, their reads begun at once: one round trip, not one per key
265
294
  (an http open probes five). Each value is the document, None when it is missing, or the
266
295
  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."""
296
+ left out, so a server that never answers costs one key's attempts, not five, and so
297
+ does a failed login, which every key would meet."""
268
298
  kv = _metadata_kv(spec)
269
299
  started = {key: kv.read(key) for key in keys}
270
300
  out: dict[str, bytes | None | BaseException] = {}
@@ -278,7 +308,7 @@ def _read_keys(spec: dict, keys: Sequence[str]) -> dict[str, bytes | None | Base
278
308
  result = _wait(start, spec, _where(spec, key), "read")
279
309
  except Exception as e: # noqa: BLE001 the caller decides what a failed key means
280
310
  out[key] = e
281
- if not is_refused(e):
311
+ if not is_refused(e) or login_failed(e):
282
312
  for future in started.values():
283
313
  future.cancel()
284
314
  return out
@@ -325,14 +355,14 @@ class Location:
325
355
  """All of ``keys`` under one way in, read at once (``_read_keys``). A bucket with
326
356
  listing off answers a refusal for a missing key, so a refusal on one key counts as
327
357
  missing when another key was read under the same way; only when every key is refused
328
- is the next way tried."""
358
+ is the next way tried, and at once after a failed login, which answers no key."""
329
359
 
330
360
  def attempt(spec: dict) -> dict[str, bytes | None]:
331
361
  found: dict[str, bytes | None] = {}
332
362
  refusals: dict[str, BaseException] = {}
333
363
  for key, value in _read_keys(spec, keys).items():
334
364
  if isinstance(value, BaseException):
335
- if not is_refused(value):
365
+ if not is_refused(value) or login_failed(value):
336
366
  raise value
337
367
  refusals[key] = value
338
368
  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,44 @@ 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
+ # tensorstore 0.1.86 leaves source locations out; the token endpoint's error code remains
120
+ login = r"Bad Request [HTTP code 400] [http_response_body='{\"error\": \"invalid_grant\"}']"
121
+ store_400 = r"Bad Request [HTTP code 400] [http_response_body='{\"error\": {\"code\": 400}}']"
122
+ assert store.is_refused(ValueError(login)) and not store.is_refused(ValueError(store_400))
123
+ unreachable = (
124
+ "UNAVAILABLE: CURL error Could not resolve host [source locations='"
125
+ "tensorstore/internal/curl/curl_transport.cc:475\n"
126
+ "tensorstore/internal/oauth2/oauth2_auth_provider.cc:81']"
127
+ )
128
+ assert store.is_refused(ValueError(_STALE_LOGIN)) and store.is_refused(ValueError(unreachable))
129
+ assert not store.is_refused(ValueError("Unknown [HTTP code 400] [source locations='x.cc:1']"))
130
+ specs = [
131
+ {"driver": "gcs", "bucket": "b"},
132
+ {"driver": "http", "base_url": "https://storage.googleapis.com", "path": "/b/"},
133
+ ]
134
+
135
+ def attempt(spec, public=True):
136
+ if spec["driver"] == "gcs":
137
+ raise ValueError(_STALE_LOGIN)
138
+ if public:
139
+ return "ok"
140
+ raise ValueError("PERMISSION_DENIED: Forbidden [http_response_code='403']")
141
+
142
+ assert store.with_access(specs, attempt) == "ok"
143
+ store._admitted.clear()
144
+ with pytest.raises(ValueError, match="PERMISSION_DENIED") as raised:
145
+ store.with_access(specs, functools.partial(attempt, public=False))
146
+ assert "invalid_grant" in str(raised.value.__cause__) # the stale login is in the traceback
147
+
148
+
107
149
  def test_detection_ignores_the_suffix_and_finds_precomputed_scale_keys(tmp_path):
108
150
  misnamed = tmp_path / "actually_zarr.n5"
109
151
  write_zarr2_group(misnamed)
@@ -481,6 +523,121 @@ def test_an_http_open_probes_its_keys_at_once(tmp_path):
481
523
  server.shutdown()
482
524
 
483
525
 
526
+ def _google_tls(tmp_path):
527
+ """A test CA's certificate, and a TLS context serving a certificate it signed for
528
+ Google's token hosts, with the extensions macOS requires of a TLS server's."""
529
+ (tmp_path / "test.cnf").write_text(
530
+ textwrap.dedent("""
531
+ [req]
532
+ distinguished_name = dn
533
+ [dn]
534
+ [ca]
535
+ basicConstraints = critical, CA:TRUE
536
+ keyUsage = critical, keyCertSign, cRLSign
537
+ [leaf]
538
+ basicConstraints = critical, CA:FALSE
539
+ keyUsage = critical, digitalSignature, keyEncipherment
540
+ extendedKeyUsage = serverAuth
541
+ subjectAltName = DNS:www.googleapis.com, DNS:oauth2.googleapis.com
542
+ """)
543
+ )
544
+ for args in (
545
+ "req -x509 -config test.cnf -extensions ca -newkey rsa:2048 -nodes -keyout ca.key"
546
+ " -out ca.pem -subj /CN=ca -days 1 -sha256",
547
+ "req -new -config test.cnf -newkey rsa:2048 -nodes -keyout leaf.key -out leaf.csr"
548
+ " -subj /CN=www.googleapis.com -sha256",
549
+ "x509 -req -in leaf.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out leaf.pem -days 1"
550
+ " -sha256 -extfile test.cnf -extensions leaf",
551
+ ):
552
+ subprocess.run(["openssl", *args.split()], cwd=tmp_path, check=True, capture_output=True)
553
+ tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
554
+ tls.load_cert_chain(tmp_path / "leaf.pem", tmp_path / "leaf.key")
555
+ return tmp_path / "ca.pem", tls
556
+
557
+
558
+ @pytest.mark.skipif(shutil.which("openssl") is None, reason="the test CA is made with openssl")
559
+ def test_a_stale_google_login_falls_back_to_the_public_url(tmp_path):
560
+ """Google answers the token refresh of an expired gcloud login 400 invalid_grant, which
561
+ starts neither refusal code (cellmap-flow, on David's Mac). An https proxy plays
562
+ www.googleapis.com, under a CA tensorstore is told to trust, and a local http server is
563
+ the bucket's public URL."""
564
+ public = tmp_path / "public"
565
+ write_zarr2(public / "bucket" / "vol.zarr" / "s0", pattern((4, 4)))
566
+ ca, tls = _google_tls(tmp_path)
567
+ refreshes = []
568
+
569
+ class Google(socketserver.StreamRequestHandler):
570
+ def handle(self):
571
+ target = self.rfile.readline().split()[1]
572
+ while self.rfile.readline() not in (b"\r\n", b""):
573
+ pass
574
+ if target != b"www.googleapis.com:443":
575
+ self.wfile.write(b"HTTP/1.1 502 Bad Gateway\r\n\r\n")
576
+ return
577
+ self.wfile.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
578
+ with tls.wrap_socket(self.connection, server_side=True) as conn:
579
+ request = conn.makefile("rb")
580
+ headers = []
581
+ while (line := request.readline()) not in (b"\r\n", b""):
582
+ headers.append(line.decode())
583
+ sizes = [h.split(":")[1] for h in headers if h.lower().startswith("content-length")]
584
+ request.read(int(sizes[0]) if sizes else 0)
585
+ refreshes.append(headers[0].split()[1])
586
+ body = b'{"error": "invalid_grant", "error_description": "reauth related error"}'
587
+ conn.sendall(
588
+ b"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\n"
589
+ b"Connection: close\r\nContent-Length: %d\r\n\r\n%s" % (len(body), body)
590
+ )
591
+
592
+ class Files(http.server.SimpleHTTPRequestHandler):
593
+ def log_message(self, *args):
594
+ pass
595
+
596
+ proxy = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Google)
597
+ proxy.daemon_threads = True
598
+ files = http.server.ThreadingHTTPServer(
599
+ ("127.0.0.1", 0), functools.partial(Files, directory=str(public))
600
+ )
601
+ for server in (proxy, files):
602
+ threading.Thread(target=server.serve_forever, daemon=True).start()
603
+ login = tmp_path / "adc.json" # gcloud's application default credentials, for a user
604
+ login.write_text(
605
+ json.dumps(
606
+ {
607
+ "type": "authorized_user",
608
+ "client_id": "c",
609
+ "client_secret": "s",
610
+ "refresh_token": "r",
611
+ }
612
+ )
613
+ )
614
+ env = {k: v for k, v in os.environ.items() if "proxy" not in k.lower()}
615
+ env.update(
616
+ HTTPS_PROXY=f"http://127.0.0.1:{proxy.server_address[1]}",
617
+ NO_PROXY="127.0.0.1",
618
+ TENSORSTORE_CA_BUNDLE=str(ca),
619
+ GOOGLE_APPLICATION_CREDENTIALS=str(login),
620
+ TENSORSTORE_GCS_HTTP_URL=f"http://127.0.0.1:{files.server_address[1]}",
621
+ TENSORSTORE_CURL_VERBOSE="1", # why a handshake with the proxy failed, if one does
622
+ )
623
+ read = (
624
+ "import json, worldarray as wa; "
625
+ "vol = wa.open('gs://bucket/vol.zarr/s0', units=None); "
626
+ "print(json.dumps(vol.ts.read().result().tolist()))"
627
+ )
628
+ try:
629
+ done = subprocess.run(
630
+ [sys.executable, "-c", read], env=env, capture_output=True, text=True, timeout=120
631
+ )
632
+ finally:
633
+ proxy.shutdown()
634
+ files.shutdown()
635
+ assert done.returncode == 0, done.stderr
636
+ assert json.loads(done.stdout) == pattern((4, 4)).tolist()
637
+ # the login was tried, and refused
638
+ assert refreshes and set(refreshes) == {"/oauth2/v3/token"}, done.stderr[-4000:]
639
+
640
+
484
641
  def test_capped_contexts_size_file_io_apart_and_write_without_sync_or_locks():
485
642
  spec = wa.capped_context_spec(concurrency=1, network=False)
486
643
  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.2"
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