Repository navigation
Accept a file-like source in the h5py tilesets — Closes #14 - #18
Merged
Merged
Conversation
The check ran after the opened file was already assigned, so the bad file stayed cached: the intended ValueError fired exactly once, and every later access raised a bare KeyError from the group lookup instead. KeyError is not a TilesetError, so a server answered the first request for such a file with a readable refusal and every retry after it with a 500. Open into a local, check it, and publish only once it has passed, closing the file on the way out if it has not.
Needed only by the tests, to show a real remote-capable filesystem meeting the plain callable contract a tileset source will accept. clodius itself does not import fsspec and must not: the contract is a stdlib callable, and smart_open, boto3 and a bare lambda satisfy it equally.
A tileset takes a filesystem path and opens it itself, so a server fronting remote data has to download each file first and keep it on disk for the tileset's lifetime. Source widens that to a second shape: a factory, a zero-argument callable returning a freshly opened binary handle, so the caller owns the fetch and clodius owns only the tiling. A factory rather than an open handle, because the readers disagree and only one shape satisfies both. oxbow may reopen its source between queries, so it takes a callable; h5py and pybigtools want a live object. Calling a factory once serves everyone, while a handle cannot be un-consumed. An already-open file is therefore refused at construction, since the repair a caller reaches for -- wrapping it in a lambda -- returns the same exhausted handle every time and would serve records from wherever the last query left the cursor. A path-backed source hands the reader back the path string it was given, so adopting this leaves the existing call unchanged rather than equivalent. Nothing here imports fsspec; the contract is stdlib.
A tileset was anonymous in a stack trace, so a server handling many datasets gave no way to tell which file a failure came from. The two families name their source differently -- the readers that take a path or a factory keep a source object, the SQLite-backed pair keep a path -- so both are read, and a tileset carrying neither, or one whose construction failed before assigning either, renders as its class rather than raising from inside the traceback already being reported. Also state the ownership rule on close: a tileset releases what it opened and nothing else. A caller supplies a path or a factory, which is a recipe rather than a resource, so there is never a caller-owned handle to close by mistake.
Both take their source through Source now, so either accepts a callable that opens a fresh binary handle in place of a path. h5py reads a Python file object directly, and cooler.Cooler is already handed an h5py group rather than a filename, so nothing downstream needed changing. A path keeps working exactly as before: Source hands h5py back the same string it was given. close() releases the handle as well as the HDF5 file, because h5py.File.close() does not close a Python file object it was handed -- closing only the latter leaks a descriptor per live tileset, which on a server that caches them is a leak proportional to the datasets served. The handle is opened into a local and published only once h5py has accepted it, for the same reason the resolutions check is: a failed open otherwise left a handle assigned that the next attempt would overwrite, so a file that is not HDF5 orphaned one handle per access while the source contract promised to close what it opened.
bed, bedpe and gxf each carried a byte-identical two-line probe for a sibling tabix index. All three tested os.path.exists, which a directory named <path>.tbi satisfies, so a tileset declared itself indexed and then failed on first open -- and is_indexed is what decides whether a tile range-queries or scans the whole file. Source.has_sibling tests os.path.isfile and is the implementation the remaining backends will adopt, so the three copies go away rather than being corrected in place. variant and alignment keep their own probes until their slices land.
conradbzura
force-pushed
the
14-file-like-source-h5py
branch
from
October 1, 2026 18:43
727e4f5 to
f5e6b58
Compare
conradbzura
marked this pull request as ready for review
October 2, 2026 15:18
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Every
tiles_v2tileset takes a filesystem path and opens it itself, so a server fronting remote data must download each file and keep it on disk for the tileset's lifetime. AddSource, a shared abstraction over "a path, or a factory that opens a fresh binary handle", and adopt it in the two h5py-backed tilesets.This is the first of four backend-scoped slices under #13. h5py goes first because
CoolerTilesetandMultivecTilesethave no index file, no sibling detection and no scan ceiling — the smallest honest proof of the abstraction. The remaining slices are #15 (pybigtools), #16 (oxbow) and #17 (apsw).A factory, not an open handle, is the accepted shape: oxbow may reopen its source between queries so it needs a callable, while h5py and pybigtools want a live object. Calling a factory once serves both; a handle cannot be un-consumed. An already-open file is refused at construction, because the repair a caller reaches for — wrapping it in
lambda: handle— returns the same exhausted handle every time and would serve records from wherever the last query left the cursor. The handle a factory returns must be fresh, seekable and binary, and all three are checked where the factory is called rather than left to fail inside a reader — h5py reports a missingseekas "expected str, bytes or os.PathLike object", which sends the caller looking for a path bug.A path is still handed to h5py as a path. That is load-bearing rather than cosmetic: routing a path through an opened handle selects HDF5's
fileobjdriver instead of the nativesec2, andsec2is what lets HDF5 recognise a file already open read-only in the process and share one file struct. Fifty tilesets over one file cost one descriptor rather than fifty.Source.for_reader()returns the path string it was given, so the path branch is the same call it always was.clodius does not depend on fsspec — the contract is a stdlib
Callable[[], IO[bytes]], which fsspec, smart_open, boto3 and a bare lambda all satisfy. fsspec is a dev dependency so the tests can show a real remote-capable filesystem meeting it.Fixes #14. Part of #13.
Proposed changes
clodius/core/source.py(new) —Source, stdlib-only and public, exported fromclodius.core. Two entry points:coercetakes a caller's untyped argument and decides which shape it is; the constructor takes the shape already decided and holds every invariantcoercewould — a path is decoded withos.fsdecodeand must be non-empty, a factory must be callable, exactly one of the two is given. Keyword-only, so a factory cannot land in the path slot.path/for_reader/open/open_textserve the readers;size/sibling/has_siblingback the scan ceiling and index detection.siblingreturns nothing for a factory rather than guessing a filename, which is what forces an index to be passed explicitly.clodius/tiles_v2/_h5.py(new) —H5Backed, holding the open-and-release protocol both HDF5 tilesets share, with a_validate(file)hook each overrides for its own schema check. It lives intiles_v2rather than besideSourcebecauseclodius.coremust not depend onh5py. The property opens lazily under a double-checked lock, so concurrent first access calls the caller's factory once rather than N times and leaves nothingclose()cannot reach;close()releases the handle in afinally, because a failing HDF5 close must not strand it. A path-backed source produces no handle at all.cooler.py,multivec.py— take their source throughH5Backedand declare neitherfilenorclose. The first positional parameter issource: SourceLike, documented in both class docstrings along with every other parameter. Each constructor releases what it opened if the work after the open raises, since a constructor that raises returns no object for anyone to close.core/tileset.py—_src: Sourcedeclared as an annotated, unassigned attribute besidendim, and a__repr__reading it; a tileset was previously anonymous in a stack trace. The ownership rule is stated onclose(): a tileset releases what it opened and nothing else.bed.py,bedpe.py,gxf.py,variant.py— sibling-index detection routed throughSource.has_sibling, deleting three byte-identical copies of the probe. The copies testedos.path.exists, which a directory named<path>.tbisatisfies, so a tileset declared itself indexed and failed on first open — and that flag decides whether a tile range-queries or scans.alignment.pykeeps its own probe until its slice.cooler.py(separate fix, first commit) — a cooler with noresolutionsgroup was refused exactly once, because the check ran after the opened file was cached; every later access raised a bareKeyError. The refusal is now repeatable and raisesTilesetUnavailable, so a server renders it as a refusal rather than a 500. This is breaking for anything catchingValueErrorfrom that path.Test cases
test_sourcebytes, orPathLiketest_sourcetest_sourceTypeErrornaming the callable formtest_sourceValueErrortest_sourceTypeErrortest_sourceTypeErrororValueErrortest_sourcetest_sourcetest_sourceTypeErrornaming the missing capability, handle releasedtest_sourcetest_sourcetest_sourcetest_cooler,test_multivectest_cooler,test_multivecsec2driver, andfileobjrespectivelytest_coolertest_coolertest_coolerh5py.File.closeraisestest_cooler,test_multivectest_coolerresolutionsgroupTilesetUnavailableeach timetest_cooler,test_multivectest_cooler,test_multivectest_bed<path>.tbibeside ittest_tilesettest_tileset_srcis declared and unassigned