fix(py): index lists of padded compounds no longer return uninitialised padding

np.concatenate copies structured dtypes field by field into np.empty, so
the padding of ds[[0, 3, 6]] held process memory. The runs' bytes are
joined in Rust, whole elements at a time, before anything becomes numpy:
the padding is the file's bytes (h5py's) and the result is still a view
of the Rust buffer. The h5py comparisons now compare every byte of
structured values; the new test failed on the padding before.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 08:54:04 -05:00
co-authored by Claude Opus 5.5
parent 24412a0e59
commit 8c51b05b9c
4 changed files with 116 additions and 52 deletions
+39 -49
View File
@@ -82,70 +82,60 @@ impl PyDataset {
conv.empty(py, &out_shape)?
} else {
let (reads, list_axis) = plan.reads(dims);
let read_shape = plan.read_shape();
let file = &*self.file;
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 read = || -> Result<_, ReadError> {
let read = || -> Result<Elements, ReadError> {
let ds = file.dataset(path)?;
let sb = file.superblock();
let mut blocks = Vec::with_capacity(reads.len());
for (sel, shape) in reads {
let raw = ds.read_selection(&sel)?;
let n: usize = shape.iter().product();
let data = if vl {
if raw.len() != n * elem_size {
return Err(ReadError::Other(format!(
"read {} bytes of variable-length references, expected {}",
raw.len(),
n * elem_size
)));
}
Elements::Vl(
resolve_vl(
file.as_bytes(),
&raw,
n,
sb.offset_size,
sb.length_size,
unit,
)
.map_err(ReadError::Other)?,
)
} else {
Elements::Bytes(raw)
};
blocks.push((data, shape));
let want = shape.iter().product::<usize>() * elem_size;
if raw.len() != want {
return Err(ReadError::Other(format!(
"read {} bytes, expected {want}",
raw.len()
)));
}
blocks.push((raw, shape));
}
Ok(blocks)
// Several blocks only for a list index: join their bytes
// (every byte of every element, padding included) along
// that axis before anything becomes numpy.
let raw = match (blocks.len(), list_axis) {
(1, _) => blocks.pop().expect("one block").0,
(_, Some(axis)) => select::join_along(&blocks, axis, elem_size),
_ => {
return Err(ReadError::Other(
"several reads without an index list".into(),
));
}
};
if !vl {
return Ok(Elements::Bytes(raw));
}
let sb = file.superblock();
let n = read_shape.iter().product();
resolve_vl(
file.as_bytes(),
&raw,
n,
sb.offset_size,
sb.length_size,
unit,
)
.map(Elements::Vl)
.map_err(ReadError::Other)
};
let blocks: Vec<(Elements, Vec<usize>)> = py
let data = 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))?;
let mut arrays = Vec::with_capacity(blocks.len());
for (data, shape) in blocks {
arrays.push(conv.to_array(py, data, &shape, false)?);
}
let joined = if arrays.len() == 1 {
arrays.pop().expect("one block")
} else {
let axis = list_axis.expect("several reads only for a list index");
// Name the dtype: left to itself numpy canonicalises a
// structured dtype here (drops padding, native byte order).
let kwargs = pyo3::types::PyDict::new(py);
kwargs.set_item("axis", axis)?;
kwargs.set_item("dtype", arrays[0].getattr("dtype")?)?;
kwargs.set_item("casting", "no")?;
py.import("numpy")?.call_method(
"concatenate",
(PyList::new(py, arrays)?,),
Some(&kwargs),
)?
};
let joined = conv.to_array(py, data, &read_shape, false)?;
// Drop the axes indexed by an integer (length 1 in the blocks).
let mut shape = out_shape.clone();
if let crate::convert::Layout::Subarray(sub) = &conv.layout {