Skip to content

Commit aa09615

Browse files
committed
fix: add closed, readable, seekable, writable properties
1 parent cfea977 commit aa09615

8 files changed

Lines changed: 181 additions & 1 deletion

File tree

docs/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ Utilities for interacting with object storage, based on [obspec](https://github.
1212
[`EagerStoreReader`][obspec_utils.readers.EagerStoreReader], [`BlockStoreReader`][obspec_utils.readers.BlockStoreReader])
1313
for reading from object stores
1414
- **`obspec_utils.stores`**: Alternative store implementations (e.g., [`AiohttpStore`][obspec_utils.stores.AiohttpStore] for generic HTTP access)
15-
- **`obspec_utils.wrappers`**: Composable store wrappers for caching, tracing, and concurrent fetching
15+
- **`obspec_utils.wrappers`**: Composable store wrappers for [caching][obspec_utils.wrappers.CachingReadableStore],
16+
[tracing][obspec_utils.wrappers.TracingReadableStore], and [concurrent fetching][obspec_utils.wrappers.SplittingReadableStore]
1617
- **`obspec_utils.registry`**: [`ObjectStoreRegistry`][obspec_utils.registry.ObjectStoreRegistry] for managing multiple stores and resolving URLs
1718

1819
## Design Philosophy

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ xarray = [
5959
"h5netcdf",
6060
"h5py",
6161
"cftime",
62+
"scipy",
6263
]
6364
fsspec = [
6465
"s3fs",
@@ -101,3 +102,8 @@ exclude_lines = [
101102
"if __name__ == .__main__.:",
102103
"if TYPE_CHECKING:",
103104
]
105+
106+
[tool.pytest.ini_options]
107+
markers = [
108+
"network: marks tests as requiring network access (run with --network)",
109+
]

src/obspec_utils/readers/_block.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def __init__(
8787
self._max_cached_blocks = max_cached_blocks
8888
self._position = 0
8989
self._size: int | None = None
90+
self._closed = False
9091
# LRU cache: OrderedDict with block_index -> bytes
9192
self._cache: OrderedDict[int, bytes] = OrderedDict()
9293

@@ -242,9 +243,27 @@ def tell(self) -> int:
242243
"""
243244
return self._position
244245

246+
@property
247+
def closed(self) -> bool:
248+
"""Return True if the reader has been closed."""
249+
return self._closed
250+
251+
def readable(self) -> bool:
252+
"""Return True, indicating this reader supports reading."""
253+
return True
254+
255+
def seekable(self) -> bool:
256+
"""Return True, indicating this reader supports seeking."""
257+
return True
258+
259+
def writable(self) -> bool:
260+
"""Return False, indicating this reader does not support writing."""
261+
return False
262+
245263
def close(self) -> None:
246264
"""Close the reader and release the block cache."""
247265
self._cache.clear()
266+
self._closed = True
248267

249268
def __enter__(self) -> "BlockStoreReader":
250269
"""Enter the context manager."""

src/obspec_utils/readers/_buffered.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def __init__(
7474
self._buffer_size = buffer_size
7575
self._position = 0
7676
self._size: int | None = None
77+
self._closed = False
7778
# Read-ahead buffer
7879
self._buffer = b""
7980
self._buffer_start = 0
@@ -194,10 +195,28 @@ def tell(self) -> int:
194195
"""
195196
return self._position
196197

198+
@property
199+
def closed(self) -> bool:
200+
"""Return True if the reader has been closed."""
201+
return self._closed
202+
203+
def readable(self) -> bool:
204+
"""Return True, indicating this reader supports reading."""
205+
return True
206+
207+
def seekable(self) -> bool:
208+
"""Return True, indicating this reader supports seeking."""
209+
return True
210+
211+
def writable(self) -> bool:
212+
"""Return False, indicating this reader does not support writing."""
213+
return False
214+
197215
def close(self) -> None:
198216
"""Close the reader and release the read-ahead buffer."""
199217
self._buffer = b""
200218
self._buffer_start = 0
219+
self._closed = True
201220

202221
def __enter__(self) -> "BufferedStoreReader":
203222
"""Enter the context manager."""

src/obspec_utils/readers/_eager.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ def __init__(
9595
"""
9696
self._store = store
9797
self._path = path
98+
self._closed = False
9899

99100
# Determine file size if not provided
100101
if file_size is None:
@@ -155,9 +156,27 @@ def tell(self) -> int:
155156
"""Return the current position in the cached file."""
156157
return self._buffer.tell()
157158

159+
@property
160+
def closed(self) -> bool:
161+
"""Return True if the reader has been closed."""
162+
return self._closed
163+
164+
def readable(self) -> bool:
165+
"""Return True, indicating this reader supports reading."""
166+
return True
167+
168+
def seekable(self) -> bool:
169+
"""Return True, indicating this reader supports seeking."""
170+
return True
171+
172+
def writable(self) -> bool:
173+
"""Return False, indicating this reader does not support writing."""
174+
return False
175+
158176
def close(self) -> None:
159177
"""Close the reader and release the in-memory buffer."""
160178
self._buffer = io.BytesIO(b"")
179+
self._closed = True
161180

162181
def __enter__(self) -> "EagerStoreReader":
163182
"""Enter the context manager."""

tests/conftest.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,25 @@
66
import xarray as xr
77

88

9+
def pytest_addoption(parser):
10+
parser.addoption(
11+
"--network",
12+
action="store_true",
13+
default=False,
14+
help="run tests that require network access",
15+
)
16+
17+
18+
def pytest_collection_modifyitems(config, items):
19+
if config.getoption("--network"):
20+
# --network given: do not skip network tests
21+
return
22+
skip_network = pytest.mark.skip(reason="need --network option to run")
23+
for item in items:
24+
if "network" in item.keywords:
25+
item.add_marker(skip_network)
26+
27+
928
@pytest.fixture(scope="session")
1029
def container():
1130
import docker

tests/test_readers.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,3 +560,66 @@ def test_old_parameters_work(self):
560560
def test_is_subclass_of_block_store_reader(self):
561561
"""ParallelStoreReader should be a subclass of BlockStoreReader."""
562562
assert issubclass(ParallelStoreReader, BlockStoreReader)
563+
564+
565+
# =============================================================================
566+
# HTTP Store / xarray integration tests
567+
# =============================================================================
568+
569+
570+
@pytest.mark.parametrize("ReaderClass", ALL_READERS)
571+
def test_reader_closed_property(ReaderClass):
572+
"""Test that readers have a closed property for file-like compatibility."""
573+
memstore = MemoryStore()
574+
memstore.put("test.txt", b"hello world")
575+
576+
reader = ReaderClass(memstore, "test.txt")
577+
assert reader.closed is False
578+
579+
reader.close()
580+
assert reader.closed is True
581+
582+
583+
@pytest.mark.parametrize("ReaderClass", ALL_READERS)
584+
def test_reader_closed_property_with_context_manager(ReaderClass):
585+
"""Test that closed property is True after exiting context manager."""
586+
memstore = MemoryStore()
587+
memstore.put("test.txt", b"hello world")
588+
589+
with ReaderClass(memstore, "test.txt") as reader:
590+
assert reader.closed is False
591+
592+
assert reader.closed is True
593+
594+
595+
@pytest.mark.parametrize("ReaderClass", ALL_READERS)
596+
def test_reader_readable(ReaderClass):
597+
"""Test that readers report as readable."""
598+
memstore = MemoryStore()
599+
memstore.put("test.txt", b"hello world")
600+
601+
reader = ReaderClass(memstore, "test.txt")
602+
assert reader.readable() is True
603+
reader.close()
604+
605+
606+
@pytest.mark.parametrize("ReaderClass", ALL_READERS)
607+
def test_reader_seekable(ReaderClass):
608+
"""Test that readers report as seekable."""
609+
memstore = MemoryStore()
610+
memstore.put("test.txt", b"hello world")
611+
612+
reader = ReaderClass(memstore, "test.txt")
613+
assert reader.seekable() is True
614+
reader.close()
615+
616+
617+
@pytest.mark.parametrize("ReaderClass", ALL_READERS)
618+
def test_reader_writable(ReaderClass):
619+
"""Test that readers report as not writable."""
620+
memstore = MemoryStore()
621+
memstore.put("test.txt", b"hello world")
622+
623+
reader = ReaderClass(memstore, "test.txt")
624+
assert reader.writable() is False
625+
reader.close()

tests/test_xarray.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,37 @@ def test_reader_with_xarray(local_netcdf4_file, ReaderClass) -> None:
1818
reader = ReaderClass(store=LocalStore(), path=local_netcdf4_file)
1919
ds_obstore = xr.open_dataset(reader, engine="h5netcdf")
2020
xr.testing.assert_allclose(ds_fsspec, ds_obstore)
21+
22+
23+
@pytest.mark.network
24+
def test_eager_reader_xarray_http_store():
25+
"""
26+
Regression test: EagerStoreReader works with HTTPStore and xarray.
27+
28+
This tests that:
29+
1. HTTPStore can fetch remote files
30+
2. EagerStoreReader correctly buffers the data
31+
3. The reader has the 'closed' property required by scipy/xarray
32+
4. xarray can open the dataset using the scipy engine
33+
"""
34+
pytest.importorskip("xarray")
35+
import xarray as xr
36+
from obstore.store import HTTPStore
37+
38+
store = HTTPStore.from_url(
39+
"https://raw.githubusercontent.com/pydata/xarray-data/refs/heads/master/"
40+
)
41+
with EagerStoreReader(store, "air_temperature.nc") as reader:
42+
# Verify reader has data
43+
assert len(reader._buffer.getvalue()) > 0
44+
45+
# Verify closed property exists and is False
46+
assert reader.closed is False
47+
48+
# Open with xarray (scipy engine for NetCDF Classic format)
49+
ds = xr.open_dataset(reader, engine="scipy")
50+
assert "air" in ds.data_vars
51+
ds.close()
52+
53+
# Verify closed is True after context exit
54+
assert reader.closed is True

0 commit comments

Comments
 (0)