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.
- {worldarray-0.10.0 → worldarray-0.10.2}/.github/workflows/ci.yml +16 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/CHANGELOG.md +27 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/PKG-INFO +1 -1
- {worldarray-0.10.0 → worldarray-0.10.2}/docs/spec.md +18 -2
- {worldarray-0.10.0 → worldarray-0.10.2}/pyproject.toml +1 -1
- {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/__init__.py +1 -1
- {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/store.py +38 -8
- {worldarray-0.10.0 → worldarray-0.10.2}/tests/test_access.py +157 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/uv.lock +1 -1
- {worldarray-0.10.0 → worldarray-0.10.2}/.github/workflows/docs.yml +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/.github/workflows/publish.yml +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/.gitignore +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/LICENSE +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/README.md +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/docs/changelog.md +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/docs/index.md +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/mkdocs.yml +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/array.py +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/context.py +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/geometry.py +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/grid.py +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/levels.py +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/placement.py +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/resample.py +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/retry.py +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/units.py +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/src/worldarray/write.py +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/tests/conftest.py +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/tests/fixtures/metadata.json +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/tests/fixtures/write.json +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/tests/test_axes_order.py +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/tests/test_geometry.py +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/tests/test_grid.py +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/tests/test_lost_wakeup.py +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/tests/test_metadata.py +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/tests/test_read.py +0 -0
- {worldarray-0.10.0 → worldarray-0.10.2}/tests/test_resample.py +0 -0
- {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.
|
|
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
|
|
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.
|
|
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" }
|
|
@@ -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
|
|
163
|
-
(
|
|
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
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|