Fast contiguous and concurrent reads, VL data, nested groups and links, Python bindings #15

Merged
osobh merged 41 commits from feat/p2-perf-coverage into main 2026-09-26 14:57:01 +00:00
8 changed files with 289 additions and 176 deletions
Showing only changes of commit 24412a0e59 - Show all commits
+6
View File
@@ -54,6 +54,12 @@
h5py 3.16 (HDF5 2.0) on a file h5py writes. One difference is h5py's:
it returns variable-length sequences of big-endian floats unswapped; this
package returns the stored values.
- **A panic in the library is an ordinary Python exception.** PyO3 turns a
Rust panic into `PanicException`, a `BaseException` that `except
Exception` does not catch. Every call from the bindings into the library
is now guarded and a panic becomes `clawhdf5.InternalError` (a
`RuntimeError`) naming the object; with the implicit-index panic above
restored, `ds[0:30]` raises it.
- **CI builds and tests the Python package.** It was excluded from CI.
`scripts/ci-test.sh` now lints `clawhdf5-py`, builds the wheel with
maturin, unpacks it under `target/` and runs the pytest suite; skipped
+2
View File
@@ -191,6 +191,7 @@ fn attr_to_py<'py>(
file: &clawhdf5_rs::File,
attr: &AttributeMessage,
) -> PyResult<Bound<'py, PyAny>> {
crate::no_panic(|| {
let sb = file.superblock();
let conv = Converter::new(py, &attr.datatype, sb.offset_size)
.map_err(|e| prefix_err(py, &attr.name, e))?;
@@ -231,6 +232,7 @@ fn attr_to_py<'py>(
return arr.get_item(PyTuple::empty(py));
}
Ok(arr)
})
}
fn prefix_err(py: Python<'_>, name: &str, e: PyErr) -> PyErr {
+15 -2
View File
@@ -42,6 +42,7 @@ impl PyDataset {
file: Arc<clawhdf5_rs::File>,
path: String,
) -> PyResult<Self> {
crate::no_panic(|| {
let hdr = node::header(&file, &path)?;
let null = node::is_null(&node::dataspace(&file, &hdr)?);
let (shape, datatype) = {
@@ -62,6 +63,7 @@ impl PyDataset {
datatype,
conv,
})
})
}
fn converter(&self) -> PyResult<&Converter> {
@@ -84,8 +86,7 @@ impl PyDataset {
let path = self.path.as_str();
let (vl, elem_size, unit) = (conv.is_vl(), conv.elem_size, conv.vl_unit);
// Everything below touches only Rust data: release the GIL.
let blocks: Vec<(Elements, Vec<usize>)> = py
.detach(|| -> Result<_, ReadError> {
let read = || -> Result<_, ReadError> {
let ds = file.dataset(path)?;
let sb = file.superblock();
let mut blocks = Vec::with_capacity(reads.len());
@@ -117,6 +118,11 @@ impl PyDataset {
blocks.push((data, shape));
}
Ok(blocks)
};
let blocks: Vec<(Elements, Vec<usize>)> = py
.detach(|| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(read))
.unwrap_or_else(|p| Err(ReadError::Panic(crate::panic_text(&*p))))
})
.map_err(|e| e.into_py(&self.path))?;
@@ -160,6 +166,7 @@ impl PyDataset {
enum ReadError {
Lib(clawhdf5_rs::Error),
Other(String),
Panic(String),
}
impl From<clawhdf5_rs::Error> for ReadError {
@@ -173,6 +180,10 @@ impl ReadError {
match self {
ReadError::Lib(e) => to_py_err(e),
ReadError::Other(msg) => PyValueError::new_err(format!("{}: {msg}", node::name(path))),
ReadError::Panic(msg) => crate::InternalError::new_err(format!(
"{}: clawhdf5 internal error (please report it): {msg}",
node::name(path)
)),
}
}
}
@@ -223,6 +234,7 @@ impl PyDataset {
/// The maximum shape (`None` per unlimited dimension), like h5py.
#[getter]
fn maxshape<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
crate::no_panic(|| {
let Some(shape) = &self.shape else {
return Ok(py.None().into_bound(py));
};
@@ -237,6 +249,7 @@ impl PyDataset {
.map(|d| (d != u64::MAX).then_some(d))
.collect();
Ok(PyTuple::new(py, items)?.into_any())
})
}
/// The dataset's numpy dtype, as h5py reports it.
+5 -3
View File
@@ -57,9 +57,9 @@ impl PyFile {
let filename = path.to_string();
match mode {
"r" => {
let file = py
.detach(|| clawhdf5_rs::File::open(path))
.map_err(to_py_err)?;
let file = py.detach(|| {
crate::no_panic(|| clawhdf5_rs::File::open(path).map_err(to_py_err))
})?;
Ok(Self {
inner: Some(FileInner::Read(Arc::new(file))),
filename,
@@ -277,6 +277,7 @@ fn parse_compression(
/// Build and write the HDF5 file from accumulated write state.
fn finalize_write(state: WriteState) -> PyResult<()> {
crate::no_panic(|| {
let mut builder = clawhdf5_rs::FileBuilder::new();
// Root attributes
@@ -300,6 +301,7 @@ fn finalize_write(state: WriteState) -> PyResult<()> {
builder.write(&state.path).map_err(to_py_err)?;
Ok(())
})
}
#[cfg(test)]
+2
View File
@@ -73,6 +73,7 @@ pub(crate) fn get_item(
/// Names of the group's datasets and subgroups, sorted (h5py's order).
pub(crate) fn member_names(file: &clawhdf5_rs::File, path: &str) -> PyResult<Vec<String>> {
crate::no_panic(|| {
let group = if path.is_empty() {
file.root()
} else {
@@ -83,6 +84,7 @@ pub(crate) fn member_names(file: &clawhdf5_rs::File, path: &str) -> PyResult<Vec
names.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
names.dedup();
Ok(names)
})
}
pub(crate) fn contains(file: &clawhdf5_rs::File, path: &str, key: &str) -> bool {
+38
View File
@@ -24,6 +24,42 @@ pub(crate) use dataset::PyDataset;
pub(crate) use file::PyFile;
pub(crate) use group::PyGroup;
pyo3::create_exception!(
clawhdf5,
InternalError,
pyo3::exceptions::PyRuntimeError,
"A bug in clawhdf5 met while reading or writing a file (a Rust panic, \
caught). Derived from RuntimeError, so `except Exception` handles it."
);
/// The text of a caught panic.
pub(crate) fn panic_text(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".to_string())
}
/// Run `f`, turning a panic in the library into [`InternalError`] instead of
/// PyO3's `PanicException` (a `BaseException`, which `except Exception`
/// does not catch). Wraps every call into the library.
pub(crate) fn no_panic<T>(f: impl FnOnce() -> PyResult<T>) -> PyResult<T> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|p| {
Err(InternalError::new_err(format!(
"clawhdf5 internal error (please report it): {}",
panic_text(&*p)
)))
})
}
/// A test hook: panics inside [`no_panic`], so the tests can check that a
/// library panic reaches Python as an ordinary exception.
#[pyfunction]
fn _panic_for_test() -> PyResult<()> {
no_panic(|| panic!("deliberate panic for the test suite"))
}
/// Convert a `clawhdf5_rs::Error` into a `PyErr`.
///
/// Maps different error variants to more specific Python exception types:
@@ -276,6 +312,8 @@ fn clawhdf5(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyGroup>()?;
m.add_class::<PyAttrs>()?;
m.add_class::<PyEmpty>()?;
m.add("InternalError", m.py().get_type::<InternalError>())?;
m.add_function(wrap_pyfunction!(_panic_for_test, m)?)?;
Ok(())
}
+6
View File
@@ -35,6 +35,7 @@ pub(crate) fn name(path: &str) -> String {
/// The object header of the object at `path`.
pub(crate) fn header(file: &clawhdf5_rs::File, path: &str) -> PyResult<ObjectHeader> {
crate::no_panic(|| {
let sb = file.superblock();
let data = file.as_bytes();
let addr = if path.is_empty() {
@@ -51,6 +52,7 @@ pub(crate) fn header(file: &clawhdf5_rs::File, path: &str) -> PyResult<ObjectHea
.map_err(|_| PyValueError::new_err(format!("{}: address out of range", name(path))))?;
ObjectHeader::parse(data, addr, sb.offset_size, sb.length_size)
.map_err(|e| PyValueError::new_err(format!("{}: {e}", name(path))))
})
}
/// What an object header describes.
@@ -115,6 +117,7 @@ pub(crate) fn exists(file: &clawhdf5_rs::File, path: &str) -> bool {
/// The dataspace message of an object header.
pub(crate) fn dataspace(file: &clawhdf5_rs::File, hdr: &ObjectHeader) -> PyResult<Dataspace> {
crate::no_panic(|| {
let sb = file.superblock();
let msg = hdr
.messages
@@ -129,6 +132,7 @@ pub(crate) fn dataspace(file: &clawhdf5_rs::File, hdr: &ObjectHeader) -> PyResul
)
.map_err(|e| PyValueError::new_err(e.to_string()))?;
Dataspace::parse(&data, sb.length_size).map_err(|e| PyValueError::new_err(e.to_string()))
})
}
pub(crate) fn is_null(space: &Dataspace) -> bool {
@@ -139,6 +143,7 @@ pub(crate) fn is_null(space: &Dataspace) -> bool {
/// Attributes whose messages cannot be parsed are left out, as the facade's
/// `attrs()` does.
pub(crate) fn attributes(file: &clawhdf5_rs::File, path: &str) -> PyResult<Vec<AttributeMessage>> {
crate::no_panic(|| {
let hdr = header(file, path)?;
let sb = file.superblock();
let (mut attrs, _errors) = clawhdf5_format::attribute::extract_attributes_tolerant(
@@ -150,6 +155,7 @@ pub(crate) fn attributes(file: &clawhdf5_rs::File, path: &str) -> PyResult<Vec<A
.map_err(|e| PyValueError::new_err(format!("{}: {e}", name(path))))?;
attrs.sort_by(|a, b| a.name.as_bytes().cmp(b.name.as_bytes()));
Ok(attrs)
})
}
#[cfg(test)]
@@ -467,3 +467,47 @@ def test_threads_read_the_same_file(pair):
with ThreadPoolExecutor(8) as pool:
list(pool.map(work, range(8)))
assert not errors, errors[:3]
def _v4_index_fixture(h5py, path):
"""One 2-D dataset per v4 chunk index (HDF5 1.10+ layout, libver='latest')."""
data = (np.arange(37 * 23, dtype="<i4") * 7 - 1000).reshape(37, 23)
early = h5py.h5p.create(h5py.h5p.DATASET_CREATE)
early.set_alloc_time(h5py.h5d.ALLOC_TIME_EARLY)
with h5py.File(path, "w", libver="latest") as f:
f.create_dataset("implicit", data=data, chunks=(5, 4), dcpl=early)
f.create_dataset("fixed_array", data=data, chunks=(5, 4), compression="gzip")
f.create_dataset("extensible_array", data=data, chunks=(5, 4), maxshape=(None, 23), compression="gzip")
f.create_dataset("btree2", data=data, chunks=(5, 4), maxshape=(None, None), compression="gzip")
f.create_dataset("single_chunk", data=data, chunks=(37, 23), compression="gzip")
V4_KEYS = [
slice(0, 3), slice(0, 30), (slice(7, 16), slice(3, 9)), (36, 22), (slice(None, None, 3), slice(1, None, 4)),
(slice(1, None, 2), Ellipsis), (Ellipsis, slice(2, 22)), [0, 5, 6, 36], (slice(None), [0, 3, 22]), -1, (),
]
def test_every_v4_chunk_index_matches_h5py(h5py, tmp_path):
"""Partial reads of each v4 chunk index. The implicit index (early
allocation, no filters) used to panic in the library for any selection
covering more than half the dataset, e.g. ds[0:30]."""
path = str(tmp_path / "v4.h5")
_v4_index_fixture(h5py, path)
with h5py.File(path, "r") as theirs, clawhdf5.File(path, "r") as ours:
for name in theirs:
for key in V4_KEYS:
assert_same(ours[name][key], theirs[name][key], f"{name}[{key!r}]")
def test_a_library_panic_is_an_ordinary_exception():
"""PyO3 turns a Rust panic into PanicException, a BaseException that
`except Exception` does not catch. Every call into the library is
guarded, so a panic surfaces as clawhdf5.InternalError instead."""
assert issubclass(clawhdf5.InternalError, RuntimeError)
with pytest.raises(clawhdf5.InternalError, match="deliberate panic"):
clawhdf5._panic_for_test()
try:
clawhdf5._panic_for_test()
except Exception: # noqa: BLE001 - the point of the test
pass