Skip to content

Commit 42fac93

Browse files
FrancescAltedclaude
andcommitted
Test the fsspec support against a real S3 endpoint
memory:// cannot see the class of bug that lives on a real backend, and has now hidden two: aget_chunk awaiting an async filesystem's coroutine (fine on memory://, which is not async, and broken on every chunk against s3fs), and a cache stamp that degraded to size-only because memory:// exposes no mtime. tests/test_fsspec_s3.py runs moto in-process (ThreadedMotoServer on a free port, so xdist workers do not collide) with s3fs in front of it: a real S3 protocol, real range requests, a real async backend, and still offline -- no credentials, no network, so no `network` marker. Eight tests in ~3 s. Reverting the aget_chunk fix fails exactly the two that cover it. moto[server] and s3fs go into the test group as well as dev, so this runs on push rather than only when someone remembers. That is a heavier dependency tree on every job; if it ever churns badly enough to break installs, moving both to dev-only and running them nightly is the fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 01dfef5 commit 42fac93

2 files changed

Lines changed: 126 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ dev = [
7777
"matplotlib",
7878
"pandas",
7979
"plotly",
80+
"moto[server]",
8081
"pre-commit",
8182
"pyarrow",
8283
"ruff",
@@ -87,10 +88,15 @@ dev = [
8788
test = [
8889
"pytest",
8990
# tests/test_fsspec.py importorskips fsspec, so without this the whole fsspec
90-
# feature silently skips in CI. Protocol backends (s3fs, gcsfs...) stay out:
91-
# the tests run on memory:// and a local zip, which is also the configuration
92-
# most users installing [fsspec] are in.
91+
# feature silently skips in CI. memory:// covers the protocol-generic paths,
92+
# and is also the configuration most users installing [fsspec] are in.
9393
"fsspec; platform_machine != 'wasm32'",
94+
# tests/test_fsspec_s3.py needs a real S3 endpoint (moto, served locally, so
95+
# still offline) and a real *async* backend (s3fs). memory:// is neither, and
96+
# cannot see the class of bug that lives there: awaiting an async filesystem's
97+
# own coroutine fails on s3fs and passes silently on memory://. Runs in ~3 s.
98+
"moto[server]; platform_machine != 'wasm32'",
99+
"s3fs; platform_machine != 'wasm32'",
94100
# pytest.ini defaults to `-n auto`; where xdist is absent (wasm32, or a
95101
# bare `pip install pytest`) the root conftest.py degrades it to a serial run
96102
"pytest-xdist; platform_machine != 'wasm32'",

tests/test_fsspec_s3.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#######################################################################
2+
# Copyright (c) 2019-present, Blosc Development Team <blosc@blosc.org>
3+
# All rights reserved.
4+
#
5+
# This source code is licensed under a BSD-style license (found in the
6+
# LICENSE file in the root directory of this source tree)
7+
#######################################################################
8+
9+
"""fsspec reads against a real S3 endpoint, served locally by moto.
10+
11+
Everything else about the fsspec support is tested over ``memory://``, which is
12+
protocol-generic and needs no dependencies. Two things it structurally cannot
13+
cover, both of which have already hidden a bug:
14+
15+
- it is not an *async* backend, so ``aget_chunk`` always took its blocking
16+
fallback there, while against s3fs it raised "got Future attached to a
17+
different loop" on every chunk;
18+
- it is poorer in metadata than any real store (no mtime), which let a
19+
size-only cache stamp serve a stale chunk cache.
20+
21+
These run offline -- moto is a local server, no credentials, no network -- so
22+
they are not marked ``network``.
23+
"""
24+
25+
import asyncio
26+
27+
import numpy as np
28+
import pytest
29+
30+
import blosc2
31+
32+
pytest.importorskip("s3fs")
33+
pytest.importorskip("moto")
34+
fsspec = pytest.importorskip("fsspec")
35+
36+
BUCKET = "blosc2-test"
37+
38+
39+
@pytest.fixture(scope="module")
40+
def s3_endpoint():
41+
"""A local S3 server, and fsspec configured to reach it."""
42+
import fsspec.config
43+
from moto.server import ThreadedMotoServer
44+
45+
server = ThreadedMotoServer(ip_address="127.0.0.1", port=0, verbose=False)
46+
server.start()
47+
host, port = server.get_host_and_port()
48+
endpoint = f"http://{host}:{port}"
49+
50+
# blosc2.open() has no storage_options passthrough, so the endpoint and the
51+
# dummy credentials go through fsspec's own per-protocol defaults
52+
previous = fsspec.config.conf.get("s3")
53+
fsspec.config.conf["s3"] = {
54+
"endpoint_url": endpoint,
55+
"key": "testing",
56+
"secret": "testing",
57+
# Not us-east-1: creating a bucket there must carry no location
58+
# constraint, and s3fs sends one whenever it knows the region
59+
"client_kwargs": {"region_name": "eu-west-1"},
60+
}
61+
fsspec.filesystem("s3", **fsspec.config.conf["s3"]).mkdir(BUCKET)
62+
yield endpoint
63+
64+
fsspec.config.conf.pop("s3", None)
65+
if previous is not None:
66+
fsspec.config.conf["s3"] = previous
67+
server.stop()
68+
69+
70+
@pytest.fixture(scope="module")
71+
def stored(s3_endpoint):
72+
"""A 10-chunk array in the bucket, plus the array it was made from."""
73+
a = blosc2.arange(0, 1000, dtype=np.int32, chunks=(100,))
74+
urlpath = f"s3://{BUCKET}/ds.b2nd"
75+
a.save(urlpath)
76+
return urlpath, a
77+
78+
79+
def test_save_and_open_whole(stored):
80+
urlpath, a = stored
81+
assert np.array_equal(blosc2.open(urlpath)[:], a[:])
82+
83+
84+
def test_cache_storage(stored, tmp_path):
85+
urlpath, a = stored
86+
b = blosc2.open(urlpath, cache_storage=tmp_path, mmap_mode="r")
87+
assert np.array_equal(b[:], a[:])
88+
89+
90+
def test_lazy_range_reads(stored):
91+
urlpath, a = stored
92+
p = blosc2.open(urlpath, lazy=True)
93+
assert np.array_equal(p[150:250], a[150:250])
94+
assert np.array_equal(p[:], a[:])
95+
96+
97+
@pytest.mark.parametrize("max_concurrency", [1, 8])
98+
def test_lazy_concurrency(stored, max_concurrency):
99+
urlpath, a = stored
100+
p = blosc2.open(urlpath, lazy=True, max_concurrency=max_concurrency)
101+
assert np.array_equal(p[:], a[:])
102+
103+
104+
@pytest.mark.parametrize("max_concurrency", [1, 8])
105+
def test_afetch_on_an_async_backend(stored, max_concurrency):
106+
# The regression this file exists for: s3fs runs its coroutines on a private
107+
# event loop, so awaiting one from the caller's loop fails outright
108+
urlpath, a = stored
109+
p = blosc2.open(urlpath, lazy=True)
110+
cache = asyncio.run(p.afetch(slice(150, 250), max_concurrency=max_concurrency))
111+
assert np.array_equal(cache[150:250], a[150:250])
112+
113+
114+
def test_lazy_expression(stored):
115+
urlpath, a = stored
116+
p = blosc2.open(urlpath, lazy=True)
117+
assert np.array_equal((p * 2)[150:250], a[150:250] * 2)

0 commit comments

Comments
 (0)