Skip to content

Commit d2fe249

Browse files
FrancescAltedclaude
andcommitted
Address the PR review: file:// URLs, cache identity, cache geometry
Three real defects, all confirmed by repro before fixing. file:// was excluded from the fsspec branch so it could keep mmap and the directory formats, but nothing downstream stripped the scheme, so it reached os.path.exists() and the C layer as a literal filename and failed. The docstring promising otherwise was simply wrong. Normalized to a native path in open(), NDArray.save(), Storage, and the two constructor paths that bypass Storage. The directory cache manifest compared name, size and mtime, which is the same mistake already fixed for the lazy chunk cache and missed here: on a backend with no mtime -- memory://, and the tests only use memory:// -- a same-size rewrite left the manifest unchanged and served stale files. It now hashes each entry with tokenize(), which is what fs.ukey() uses. Reusing a proxy cache checked shape and dtype only. Chunk numbers are the currency between cache and source, so a same-shaped source chunked differently fetched the wrong chunks and returned wrong data with no error at all; chunks and blocks are compared now, and non-ND sources get the same check on nbytes, chunksize and typesize instead of none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 42fac93 commit d2fe249

8 files changed

Lines changed: 108 additions & 19 deletions

File tree

plans/fsspec-support.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,10 @@ Notes on the details:
181181

182182
- The `file://` exclusion lets fsspec-style local URLs keep working through
183183
the normal local path, which supports mmap and every container format.
184+
*(This turned out to need more than the exclusion: nothing downstream stripped
185+
the scheme, so a `file://` URL was taken as a literal filename and failed. It
186+
is normalized to a native path now, in `open()`, `NDArray.save()`, `Storage`
187+
and the two constructor paths that bypass `Storage`.)*
184188
- `offset != 0` should raise for now; the embedded-object case is a phase-3
185189
concern.
186190
- `copy=False` on `from_cframe` is tempting (it pins the read buffer instead of

src/blosc2/core.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import shutil
2222
import subprocess
2323
import sys
24+
import urllib.parse
25+
import urllib.request
2426
from dataclasses import asdict
2527
from functools import lru_cache
2628
from typing import TYPE_CHECKING, ClassVar
@@ -616,6 +618,17 @@ def load_array(urlpath: str, dparams: dict | None = None) -> np.ndarray:
616618
return load_tensor(urlpath, dparams=dparams)
617619

618620

621+
def normalize_urlpath(urlpath: object) -> object:
622+
"""Turn a `file://` URL into the native path it names, leaving anything else alone.
623+
624+
Local URLs are kept off the fsspec branch so they can use mmap and every
625+
container format, which only works if the scheme is stripped first.
626+
"""
627+
if isinstance(urlpath, str) and urlpath.startswith("file://"):
628+
return urllib.request.url2pathname(urllib.parse.urlparse(urlpath).path)
629+
return urlpath
630+
631+
619632
def is_fsspec_url(urlpath: object) -> bool:
620633
"""Whether *urlpath* should be routed through fsspec.
621634
@@ -665,6 +678,8 @@ def localize_fsspec_url(urlpath: str, cache_storage: str | pathlib.Path) -> str:
665678
matching the manifest written at download time.
666679
"""
667680
fsspec = _import_fsspec(urlpath)
681+
from fsspec.utils import tokenize
682+
668683
cache_storage = str(cache_storage)
669684
fs, path = fsspec.url_to_fs(urlpath)
670685

@@ -678,12 +693,11 @@ def localize_fsspec_url(urlpath: str, cache_storage: str | pathlib.Path) -> str:
678693

679694
localdir = fsspec_cache_path(urlpath, cache_storage)
680695
manifest = pathlib.Path(localdir + ".json")
696+
# tokenize(info) is what fs.ukey() hashes, so this asks each backend what
697+
# identifies a file rather than guessing which fields it exposes -- size and
698+
# mtime miss a same-size rewrite, and memory:// has no mtime at all
681699
listing = json.dumps(
682-
{
683-
name: (entry.get("size"), entry.get("mtime") or entry.get("LastModified"))
684-
for name, entry in sorted(fs.find(path, detail=True).items())
685-
},
686-
default=str,
700+
{name: tokenize(entry) for name, entry in sorted(fs.find(path, detail=True).items())}
687701
)
688702
if not manifest.exists() or manifest.read_text() != listing:
689703
shutil.rmtree(localdir, ignore_errors=True)

src/blosc2/ndarray.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232

3333
import blosc2
3434
from blosc2 import SpecialValue, blosc2_ext, compute_chunks_blocks
35-
from blosc2.core import fsspec_open, is_fsspec_url
35+
from blosc2.core import fsspec_open, is_fsspec_url, normalize_urlpath
3636
from blosc2.info import InfoReporter, format_nbytes_info
3737
from blosc2.schunk import SChunk
3838

@@ -5078,6 +5078,7 @@ def save(self, urlpath: str, contiguous=True, **kwargs: Any) -> None:
50785078
>>> # Save the array to a file
50795079
>>> a.save("array.b2frame")
50805080
"""
5081+
urlpath = normalize_urlpath(urlpath)
50815082
if is_fsspec_url(urlpath):
50825083
if not contiguous:
50835084
raise NotImplementedError(
@@ -7020,6 +7021,11 @@ def astype(
70207021

70217022

70227023
def _check_ndarray_kwargs(**kwargs): # noqa: C901
7024+
if kwargs.get("urlpath") is not None:
7025+
# A Storage instance normalizes its own; a bare kwarg has to be done here,
7026+
# since it takes precedence over the defaults built from it below
7027+
kwargs["urlpath"] = normalize_urlpath(kwargs["urlpath"])
7028+
70237029
storage = kwargs.get("storage")
70247030
if storage is not None:
70257031
for key in kwargs:

src/blosc2/proxy.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -322,12 +322,24 @@ def _reopen_cache(self, urlpath: str):
322322
raise ValueError(
323323
f"{urlpath} is not a proxy cache; pass mode='w' to overwrite it or choose another urlpath"
324324
)
325-
if hasattr(self.src, "shape") and (
326-
tuple(cached.shape) != tuple(self.src.shape) or cached.dtype != self.src.dtype
327-
):
325+
# Chunk *numbers* are the currency between cache and source, so the
326+
# partitioning has to match, not just the logical shape: fetch() would
327+
# otherwise ask the source for chunk n meaning something else entirely
328+
if hasattr(self.src, "shape"):
329+
here = (tuple(cached.shape), cached.dtype, tuple(cached.chunks), tuple(cached.blocks))
330+
there = (
331+
tuple(self.src.shape),
332+
np.dtype(self.src.dtype),
333+
tuple(self.src.chunks),
334+
tuple(self.src.blocks),
335+
)
336+
else:
337+
here = (schunk.nbytes, schunk.chunksize, schunk.typesize)
338+
there = (self.src.nbytes, self.src.chunksize, self.src.typesize)
339+
if here != there:
328340
raise ValueError(
329-
f"the cache at {urlpath} holds a {cached.shape} {cached.dtype} array, which "
330-
f"does not fit the {self.src.shape} {self.src.dtype} source"
341+
f"the cache at {urlpath} was built for a different source: it holds {here}, "
342+
f"the source is {there} (shape, dtype, chunks, blocks)"
331343
)
332344
return cached
333345

src/blosc2/schunk.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@
2222

2323
import blosc2
2424
from blosc2 import SpecialValue, blosc2_ext
25-
from blosc2.core import fsspec_cache_path, fsspec_open, is_fsspec_url, localize_fsspec_url
25+
from blosc2.core import (
26+
fsspec_cache_path,
27+
fsspec_open,
28+
is_fsspec_url,
29+
localize_fsspec_url,
30+
normalize_urlpath,
31+
)
2632
from blosc2.info import InfoReporter, format_nbytes_info
2733
from blosc2.msgpack_utils import msgpack_packb, msgpack_unpackb
2834

@@ -367,7 +373,9 @@ def __init__( # noqa: C901
367373
if isinstance(kwargs.get("dparams"), blosc2.DParams):
368374
kwargs["dparams"] = asdict(kwargs.get("dparams"))
369375

370-
urlpath = kwargs.get("urlpath")
376+
urlpath = normalize_urlpath(kwargs.get("urlpath"))
377+
if urlpath is not None:
378+
kwargs["urlpath"] = urlpath
371379
if is_fsspec_url(urlpath):
372380
raise ValueError(
373381
f"{urlpath} is an fsspec URL, which cannot back a container as it is written; "
@@ -2181,6 +2189,7 @@ def open(
21812189

21822190
if isinstance(urlpath, pathlib.PurePath):
21832191
urlpath = str(urlpath)
2192+
urlpath = normalize_urlpath(urlpath)
21842193

21852194
if is_fsspec_url(urlpath):
21862195
return _open_fsspec_url(urlpath, mode, offset, kwargs)

src/blosc2/storage.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from dataclasses import asdict, dataclass, field, fields
1111

1212
import blosc2
13-
from blosc2.core import is_fsspec_url
13+
from blosc2.core import is_fsspec_url, normalize_urlpath
1414

1515

1616
def default_nthreads():
@@ -249,6 +249,7 @@ class Storage:
249249
meta: dict = None
250250

251251
def __post_init__(self):
252+
self.urlpath = normalize_urlpath(self.urlpath)
252253
if is_fsspec_url(self.urlpath):
253254
# The C layer writes a container incrementally, rewriting its header
254255
# and offsets as chunks land; an object store has no partial writes

tests/ndarray/test_proxy.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,14 +155,25 @@ def test_reuse_cache_rejects_foreign_container(tmp_path):
155155
blosc2.Proxy(source, urlpath=path, mode="a")
156156

157157

158-
def test_reuse_cache_rejects_mismatched_source(tmp_path):
158+
@pytest.mark.parametrize(
159+
"other",
160+
[
161+
lambda data: blosc2.asarray(np.arange(50, dtype=np.float64)),
162+
# Same shape and dtype, different partitioning: chunk numbers are what
163+
# the proxy passes to the source, so this would silently fetch the
164+
# wrong chunk or run off the end
165+
lambda data: blosc2.asarray(data, chunks=(2, 5), blocks=(1, 5)),
166+
],
167+
ids=["shape", "chunks"],
168+
)
169+
def test_reuse_cache_rejects_mismatched_source(tmp_path, other):
159170
proxy_path = str(tmp_path / "proxy.b2nd")
160171
data = np.arange(120, dtype=np.int32).reshape(12, 10)
161-
blosc2.Proxy(blosc2.asarray(data), urlpath=proxy_path, mode="a").fetch()
172+
source = blosc2.asarray(data, chunks=(4, 5), blocks=(2, 5))
173+
blosc2.Proxy(source, urlpath=proxy_path, mode="a").fetch()
162174

163-
other = blosc2.asarray(np.arange(50, dtype=np.float64))
164-
with pytest.raises(ValueError, match="does not fit"):
165-
blosc2.Proxy(other, urlpath=proxy_path, mode="a")
175+
with pytest.raises(ValueError, match="different source"):
176+
blosc2.Proxy(other(data), urlpath=proxy_path, mode="a")
166177

167178

168179
# Test the ProxyNDSources interface

tests/test_fsspec.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,38 @@ def test_http_does_not_reach_fsspec():
480480
blosc2.open("http://localhost:1/foo.b2nd")
481481

482482

483+
def test_file_url_uses_the_local_path(tmp_path):
484+
# file:// is kept off the fsspec branch so it can use mmap and every
485+
# container format, which only works if the scheme is stripped first
486+
a = blosc2.arange(10, dtype="i4")
487+
url = (tmp_path / "f.b2nd").as_uri()
488+
489+
a.save(url)
490+
assert (tmp_path / "f.b2nd").is_file()
491+
assert np.array_equal(blosc2.open(url)[:], a[:])
492+
assert np.array_equal(blosc2.open(url, mmap_mode="r")[:], a[:])
493+
494+
495+
def test_file_url_backs_a_container(tmp_path):
496+
url = (tmp_path / "c.b2nd").as_uri()
497+
a = blosc2.arange(10, dtype="i4", urlpath=url, mode="w")
498+
a[0:5] = 7
499+
assert np.array_equal(blosc2.open(url)[:], a[:])
500+
501+
502+
def test_cached_dir_refetches_on_same_size_change(tmp_path):
503+
# Sizes and names alone cannot see this, and memory:// has no mtime to fall
504+
# back on, so the manifest has to use each backend's own identity token
505+
memfs = fsspec.filesystem("memory")
506+
memfs.pipe_file("/samesize.b2d/a.bin", b"A" * 100)
507+
localdir = blosc2.core.localize_fsspec_url("memory://samesize.b2d", tmp_path)
508+
assert pathlib.Path(localdir, "a.bin").read_bytes() == b"A" * 100
509+
510+
memfs.pipe_file("/samesize.b2d/a.bin", b"B" * 100)
511+
localdir = blosc2.core.localize_fsspec_url("memory://samesize.b2d", tmp_path)
512+
assert pathlib.Path(localdir, "a.bin").read_bytes() == b"B" * 100
513+
514+
483515
def test_local_path_untouched(tmp_path):
484516
urlpath = str(tmp_path / "local.b2nd")
485517
a = blosc2.arange(10, dtype="i4", urlpath=urlpath, mode="w")

0 commit comments

Comments
 (0)