feat(py): h5py-style reads of only the selected elements, GIL released

ds[key] read the whole dataset and sliced it in numpy, and knew six
dtypes. Keys (ints, positive-step slices, Ellipsis, one increasing index
list, compound field names) now map onto hyperslab selections, and the
facade's read_selection bytes become the numpy buffer without a copy
(PyArray::from_vec viewed as the dtype). dtype mapping follows h5py for
all integer/IEEE float widths and byte orders, bool, enum, complex, fixed
and variable-length strings, vlen sequences, opaque, array types and
(nested, padded) compounds; anything it cannot describe exactly is a
TypeError. Attributes return what h5py returns; groups and files gain
the rest of the h5py mapping interface. Reads run under py.detach.

tests/test_read_vs_h5py.py compares >500 reads with h5py 3.16 on an
h5py-written file, checks errors match, that a damaged chunk outside the
selection is never touched, and 8 threads reading at once.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 08:19:48 -05:00
co-authored by Claude Opus 5.5
parent 006bf3b131
commit 2d4b211523
12 changed files with 2305 additions and 385 deletions
+135 -93
View File
@@ -2,12 +2,12 @@
use std::sync::{Arc, Mutex};
use pyo3::exceptions::{PyIOError, PyKeyError};
use pyo3::prelude::*;
use pyo3::types::PyList;
use crate::attrs::PyAttrs;
use crate::dataset::PyDataset;
use crate::{DatasetSpec, OwnedAttrValue, apply_dataset_spec, extract_numpy_data, to_py_err};
use crate::{DatasetSpec, OwnedAttrValue, apply_dataset_spec, extract_numpy_data, node, to_py_err};
/// Shared state for a group being written.
pub(crate) struct WriteGroupState {
@@ -18,15 +18,10 @@ pub(crate) struct WriteGroupState {
/// An HDF5 group.
///
/// In read mode, provides `__getitem__` navigation and child listing.
/// In write mode, supports `create_dataset` and `create_group` and
/// attribute setting.
///
/// ```python
/// grp = f['group_name']
/// grp.keys()
/// ds = grp['dataset']
/// ```
/// In read mode it behaves like an h5py group: `grp['name']`,
/// `grp['sub/path']` and `grp['/absolute/path']`, `keys()`, `values()`,
/// `items()`, iteration, `len()`, `in`, `get()`, `name` and `attrs`.
/// In write mode, supports `create_dataset` and attribute setting.
#[pyclass(name = "Group")]
pub struct PyGroup {
inner: GroupInner,
@@ -52,46 +47,90 @@ impl PyGroup {
inner: GroupInner::Write(state),
}
}
fn read_parts(&self, what: &str) -> PyResult<(&Arc<clawhdf5_rs::File>, &str)> {
match &self.inner {
GroupInner::Read { file, path } => Ok((file, path)),
GroupInner::Write(_) => Err(PyIOError::new_err(format!(
"cannot {what} a group opened for writing"
))),
}
}
}
// Read-mode operations shared by `Group` and `File` (a file is its root
// group, as in h5py).
/// `group[key]`.
pub(crate) fn get_item(
py: Python<'_>,
file: &Arc<clawhdf5_rs::File>,
path: &str,
key: &str,
) -> PyResult<Py<PyAny>> {
node::open(py, file, node::join(path, key))
}
/// 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>> {
let group = if path.is_empty() {
file.root()
} else {
file.group(path).map_err(to_py_err)?
};
let mut names = group.datasets().map_err(to_py_err)?;
names.extend(group.groups().map_err(to_py_err)?);
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 {
node::exists(file, &node::join(path, key))
}
pub(crate) fn values(
py: Python<'_>,
file: &Arc<clawhdf5_rs::File>,
path: &str,
) -> PyResult<Vec<Py<PyAny>>> {
member_names(file, path)?
.iter()
.map(|n| get_item(py, file, path, n))
.collect()
}
pub(crate) fn items(
py: Python<'_>,
file: &Arc<clawhdf5_rs::File>,
path: &str,
) -> PyResult<Vec<(String, Py<PyAny>)>> {
member_names(file, path)?
.into_iter()
.map(|n| {
let v = get_item(py, file, path, &n)?;
Ok((n, v))
})
.collect()
}
#[pymethods]
impl PyGroup {
/// Get a child object (dataset or subgroup) by name or path.
fn __getitem__(&self, py: Python<'_>, key: &str) -> PyResult<Py<PyAny>> {
match &self.inner {
GroupInner::Read { file, path } => {
let full_path = if path.is_empty() {
key.to_string()
} else {
format!("{path}/{key}")
};
// Try dataset first
match file.dataset(&full_path) {
Ok(_) => {
let ds = PyDataset::new(Arc::clone(file), full_path)?;
Ok(ds.into_pyobject(py)?.into_any().unbind())
}
Err(clawhdf5_rs::Error::NotADataset(_)) => {
let grp = PyGroup::from_read(Arc::clone(file), full_path);
Ok(grp.into_pyobject(py)?.into_any().unbind())
}
Err(e) => {
// Could be a group without a DataLayout message
match file.group(&full_path) {
Ok(_) => {
let grp = PyGroup::from_read(Arc::clone(file), full_path);
Ok(grp.into_pyobject(py)?.into_any().unbind())
}
Err(_) => Err(PyErr::new::<pyo3::exceptions::PyKeyError, _>(format!(
"{key}: {e}"
))),
}
}
}
let (file, path) = self.read_parts("read children from")?;
get_item(py, file, path, key)
}
/// `group.get(key, default=None)`.
#[pyo3(signature = (key, default=None))]
fn get(&self, py: Python<'_>, key: &str, default: Option<Py<PyAny>>) -> PyResult<Py<PyAny>> {
let (file, path) = self.read_parts("read children from")?;
match get_item(py, file, path, key) {
Err(e) if e.is_instance_of::<PyKeyError>(py) => {
Ok(default.unwrap_or_else(|| py.None()))
}
GroupInner::Write(_) => Err(PyErr::new::<pyo3::exceptions::PyIOError, _>(
"cannot read children from a group opened for writing",
)),
other => other,
}
}
@@ -99,16 +138,7 @@ impl PyGroup {
fn keys(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
match &self.inner {
GroupInner::Read { file, path } => {
let group = if path.is_empty() {
file.root()
} else {
file.group(path).map_err(to_py_err)?
};
let mut names = group.datasets().map_err(to_py_err)?;
let groups = group.groups().map_err(to_py_err)?;
names.extend(groups);
names.sort();
let list = PyList::new(py, &names)?;
let list = PyList::new(py, member_names(file, path)?)?;
Ok(list.into_any().unbind())
}
GroupInner::Write(state) => {
@@ -120,6 +150,38 @@ impl PyGroup {
}
}
fn values(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
let (file, path) = self.read_parts("read children from")?;
Ok(PyList::new(py, values(py, file, path)?)?
.into_any()
.unbind())
}
fn items(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
let (file, path) = self.read_parts("read children from")?;
Ok(PyList::new(py, items(py, file, path)?)?.into_any().unbind())
}
fn __iter__(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
self.keys(py)?.call_method0(py, "__iter__")
}
fn __len__(&self) -> PyResult<usize> {
match &self.inner {
GroupInner::Read { file, path } => Ok(member_names(file, path)?.len()),
GroupInner::Write(state) => Ok(state.lock().unwrap().datasets.len()),
}
}
/// The group's full name, e.g. `/sensors`.
#[getter]
fn name(&self) -> String {
match &self.inner {
GroupInner::Read { path, .. } => node::name(path),
GroupInner::Write(state) => node::name(&state.lock().unwrap().name),
}
}
/// Create a dataset inside this group (write mode only).
///
/// Parameters:
@@ -161,7 +223,7 @@ impl PyGroup {
state.lock().unwrap().datasets.push(spec);
Ok(())
}
GroupInner::Read { .. } => Err(PyErr::new::<pyo3::exceptions::PyIOError, _>(
GroupInner::Read { .. } => Err(PyIOError::new_err(
"cannot create datasets on a read-only group",
)),
}
@@ -171,15 +233,7 @@ impl PyGroup {
#[getter]
fn attrs(&self) -> PyResult<PyAttrs> {
match &self.inner {
GroupInner::Read { file, path } => {
let group = if path.is_empty() {
file.root()
} else {
file.group(path).map_err(to_py_err)?
};
let map = group.attrs().map_err(to_py_err)?;
Ok(PyAttrs::from_read(map))
}
GroupInner::Read { file, path } => PyAttrs::read(Arc::clone(file), path),
GroupInner::Write(state) => {
let store = Arc::clone(&state.lock().unwrap().attrs);
Ok(PyAttrs::from_write(store))
@@ -189,12 +243,9 @@ impl PyGroup {
fn __repr__(&self) -> String {
match &self.inner {
GroupInner::Read { path, .. } => {
if path.is_empty() {
"<HDF5 Group \"/\" (root)>".to_string()
} else {
format!("<HDF5 Group \"/{path}\">")
}
GroupInner::Read { file, path } => {
let n = member_names(file, path).map_or(0, |m| m.len());
format!("<HDF5 group \"{}\" ({n} members)>", node::name(path))
}
GroupInner::Write(state) => {
let name = &state.lock().unwrap().name;
@@ -205,14 +256,7 @@ impl PyGroup {
fn __contains__(&self, key: &str) -> PyResult<bool> {
match &self.inner {
GroupInner::Read { file, path } => {
let full_path = if path.is_empty() {
key.to_string()
} else {
format!("{path}/{key}")
};
Ok(file.dataset(&full_path).is_ok() || file.group(&full_path).is_ok())
}
GroupInner::Read { file, path } => Ok(contains(file, path, key)),
GroupInner::Write(state) => {
let guard = state.lock().unwrap();
Ok(guard.datasets.iter().any(|d| d.name == key))
@@ -244,26 +288,24 @@ mod tests {
use super::*;
#[test]
fn read_group_construction() {
fn member_names_are_sorted() {
let mut b = clawhdf5_rs::FileBuilder::new();
let mut g = b.create_group("grp");
b.create_dataset("zeta").with_f64_data(&[1.0]);
b.create_dataset("alpha").with_f64_data(&[1.0]);
let mut g = b.create_group("mid");
g.create_dataset("x").with_f64_data(&[1.0]);
let finished = g.finish();
b.add_group(finished);
let bytes = b.finish().unwrap();
let file = Arc::new(clawhdf5_rs::File::from_bytes(bytes).unwrap());
let _grp = PyGroup::from_read(file, "grp".into());
}
#[test]
fn write_group_state() {
let state = WriteGroupState {
name: "test".into(),
datasets: vec![],
attrs: Arc::new(Mutex::new(vec![])),
};
let arc = Arc::new(Mutex::new(state));
let _grp = PyGroup::from_write(arc);
let file = clawhdf5_rs::File::from_bytes(bytes).unwrap();
assert_eq!(
member_names(&file, "").unwrap(),
vec!["alpha", "mid", "zeta"]
);
assert_eq!(member_names(&file, "mid").unwrap(), vec!["x"]);
assert!(contains(&file, "", "mid/x"));
assert!(contains(&file, "mid", "/alpha"));
assert!(!contains(&file, "", "nope"));
}
#[test]