Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4e2c2fc
feat: Handle all forms of range requests
gschulze Aug 12, 2026
0c44f4d
fix: Respect `max_gap` in `FsspecStore.cat_ranges`
gschulze Aug 12, 2026
ebca428
refactor: Accept None for coalesce so the default lives in one place
gschulze Aug 12, 2026
275e3c6
revert: Undo range request changes
gschulze Aug 21, 2026
6712966
feat: Handle all forms of range requests in fsspec
gschulze Aug 21, 2026
b89eb43
refactor: Remove `_CoalesceKwarg`
gschulze Aug 25, 2026
33eed46
fix: Apply object size only to ranges that need it
gschulze Aug 25, 2026
a964a5e
docs: Add reference that supports typing of `_cat_ranges`
gschulze Aug 25, 2026
a148dac
refactor: Extract splitting of requests from `_cat_ranges`
gschulze Aug 27, 2026
361e2d3
fix: Handle errors correctly in `_cat_ranges`
gschulze Aug 27, 2026
ad5d2f1
fix: Read the whole object when a range in `_cat_ranges` is unbounded
gschulze Aug 27, 2026
3782cd2
docs: Add docstrings for `_cat_file` and `_cat_ranges`
gschulze Aug 28, 2026
1a33600
fix: Fall back to a bounded read where suffix ranges are unsupported
gschulze Aug 28, 2026
33ed3e2
fix: Pass `batch_size` to `_sizes`
gschulze Sep 1, 2026
833a6ba
refactor: Normalize starts once in `_resolve_inexpressible_bounds`
gschulze Sep 2, 2026
a2e3e73
test: Fold the zero-start check into test_cat_file
gschulze Sep 2, 2026
de6c0ec
test: Name spies and captures after what they observe
gschulze Sep 5, 2026
5f2dac2
test: Assert that bounded reads reach `get_range`
gschulze Sep 5, 2026
14c097b
refactor: Drop unnecessary sort in `_resolve_inexpressible_bounds`
gschulze Sep 5, 2026
86ebbde
test: Fix an inaccurate comment in `test_cat_ranges_mixed`
gschulze Sep 5, 2026
f719d0a
test: Cover oversized suffixes and mixed-sign bounds
gschulze Sep 5, 2026
a0e58fe
test: Cover scalar broadcasts in `cat_ranges`
gschulze Sep 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
284 changes: 242 additions & 42 deletions obstore/python/obstore/fsspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,28 +32,35 @@

from __future__ import annotations

import asyncio
import warnings
from collections import defaultdict
from collections.abc import Iterable
from functools import cached_property, lru_cache
from pathlib import Path
from typing import TYPE_CHECKING, Literal, overload
from typing import TYPE_CHECKING, Literal, cast, overload
from urllib.parse import urlparse

import fsspec.asyn
import fsspec.spec
from fsspec.implementations.local import make_path_posix

from obstore import open_reader, open_writer
from obstore.exceptions import NotSupportedError
from obstore.store import from_url

if TYPE_CHECKING:
import sys
from collections.abc import Coroutine, Iterable
from collections.abc import Coroutine, Sequence
from datetime import datetime
from typing import Any

from obstore import Attributes, Bytes, ReadableFile, WritableFile
from obstore import (
Attributes,
Bytes,
GetOptions,
ReadableFile,
WritableFile,
)
from obstore.store import (
AzureConfig,
AzureCredentialProvider,
Expand Down Expand Up @@ -113,6 +120,81 @@
"""A type hint for all supported protocols."""


def _needs_object_size(start: int, end: int | None) -> bool:
"""Whether resolving a range requires knowing the size of the object.

Negative bounds require the size to be known, except a bare negative
`start`, which maps directly to a suffix request.
"""
return end is not None and (end < 0 or start < 0)


def _apply_object_size(
start: int,
end: int | None,
size: int,
) -> tuple[int, int | None]:
"""Resolve bounds that count back from the end of an object of `size`."""
return (
max(0, size + start) if start < 0 else start,
max(0, size + end) if end is not None and end < 0 else end,
)


def _split_requests(
paths: list[str],
bounds: Sequence[tuple[int, int | None]],
) -> tuple[
dict[str, list[tuple[int, int, int]]],
list[tuple[int, str, int]],
]:
"""Split the requested ranges into bounded per-object and open-ended."""
# `get_ranges` takes only bounded ranges, and merges only within one object,
# so split as zarr's obstore store does.
# Ref: https://github.com/zarr-developers/zarr-python/blob/de2cce1adc41a4d38721bf62b25eb312a52066dd/src/zarr/storage/_obstore.py#L440-L512
per_file_bounded_requests: dict[str, list[tuple[int, int, int]]] = defaultdict(list)
open_ended_requests: list[tuple[int, str, int]] = []
for idx, (path, (start, end)) in enumerate(zip(paths, bounds, strict=True)):
if end is not None:
per_file_bounded_requests[path].append((idx, start, end))
else:
open_ended_requests.append((idx, path, start))
return per_file_bounded_requests, open_ended_requests


async def _get_open_ended(
store: ObjectStore,
path: str,
start: int,
) -> Bytes:
"""Get an open-ended range, working around stores without suffix support."""
options: GetOptions
if start == 0:
# `{"offset": 0}` fails on an empty object, so send no range instead.
options = {}
elif start < 0:
options = {"range": {"suffix": -start}}
else:
options = {"range": {"offset": start}}
try:
resp = await store.get_async(path, options=options)
return await resp.bytes_async()
except NotSupportedError:
if start >= 0:
# The store refused something other than a suffix.
raise

# Azure rejects suffix ranges, so fall back to a size lookup and a bounded
# read, as zarr's obstore store does. A suffix covering the whole object
# becomes a plain `get`.
suffix = -start
size = (await store.head_async(path))["size"]
if suffix >= size:
resp = await store.get_async(path)
return await resp.bytes_async()
return await store.get_range_async(path, start=size - suffix, length=suffix)


class FsspecStore(fsspec.asyn.AsyncFileSystem):
"""An fsspec implementation based on a obstore Store.

Expand Down Expand Up @@ -382,19 +464,27 @@ async def _cat_file(
end: int | None = None,
**_kwargs: Any,
) -> bytes:
bucket, path = self._split_path(path)
"""Get a byte range, interpreting `start` and `end` like Python slices.

Zero-length, inverted, and past-the-end ranges raise an error where a
slice would return `b""`.
"""
bucket, path_in_bucket = self._split_path(path)
store = self._construct_store(bucket)

if start is None and end is None:
resp = await store.get_async(path)
return (await resp.bytes_async()).to_bytes()
start = 0 if start is None else start

if start is None or end is None:
raise NotImplementedError(
"cat_file not implemented for start=None xor end=None",
)
if _needs_object_size(start, end):
# No range-header equivalent, so resolve against the object size.
size = (await self._info(path))["size"]
start, end = _apply_object_size(start, end, size)

if end is None:
buffer = await _get_open_ended(store, path_in_bucket, start)
return buffer.to_bytes()

range_bytes = await store.get_range_async(path, start=start, end=end)
# `get_range` only takes bounded ranges, mirroring the Rust API.
range_bytes = await store.get_range_async(path_in_bucket, start=start, end=end)
return range_bytes.to_bytes()

async def _cat( # type: ignore (fsspec has bad typing)
Expand Down Expand Up @@ -426,48 +516,158 @@ async def _cat( # type: ignore (fsspec has bad typing)
async def _cat_ranges( # noqa: PLR0913, PLR0917 # type: ignore (fsspec has bad typing)
self,
paths: list[str],
starts: list[int] | int,
ends: list[int] | int,
max_gap=None, # noqa: ANN001, ARG002
batch_size=None, # noqa: ANN001, ARG002
on_error="return", # noqa: ANN001, ARG002
starts: Sequence[int | None] | int | None,
ends: Sequence[int | None] | int | None,
Comment on lines +519 to +520

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you link to source code in fsspec that supports this typing? I.e. are there tests or a code path where we know that starts and ends can take None, either in isolation or as an element in a sequence?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment with a permalink in _cat_ranges.

max_gap: int | None = None,
batch_size: int | None = None,
on_error: str = "return",
**_kwargs: Any,
) -> list[bytes]:
if isinstance(starts, int):
) -> list[bytes | BaseException]:
"""Get ranges whose bounds are interpreted as in `_cat_file`.

Failures are returned in place of the bytes, or the first is raised
when `on_error` is "raise".
"""
# The base class implementation `AsyncFileSystem._cat_ranges` forwards each
# element to `_cat_file`, which documents negative bounds and `None` for
# either end.
# Ref: https://github.com/fsspec/filesystem_spec/blob/e6668a146cd07b9f50530c49ea3916d8ab13e169/fsspec/spec.py#L790-L800

# A non-iterable start or end applies to every path, since `None` is not
# `Iterable`.
if not isinstance(starts, Iterable):
starts = [starts] * len(paths)
if isinstance(ends, int):
if not isinstance(ends, Iterable):
ends = [ends] * len(paths)
if not len(paths) == len(starts) == len(ends):
raise ValueError

per_file_requests: dict[str, list[tuple[int, int, int]]] = defaultdict(list)
# When upgrading to Python 3.10, use strict=True
for idx, (path, start, end) in enumerate(zip(paths, starts, ends)):
per_file_requests[path].append((start, end, idx))
bounds = await self._resolve_inexpressible_bounds(
paths,
starts,
ends,
batch_size=batch_size,
)
per_file_bounded_requests, open_ended_requests = _split_requests(paths, bounds)

futs: list[Coroutine[Any, Any, list[Bytes]]] = []
for path, ranges in per_file_requests.items():
bucket, path_no_bucket = self._split_path(path)
store = self._construct_store(bucket)
futs: list[Coroutine[Any, Any, list[tuple[int, bytes | BaseException]]]] = [
self._cat_bounded_ranges(path, ranges, max_gap)
for path, ranges in per_file_bounded_requests.items()
]
futs += [
self._cat_open_ended_range(idx, path, start)
for idx, path, start in open_ended_requests
]

offsets = [r[0] for r in ranges]
ends = [r[1] for r in ranges]
fut = store.get_ranges_async(path_no_bucket, starts=offsets, ends=ends)
futs.append(fut)
# Batched, to limit how many requests are in flight at once.
results = cast(
"list[list[tuple[int, bytes | BaseException]]]",
await fsspec.asyn._run_coros_in_chunks( # noqa: SLF001
futs,
batch_size=batch_size or self.batch_size,
nofiles=True,
),
)

result = await asyncio.gather(*futs)
output_buffers: list[bytes | BaseException] = [b""] * len(paths)
for responses in results:
for idx, buffer in responses:
output_buffers[idx] = buffer

output_buffers: list[bytes] = [b""] * len(paths)
# When upgrading to Python 3.10, use strict=True
for per_file_request, buffers in zip(per_file_requests.items(), result):
path, ranges = per_file_request
# When upgrading to Python 3.10, use strict=True
for buffer, ranges_ in zip(buffers, ranges):
initial_index = ranges_[2]
output_buffers[initial_index] = buffer.to_bytes()
# Anything except "raise" returns failures in place.
if on_error == "raise":
for buffer in output_buffers:
if isinstance(buffer, BaseException):
raise buffer

return output_buffers

async def _cat_bounded_ranges(
self,
path: str,
ranges: list[tuple[int, int, int]], # (output index, start, end)
max_gap: int | None,
) -> list[tuple[int, bytes | BaseException]]:
"""Get several bounded ranges of one object, merging nearby ones."""
indices, starts, ends = zip(*ranges, strict=True)
# fsspec's `max_gap` is obstore's `coalesce`. It is left out when unset, so
# that obstore's own default applies.
coalesce: dict[str, int] = {} if max_gap is None else {"coalesce": max_gap}
try:
bucket, path_in_bucket = self._split_path(path)
store = self._construct_store(bucket)
buffers = await store.get_ranges_async(
path_in_bucket,
starts=starts,
ends=ends,
lengths=None,
**coalesce,
)
except Exception as exc: # noqa: BLE001
# The ranges share one request, and thus the exception.
return [(idx, exc) for idx in indices]
return [
(idx, buffer.to_bytes())
for idx, buffer in zip(indices, buffers, strict=True)
]

async def _cat_open_ended_range(
self,
idx: int,
path: str,
start: int,
) -> list[tuple[int, bytes | BaseException]]:
"""Get one open-ended range, which `get_ranges` cannot express.

Returns a list for symmetry with `_cat_bounded_ranges`, so that both can
be batched together.
"""
try:
bucket, path_in_bucket = self._split_path(path)
store = self._construct_store(bucket)
buffer = await _get_open_ended(store, path_in_bucket, start)
except Exception as exc: # noqa: BLE001
return [(idx, exc)]
return [(idx, buffer.to_bytes())]

async def _resolve_inexpressible_bounds(
self,
paths: list[str],
starts: Sequence[int | None],
ends: Sequence[int | None],
*,
batch_size: int | None,
) -> list[tuple[int, int | None]]:
"""Resolve the bounds that obstore cannot express."""
normalized_starts = [0 if start is None else start for start in starts]
paths_needing_size = list(
{
path
for path, start, end in zip(paths, normalized_starts, ends, strict=True)
if _needs_object_size(start, end)
},
)
sizes: dict[str, int] = {}
if paths_needing_size:
# `_sizes` goes through `_info`, so the dircache may serve these.
# Type checkers infer `list[None]` here, but the values are sizes.
sizes = dict(
zip(
paths_needing_size,
cast(
"list[int]",
await self._sizes(paths_needing_size, batch_size=batch_size),
),
strict=True,
),
)
return [
_apply_object_size(start, end, sizes[path])
if _needs_object_size(start, end)
else (start, end)
for path, start, end in zip(paths, normalized_starts, ends, strict=True)
]

async def _put_file(
self,
lpath: str,
Expand Down
Loading
Loading