Fast contiguous and concurrent reads, VL data, nested groups and links, Python bindings #15
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user