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: 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 it returns variable-length sequences of big-endian floats unswapped; this
package returns the stored values. 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. - **CI builds and tests the Python package.** It was excluded from CI.
`scripts/ci-test.sh` now lints `clawhdf5-py`, builds the wheel with `scripts/ci-test.sh` now lints `clawhdf5-py`, builds the wheel with
maturin, unpacks it under `target/` and runs the pytest suite; skipped 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, file: &clawhdf5_rs::File,
attr: &AttributeMessage, attr: &AttributeMessage,
) -> PyResult<Bound<'py, PyAny>> { ) -> PyResult<Bound<'py, PyAny>> {
crate::no_panic(|| {
let sb = file.superblock(); let sb = file.superblock();
let conv = Converter::new(py, &attr.datatype, sb.offset_size) let conv = Converter::new(py, &attr.datatype, sb.offset_size)
.map_err(|e| prefix_err(py, &attr.name, e))?; .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)); return arr.get_item(PyTuple::empty(py));
} }
Ok(arr) Ok(arr)
})
} }
fn prefix_err(py: Python<'_>, name: &str, e: PyErr) -> PyErr { 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>, file: Arc<clawhdf5_rs::File>,
path: String, path: String,
) -> PyResult<Self> { ) -> PyResult<Self> {
crate::no_panic(|| {
let hdr = node::header(&file, &path)?; let hdr = node::header(&file, &path)?;
let null = node::is_null(&node::dataspace(&file, &hdr)?); let null = node::is_null(&node::dataspace(&file, &hdr)?);
let (shape, datatype) = { let (shape, datatype) = {
@@ -62,6 +63,7 @@ impl PyDataset {
datatype, datatype,
conv, conv,
}) })
})
} }
fn converter(&self) -> PyResult<&Converter> { fn converter(&self) -> PyResult<&Converter> {
@@ -84,8 +86,7 @@ impl PyDataset {
let path = self.path.as_str(); let path = self.path.as_str();
let (vl, elem_size, unit) = (conv.is_vl(), conv.elem_size, conv.vl_unit); let (vl, elem_size, unit) = (conv.is_vl(), conv.elem_size, conv.vl_unit);
// Everything below touches only Rust data: release the GIL. // Everything below touches only Rust data: release the GIL.
let blocks: Vec<(Elements, Vec<usize>)> = py let read = || -> Result<_, ReadError> {
.detach(|| -> Result<_, ReadError> {
let ds = file.dataset(path)?; let ds = file.dataset(path)?;
let sb = file.superblock(); let sb = file.superblock();
let mut blocks = Vec::with_capacity(reads.len()); let mut blocks = Vec::with_capacity(reads.len());
@@ -117,6 +118,11 @@ impl PyDataset {
blocks.push((data, shape)); blocks.push((data, shape));
} }
Ok(blocks) 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))?; .map_err(|e| e.into_py(&self.path))?;
@@ -160,6 +166,7 @@ impl PyDataset {
enum ReadError { enum ReadError {
Lib(clawhdf5_rs::Error), Lib(clawhdf5_rs::Error),
Other(String), Other(String),
Panic(String),
} }
impl From<clawhdf5_rs::Error> for ReadError { impl From<clawhdf5_rs::Error> for ReadError {
@@ -173,6 +180,10 @@ impl ReadError {
match self { match self {
ReadError::Lib(e) => to_py_err(e), ReadError::Lib(e) => to_py_err(e),
ReadError::Other(msg) => PyValueError::new_err(format!("{}: {msg}", node::name(path))), 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. /// The maximum shape (`None` per unlimited dimension), like h5py.
#[getter] #[getter]
fn maxshape<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> { fn maxshape<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
crate::no_panic(|| {
let Some(shape) = &self.shape else { let Some(shape) = &self.shape else {
return Ok(py.None().into_bound(py)); return Ok(py.None().into_bound(py));
}; };
@@ -237,6 +249,7 @@ impl PyDataset {
.map(|d| (d != u64::MAX).then_some(d)) .map(|d| (d != u64::MAX).then_some(d))
.collect(); .collect();
Ok(PyTuple::new(py, items)?.into_any()) Ok(PyTuple::new(py, items)?.into_any())
})
} }
/// The dataset's numpy dtype, as h5py reports it. /// The dataset's numpy dtype, as h5py reports it.
+5 -3
View File
@@ -57,9 +57,9 @@ impl PyFile {
let filename = path.to_string(); let filename = path.to_string();
match mode { match mode {
"r" => { "r" => {
let file = py let file = py.detach(|| {
.detach(|| clawhdf5_rs::File::open(path)) crate::no_panic(|| clawhdf5_rs::File::open(path).map_err(to_py_err))
.map_err(to_py_err)?; })?;
Ok(Self { Ok(Self {
inner: Some(FileInner::Read(Arc::new(file))), inner: Some(FileInner::Read(Arc::new(file))),
filename, filename,
@@ -277,6 +277,7 @@ fn parse_compression(
/// Build and write the HDF5 file from accumulated write state. /// Build and write the HDF5 file from accumulated write state.
fn finalize_write(state: WriteState) -> PyResult<()> { fn finalize_write(state: WriteState) -> PyResult<()> {
crate::no_panic(|| {
let mut builder = clawhdf5_rs::FileBuilder::new(); let mut builder = clawhdf5_rs::FileBuilder::new();
// Root attributes // Root attributes
@@ -300,6 +301,7 @@ fn finalize_write(state: WriteState) -> PyResult<()> {
builder.write(&state.path).map_err(to_py_err)?; builder.write(&state.path).map_err(to_py_err)?;
Ok(()) Ok(())
})
} }
#[cfg(test)] #[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). /// 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>> { pub(crate) fn member_names(file: &clawhdf5_rs::File, path: &str) -> PyResult<Vec<String>> {
crate::no_panic(|| {
let group = if path.is_empty() { let group = if path.is_empty() {
file.root() file.root()
} else { } 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.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
names.dedup(); names.dedup();
Ok(names) Ok(names)
})
} }
pub(crate) fn contains(file: &clawhdf5_rs::File, path: &str, key: &str) -> bool { 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 file::PyFile;
pub(crate) use group::PyGroup; 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`. /// Convert a `clawhdf5_rs::Error` into a `PyErr`.
/// ///
/// Maps different error variants to more specific Python exception types: /// 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::<PyGroup>()?;
m.add_class::<PyAttrs>()?; m.add_class::<PyAttrs>()?;
m.add_class::<PyEmpty>()?; m.add_class::<PyEmpty>()?;
m.add("InternalError", m.py().get_type::<InternalError>())?;
m.add_function(wrap_pyfunction!(_panic_for_test, m)?)?;
Ok(()) Ok(())
} }
+6
View File
@@ -35,6 +35,7 @@ pub(crate) fn name(path: &str) -> String {
/// The object header of the object at `path`. /// The object header of the object at `path`.
pub(crate) fn header(file: &clawhdf5_rs::File, path: &str) -> PyResult<ObjectHeader> { pub(crate) fn header(file: &clawhdf5_rs::File, path: &str) -> PyResult<ObjectHeader> {
crate::no_panic(|| {
let sb = file.superblock(); let sb = file.superblock();
let data = file.as_bytes(); let data = file.as_bytes();
let addr = if path.is_empty() { 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))))?; .map_err(|_| PyValueError::new_err(format!("{}: address out of range", name(path))))?;
ObjectHeader::parse(data, addr, sb.offset_size, sb.length_size) ObjectHeader::parse(data, addr, sb.offset_size, sb.length_size)
.map_err(|e| PyValueError::new_err(format!("{}: {e}", name(path)))) .map_err(|e| PyValueError::new_err(format!("{}: {e}", name(path))))
})
} }
/// What an object header describes. /// 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. /// The dataspace message of an object header.
pub(crate) fn dataspace(file: &clawhdf5_rs::File, hdr: &ObjectHeader) -> PyResult<Dataspace> { pub(crate) fn dataspace(file: &clawhdf5_rs::File, hdr: &ObjectHeader) -> PyResult<Dataspace> {
crate::no_panic(|| {
let sb = file.superblock(); let sb = file.superblock();
let msg = hdr let msg = hdr
.messages .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()))?; .map_err(|e| PyValueError::new_err(e.to_string()))?;
Dataspace::parse(&data, sb.length_size).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 { 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 /// Attributes whose messages cannot be parsed are left out, as the facade's
/// `attrs()` does. /// `attrs()` does.
pub(crate) fn attributes(file: &clawhdf5_rs::File, path: &str) -> PyResult<Vec<AttributeMessage>> { pub(crate) fn attributes(file: &clawhdf5_rs::File, path: &str) -> PyResult<Vec<AttributeMessage>> {
crate::no_panic(|| {
let hdr = header(file, path)?; let hdr = header(file, path)?;
let sb = file.superblock(); let sb = file.superblock();
let (mut attrs, _errors) = clawhdf5_format::attribute::extract_attributes_tolerant( 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))))?; .map_err(|e| PyValueError::new_err(format!("{}: {e}", name(path))))?;
attrs.sort_by(|a, b| a.name.as_bytes().cmp(b.name.as_bytes())); attrs.sort_by(|a, b| a.name.as_bytes().cmp(b.name.as_bytes()));
Ok(attrs) Ok(attrs)
})
} }
#[cfg(test)] #[cfg(test)]
@@ -467,3 +467,47 @@ def test_threads_read_the_same_file(pair):
with ThreadPoolExecutor(8) as pool: with ThreadPoolExecutor(8) as pool:
list(pool.map(work, range(8))) list(pool.map(work, range(8)))
assert not errors, errors[:3] 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