fix(py): a panic in the library raises clawhdf5.InternalError, not PanicException
PanicException derives from BaseException, so `except Exception` let a library bug through. Every call from the bindings into the library now runs under catch_unwind and a panic becomes InternalError (RuntimeError) naming the object. Tests: a hidden hook panics inside the guard; and the v4 chunk indexes are compared with h5py from Python — with the library fix reverted, ds[0:30] of the implicit-index dataset now raises InternalError instead of aborting the test run. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user