Fast contiguous and concurrent reads, VL data, nested groups and links, Python bindings #15
@@ -9,6 +9,38 @@
|
||||
tests imported `rustyhdf5`, so they failed at collection. Distribution,
|
||||
module and tests now all say `clawhdf5`, and the module has
|
||||
`__version__`.
|
||||
- **h5py-style reads that read only what is selected.** `ds[...]` used to
|
||||
read the whole dataset and slice it in numpy, and knew six dtypes. Now
|
||||
integers (negative from the end), slices with positive steps, `...`, one
|
||||
increasing list of integers per key and compound field names map onto the
|
||||
facade's hyperslab selection (a list becomes one hyperslab per run of
|
||||
consecutive indices), with h5py's results (numpy scalar for an all-integer
|
||||
key, 0-d array for `scalar[...]`) and h5py's errors for everything else
|
||||
(negative steps, `None`, boolean masks, out-of-range indices).
|
||||
`Dataset.dtype` is the numpy dtype h5py reports, for every integer and
|
||||
IEEE float width (incl. `float16`) in either byte order, `bool`, enums
|
||||
(base integer with `metadata['enum']`), complex (`r`/`i` compounds),
|
||||
fixed strings (`S<n>`), variable-length strings (`object` of `bytes`, as
|
||||
h5py), variable-length sequences (`object` of arrays), opaque (`V<n>`),
|
||||
HDF5 array types and compounds (numpy structured, offsets and padding
|
||||
kept, nested). The bytes the library returns become the numpy array's
|
||||
buffer without a copy. Types the mapping cannot describe exactly
|
||||
(references, bitfields, time, non-IEEE floats, integers with padding
|
||||
bits, variable-length members inside compounds) raise `TypeError` rather
|
||||
than return guessed data. Attributes come back as h5py returns them
|
||||
(numpy scalars and arrays with the stored dtype, `str` for
|
||||
variable-length strings, `numpy.bytes_` for fixed ones — **a change**:
|
||||
string attributes written by this package are fixed-length and used to
|
||||
come back as `str` — and `clawhdf5.Empty` for a null dataspace, which
|
||||
datasets return too). `Group`/`File` gain `get`, `values`, `items`,
|
||||
iteration, `len`, `name`, absolute and relative paths (`g['/a/b']`,
|
||||
`g['c/d']`, `f['/']`); `Dataset` gains `ndim`, `size`, `maxshape`,
|
||||
`name`, `len()` and `numpy.asarray(ds)`. File access and decoding run
|
||||
with the GIL released, so Python threads read in parallel.
|
||||
`crates/clawhdf5-py/tests/test_read_vs_h5py.py` compares every read with
|
||||
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.
|
||||
|
||||
### Plugin filters (2026-09-26)
|
||||
- **LZF, bitshuffle, bzip2 and Blosc read and write, in pure Rust.** Files
|
||||
|
||||
+103
-45
@@ -1,24 +1,31 @@
|
||||
//! PyAttrs — dict-like access to HDF5 attributes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use clawhdf5_format::attribute::AttributeMessage;
|
||||
use pyo3::exceptions::{PyKeyError, PyTypeError, PyValueError};
|
||||
use pyo3::prelude::*;
|
||||
use pyo3::types::PyList;
|
||||
use pyo3::types::{PyList, PyTuple};
|
||||
|
||||
use crate::{OwnedAttrValue, attr_value_to_py, py_to_attr_value};
|
||||
use crate::convert::{Converter, Elements, resolve_vl};
|
||||
use crate::{OwnedAttrValue, PyEmpty, attr_value_to_py, node, py_to_attr_value};
|
||||
|
||||
/// Backing storage for attributes.
|
||||
enum AttrsInner {
|
||||
/// Read-only attributes from an existing HDF5 object.
|
||||
Read(HashMap<String, clawhdf5_rs::AttrValue>),
|
||||
/// Attributes of an object in a file opened for reading, sorted by name.
|
||||
Read {
|
||||
file: Arc<clawhdf5_rs::File>,
|
||||
attrs: Vec<AttributeMessage>,
|
||||
},
|
||||
/// Writable attribute list shared with a parent (PyFile or PyGroup).
|
||||
Write(Arc<Mutex<Vec<(String, OwnedAttrValue)>>>),
|
||||
}
|
||||
|
||||
/// Dict-like access to HDF5 attributes.
|
||||
///
|
||||
/// In read mode, provides immutable access to attribute key/value pairs.
|
||||
/// In read mode, values are what h5py returns: numpy scalars for scalar
|
||||
/// attributes, numpy arrays otherwise, `str` for variable-length strings,
|
||||
/// `numpy.bytes_` for fixed-length ones, and `Empty` for a null dataspace.
|
||||
/// In write mode, attributes set here are accumulated and written when
|
||||
/// the parent file is closed.
|
||||
#[pyclass(name = "Attrs")]
|
||||
@@ -27,11 +34,12 @@ pub struct PyAttrs {
|
||||
}
|
||||
|
||||
impl PyAttrs {
|
||||
/// Create a read-only attrs from an existing attribute map.
|
||||
pub(crate) fn from_read(map: HashMap<String, clawhdf5_rs::AttrValue>) -> Self {
|
||||
Self {
|
||||
inner: AttrsInner::Read(map),
|
||||
}
|
||||
/// The attributes of the object at `path` in a file opened for reading.
|
||||
pub(crate) fn read(file: Arc<clawhdf5_rs::File>, path: &str) -> PyResult<Self> {
|
||||
let attrs = node::attributes(&file, path)?;
|
||||
Ok(Self {
|
||||
inner: AttrsInner::Read { file, attrs },
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a writable attrs that shares storage with a parent object.
|
||||
@@ -46,11 +54,11 @@ impl PyAttrs {
|
||||
impl PyAttrs {
|
||||
fn __getitem__(&self, py: Python<'_>, key: &str) -> PyResult<Py<PyAny>> {
|
||||
match &self.inner {
|
||||
AttrsInner::Read(map) => match map.get(key) {
|
||||
Some(val) => Ok(attr_value_to_py(py, val)),
|
||||
None => Err(PyErr::new::<pyo3::exceptions::PyKeyError, _>(
|
||||
key.to_string(),
|
||||
)),
|
||||
AttrsInner::Read { file, attrs } => match attrs.iter().find(|a| a.name == key) {
|
||||
Some(attr) => Ok(attr_to_py(py, file, attr)?.unbind()),
|
||||
None => Err(PyKeyError::new_err(format!(
|
||||
"Can't open attribute (can't locate attribute: '{key}')"
|
||||
))),
|
||||
},
|
||||
AttrsInner::Write(store) => {
|
||||
let guard = store.lock().unwrap();
|
||||
@@ -60,16 +68,14 @@ impl PyAttrs {
|
||||
return Ok(attr_value_to_py(py, &attr_val));
|
||||
}
|
||||
}
|
||||
Err(PyErr::new::<pyo3::exceptions::PyKeyError, _>(
|
||||
key.to_string(),
|
||||
))
|
||||
Err(PyKeyError::new_err(key.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn __setitem__(&self, key: &str, value: &Bound<'_, PyAny>) -> PyResult<()> {
|
||||
match &self.inner {
|
||||
AttrsInner::Read(_) => Err(PyErr::new::<pyo3::exceptions::PyIOError, _>(
|
||||
AttrsInner::Read { .. } => Err(PyErr::new::<pyo3::exceptions::PyIOError, _>(
|
||||
"cannot set attributes on a read-only file",
|
||||
)),
|
||||
AttrsInner::Write(store) => {
|
||||
@@ -88,14 +94,14 @@ impl PyAttrs {
|
||||
|
||||
fn __len__(&self) -> usize {
|
||||
match &self.inner {
|
||||
AttrsInner::Read(map) => map.len(),
|
||||
AttrsInner::Read { attrs, .. } => attrs.len(),
|
||||
AttrsInner::Write(store) => store.lock().unwrap().len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn __contains__(&self, key: &str) -> bool {
|
||||
match &self.inner {
|
||||
AttrsInner::Read(map) => map.contains_key(key),
|
||||
AttrsInner::Read { attrs, .. } => attrs.iter().any(|a| a.name == key),
|
||||
AttrsInner::Write(store) => store.lock().unwrap().iter().any(|(k, _)| k == key),
|
||||
}
|
||||
}
|
||||
@@ -111,10 +117,20 @@ impl PyAttrs {
|
||||
format!("<HDF5 Attrs ({n} members)>")
|
||||
}
|
||||
|
||||
/// The value of `key`, or `default` if there is no such attribute.
|
||||
#[pyo3(signature = (key, default=None))]
|
||||
fn get(&self, py: Python<'_>, key: &str, default: Option<Py<PyAny>>) -> PyResult<Py<PyAny>> {
|
||||
if self.__contains__(key) {
|
||||
self.__getitem__(py, key)
|
||||
} else {
|
||||
Ok(default.unwrap_or_else(|| py.None()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Return attribute names as a list.
|
||||
fn keys(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||
let names: Vec<String> = match &self.inner {
|
||||
AttrsInner::Read(map) => map.keys().cloned().collect(),
|
||||
AttrsInner::Read { attrs, .. } => attrs.iter().map(|a| a.name.clone()).collect(),
|
||||
AttrsInner::Write(store) => store
|
||||
.lock()
|
||||
.unwrap()
|
||||
@@ -129,7 +145,10 @@ impl PyAttrs {
|
||||
/// Return attribute values as a list.
|
||||
fn values(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||
let vals: Vec<Py<PyAny>> = match &self.inner {
|
||||
AttrsInner::Read(map) => map.values().map(|v| attr_value_to_py(py, v)).collect(),
|
||||
AttrsInner::Read { file, attrs } => attrs
|
||||
.iter()
|
||||
.map(|a| attr_to_py(py, file, a).map(Bound::unbind))
|
||||
.collect::<PyResult<_>>()?,
|
||||
AttrsInner::Write(store) => store
|
||||
.lock()
|
||||
.unwrap()
|
||||
@@ -147,10 +166,10 @@ impl PyAttrs {
|
||||
/// Return attribute (key, value) pairs as a list of tuples.
|
||||
fn items(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||
let pairs: Vec<(String, Py<PyAny>)> = match &self.inner {
|
||||
AttrsInner::Read(map) => map
|
||||
AttrsInner::Read { file, attrs } => attrs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), attr_value_to_py(py, v)))
|
||||
.collect(),
|
||||
.map(|a| Ok((a.name.clone(), attr_to_py(py, file, a)?.unbind())))
|
||||
.collect::<PyResult<_>>()?,
|
||||
AttrsInner::Write(store) => store
|
||||
.lock()
|
||||
.unwrap()
|
||||
@@ -166,28 +185,67 @@ impl PyAttrs {
|
||||
}
|
||||
}
|
||||
|
||||
/// An attribute's value as h5py returns it.
|
||||
fn attr_to_py<'py>(
|
||||
py: Python<'py>,
|
||||
file: &clawhdf5_rs::File,
|
||||
attr: &AttributeMessage,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let sb = file.superblock();
|
||||
let conv = Converter::new(py, &attr.datatype, sb.offset_size)
|
||||
.map_err(|e| prefix_err(py, &attr.name, e))?;
|
||||
if node::is_null(&attr.dataspace) {
|
||||
return Ok(PyEmpty::new(conv.dtype).into_pyobject(py)?.into_any());
|
||||
}
|
||||
let shape: Vec<usize> = attr
|
||||
.dataspace
|
||||
.dimensions
|
||||
.iter()
|
||||
.map(|&d| d as usize)
|
||||
.collect();
|
||||
let n: usize = shape.iter().product();
|
||||
let data = if conv.is_vl() {
|
||||
let want = n * conv.elem_size;
|
||||
if attr.raw_data.len() < want {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"attribute {}: {} bytes of variable-length references, expected {want}",
|
||||
attr.name,
|
||||
attr.raw_data.len(),
|
||||
)));
|
||||
}
|
||||
let raw = &attr.raw_data[..want];
|
||||
let file_data = file.as_bytes();
|
||||
let (osz, lsz, unit) = (sb.offset_size, sb.length_size, conv.vl_unit);
|
||||
Elements::Vl(
|
||||
py.detach(|| resolve_vl(file_data, raw, n, osz, lsz, unit))
|
||||
.map_err(|e| PyValueError::new_err(format!("attribute {}: {e}", attr.name)))?,
|
||||
)
|
||||
} else {
|
||||
Elements::Bytes(attr.raw_data.clone())
|
||||
};
|
||||
let arr = conv
|
||||
.to_array(py, data, &shape, true)
|
||||
.map_err(|e| prefix_err(py, &attr.name, e))?;
|
||||
if shape.is_empty() {
|
||||
// A scalar dataspace: h5py returns the element itself.
|
||||
return arr.get_item(PyTuple::empty(py));
|
||||
}
|
||||
Ok(arr)
|
||||
}
|
||||
|
||||
fn prefix_err(py: Python<'_>, name: &str, e: PyErr) -> PyErr {
|
||||
let msg = format!("attribute {name}: {}", e.value(py));
|
||||
if e.is_instance_of::<PyTypeError>(py) {
|
||||
PyTypeError::new_err(msg)
|
||||
} else {
|
||||
PyValueError::new_err(msg)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn read_attrs_len() {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("a".into(), clawhdf5_rs::AttrValue::I64(1));
|
||||
map.insert("b".into(), clawhdf5_rs::AttrValue::F64(2.0));
|
||||
let attrs = PyAttrs::from_read(map);
|
||||
assert_eq!(attrs.__len__(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_attrs_contains() {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("x".into(), clawhdf5_rs::AttrValue::String("hello".into()));
|
||||
let attrs = PyAttrs::from_read(map);
|
||||
assert!(attrs.__contains__("x"));
|
||||
assert!(!attrs.__contains__("y"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_attrs_len() {
|
||||
let store = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
//! HDF5 datatypes as numpy dtypes, and element bytes as numpy arrays.
|
||||
//!
|
||||
//! The dtype a file's datatype maps to is the one h5py reports for it
|
||||
//! (byte order kept, compound offsets and padding kept, `r`/`i` compounds as
|
||||
//! complex, the `FALSE`/`TRUE` enum as `bool`, fixed strings as `S<n>`,
|
||||
//! variable-length data as `object`). For every fixed-size type that dtype
|
||||
//! describes the file's element bytes exactly, so the bytes the library
|
||||
//! returns become the array's buffer as they are: the `Vec<u8>` is handed to
|
||||
//! numpy without a copy and viewed as the dtype.
|
||||
//!
|
||||
//! Anything this mapping cannot describe exactly — non-IEEE floats, integers
|
||||
//! with padding bits, VAX byte order, references, bitfields, time, and
|
||||
//! variable-length members inside compounds or arrays — is a `TypeError`,
|
||||
//! never a best-effort guess.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use clawhdf5_format::datatype::{CharacterSet, Datatype, DatatypeByteOrder};
|
||||
use clawhdf5_format::global_heap::GlobalHeapCollection;
|
||||
use numpy::PyArray1;
|
||||
use pyo3::exceptions::{PyTypeError, PyValueError};
|
||||
use pyo3::prelude::*;
|
||||
use pyo3::types::{PyBytes, PyDict, PyList, PyString, PyTuple};
|
||||
|
||||
/// How the elements of a datatype become Python values.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum Layout {
|
||||
/// Fixed-size elements numpy reads as they are.
|
||||
Fixed,
|
||||
/// A top-level HDF5 array type: elements are viewed as the base dtype and
|
||||
/// the array's dimensions are appended to the shape (as h5py does).
|
||||
Subarray(Vec<usize>),
|
||||
/// Variable-length string: a global heap reference per element.
|
||||
VlString { utf8: bool },
|
||||
/// Variable-length sequence of a fixed-size base type.
|
||||
VlSequence,
|
||||
}
|
||||
|
||||
/// Everything needed to turn a dataset's or attribute's bytes into numpy.
|
||||
pub(crate) struct Converter {
|
||||
/// The dtype reported to Python (`Dataset.dtype`).
|
||||
pub dtype: Py<PyAny>,
|
||||
/// The dtype the element bytes are viewed as: `dtype` itself, the base
|
||||
/// of a subarray, or the base of a variable-length sequence.
|
||||
pub view: Py<PyAny>,
|
||||
pub layout: Layout,
|
||||
/// Bytes per element in the raw buffer the library returns.
|
||||
pub elem_size: usize,
|
||||
/// For variable-length data, bytes per unit of an element's stored
|
||||
/// length: 1 for strings, the base type's size for sequences.
|
||||
pub vl_unit: usize,
|
||||
}
|
||||
|
||||
fn unsupported(what: impl std::fmt::Display) -> PyErr {
|
||||
PyTypeError::new_err(format!(
|
||||
"clawhdf5 cannot read this datatype into numpy: {what}"
|
||||
))
|
||||
}
|
||||
|
||||
fn byte_order_char(order: &DatatypeByteOrder, size: u32) -> PyResult<&'static str> {
|
||||
if size == 1 {
|
||||
return Ok("|");
|
||||
}
|
||||
match order {
|
||||
DatatypeByteOrder::LittleEndian => Ok("<"),
|
||||
DatatypeByteOrder::BigEndian => Ok(">"),
|
||||
DatatypeByteOrder::Vax => Err(unsupported("VAX byte order")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The numpy format string of an integer type, if it is a plain one.
|
||||
fn int_format(dt: &Datatype) -> PyResult<String> {
|
||||
match dt {
|
||||
Datatype::FixedPoint {
|
||||
size,
|
||||
byte_order,
|
||||
signed,
|
||||
bit_offset,
|
||||
bit_precision,
|
||||
} => {
|
||||
if !matches!(size, 1 | 2 | 4 | 8) {
|
||||
return Err(unsupported(format!("{size}-byte integer")));
|
||||
}
|
||||
if *bit_offset != 0 || u32::from(*bit_precision) != size * 8 {
|
||||
return Err(unsupported(format!(
|
||||
"integer with {bit_precision} significant bits at offset {bit_offset} in {size} bytes"
|
||||
)));
|
||||
}
|
||||
let kind = if *signed { 'i' } else { 'u' };
|
||||
Ok(format!(
|
||||
"{}{kind}{size}",
|
||||
byte_order_char(byte_order, *size)?
|
||||
))
|
||||
}
|
||||
other => Err(unsupported(format!("{other:?} is not an integer"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The numpy format string of an IEEE 754 binary16/32/64 type.
|
||||
fn float_format(dt: &Datatype) -> PyResult<String> {
|
||||
let Datatype::FloatingPoint {
|
||||
size,
|
||||
byte_order,
|
||||
bit_offset,
|
||||
bit_precision,
|
||||
exponent_location,
|
||||
exponent_size,
|
||||
mantissa_location,
|
||||
mantissa_size,
|
||||
exponent_bias,
|
||||
} = dt
|
||||
else {
|
||||
return Err(unsupported(format!("{dt:?} is not a float")));
|
||||
};
|
||||
// (exponent location, exponent size, mantissa size, bias) of IEEE 754.
|
||||
let ieee = match size {
|
||||
2 => (10, 5, 10, 15),
|
||||
4 => (23, 8, 23, 127),
|
||||
8 => (52, 11, 52, 1023),
|
||||
_ => return Err(unsupported(format!("{size}-byte float"))),
|
||||
};
|
||||
let layout = (
|
||||
*exponent_location,
|
||||
*exponent_size,
|
||||
*mantissa_size,
|
||||
*exponent_bias,
|
||||
);
|
||||
if *bit_offset != 0
|
||||
|| u32::from(*bit_precision) != size * 8
|
||||
|| *mantissa_location != 0
|
||||
|| layout != ieee
|
||||
{
|
||||
return Err(unsupported(format!(
|
||||
"non-IEEE {size}-byte float (exponent {exponent_size} bits at {exponent_location}, \
|
||||
mantissa {mantissa_size} bits at {mantissa_location}, bias {exponent_bias})"
|
||||
)));
|
||||
}
|
||||
Ok(format!("{}f{size}", byte_order_char(byte_order, *size)?))
|
||||
}
|
||||
|
||||
/// `r`/`i` compounds of two identical IEEE floats are complex numbers in h5py.
|
||||
fn complex_format(
|
||||
size: u32,
|
||||
members: &[clawhdf5_format::datatype::CompoundMember],
|
||||
) -> Option<String> {
|
||||
let [re, im] = members else { return None };
|
||||
if re.name != "r" || im.name != "i" || re.datatype != im.datatype {
|
||||
return None;
|
||||
}
|
||||
let Datatype::FloatingPoint {
|
||||
size: fsize,
|
||||
byte_order,
|
||||
..
|
||||
} = &re.datatype
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if !matches!(fsize, 4 | 8)
|
||||
|| re.byte_offset != 0
|
||||
|| im.byte_offset != u64::from(*fsize)
|
||||
|| size != 2 * fsize
|
||||
{
|
||||
return None;
|
||||
}
|
||||
float_format(&re.datatype).ok()?;
|
||||
let order = byte_order_char(byte_order, *fsize).ok()?;
|
||||
Some(format!("{order}c{}", 2 * fsize))
|
||||
}
|
||||
|
||||
/// The members of an enum as `{name: value}`.
|
||||
fn enum_members<'py>(
|
||||
py: Python<'py>,
|
||||
base: &Datatype,
|
||||
members: &[clawhdf5_format::datatype::EnumMember],
|
||||
) -> PyResult<Bound<'py, PyDict>> {
|
||||
let signed = matches!(base, Datatype::FixedPoint { signed: true, .. });
|
||||
let dict = PyDict::new(py);
|
||||
for m in members {
|
||||
let value: Py<PyAny> = if signed {
|
||||
let v = clawhdf5_format::data_read::read_as_i64(&m.value, base)
|
||||
.map_err(|e| PyValueError::new_err(format!("enum member {}: {e}", m.name)))?;
|
||||
let v = *v.first().ok_or_else(|| {
|
||||
PyValueError::new_err(format!("enum member {} has no value", m.name))
|
||||
})?;
|
||||
v.into_pyobject(py)?.into_any().unbind()
|
||||
} else {
|
||||
let v = clawhdf5_format::data_read::read_as_u64(&m.value, base)
|
||||
.map_err(|e| PyValueError::new_err(format!("enum member {}: {e}", m.name)))?;
|
||||
let v = *v.first().ok_or_else(|| {
|
||||
PyValueError::new_err(format!("enum member {} has no value", m.name))
|
||||
})?;
|
||||
v.into_pyobject(py)?.into_any().unbind()
|
||||
};
|
||||
dict.set_item(&m.name, value)?;
|
||||
}
|
||||
Ok(dict)
|
||||
}
|
||||
|
||||
/// Whether an enum is h5py's boolean: a one-byte integer with exactly the
|
||||
/// members `FALSE` = 0 and `TRUE` = 1.
|
||||
fn is_h5py_bool(base: &Datatype, members: &[clawhdf5_format::datatype::EnumMember]) -> bool {
|
||||
if base.type_size() != 1 || members.len() != 2 {
|
||||
return false;
|
||||
}
|
||||
let value = |name: &str| {
|
||||
members
|
||||
.iter()
|
||||
.find(|m| m.name == name)
|
||||
.and_then(|m| m.value.first().copied())
|
||||
};
|
||||
value("FALSE") == Some(0) && value("TRUE") == Some(1)
|
||||
}
|
||||
|
||||
fn np_dtype<'py>(py: Python<'py>, spec: impl IntoPyObject<'py>) -> PyResult<Bound<'py, PyAny>> {
|
||||
py.import("numpy")?.getattr("dtype")?.call1((spec,))
|
||||
}
|
||||
|
||||
fn np_dtype_with_metadata<'py>(
|
||||
py: Python<'py>,
|
||||
spec: impl IntoPyObject<'py>,
|
||||
metadata: Bound<'py, PyDict>,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let kwargs = PyDict::new(py);
|
||||
kwargs.set_item("metadata", metadata)?;
|
||||
py.import("numpy")?
|
||||
.getattr("dtype")?
|
||||
.call((spec,), Some(&kwargs))
|
||||
}
|
||||
|
||||
/// The numpy dtype of a fixed-size datatype, whose element bytes numpy can
|
||||
/// read as they are.
|
||||
pub(crate) fn fixed_dtype<'py>(py: Python<'py>, dt: &Datatype) -> PyResult<Bound<'py, PyAny>> {
|
||||
match dt {
|
||||
Datatype::FixedPoint { .. } => np_dtype(py, int_format(dt)?),
|
||||
Datatype::FloatingPoint { .. } => np_dtype(py, float_format(dt)?),
|
||||
Datatype::String { size, charset, .. } => {
|
||||
if *size == 0 {
|
||||
return Err(unsupported("zero-length fixed string"));
|
||||
}
|
||||
let meta = PyDict::new(py);
|
||||
let enc = match charset {
|
||||
CharacterSet::Ascii => "ascii",
|
||||
CharacterSet::Utf8 => "utf-8",
|
||||
};
|
||||
meta.set_item("h5py_encoding", enc)?;
|
||||
np_dtype_with_metadata(py, format!("S{size}"), meta)
|
||||
}
|
||||
Datatype::Opaque { size, .. } => {
|
||||
if *size == 0 {
|
||||
return Err(unsupported("zero-length opaque type"));
|
||||
}
|
||||
np_dtype(py, format!("V{size}"))
|
||||
}
|
||||
Datatype::Enumeration {
|
||||
base_type, members, ..
|
||||
} => {
|
||||
let base = int_format(base_type)?;
|
||||
if is_h5py_bool(base_type, members) {
|
||||
return np_dtype(py, "?");
|
||||
}
|
||||
let meta = PyDict::new(py);
|
||||
meta.set_item("enum", enum_members(py, base_type, members)?)?;
|
||||
np_dtype_with_metadata(py, base, meta)
|
||||
}
|
||||
Datatype::Compound { size, members } => {
|
||||
if let Some(c) = complex_format(*size, members) {
|
||||
return np_dtype(py, c);
|
||||
}
|
||||
let names = PyList::empty(py);
|
||||
let formats = PyList::empty(py);
|
||||
let offsets = PyList::empty(py);
|
||||
for m in members {
|
||||
let end = m.byte_offset.checked_add(u64::from(m.datatype.type_size()));
|
||||
if end.is_none_or(|end| end > u64::from(*size)) {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"compound member {} lies outside the {size}-byte compound",
|
||||
m.name
|
||||
)));
|
||||
}
|
||||
names.append(&m.name)?;
|
||||
formats.append(fixed_dtype(py, &m.datatype).map_err(|e| {
|
||||
unsupported(format!("compound member {}: {}", m.name, e.value(py)))
|
||||
})?)?;
|
||||
offsets.append(m.byte_offset)?;
|
||||
}
|
||||
let spec = PyDict::new(py);
|
||||
spec.set_item("names", names)?;
|
||||
spec.set_item("formats", formats)?;
|
||||
spec.set_item("offsets", offsets)?;
|
||||
spec.set_item("itemsize", size)?;
|
||||
np_dtype(py, spec)
|
||||
}
|
||||
Datatype::Array {
|
||||
base_type,
|
||||
dimensions,
|
||||
} => {
|
||||
let base = fixed_dtype(py, base_type)?;
|
||||
let dims = PyTuple::new(py, dimensions)?;
|
||||
np_dtype(py, (base, dims))
|
||||
}
|
||||
Datatype::VariableLength { is_string, .. } => Err(unsupported(if *is_string {
|
||||
"variable-length string inside a compound or array type"
|
||||
} else {
|
||||
"variable-length sequence inside a compound or array type"
|
||||
})),
|
||||
Datatype::Reference { .. } => Err(unsupported("object/region references")),
|
||||
Datatype::BitField { .. } => Err(unsupported("bitfield")),
|
||||
Datatype::Time { .. } => Err(unsupported("time")),
|
||||
}
|
||||
}
|
||||
|
||||
impl Converter {
|
||||
/// The converter for a dataset's or attribute's datatype.
|
||||
pub(crate) fn new(py: Python<'_>, dt: &Datatype, offset_size: u8) -> PyResult<Self> {
|
||||
match dt {
|
||||
Datatype::VariableLength {
|
||||
is_string: true,
|
||||
charset,
|
||||
..
|
||||
} => {
|
||||
let utf8 = matches!(charset, Some(CharacterSet::Utf8));
|
||||
let meta = PyDict::new(py);
|
||||
if utf8 {
|
||||
meta.set_item("vlen", py.get_type::<PyString>())?;
|
||||
} else {
|
||||
meta.set_item("vlen", py.get_type::<PyBytes>())?;
|
||||
}
|
||||
let dtype = np_dtype_with_metadata(py, "O", meta)?;
|
||||
Ok(Self {
|
||||
view: dtype.clone().unbind(),
|
||||
dtype: dtype.unbind(),
|
||||
layout: Layout::VlString { utf8 },
|
||||
elem_size: vl_ref_size(offset_size)?,
|
||||
vl_unit: 1,
|
||||
})
|
||||
}
|
||||
Datatype::VariableLength {
|
||||
is_string: false,
|
||||
base_type,
|
||||
..
|
||||
} => {
|
||||
let base = fixed_dtype(py, base_type)?;
|
||||
let meta = PyDict::new(py);
|
||||
meta.set_item("vlen", &base)?;
|
||||
let dtype = np_dtype_with_metadata(py, "O", meta)?;
|
||||
Ok(Self {
|
||||
dtype: dtype.unbind(),
|
||||
view: base.unbind(),
|
||||
layout: Layout::VlSequence,
|
||||
elem_size: vl_ref_size(offset_size)?,
|
||||
vl_unit: base_type.type_size() as usize,
|
||||
})
|
||||
}
|
||||
Datatype::Array {
|
||||
base_type,
|
||||
dimensions,
|
||||
} => {
|
||||
let dtype = fixed_dtype(py, dt)?;
|
||||
let base = fixed_dtype(py, base_type)?;
|
||||
Ok(Self {
|
||||
dtype: dtype.unbind(),
|
||||
view: base.unbind(),
|
||||
layout: Layout::Subarray(dimensions.iter().map(|&d| d as usize).collect()),
|
||||
elem_size: dt.type_size() as usize,
|
||||
vl_unit: 0,
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
let dtype = fixed_dtype(py, dt)?;
|
||||
Ok(Self {
|
||||
view: dtype.clone().unbind(),
|
||||
dtype: dtype.unbind(),
|
||||
layout: Layout::Fixed,
|
||||
elem_size: dt.type_size() as usize,
|
||||
vl_unit: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_vl(&self) -> bool {
|
||||
matches!(self.layout, Layout::VlString { .. } | Layout::VlSequence)
|
||||
}
|
||||
|
||||
/// An empty array of `shape` (some dimension is zero).
|
||||
pub(crate) fn empty<'py>(
|
||||
&self,
|
||||
py: Python<'py>,
|
||||
shape: &[usize],
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let np = py.import("numpy")?;
|
||||
match &self.layout {
|
||||
Layout::Subarray(dims) => {
|
||||
let mut full = shape.to_vec();
|
||||
full.extend_from_slice(dims);
|
||||
np.call_method1("empty", (PyTuple::new(py, full)?, self.view.bind(py)))
|
||||
}
|
||||
_ => np.call_method1("empty", (PyTuple::new(py, shape)?, self.dtype.bind(py))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn decoded element data into a numpy array of `shape`.
|
||||
///
|
||||
/// `str_values` decodes variable-length strings to `str` (what h5py
|
||||
/// does for attributes) instead of `bytes` (what it does for datasets).
|
||||
pub(crate) fn to_array<'py>(
|
||||
&self,
|
||||
py: Python<'py>,
|
||||
data: Elements,
|
||||
shape: &[usize],
|
||||
str_values: bool,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let n: usize = shape.iter().product();
|
||||
match (data, &self.layout) {
|
||||
(Elements::Bytes(bytes), Layout::Fixed) => {
|
||||
bytes_as_array(py, bytes, self.view.bind(py), shape)
|
||||
}
|
||||
(Elements::Bytes(bytes), Layout::Subarray(dims)) => {
|
||||
let mut full = shape.to_vec();
|
||||
full.extend_from_slice(dims);
|
||||
bytes_as_array(py, bytes, self.view.bind(py), &full)
|
||||
}
|
||||
(Elements::Vl(items), Layout::VlString { .. }) => {
|
||||
check_count(items.len(), n)?;
|
||||
let mut objs: Vec<Py<PyAny>> = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
let obj = if str_values {
|
||||
PyBytes::new(py, &item)
|
||||
.call_method1("decode", ("utf-8", "surrogateescape"))?
|
||||
.unbind()
|
||||
} else {
|
||||
PyBytes::new(py, &item).into_any().unbind()
|
||||
};
|
||||
objs.push(obj);
|
||||
}
|
||||
object_array(py, objs, shape)
|
||||
}
|
||||
(Elements::Vl(items), Layout::VlSequence) => {
|
||||
check_count(items.len(), n)?;
|
||||
let base = self.view.bind(py);
|
||||
let itemsize: usize = base.getattr("itemsize")?.extract()?;
|
||||
let mut objs: Vec<Py<PyAny>> = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
if item.len() % itemsize != 0 {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"variable-length element of {} bytes is not a whole number of {itemsize}-byte values",
|
||||
item.len()
|
||||
)));
|
||||
}
|
||||
let len = item.len() / itemsize;
|
||||
objs.push(bytes_as_array(py, item, base, &[len])?.unbind());
|
||||
}
|
||||
object_array(py, objs, shape)
|
||||
}
|
||||
_ => Err(PyValueError::new_err(
|
||||
"internal error: element data does not match the datatype",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Element data as read, before it becomes numpy.
|
||||
pub(crate) enum Elements {
|
||||
/// The elements' bytes, back to back.
|
||||
Bytes(Vec<u8>),
|
||||
/// Each variable-length element's bytes, resolved from the global heap.
|
||||
Vl(Vec<Vec<u8>>),
|
||||
}
|
||||
|
||||
fn vl_ref_size(offset_size: u8) -> PyResult<usize> {
|
||||
// The library sizes a variable-length element as 16 bytes (a length, an
|
||||
// 8-byte heap address and an index) whatever the file's offset size.
|
||||
// Refuse the other sizes rather than read misaligned references.
|
||||
if offset_size != 8 {
|
||||
return Err(unsupported(format!(
|
||||
"variable-length data in a file with {offset_size}-byte offsets"
|
||||
)));
|
||||
}
|
||||
Ok(4 + usize::from(offset_size) + 4)
|
||||
}
|
||||
|
||||
fn check_count(got: usize, want: usize) -> PyResult<()> {
|
||||
if got != want {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"read {got} elements, expected {want}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A numpy array over `bytes` without copying them: the `Vec` becomes the
|
||||
/// array's buffer and is viewed as `dtype` with `shape`.
|
||||
pub(crate) fn bytes_as_array<'py>(
|
||||
py: Python<'py>,
|
||||
bytes: Vec<u8>,
|
||||
dtype: &Bound<'py, PyAny>,
|
||||
shape: &[usize],
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let itemsize: usize = dtype.getattr("itemsize")?.extract()?;
|
||||
let n: usize = shape.iter().product();
|
||||
if n.checked_mul(itemsize) != Some(bytes.len()) {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"read {} bytes, expected {n} elements of {itemsize} bytes",
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
let shape = PyTuple::new(py, shape)?;
|
||||
if n == 0 {
|
||||
return py.import("numpy")?.call_method1("empty", (shape, dtype));
|
||||
}
|
||||
let raw = PyArray1::from_vec(py, bytes);
|
||||
let arr = raw
|
||||
.call_method1("view", (dtype,))?
|
||||
.call_method1("reshape", (shape,))?;
|
||||
// A `Vec<u8>` carries no alignment promise. numpy copes with unaligned
|
||||
// arrays, but slowly and not in every routine, so hand out an aligned
|
||||
// copy in the (allocator-dependent, rare) case the buffer is not.
|
||||
if !arr
|
||||
.getattr("flags")?
|
||||
.getattr("aligned")?
|
||||
.extract::<bool>()?
|
||||
{
|
||||
return arr.call_method0("copy");
|
||||
}
|
||||
Ok(arr)
|
||||
}
|
||||
|
||||
fn object_array<'py>(
|
||||
py: Python<'py>,
|
||||
objs: Vec<Py<PyAny>>,
|
||||
shape: &[usize],
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let arr = PyArray1::from_vec(py, objs);
|
||||
arr.call_method1("reshape", (PyTuple::new(py, shape)?,))
|
||||
}
|
||||
|
||||
/// Resolve variable-length elements (global heap references in `raw`) to
|
||||
/// their bytes: each element's stored length times `unit` (1 for strings,
|
||||
/// the base type's size for sequences). Pure Rust, so it runs without the
|
||||
/// GIL.
|
||||
pub(crate) fn resolve_vl(
|
||||
file_data: &[u8],
|
||||
raw: &[u8],
|
||||
count: usize,
|
||||
offset_size: u8,
|
||||
length_size: u8,
|
||||
unit: usize,
|
||||
) -> Result<Vec<Vec<u8>>, String> {
|
||||
let refs = clawhdf5_format::vl_data::parse_vl_references(raw, count as u64, offset_size)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let undefined = match offset_size {
|
||||
2 => 0xFFFF,
|
||||
4 => 0xFFFF_FFFF,
|
||||
_ => u64::MAX,
|
||||
};
|
||||
let mut collections: HashMap<u64, GlobalHeapCollection> = HashMap::new();
|
||||
let mut out = Vec::with_capacity(refs.len());
|
||||
for vl in &refs {
|
||||
if vl.collection_address == 0 || vl.collection_address == undefined {
|
||||
if vl.length != 0 {
|
||||
return Err(format!(
|
||||
"variable-length element of length {} has no heap address",
|
||||
vl.length
|
||||
));
|
||||
}
|
||||
out.push(Vec::new());
|
||||
continue;
|
||||
}
|
||||
let coll = match collections.entry(vl.collection_address) {
|
||||
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
let addr = usize::try_from(vl.collection_address)
|
||||
.map_err(|_| "global heap address out of range".to_string())?;
|
||||
e.insert(
|
||||
GlobalHeapCollection::parse(file_data, addr, length_size)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
}
|
||||
};
|
||||
let index = u16::try_from(vl.object_index)
|
||||
.map_err(|_| format!("global heap object index {} out of range", vl.object_index))?;
|
||||
let obj = coll.get_object(index).ok_or_else(|| {
|
||||
format!(
|
||||
"global heap object {index} not found in the collection at {}",
|
||||
vl.collection_address
|
||||
)
|
||||
})?;
|
||||
let need = (vl.length as usize)
|
||||
.checked_mul(unit)
|
||||
.ok_or("variable-length element too long")?;
|
||||
if need > obj.data.len() {
|
||||
return Err(format!(
|
||||
"variable-length element of {need} bytes in a {}-byte heap object",
|
||||
obj.data.len()
|
||||
));
|
||||
}
|
||||
out.push(obj.data[..need].to_vec());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
+295
-195
@@ -1,241 +1,341 @@
|
||||
//! PyDataset — read access to HDF5 datasets with numpy integration.
|
||||
//! PyDataset — h5py-style read access to HDF5 datasets.
|
||||
//!
|
||||
//! `ds[key]` parses the key into hyperslab selections (see `select`) and
|
||||
//! reads only those elements through the facade's `read_selection`; the
|
||||
//! bytes it returns become the numpy array's buffer without a copy (see
|
||||
//! `convert`). All file access and decoding runs with the GIL released, so
|
||||
//! Python threads reading the same or different datasets run in parallel.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use numpy::PyArrayDyn;
|
||||
use numpy::ndarray::{ArrayD, IxDyn};
|
||||
use clawhdf5_format::datatype::Datatype;
|
||||
use pyo3::exceptions::{PyTypeError, PyValueError};
|
||||
use pyo3::prelude::*;
|
||||
use pyo3::types::PyList;
|
||||
|
||||
use clawhdf5_rs::DType;
|
||||
use pyo3::types::{PyList, PyTuple};
|
||||
|
||||
use crate::attrs::PyAttrs;
|
||||
use crate::to_py_err;
|
||||
use crate::convert::{Converter, Elements, resolve_vl};
|
||||
use crate::select::{self, Plan};
|
||||
use crate::{PyEmpty, node, to_py_err};
|
||||
|
||||
/// A handle to an HDF5 dataset (read mode).
|
||||
/// A dataset in a file opened for reading.
|
||||
///
|
||||
/// Supports numpy-style indexing via `__getitem__`:
|
||||
/// ```python
|
||||
/// ds = f['dataset_name']
|
||||
/// data = ds[:] # read all data as numpy array
|
||||
/// shape = ds.shape
|
||||
/// dtype = ds.dtype
|
||||
/// ds = f['group/dataset']
|
||||
/// ds.shape, ds.dtype, ds.attrs['units']
|
||||
/// block = ds[10:20, ::2] # reads only the selected elements
|
||||
/// ```
|
||||
#[pyclass(name = "Dataset")]
|
||||
pub struct PyDataset {
|
||||
file: Arc<clawhdf5_rs::File>,
|
||||
path: String,
|
||||
cached_shape: Vec<u64>,
|
||||
cached_dtype: DType,
|
||||
/// `None` for a dataset with a null dataspace (h5py's `Empty`).
|
||||
shape: Option<Vec<u64>>,
|
||||
datatype: Datatype,
|
||||
/// Why the datatype cannot be read into numpy, if it cannot.
|
||||
conv: Result<Converter, String>,
|
||||
}
|
||||
|
||||
impl PyDataset {
|
||||
pub fn new(file: Arc<clawhdf5_rs::File>, path: String) -> PyResult<Self> {
|
||||
pub(crate) fn open(
|
||||
py: Python<'_>,
|
||||
file: Arc<clawhdf5_rs::File>,
|
||||
path: String,
|
||||
) -> PyResult<Self> {
|
||||
let hdr = node::header(&file, &path)?;
|
||||
let null = node::is_null(&node::dataspace(&file, &hdr)?);
|
||||
let (shape, datatype) = {
|
||||
let ds = file.dataset(&path).map_err(to_py_err)?;
|
||||
let cached_shape = ds.shape().map_err(to_py_err)?;
|
||||
let cached_dtype = ds.dtype().map_err(to_py_err)?;
|
||||
let shape = if null {
|
||||
None
|
||||
} else {
|
||||
Some(ds.shape().map_err(to_py_err)?)
|
||||
};
|
||||
(shape, ds.raw_datatype().map_err(to_py_err)?)
|
||||
};
|
||||
let conv = Converter::new(py, &datatype, file.superblock().offset_size)
|
||||
.map_err(|e| e.value(py).to_string());
|
||||
Ok(Self {
|
||||
file,
|
||||
path,
|
||||
cached_shape,
|
||||
cached_dtype,
|
||||
shape,
|
||||
datatype,
|
||||
conv,
|
||||
})
|
||||
}
|
||||
|
||||
fn converter(&self) -> PyResult<&Converter> {
|
||||
self.conv
|
||||
.as_ref()
|
||||
.map_err(|msg| PyTypeError::new_err(format!("{}: {msg}", node::name(&self.path))))
|
||||
}
|
||||
|
||||
/// Read the selection described by `plan` into a numpy array.
|
||||
fn read_plan<'py>(&self, py: Python<'py>, plan: &Plan) -> PyResult<Bound<'py, PyAny>> {
|
||||
let conv = self.converter()?;
|
||||
let dims = self.shape.as_deref().unwrap_or(&[]);
|
||||
let out_shape = plan.out_shape();
|
||||
|
||||
let arr = if plan.is_empty() {
|
||||
conv.empty(py, &out_shape)?
|
||||
} else {
|
||||
let (reads, list_axis) = plan.reads(dims);
|
||||
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 blocks: Vec<(Elements, Vec<usize>)> = py
|
||||
.detach(|| -> Result<_, 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));
|
||||
}
|
||||
Ok(blocks)
|
||||
})
|
||||
.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),
|
||||
)?
|
||||
};
|
||||
// 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 {
|
||||
shape.extend_from_slice(sub);
|
||||
}
|
||||
joined.call_method1("reshape", (PyTuple::new(py, shape)?,))?
|
||||
};
|
||||
|
||||
let arr = select_fields(py, arr, &plan.fields)?;
|
||||
if plan.scalar {
|
||||
return arr.get_item(PyTuple::empty(py));
|
||||
}
|
||||
Ok(arr)
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a `DType` to a numpy dtype string.
|
||||
fn dtype_to_numpy_str(dt: &DType) -> &'static str {
|
||||
match dt {
|
||||
DType::F64 => "float64",
|
||||
DType::F32 => "float32",
|
||||
DType::I64 => "int64",
|
||||
DType::I32 => "int32",
|
||||
DType::I16 => "int16",
|
||||
DType::I8 => "int8",
|
||||
DType::U64 => "uint64",
|
||||
DType::U32 => "uint32",
|
||||
DType::U16 => "uint16",
|
||||
DType::U8 => "uint8",
|
||||
DType::String | DType::VariableLengthString => "object",
|
||||
_ => "object",
|
||||
/// An error from the read closure, turned into a Python error with the GIL.
|
||||
enum ReadError {
|
||||
Lib(clawhdf5_rs::Error),
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl From<clawhdf5_rs::Error> for ReadError {
|
||||
fn from(e: clawhdf5_rs::Error) -> Self {
|
||||
ReadError::Lib(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl ReadError {
|
||||
fn into_py(self, path: &str) -> PyErr {
|
||||
match self {
|
||||
ReadError::Lib(e) => to_py_err(e),
|
||||
ReadError::Other(msg) => PyValueError::new_err(format!("{}: {msg}", node::name(path))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep only the named compound fields, as h5py's `ds['x']` / `ds['x', 'y']`.
|
||||
fn select_fields<'py>(
|
||||
py: Python<'py>,
|
||||
arr: Bound<'py, PyAny>,
|
||||
fields: &[String],
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
if fields.is_empty() {
|
||||
return Ok(arr);
|
||||
}
|
||||
let names = arr.getattr("dtype")?.getattr("names")?;
|
||||
if names.is_none() {
|
||||
return Err(PyValueError::new_err(
|
||||
"Field names only allowed for compound types",
|
||||
));
|
||||
}
|
||||
let names: Vec<String> = names.extract()?;
|
||||
for f in fields {
|
||||
if !names.contains(f) {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"Field {f} does not appear in this type."
|
||||
)));
|
||||
}
|
||||
}
|
||||
let np = py.import("numpy")?;
|
||||
if let [one] = fields {
|
||||
return np.call_method1("ascontiguousarray", (arr.get_item(one)?,));
|
||||
}
|
||||
let picked = arr.get_item(PyList::new(py, fields)?)?;
|
||||
py.import("numpy.lib.recfunctions")?
|
||||
.call_method1("repack_fields", (picked,))
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyDataset {
|
||||
/// The shape of the dataset as a tuple.
|
||||
/// The shape of the dataset (`None` for an empty/null dataspace).
|
||||
#[getter]
|
||||
fn shape(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||
let tuple = pyo3::types::PyTuple::new(py, self.cached_shape.iter().map(|&d| d as usize))?;
|
||||
Ok(tuple.into_any().unbind())
|
||||
fn shape<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
|
||||
match &self.shape {
|
||||
Some(s) => Ok(PyTuple::new(py, s)?.into_any()),
|
||||
None => Ok(py.None().into_bound(py)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The numpy dtype string of the dataset.
|
||||
/// The maximum shape (`None` per unlimited dimension), like h5py.
|
||||
#[getter]
|
||||
fn dtype(&self) -> &'static str {
|
||||
dtype_to_numpy_str(&self.cached_dtype)
|
||||
fn maxshape<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
|
||||
let Some(shape) = &self.shape else {
|
||||
return Ok(py.None().into_bound(py));
|
||||
};
|
||||
let max = self
|
||||
.file
|
||||
.dataset(&self.path)
|
||||
.and_then(|ds| ds.max_dimensions())
|
||||
.map_err(to_py_err)?
|
||||
.unwrap_or_else(|| shape.clone());
|
||||
let items: Vec<Option<u64>> = max
|
||||
.into_iter()
|
||||
.map(|d| (d != u64::MAX).then_some(d))
|
||||
.collect();
|
||||
Ok(PyTuple::new(py, items)?.into_any())
|
||||
}
|
||||
|
||||
/// Attribute access (read-only).
|
||||
/// The dataset's numpy dtype, as h5py reports it.
|
||||
#[getter]
|
||||
fn dtype<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
|
||||
Ok(self.converter()?.dtype.bind(py).clone())
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn ndim(&self) -> usize {
|
||||
self.shape.as_ref().map_or(0, Vec::len)
|
||||
}
|
||||
|
||||
/// Number of elements (`None` for an empty/null dataspace, as h5py).
|
||||
#[getter]
|
||||
fn size(&self) -> Option<u64> {
|
||||
self.shape.as_ref().map(|s| s.iter().product())
|
||||
}
|
||||
|
||||
/// The dataset's full name, e.g. `/group/data`.
|
||||
#[getter]
|
||||
fn name(&self) -> String {
|
||||
node::name(&self.path)
|
||||
}
|
||||
|
||||
/// The dataset's attributes (read-only, dict-like).
|
||||
#[getter]
|
||||
fn attrs(&self) -> PyResult<PyAttrs> {
|
||||
let ds = self.file.dataset(&self.path).map_err(to_py_err)?;
|
||||
let map = ds.attrs().map_err(to_py_err)?;
|
||||
Ok(PyAttrs::from_read(map))
|
||||
PyAttrs::read(Arc::clone(&self.file), &self.path)
|
||||
}
|
||||
|
||||
/// Read data via indexing. Supports `ds[:]`, `ds[0]`, `ds[0:5]`, etc.
|
||||
///
|
||||
/// The full dataset is always read from the underlying file; the index
|
||||
/// is then applied on the resulting numpy array.
|
||||
fn __getitem__<'py>(&self, py: Python<'py>, key: &Bound<'py, PyAny>) -> PyResult<Py<PyAny>> {
|
||||
let arr = self.read_as_numpy(py)?;
|
||||
let indexed = arr.get_item(key)?;
|
||||
Ok(indexed.unbind())
|
||||
/// Read with h5py indexing: integers, slices with positive steps,
|
||||
/// `...`, one increasing list of integers, and compound field names.
|
||||
/// Only the selected elements are read from the file.
|
||||
fn __getitem__<'py>(
|
||||
&self,
|
||||
py: Python<'py>,
|
||||
key: &Bound<'py, PyAny>,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let Some(dims) = &self.shape else {
|
||||
let is_empty_tuple = key.cast::<PyTuple>().is_ok_and(|t| t.is_empty());
|
||||
let is_ellipsis = key.is_instance_of::<pyo3::types::PyEllipsis>();
|
||||
if is_empty_tuple || is_ellipsis {
|
||||
let empty = PyEmpty::new(self.converter()?.dtype.clone_ref(py));
|
||||
return Ok(empty.into_pyobject(py)?.into_any());
|
||||
}
|
||||
return Err(PyValueError::new_err("Empty datasets cannot be sliced"));
|
||||
};
|
||||
let plan = select::parse(key, dims)?;
|
||||
self.read_plan(py, &plan)
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
/// `numpy.asarray(ds)` reads the whole dataset.
|
||||
#[pyo3(signature = (dtype=None, copy=None))]
|
||||
fn __array__<'py>(
|
||||
&self,
|
||||
py: Python<'py>,
|
||||
dtype: Option<&Bound<'py, PyAny>>,
|
||||
copy: Option<bool>,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let _ = copy; // every read is a fresh array
|
||||
let Some(dims) = &self.shape else {
|
||||
return Err(PyValueError::new_err("an empty dataset has no array value"));
|
||||
};
|
||||
let ellipsis = pyo3::types::PyEllipsis::get(py).to_owned().into_any();
|
||||
let plan = select::parse(&ellipsis, dims)?;
|
||||
let arr = self.read_plan(py, &plan)?;
|
||||
match dtype {
|
||||
Some(dt) => arr.call_method1("astype", (dt,)),
|
||||
None => Ok(arr),
|
||||
}
|
||||
}
|
||||
|
||||
fn __len__(&self) -> PyResult<usize> {
|
||||
match self.shape.as_deref() {
|
||||
Some([first, ..]) => Ok(*first as usize),
|
||||
_ => Err(PyTypeError::new_err(
|
||||
"Attempt to take len() of scalar dataset",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn __repr__(&self, py: Python<'_>) -> String {
|
||||
let dtype = match &self.conv {
|
||||
Ok(c) => c
|
||||
.dtype
|
||||
.bind(py)
|
||||
.str()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default(),
|
||||
Err(_) => format!("{:?}", self.datatype),
|
||||
};
|
||||
let shape = match &self.shape {
|
||||
Some(s) => format!("{s:?}"),
|
||||
None => "None".to_string(),
|
||||
};
|
||||
format!(
|
||||
"<HDF5 Dataset \"{}\": shape {:?}, dtype {}>",
|
||||
self.path,
|
||||
self.cached_shape,
|
||||
dtype_to_numpy_str(&self.cached_dtype),
|
||||
"<HDF5 dataset \"{}\": shape {shape}, type \"{dtype}\">",
|
||||
node::name(&self.path)
|
||||
)
|
||||
}
|
||||
|
||||
fn __len__(&self) -> usize {
|
||||
self.cached_shape.first().copied().unwrap_or(0) as usize
|
||||
}
|
||||
}
|
||||
|
||||
impl PyDataset {
|
||||
/// Read the full dataset and return it as a numpy array (or list for strings).
|
||||
///
|
||||
/// For numeric types, the Rust I/O (file reading + decompression) is
|
||||
/// performed inside `py.detach()` so that the GIL is released
|
||||
/// during the potentially expensive operation. The numpy array
|
||||
/// construction still happens with the GIL held.
|
||||
fn read_as_numpy<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
|
||||
let file = &self.file;
|
||||
let path = &self.path;
|
||||
let shape: Vec<usize> = self.cached_shape.iter().map(|&d| d as usize).collect();
|
||||
|
||||
match &self.cached_dtype {
|
||||
DType::F64 => {
|
||||
let data = py
|
||||
.detach(|| file.dataset(path).and_then(|ds| ds.read_f64()))
|
||||
.map_err(to_py_err)?;
|
||||
let nd = ArrayD::from_shape_vec(IxDyn(&shape), data)
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
|
||||
let arr = PyArrayDyn::from_owned_array(py, nd);
|
||||
Ok(arr.into_any())
|
||||
}
|
||||
DType::F32 => {
|
||||
let data = py
|
||||
.detach(|| file.dataset(path).and_then(|ds| ds.read_f32()))
|
||||
.map_err(to_py_err)?;
|
||||
let nd = ArrayD::from_shape_vec(IxDyn(&shape), data)
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
|
||||
let arr = PyArrayDyn::from_owned_array(py, nd);
|
||||
Ok(arr.into_any())
|
||||
}
|
||||
DType::I32 => {
|
||||
let data = py
|
||||
.detach(|| file.dataset(path).and_then(|ds| ds.read_i32()))
|
||||
.map_err(to_py_err)?;
|
||||
let nd = ArrayD::from_shape_vec(IxDyn(&shape), data)
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
|
||||
let arr = PyArrayDyn::from_owned_array(py, nd);
|
||||
Ok(arr.into_any())
|
||||
}
|
||||
DType::I64 => {
|
||||
let data = py
|
||||
.detach(|| file.dataset(path).and_then(|ds| ds.read_i64()))
|
||||
.map_err(to_py_err)?;
|
||||
let nd = ArrayD::from_shape_vec(IxDyn(&shape), data)
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
|
||||
let arr = PyArrayDyn::from_owned_array(py, nd);
|
||||
Ok(arr.into_any())
|
||||
}
|
||||
DType::U8 => {
|
||||
// Try zero-copy first (contiguous layout), fall back to
|
||||
// read_u64 + cast for chunked/compact datasets.
|
||||
let data: Vec<u8> = py
|
||||
.detach(|| {
|
||||
let ds = file.dataset(path)?;
|
||||
match ds.read_u8_zerocopy() {
|
||||
Ok(slice) => Ok(slice.to_vec()),
|
||||
Err(_) => {
|
||||
let raw = ds.read_u64()?;
|
||||
Ok(raw.iter().map(|&v| v as u8).collect())
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(to_py_err)?;
|
||||
let nd = ArrayD::from_shape_vec(IxDyn(&shape), data)
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
|
||||
let arr = PyArrayDyn::from_owned_array(py, nd);
|
||||
Ok(arr.into_any())
|
||||
}
|
||||
DType::U64 => {
|
||||
let data = py
|
||||
.detach(|| file.dataset(path).and_then(|ds| ds.read_u64()))
|
||||
.map_err(to_py_err)?;
|
||||
let nd = ArrayD::from_shape_vec(IxDyn(&shape), data)
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
|
||||
let arr = PyArrayDyn::from_owned_array(py, nd);
|
||||
Ok(arr.into_any())
|
||||
}
|
||||
DType::String | DType::VariableLengthString => {
|
||||
// String reads need the GIL for PyList construction, but we
|
||||
// release it during the Rust I/O portion.
|
||||
let data = py
|
||||
.detach(|| file.dataset(path).and_then(|ds| ds.read_string()))
|
||||
.map_err(to_py_err)?;
|
||||
let list = PyList::new(py, &data)?;
|
||||
Ok(list.into_any())
|
||||
}
|
||||
other => Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(format!(
|
||||
"unsupported dataset dtype for reading: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dtype_mapping() {
|
||||
assert_eq!(dtype_to_numpy_str(&DType::F64), "float64");
|
||||
assert_eq!(dtype_to_numpy_str(&DType::F32), "float32");
|
||||
assert_eq!(dtype_to_numpy_str(&DType::I32), "int32");
|
||||
assert_eq!(dtype_to_numpy_str(&DType::I64), "int64");
|
||||
assert_eq!(dtype_to_numpy_str(&DType::U8), "uint8");
|
||||
assert_eq!(dtype_to_numpy_str(&DType::String), "object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dataset_from_file() {
|
||||
let mut b = clawhdf5_rs::FileBuilder::new();
|
||||
b.create_dataset("vals").with_f64_data(&[1.0, 2.0, 3.0]);
|
||||
let bytes = b.finish().unwrap();
|
||||
let file = Arc::new(clawhdf5_rs::File::from_bytes(bytes).unwrap());
|
||||
let ds = PyDataset::new(file, "vals".into()).unwrap();
|
||||
assert_eq!(ds.cached_shape, vec![3]);
|
||||
assert_eq!(ds.cached_dtype, DType::F64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dataset_len() {
|
||||
let mut b = clawhdf5_rs::FileBuilder::new();
|
||||
b.create_dataset("data")
|
||||
.with_i32_data(&[10, 20, 30, 40])
|
||||
.with_shape(&[2, 2]);
|
||||
let bytes = b.finish().unwrap();
|
||||
let file = Arc::new(clawhdf5_rs::File::from_bytes(bytes).unwrap());
|
||||
let ds = PyDataset::new(file, "data".into()).unwrap();
|
||||
assert_eq!(ds.__len__(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use pyo3::exceptions::PyKeyError;
|
||||
use pyo3::prelude::*;
|
||||
use pyo3::types::PyList;
|
||||
|
||||
use crate::attrs::PyAttrs;
|
||||
use crate::dataset::PyDataset;
|
||||
use crate::group::{PyGroup, WriteGroupState, finalize_write_group};
|
||||
use crate::group::{self, PyGroup, WriteGroupState, finalize_write_group};
|
||||
use crate::{DatasetSpec, OwnedAttrValue, apply_dataset_spec, extract_numpy_data, to_py_err};
|
||||
|
||||
/// Internal state for write mode.
|
||||
@@ -35,6 +36,7 @@ struct WriteState {
|
||||
#[pyclass(name = "File")]
|
||||
pub struct PyFile {
|
||||
inner: Option<FileInner>,
|
||||
filename: String,
|
||||
}
|
||||
|
||||
enum FileInner {
|
||||
@@ -51,15 +53,20 @@ impl PyFile {
|
||||
/// mode: 'r' for read (default), 'w' for write
|
||||
#[new]
|
||||
#[pyo3(signature = (path, mode="r"))]
|
||||
fn new(path: &str, mode: &str) -> PyResult<Self> {
|
||||
fn new(py: Python<'_>, path: &str, mode: &str) -> PyResult<Self> {
|
||||
let filename = path.to_string();
|
||||
match mode {
|
||||
"r" => {
|
||||
let file = clawhdf5_rs::File::open(path).map_err(to_py_err)?;
|
||||
let file = py
|
||||
.detach(|| clawhdf5_rs::File::open(path))
|
||||
.map_err(to_py_err)?;
|
||||
Ok(Self {
|
||||
inner: Some(FileInner::Read(Arc::new(file))),
|
||||
filename,
|
||||
})
|
||||
}
|
||||
"w" => Ok(Self {
|
||||
filename,
|
||||
inner: Some(FileInner::Write(WriteState {
|
||||
path: PathBuf::from(path),
|
||||
root_datasets: Vec::new(),
|
||||
@@ -101,44 +108,56 @@ impl PyFile {
|
||||
Ok(false) // don't suppress exceptions
|
||||
}
|
||||
|
||||
/// Get a child object (dataset or group) by path.
|
||||
/// Get a child object (dataset or group) by path; `f['/']` is the root.
|
||||
fn __getitem__(&self, py: Python<'_>, key: &str) -> PyResult<Py<PyAny>> {
|
||||
let file = self.read_file()?;
|
||||
// Try dataset first
|
||||
match file.dataset(key) {
|
||||
Ok(_) => {
|
||||
let ds = PyDataset::new(Arc::clone(file), key.to_string())?;
|
||||
Ok(ds.into_pyobject(py)?.into_any().unbind())
|
||||
}
|
||||
Err(clawhdf5_rs::Error::NotADataset(_)) => {
|
||||
let grp = PyGroup::from_read(Arc::clone(file), key.to_string());
|
||||
Ok(grp.into_pyobject(py)?.into_any().unbind())
|
||||
}
|
||||
Err(_) => {
|
||||
// Could be a group (no DataLayout message, no error)
|
||||
match file.group(key) {
|
||||
Ok(_) => {
|
||||
let grp = PyGroup::from_read(Arc::clone(file), key.to_string());
|
||||
Ok(grp.into_pyobject(py)?.into_any().unbind())
|
||||
}
|
||||
Err(e) => Err(PyErr::new::<pyo3::exceptions::PyKeyError, _>(format!(
|
||||
"{key}: {e}"
|
||||
))),
|
||||
group::get_item(py, self.read_file()?, "", key)
|
||||
}
|
||||
|
||||
/// `f.get(key, default=None)`.
|
||||
#[pyo3(signature = (key, default=None))]
|
||||
fn get(&self, py: Python<'_>, key: &str, default: Option<Py<PyAny>>) -> PyResult<Py<PyAny>> {
|
||||
match group::get_item(py, self.read_file()?, "", key) {
|
||||
Err(e) if e.is_instance_of::<PyKeyError>(py) => {
|
||||
Ok(default.unwrap_or_else(|| py.None()))
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// List the names of all children in the root group.
|
||||
fn keys(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||
let file = self.read_file()?;
|
||||
let root = file.root();
|
||||
let mut names = root.datasets().map_err(to_py_err)?;
|
||||
let groups = root.groups().map_err(to_py_err)?;
|
||||
names.extend(groups);
|
||||
names.sort();
|
||||
let list = pyo3::types::PyList::new(py, &names)?;
|
||||
Ok(list.into_any().unbind())
|
||||
let names = group::member_names(self.read_file()?, "")?;
|
||||
Ok(PyList::new(py, names)?.into_any().unbind())
|
||||
}
|
||||
|
||||
fn values(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||
let vals = group::values(py, self.read_file()?, "")?;
|
||||
Ok(PyList::new(py, vals)?.into_any().unbind())
|
||||
}
|
||||
|
||||
fn items(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||
let items = group::items(py, self.read_file()?, "")?;
|
||||
Ok(PyList::new(py, items)?.into_any().unbind())
|
||||
}
|
||||
|
||||
fn __iter__(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||
self.keys(py)?.call_method0(py, "__iter__")
|
||||
}
|
||||
|
||||
fn __len__(&self) -> PyResult<usize> {
|
||||
Ok(group::member_names(self.read_file()?, "")?.len())
|
||||
}
|
||||
|
||||
/// The root group's name, `/`.
|
||||
#[getter]
|
||||
fn name(&self) -> &'static str {
|
||||
"/"
|
||||
}
|
||||
|
||||
/// The path the file was opened with.
|
||||
#[getter]
|
||||
fn filename(&self) -> &str {
|
||||
&self.filename
|
||||
}
|
||||
|
||||
/// Create a dataset in the root group (write mode only).
|
||||
@@ -192,10 +211,7 @@ impl PyFile {
|
||||
#[getter]
|
||||
fn attrs(&self) -> PyResult<PyAttrs> {
|
||||
match self.inner.as_ref() {
|
||||
Some(FileInner::Read(file)) => {
|
||||
let map = file.root().attrs().map_err(to_py_err)?;
|
||||
Ok(PyAttrs::from_read(map))
|
||||
}
|
||||
Some(FileInner::Read(file)) => PyAttrs::read(Arc::clone(file), ""),
|
||||
Some(FileInner::Write(state)) => Ok(PyAttrs::from_write(Arc::clone(&state.root_attrs))),
|
||||
None => Err(PyErr::new::<pyo3::exceptions::PyIOError, _>(
|
||||
"file is closed",
|
||||
@@ -216,8 +232,7 @@ impl PyFile {
|
||||
}
|
||||
|
||||
fn __contains__(&self, key: &str) -> PyResult<bool> {
|
||||
let file = self.read_file()?;
|
||||
Ok(file.dataset(key).is_ok() || file.group(key).is_ok())
|
||||
Ok(group::contains(self.read_file()?, "", key))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+134
-92
@@ -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())
|
||||
let (file, path) = self.read_parts("read children from")?;
|
||||
get_item(py, file, path, key)
|
||||
}
|
||||
Err(clawhdf5_rs::Error::NotADataset(_)) => {
|
||||
let grp = PyGroup::from_read(Arc::clone(file), full_path);
|
||||
Ok(grp.into_pyobject(py)?.into_any().unbind())
|
||||
|
||||
/// `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()))
|
||||
}
|
||||
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}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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]
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
//! ```
|
||||
|
||||
mod attrs;
|
||||
mod convert;
|
||||
mod dataset;
|
||||
mod file;
|
||||
mod group;
|
||||
mod node;
|
||||
mod select;
|
||||
|
||||
use pyo3::prelude::*;
|
||||
|
||||
@@ -46,6 +49,54 @@ pub(crate) fn to_py_err(e: clawhdf5_rs::Error) -> PyErr {
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of a dataset or attribute with a null dataspace: a type but no
|
||||
/// data. Mirrors `h5py.Empty`.
|
||||
#[pyclass(name = "Empty", frozen)]
|
||||
pub struct PyEmpty {
|
||||
dtype: Py<PyAny>,
|
||||
}
|
||||
|
||||
impl PyEmpty {
|
||||
pub(crate) fn new(dtype: Py<PyAny>) -> Self {
|
||||
Self { dtype }
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyEmpty {
|
||||
#[new]
|
||||
fn py_new(py: Python<'_>, dtype: &Bound<'_, PyAny>) -> PyResult<Self> {
|
||||
let dtype = py.import("numpy")?.getattr("dtype")?.call1((dtype,))?;
|
||||
Ok(Self::new(dtype.unbind()))
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn dtype(&self, py: Python<'_>) -> Py<PyAny> {
|
||||
self.dtype.clone_ref(py)
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn shape(&self, py: Python<'_>) -> Py<PyAny> {
|
||||
py.None()
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn size(&self, py: Python<'_>) -> Py<PyAny> {
|
||||
py.None()
|
||||
}
|
||||
|
||||
fn __eq__(&self, py: Python<'_>, other: &Bound<'_, PyAny>) -> PyResult<bool> {
|
||||
match other.cast::<PyEmpty>() {
|
||||
Ok(o) => self.dtype.bind(py).eq(o.get().dtype.bind(py)),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn __repr__(&self, py: Python<'_>) -> PyResult<String> {
|
||||
Ok(format!("Empty(dtype={})", self.dtype.bind(py).repr()?))
|
||||
}
|
||||
}
|
||||
|
||||
/// The data payload for a dataset being written.
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum DatasetData {
|
||||
@@ -224,6 +275,7 @@ fn clawhdf5(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyDataset>()?;
|
||||
m.add_class::<PyGroup>()?;
|
||||
m.add_class::<PyAttrs>()?;
|
||||
m.add_class::<PyEmpty>()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -233,9 +285,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn owned_attr_value_roundtrip() {
|
||||
let val = OwnedAttrValue::F64(3.14);
|
||||
let val = OwnedAttrValue::F64(2.5);
|
||||
let attr: clawhdf5_rs::AttrValue = val.into();
|
||||
assert!(matches!(attr, clawhdf5_rs::AttrValue::F64(v) if (v - 3.14).abs() < 1e-10));
|
||||
assert!(matches!(attr, clawhdf5_rs::AttrValue::F64(v) if (v - 2.5).abs() < 1e-10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Resolving paths to objects in a file opened for reading.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use clawhdf5_format::attribute::AttributeMessage;
|
||||
use clawhdf5_format::dataspace::{Dataspace, DataspaceType};
|
||||
use clawhdf5_format::message_type::MessageType;
|
||||
use clawhdf5_format::object_header::ObjectHeader;
|
||||
use pyo3::exceptions::{PyKeyError, PyTypeError, PyValueError};
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use crate::dataset::PyDataset;
|
||||
use crate::group::PyGroup;
|
||||
|
||||
/// Join `key` onto the group path `base` the way h5py does: an absolute key
|
||||
/// starts from the root, a relative one from `base`. Paths are kept without
|
||||
/// a leading `/`; the root is `""`.
|
||||
pub(crate) fn join(base: &str, key: &str) -> String {
|
||||
let parts = if key.starts_with('/') {
|
||||
key.split('/').collect::<Vec<_>>()
|
||||
} else {
|
||||
base.split('/').chain(key.split('/')).collect()
|
||||
};
|
||||
parts
|
||||
.into_iter()
|
||||
.filter(|p| !p.is_empty() && *p != ".")
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
/// The HDF5 name (`/a/b`) of a path.
|
||||
pub(crate) fn name(path: &str) -> String {
|
||||
format!("/{path}")
|
||||
}
|
||||
|
||||
/// The object header of the object at `path`.
|
||||
pub(crate) fn header(file: &clawhdf5_rs::File, path: &str) -> PyResult<ObjectHeader> {
|
||||
let sb = file.superblock();
|
||||
let data = file.as_bytes();
|
||||
let addr = if path.is_empty() {
|
||||
sb.root_group_address
|
||||
} else {
|
||||
clawhdf5_format::group_v2::resolve_path_any(data, sb, path).map_err(|e| {
|
||||
PyKeyError::new_err(format!(
|
||||
"Unable to open object (object '{}' doesn't exist): {e}",
|
||||
name(path)
|
||||
))
|
||||
})?
|
||||
};
|
||||
let addr = usize::try_from(addr)
|
||||
.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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Kind {
|
||||
Dataset,
|
||||
Group,
|
||||
Datatype,
|
||||
}
|
||||
|
||||
pub(crate) fn kind(hdr: &ObjectHeader) -> Option<Kind> {
|
||||
let has = |t: MessageType| hdr.messages.iter().any(|m| m.msg_type == t);
|
||||
if has(MessageType::DataLayout) {
|
||||
Some(Kind::Dataset)
|
||||
} else if has(MessageType::LinkInfo)
|
||||
|| has(MessageType::Link)
|
||||
|| has(MessageType::SymbolTable)
|
||||
|| has(MessageType::GroupInfo)
|
||||
{
|
||||
Some(Kind::Group)
|
||||
} else if has(MessageType::Datatype) {
|
||||
Some(Kind::Datatype)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the object at `path` as a `Dataset` or `Group`.
|
||||
pub(crate) fn open(
|
||||
py: Python<'_>,
|
||||
file: &Arc<clawhdf5_rs::File>,
|
||||
path: String,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let hdr = header(file, &path)?;
|
||||
match kind(&hdr) {
|
||||
Some(Kind::Dataset) => Ok(PyDataset::open(py, Arc::clone(file), path)?
|
||||
.into_pyobject(py)?
|
||||
.into_any()
|
||||
.unbind()),
|
||||
Some(Kind::Group) => Ok(PyGroup::from_read(Arc::clone(file), path)
|
||||
.into_pyobject(py)?
|
||||
.into_any()
|
||||
.unbind()),
|
||||
Some(Kind::Datatype) => Err(PyTypeError::new_err(format!(
|
||||
"{}: committed (named) datatypes are not supported by clawhdf5",
|
||||
name(&path)
|
||||
))),
|
||||
None => Err(PyValueError::new_err(format!(
|
||||
"{}: not a dataset, group or datatype",
|
||||
name(&path)
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` names a dataset or group.
|
||||
pub(crate) fn exists(file: &clawhdf5_rs::File, path: &str) -> bool {
|
||||
header(file, path)
|
||||
.ok()
|
||||
.and_then(|h| kind(&h))
|
||||
.is_some_and(|k| k != Kind::Datatype)
|
||||
}
|
||||
|
||||
/// The dataspace message of an object header.
|
||||
pub(crate) fn dataspace(file: &clawhdf5_rs::File, hdr: &ObjectHeader) -> PyResult<Dataspace> {
|
||||
let sb = file.superblock();
|
||||
let msg = hdr
|
||||
.messages
|
||||
.iter()
|
||||
.find(|m| m.msg_type == MessageType::Dataspace)
|
||||
.ok_or_else(|| PyValueError::new_err("object has no dataspace message"))?;
|
||||
let data = clawhdf5_format::shared_message::message_data(
|
||||
file.as_bytes(),
|
||||
msg,
|
||||
sb.offset_size,
|
||||
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 {
|
||||
space.space_type == DataspaceType::Null
|
||||
}
|
||||
|
||||
/// The attributes of the object at `path`, sorted by name (h5py's order).
|
||||
/// 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>> {
|
||||
let hdr = header(file, path)?;
|
||||
let sb = file.superblock();
|
||||
let (mut attrs, _errors) = clawhdf5_format::attribute::extract_attributes_tolerant(
|
||||
file.as_bytes(),
|
||||
&hdr,
|
||||
sb.offset_size,
|
||||
sb.length_size,
|
||||
)
|
||||
.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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn join_paths() {
|
||||
assert_eq!(join("", "a"), "a");
|
||||
assert_eq!(join("a", "b/c"), "a/b/c");
|
||||
assert_eq!(join("a/b", "/x"), "x");
|
||||
assert_eq!(join("a", "/"), "");
|
||||
assert_eq!(join("", "/a//b/"), "a/b");
|
||||
assert_eq!(join("a", "./b"), "a/b");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
//! h5py-style indexing (`ds[1, 2:10:3, ...]`) mapped onto hyperslab
|
||||
//! selections, so only the selected elements are read.
|
||||
//!
|
||||
//! The rules and error messages follow h5py's `selections.py`: integers
|
||||
//! (negative from the end) drop their axis, slices must have a positive
|
||||
//! step, one `Ellipsis` fills the unmentioned axes, a single increasing list
|
||||
//! of integers may index one axis, and strings name compound fields.
|
||||
//! Everything else (`None`/`np.newaxis`, boolean masks, several index lists)
|
||||
//! is refused with the error h5py gives.
|
||||
|
||||
use clawhdf5_format::selection::Selection;
|
||||
use pyo3::exceptions::{PyIndexError, PyTypeError, PyValueError};
|
||||
use pyo3::prelude::*;
|
||||
use pyo3::types::{PyEllipsis, PySlice, PyString, PyTuple};
|
||||
|
||||
/// The selection along one axis.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum Axis {
|
||||
/// A single index: the axis is dropped from the result.
|
||||
Index(u64),
|
||||
/// `start, start + step, ...`, `count` of them.
|
||||
Slice { start: u64, step: u64, count: u64 },
|
||||
/// Increasing, distinct indices.
|
||||
List(Vec<u64>),
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
fn len(&self) -> u64 {
|
||||
match self {
|
||||
Axis::Index(_) => 1,
|
||||
Axis::Slice { count, .. } => *count,
|
||||
Axis::List(v) => v.len() as u64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed index expression.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Plan {
|
||||
/// One entry per dataset axis.
|
||||
pub axes: Vec<Axis>,
|
||||
/// Compound field names to keep (empty: all).
|
||||
pub fields: Vec<String>,
|
||||
/// For a scalar dataset: `ds[()]` gives a scalar, `ds[...]` a 0-d array.
|
||||
/// For other datasets: every axis was an integer, so h5py gives a scalar.
|
||||
pub scalar: bool,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
/// The shape of the result.
|
||||
pub fn out_shape(&self) -> Vec<usize> {
|
||||
self.axes
|
||||
.iter()
|
||||
.filter(|a| !matches!(a, Axis::Index(_)))
|
||||
.map(|a| a.len() as usize)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the selection is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.axes.iter().any(|a| a.len() == 0)
|
||||
}
|
||||
|
||||
/// The hyperslab reads that make up this selection, each with the shape
|
||||
/// of its block (index axes kept at length 1). More than one only when an
|
||||
/// axis is indexed by a list: one read per run of consecutive indices,
|
||||
/// concatenated along `list_axis` afterwards.
|
||||
pub fn reads(&self, dims: &[u64]) -> (Vec<(Selection, Vec<usize>)>, Option<usize>) {
|
||||
let list_axis = self.axes.iter().position(|a| matches!(a, Axis::List(_)));
|
||||
let runs: Vec<(u64, u64)> = match list_axis.map(|i| &self.axes[i]) {
|
||||
Some(Axis::List(idx)) => consecutive_runs(idx),
|
||||
_ => vec![(0, 0)],
|
||||
};
|
||||
let mut out = Vec::with_capacity(runs.len());
|
||||
for (run_start, run_len) in runs {
|
||||
let mut start = Vec::with_capacity(dims.len());
|
||||
let mut stride = Vec::with_capacity(dims.len());
|
||||
let mut count = Vec::with_capacity(dims.len());
|
||||
for axis in &self.axes {
|
||||
let (s, st, c) = match axis {
|
||||
Axis::Index(i) => (*i, 1, 1),
|
||||
Axis::Slice { start, step, count } => (*start, *step, *count),
|
||||
Axis::List(_) => (run_start, 1, run_len),
|
||||
};
|
||||
start.push(s);
|
||||
// A stride only matters between blocks; keep it >= 1.
|
||||
stride.push(if c <= 1 { 1 } else { st });
|
||||
count.push(c);
|
||||
}
|
||||
let block_shape: Vec<usize> = count.iter().map(|&c| c as usize).collect();
|
||||
let whole = start.iter().all(|&s| s == 0)
|
||||
&& stride.iter().all(|&s| s == 1)
|
||||
&& count.as_slice() == dims;
|
||||
let sel = if whole {
|
||||
Selection::All
|
||||
} else {
|
||||
let block = vec![1; dims.len()];
|
||||
Selection::Hyperslab {
|
||||
start,
|
||||
stride,
|
||||
count,
|
||||
block,
|
||||
}
|
||||
};
|
||||
out.push((sel, block_shape));
|
||||
}
|
||||
(out, list_axis)
|
||||
}
|
||||
}
|
||||
|
||||
fn consecutive_runs(idx: &[u64]) -> Vec<(u64, u64)> {
|
||||
let mut runs: Vec<(u64, u64)> = Vec::new();
|
||||
for &i in idx {
|
||||
match runs.last_mut() {
|
||||
Some((s, n)) if *s + *n == i => *n += 1,
|
||||
_ => runs.push((i, 1)),
|
||||
}
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
/// Parse `key` for a dataset of shape `dims`.
|
||||
pub(crate) fn parse(key: &Bound<'_, PyAny>, dims: &[u64]) -> PyResult<Plan> {
|
||||
let items: Vec<Bound<'_, PyAny>> = match key.cast::<PyTuple>() {
|
||||
Ok(t) => t.iter().collect(),
|
||||
Err(_) => vec![key.clone()],
|
||||
};
|
||||
let mut fields = Vec::new();
|
||||
let mut args = Vec::new();
|
||||
for item in items {
|
||||
if let Ok(s) = item.cast::<PyString>() {
|
||||
fields.push(s.to_str()?.to_owned());
|
||||
} else {
|
||||
args.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if args.iter().any(|a| a.is_none()) {
|
||||
return Err(PyTypeError::new_err(
|
||||
"Indexing with None (or np.newaxis) is not supported",
|
||||
));
|
||||
}
|
||||
|
||||
let rank = dims.len();
|
||||
if rank == 0 {
|
||||
return match args.as_slice() {
|
||||
[] => Ok(Plan {
|
||||
axes: vec![],
|
||||
fields,
|
||||
scalar: true,
|
||||
}),
|
||||
[a] if a.is_instance_of::<PyEllipsis>() => Ok(Plan {
|
||||
axes: vec![],
|
||||
fields,
|
||||
scalar: false,
|
||||
}),
|
||||
_ => Err(PyValueError::new_err(
|
||||
"Illegal slicing argument for scalar dataspace",
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
// Expand the ellipsis (at most one) to full slices.
|
||||
let n_ellipsis = args
|
||||
.iter()
|
||||
.filter(|a| a.is_instance_of::<PyEllipsis>())
|
||||
.count();
|
||||
if n_ellipsis > 1 {
|
||||
return Err(PyValueError::new_err("Only one ellipsis may be used."));
|
||||
}
|
||||
let explicit = args.len() - n_ellipsis;
|
||||
if explicit > rank {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"{explicit} indexing arguments for {rank} dimensions"
|
||||
)));
|
||||
}
|
||||
let py = key.py();
|
||||
let mut expanded: Vec<Option<Bound<'_, PyAny>>> = Vec::with_capacity(rank);
|
||||
for a in args {
|
||||
if a.is_instance_of::<PyEllipsis>() {
|
||||
for _ in 0..(rank - explicit) {
|
||||
expanded.push(None);
|
||||
}
|
||||
} else {
|
||||
expanded.push(Some(a));
|
||||
}
|
||||
}
|
||||
while expanded.len() < rank {
|
||||
expanded.push(None);
|
||||
}
|
||||
|
||||
let mut axes = Vec::with_capacity(rank);
|
||||
for (arg, &n) in expanded.iter().zip(dims) {
|
||||
axes.push(match arg {
|
||||
None => Axis::Slice {
|
||||
start: 0,
|
||||
step: 1,
|
||||
count: n,
|
||||
},
|
||||
Some(a) => parse_axis(py, a, n)?,
|
||||
});
|
||||
}
|
||||
if axes.iter().filter(|a| matches!(a, Axis::List(_))).count() > 1 {
|
||||
return Err(PyTypeError::new_err(
|
||||
"Only one indexing vector or array is currently allowed for fancy indexing",
|
||||
));
|
||||
}
|
||||
let scalar = axes.iter().all(|a| matches!(a, Axis::Index(_)));
|
||||
Ok(Plan {
|
||||
axes,
|
||||
fields,
|
||||
scalar,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_axis(py: Python<'_>, a: &Bound<'_, PyAny>, n: u64) -> PyResult<Axis> {
|
||||
if a.is_none() {
|
||||
return Err(PyTypeError::new_err(
|
||||
"Indexing with None (or np.newaxis) is not supported",
|
||||
));
|
||||
}
|
||||
if let Ok(s) = a.cast::<PySlice>() {
|
||||
let n_isize = isize::try_from(n)
|
||||
.map_err(|_| PyValueError::new_err("dimension too large to slice"))?;
|
||||
let ind = s.indices(n_isize)?;
|
||||
if ind.step < 1 {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"Step must be >= 1 (got {})",
|
||||
ind.step
|
||||
)));
|
||||
}
|
||||
// `slicelength` is the number of elements selected, >= 0.
|
||||
let count = ind.slicelength as u64;
|
||||
let start = if count == 0 { 0 } else { ind.start as u64 };
|
||||
return Ok(Axis::Slice {
|
||||
start,
|
||||
step: ind.step as u64,
|
||||
count,
|
||||
});
|
||||
}
|
||||
let np = py.import("numpy")?;
|
||||
let is_bool =
|
||||
a.is_instance_of::<pyo3::types::PyBool>() || a.is_instance(&np.getattr("bool_")?)?;
|
||||
let is_array_like = a.is_instance(&np.getattr("ndarray")?)?
|
||||
|| a.is_instance_of::<pyo3::types::PyList>()
|
||||
|| a.is_instance_of::<PyTuple>();
|
||||
if !is_bool && !is_array_like && a.hasattr("__index__")? {
|
||||
let i: i128 = a.call_method0("__index__")?.extract()?;
|
||||
return Ok(Axis::Index(normalize(i, n)?));
|
||||
}
|
||||
if is_array_like {
|
||||
let arr = np.call_method1("asarray", (a,))?;
|
||||
let kind: String = arr.getattr("dtype")?.getattr("kind")?.extract()?;
|
||||
if kind == "b" {
|
||||
return Err(PyTypeError::new_err(
|
||||
"Boolean mask indexing is not supported by clawhdf5",
|
||||
));
|
||||
}
|
||||
let ndim: usize = arr.getattr("ndim")?.extract()?;
|
||||
let size: usize = arr.getattr("size")?.extract()?;
|
||||
if size > 0 && kind != "i" && kind != "u" {
|
||||
return Err(PyTypeError::new_err(
|
||||
"Indexing arrays must have integer dtypes",
|
||||
));
|
||||
}
|
||||
if ndim > 1 {
|
||||
return Err(PyTypeError::new_err(
|
||||
"Only 1-D integer lists or arrays can be used for fancy indexing",
|
||||
));
|
||||
}
|
||||
let vals: Vec<i128> = arr.call_method0("tolist")?.extract()?;
|
||||
let mut idx = Vec::with_capacity(vals.len());
|
||||
for v in vals {
|
||||
idx.push(normalize(v, n)?);
|
||||
}
|
||||
if idx.windows(2).any(|w| w[0] >= w[1]) {
|
||||
return Err(PyTypeError::new_err(
|
||||
"Indexing elements must be in increasing order",
|
||||
));
|
||||
}
|
||||
return Ok(Axis::List(idx));
|
||||
}
|
||||
Err(PyTypeError::new_err(format!(
|
||||
"Illegal index type for clawhdf5 datasets: {}",
|
||||
a.get_type().name()?
|
||||
)))
|
||||
}
|
||||
|
||||
fn normalize(i: i128, n: u64) -> PyResult<u64> {
|
||||
let n_i = i128::from(n);
|
||||
let j = if i < 0 { i + n_i } else { i };
|
||||
if j < 0 || j >= n_i {
|
||||
let hi = n_i - 1;
|
||||
return Err(PyIndexError::new_err(format!(
|
||||
"Index ({i}) out of range for (0-{hi})"
|
||||
)));
|
||||
}
|
||||
Ok(j as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn runs_group_consecutive_indices() {
|
||||
assert_eq!(
|
||||
consecutive_runs(&[1, 2, 3, 7, 9, 10]),
|
||||
vec![(1, 3), (7, 1), (9, 2)]
|
||||
);
|
||||
assert_eq!(consecutive_runs(&[]), vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_selection_reads_everything() {
|
||||
let plan = Plan {
|
||||
axes: vec![
|
||||
Axis::Slice {
|
||||
start: 0,
|
||||
step: 1,
|
||||
count: 4,
|
||||
},
|
||||
Axis::Slice {
|
||||
start: 0,
|
||||
step: 1,
|
||||
count: 3,
|
||||
},
|
||||
],
|
||||
fields: vec![],
|
||||
scalar: false,
|
||||
};
|
||||
let (reads, list) = plan.reads(&[4, 3]);
|
||||
assert_eq!(list, None);
|
||||
assert_eq!(reads, vec![(Selection::All, vec![4, 3])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_and_step_map_to_a_hyperslab() {
|
||||
let plan = Plan {
|
||||
axes: vec![
|
||||
Axis::Index(2),
|
||||
Axis::Slice {
|
||||
start: 1,
|
||||
step: 3,
|
||||
count: 2,
|
||||
},
|
||||
],
|
||||
fields: vec![],
|
||||
scalar: false,
|
||||
};
|
||||
let (reads, _) = plan.reads(&[4, 8]);
|
||||
assert_eq!(
|
||||
reads,
|
||||
vec![(
|
||||
Selection::Hyperslab {
|
||||
start: vec![2, 1],
|
||||
stride: vec![1, 3],
|
||||
count: vec![1, 2],
|
||||
block: vec![1, 1],
|
||||
},
|
||||
vec![1, 2]
|
||||
)]
|
||||
);
|
||||
assert_eq!(plan.out_shape(), vec![2]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Shared fixtures for the clawhdf5 Python binding tests."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def h5py():
|
||||
"""h5py, or a skip — unless CLAWHDF5_REQUIRE_INTEROP=1, which makes a
|
||||
missing h5py a failure (as for the Rust interop suites)."""
|
||||
try:
|
||||
import h5py as mod
|
||||
except ImportError:
|
||||
if os.environ.get("CLAWHDF5_REQUIRE_INTEROP") == "1":
|
||||
pytest.fail("h5py is required (CLAWHDF5_REQUIRE_INTEROP=1) but not importable")
|
||||
pytest.skip("h5py not installed")
|
||||
return mod
|
||||
@@ -0,0 +1,469 @@
|
||||
"""Every read through clawhdf5 compared against h5py (libhdf5) on files h5py
|
||||
writes: dtypes, shapes, values and the type of what comes back (array,
|
||||
numpy scalar, bytes, str, Empty), for every datatype the bindings map and a
|
||||
spread of index expressions; plus the errors h5py gives for the same keys."""
|
||||
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import clawhdf5
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The generated file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NUMERIC = [
|
||||
"<i1", "<i2", "<i4", "<i8", "<u1", "<u2", "<u4", "<u8",
|
||||
">i2", ">i4", ">i8", ">u2", ">u4", ">u8",
|
||||
"<f2", "<f4", "<f8", ">f2", ">f4", ">f8",
|
||||
]
|
||||
|
||||
|
||||
def _values(dtype, shape, seed):
|
||||
rng = np.random.default_rng(seed)
|
||||
dt = np.dtype(dtype)
|
||||
n = int(np.prod(shape))
|
||||
if dt.kind == "f":
|
||||
return rng.standard_normal(n).astype(dt).reshape(shape)
|
||||
info = np.iinfo(dt)
|
||||
return rng.integers(info.min, info.max, size=n, dtype=dt.newbyteorder("=")).astype(dt).reshape(shape)
|
||||
|
||||
|
||||
def _compound_dtype():
|
||||
return np.dtype(
|
||||
{
|
||||
"names": ["id", "pos", "label", "flag", "vec"],
|
||||
"formats": ["<i4", ">f8", "S6", "u1", ("<f4", (3,))],
|
||||
"offsets": [0, 8, 16, 22, 24],
|
||||
"itemsize": 40,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _nested_dtype():
|
||||
inner = np.dtype([("a", "<i2"), ("b", "<f4")])
|
||||
return np.dtype([("x", "<u8"), ("inner", inner), ("c", "<c8")])
|
||||
|
||||
|
||||
def _write_fixture(h5py, path):
|
||||
str_dt = h5py.string_dtype()
|
||||
ascii_dt = h5py.string_dtype("ascii")
|
||||
with h5py.File(path, "w") as f:
|
||||
# Numeric types, both byte orders, 1-D contiguous and 2-D chunked+gzip.
|
||||
for i, dt in enumerate(NUMERIC):
|
||||
name = dt.replace("<", "le_").replace(">", "be_")
|
||||
f.create_dataset(f"num/{name}_1d", data=_values(dt, (37,), i))
|
||||
f.create_dataset(
|
||||
f"num/{name}_2d_gzip",
|
||||
data=_values(dt, (13, 11), 100 + i),
|
||||
chunks=(4, 5),
|
||||
compression="gzip",
|
||||
)
|
||||
f.create_dataset("num/f8_3d", data=_values("<f8", (6, 7, 5), 7), chunks=(2, 3, 5))
|
||||
f.create_dataset(
|
||||
"num/i4_3d_shuffle",
|
||||
data=_values("<i4", (5, 9, 4), 8),
|
||||
chunks=(3, 4, 2),
|
||||
shuffle=True,
|
||||
fletcher32=True,
|
||||
compression="gzip",
|
||||
)
|
||||
f.create_dataset("num/scalar_f8", data=np.float64(3.25))
|
||||
f.create_dataset("num/scalar_i2_be", data=np.array(-7, dtype=">i2"))
|
||||
f.create_dataset("num/zero_size", shape=(0, 3), dtype="<f4")
|
||||
f.create_dataset("num/empty", data=h5py.Empty("<f8"))
|
||||
sparse = f.create_dataset("num/sparse_fill", shape=(40,), chunks=(8,), dtype="<i4", fillvalue=-3)
|
||||
sparse[10:14] = [1, 2, 3, 4]
|
||||
f.create_dataset("num/resizable", data=np.arange(12.0), maxshape=(None,), chunks=(5,))
|
||||
# Compact layout (low level: h5py's create_dataset cannot ask for it).
|
||||
dcpl = h5py.h5p.create(h5py.h5p.DATASET_CREATE)
|
||||
dcpl.set_layout(h5py.h5d.COMPACT)
|
||||
space = h5py.h5s.create_simple((9,))
|
||||
dsid = h5py.h5d.create(f.id, b"num/compact_u2", h5py.h5t.py_create(np.dtype("<u2")), space, dcpl=dcpl)
|
||||
dsid.write(h5py.h5s.ALL, h5py.h5s.ALL, np.arange(9, dtype="<u2") * 7)
|
||||
|
||||
# bool, enum, complex.
|
||||
f.create_dataset("misc/bool", data=np.array([True, False, True, True, False]))
|
||||
enum_dt = h5py.enum_dtype({"RED": 0, "GREEN": 1, "BLUE": 42}, basetype="i2")
|
||||
f.create_dataset("misc/enum", data=np.array([0, 42, 1, 1, 0], dtype="<i2"), dtype=enum_dt)
|
||||
uenum_dt = h5py.enum_dtype({"LO": 0, "HI": 2**40}, basetype="u8")
|
||||
f.create_dataset("misc/enum_u8", data=np.array([0, 2**40], dtype="<u8"), dtype=uenum_dt)
|
||||
f.create_dataset("misc/c8", data=(np.arange(6) + 1j * np.arange(6)).astype("<c8"))
|
||||
f.create_dataset("misc/c16_2d", data=(np.arange(12) - 2j).reshape(3, 4).astype("<c16"))
|
||||
f.create_dataset("misc/opaque", data=np.array([b"\x00\x01\x02", b"\xff\xfe\xfd"], dtype="V3"))
|
||||
|
||||
# Strings.
|
||||
f.create_dataset("str/fixed", data=np.array([b"alpha", b"be", b"", b"gamma!"], dtype="S6"))
|
||||
utf8_4 = h5py.string_dtype("utf-8", 4)
|
||||
f.create_dataset(
|
||||
"str/fixed_2d_utf8",
|
||||
data=np.array([["é".encode(), b"b"], [b"c", b"dd"]], dtype=utf8_4),
|
||||
)
|
||||
f.create_dataset("str/vlen", data=["", "one", "twø", "a" * 300, "ünïcödé"], dtype=str_dt)
|
||||
f.create_dataset("str/vlen_ascii", data=[b"x", b"yy", b"zzz"], dtype=ascii_dt)
|
||||
f.create_dataset(
|
||||
"str/vlen_2d_gzip",
|
||||
data=np.array([[f"r{r}c{c}" * (r + c) for c in range(5)] for r in range(6)], dtype=object),
|
||||
dtype=str_dt,
|
||||
chunks=(2, 2),
|
||||
compression="gzip",
|
||||
)
|
||||
f.create_dataset("str/vlen_scalar", data="just one", dtype=str_dt)
|
||||
|
||||
# Variable-length sequences.
|
||||
vl_i = h5py.vlen_dtype(np.dtype("<i4"))
|
||||
seqs = np.empty(4, dtype=object)
|
||||
seqs[:] = [np.arange(3, dtype="<i4"), np.array([], dtype="<i4"), np.arange(10, dtype="<i4") * -1, np.array([7], dtype="<i4")]
|
||||
f.create_dataset("vlen/i4", data=seqs, dtype=vl_i)
|
||||
vl_f = h5py.vlen_dtype(np.dtype(">f8"))
|
||||
fseqs = np.empty(3, dtype=object)
|
||||
fseqs[:] = [np.linspace(0, 1, 5).astype(">f8"), np.array([2.5], dtype=">f8"), np.array([], dtype=">f8")]
|
||||
f.create_dataset("vlen/f8_be", data=fseqs, dtype=vl_f)
|
||||
|
||||
# Compounds.
|
||||
cdt = _compound_dtype()
|
||||
rec = np.zeros(10, dtype=cdt)
|
||||
rec["id"] = np.arange(10) * 3
|
||||
rec["pos"] = np.linspace(-1, 1, 10)
|
||||
rec["label"] = [f"n{i}".encode() for i in range(10)]
|
||||
rec["flag"] = np.arange(10) % 2
|
||||
rec["vec"] = np.arange(30, dtype="<f4").reshape(10, 3)
|
||||
f.create_dataset("cmp/padded", data=rec)
|
||||
f.create_dataset("cmp/padded_chunked", data=rec, chunks=(3,), compression="gzip")
|
||||
ndt = _nested_dtype()
|
||||
nrec = np.zeros((4, 3), dtype=ndt)
|
||||
nrec["x"] = np.arange(12).reshape(4, 3)
|
||||
nrec["inner"]["a"] = -np.arange(12).reshape(4, 3)
|
||||
nrec["inner"]["b"] = np.arange(12).reshape(4, 3) / 4
|
||||
nrec["c"] = np.arange(12).reshape(4, 3) * (1 + 1j)
|
||||
f.create_dataset("cmp/nested_2d", data=nrec)
|
||||
vdt = np.dtype([("n", "<i4"), ("s", h5py.string_dtype())])
|
||||
vrec = np.array([(1, "a"), (2, "bb")], dtype=vdt)
|
||||
f.create_dataset("cmp/with_vlen", data=vrec)
|
||||
|
||||
# A true HDF5 array datatype (h5py's high level would widen the shape).
|
||||
tid = h5py.h5t.array_create(h5py.h5t.py_create(np.dtype("<i4")), (2, 3))
|
||||
space = h5py.h5s.create_simple((4,))
|
||||
dsid = h5py.h5d.create(f.id, b"cmp/array_type", tid, space)
|
||||
dsid.write(h5py.h5s.ALL, h5py.h5s.ALL, np.arange(24, dtype="<i4").reshape(4, 2, 3), mtype=tid)
|
||||
|
||||
# Unsupported: references.
|
||||
f.create_dataset("unsupported/refs", data=[f["num"].ref, f["misc"].ref], dtype=h5py.ref_dtype)
|
||||
|
||||
# Groups and attributes.
|
||||
g = f.create_group("deep/er/est")
|
||||
g.create_dataset("leaf", data=np.arange(5))
|
||||
f.create_group("empty_group")
|
||||
f.attrs["i4"] = np.int32(-5)
|
||||
f.attrs["u8"] = np.uint64(2**63 + 1)
|
||||
f.attrs["f2"] = np.float16(1.5)
|
||||
f.attrs["f8_arr"] = np.array([1.0, 2.5, -3.0])
|
||||
f.attrs["f8_one"] = np.array([4.0])
|
||||
f.attrs["i2_2d_be"] = np.arange(6, dtype=">i2").reshape(2, 3)
|
||||
f.attrs["bool"] = True
|
||||
f.attrs["bool_arr"] = np.array([True, False])
|
||||
f.attrs["vstr"] = "héllo"
|
||||
f.attrs["vstr_arr"] = ["a", "bcd", ""]
|
||||
f.attrs["fstr"] = np.bytes_(b"fixed")
|
||||
f.attrs["fstr_arr"] = np.array([b"x", b"yz"], dtype="S2")
|
||||
f.attrs["empty"] = h5py.Empty("<i4")
|
||||
f.attrs["complex"] = np.complex128(1 - 2j)
|
||||
f.attrs["compound"] = np.array((7, 2.5), dtype=[("a", "<i2"), ("b", "<f8")])
|
||||
f.attrs["enum"] = np.array(42, dtype=enum_dt)
|
||||
f["num/le_f8_1d"].attrs["units"] = "m/s"
|
||||
f["num/le_f8_1d"].attrs["scale"] = np.float32(0.5)
|
||||
g.attrs["depth"] = np.int8(3)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pair(h5py, tmp_path_factory):
|
||||
path = str(tmp_path_factory.mktemp("h5") / "fixture.h5")
|
||||
_write_fixture(h5py, path)
|
||||
theirs = h5py.File(path, "r")
|
||||
ours = clawhdf5.File(path, "r")
|
||||
yield ours, theirs, path
|
||||
theirs.close()
|
||||
ours.close()
|
||||
|
||||
|
||||
def _all_datasets(h5py, f):
|
||||
names = []
|
||||
f.visititems(lambda n, o: names.append(n) if isinstance(o, h5py.Dataset) else None)
|
||||
return sorted(names)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comparison helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def assert_same(ours, theirs, what=""):
|
||||
if type(theirs).__name__ == "Empty":
|
||||
assert isinstance(ours, clawhdf5.Empty), what
|
||||
assert ours.dtype == theirs.dtype, what
|
||||
return
|
||||
assert type(ours) is type(theirs), f"{what}: {type(ours)} vs {type(theirs)}"
|
||||
if isinstance(theirs, np.ndarray):
|
||||
assert ours.shape == theirs.shape, what
|
||||
assert ours.dtype == theirs.dtype, f"{what}: {ours.dtype} vs {theirs.dtype}"
|
||||
if theirs.dtype == object:
|
||||
for a, b in zip(ours.ravel(), theirs.ravel()):
|
||||
assert_same(a, b, what)
|
||||
elif theirs.dtype.names is None and theirs.dtype.kind == "V":
|
||||
assert ours.tobytes() == theirs.tobytes(), what
|
||||
else:
|
||||
np.testing.assert_array_equal(ours, theirs, err_msg=what)
|
||||
elif isinstance(theirs, np.generic):
|
||||
assert ours.dtype == theirs.dtype, what
|
||||
if theirs.dtype.names is not None:
|
||||
np.testing.assert_array_equal(np.asarray(ours), np.asarray(theirs), err_msg=what)
|
||||
else:
|
||||
assert ours == theirs or (ours != ours and theirs != theirs), what
|
||||
else:
|
||||
assert ours == theirs, what
|
||||
|
||||
|
||||
def keys_for(shape):
|
||||
if shape == ():
|
||||
return [(), Ellipsis]
|
||||
keys = [(), Ellipsis, 0, -1, slice(None), slice(None, None, 2), slice(1, None, 3), slice(0, 0), np.int64(0)]
|
||||
n0 = shape[0]
|
||||
if n0 == 0:
|
||||
return [(), Ellipsis, slice(None), slice(None, None, 2), slice(0, 0)]
|
||||
keys += [slice(n0 // 2, None), slice(-3, None), [0, n0 - 1] if n0 > 1 else [0], (Ellipsis,)]
|
||||
if n0 > 5:
|
||||
keys += [[1, 2, 3, 5], [0, 4, 5]]
|
||||
if len(shape) >= 2:
|
||||
n1 = shape[1]
|
||||
keys += [
|
||||
(0, 0),
|
||||
(-1, -1),
|
||||
(slice(1, 3), slice(None, None, 2)),
|
||||
(Ellipsis, 1),
|
||||
(1, Ellipsis),
|
||||
(slice(None), [0, n1 - 1] if n1 > 1 else [0]),
|
||||
(slice(None, None, 2), 1),
|
||||
(slice(0, 2), slice(3, 1)),
|
||||
]
|
||||
if len(shape) >= 3:
|
||||
keys += [(0, slice(None), -1), (slice(1, None, 2), 2, slice(None, None, 3)), (Ellipsis, 0, 0), (0, Ellipsis, 1)]
|
||||
return keys
|
||||
|
||||
|
||||
# h5py 3.16 (HDF5 2.0) returns the elements of a variable-length sequence of
|
||||
# big-endian floats unswapped (0.25 comes back as 2.6e-319), so these are
|
||||
# checked against the values written instead (test_vlen_big_endian).
|
||||
H5PY_MISREADS = {"vlen/f8_be"}
|
||||
|
||||
ERROR_KEYS_1D = [slice(None, None, -1), 10**6, -(10**6), None, (0, 0, 0, 0, 0), [3, 1], (Ellipsis, Ellipsis), 1.5, "nope"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_every_dataset_matches_h5py(h5py, pair):
|
||||
ours, theirs, _ = pair
|
||||
checked = 0
|
||||
for name in _all_datasets(h5py, theirs):
|
||||
if name.startswith("unsupported/") or name in H5PY_MISREADS or name == "cmp/with_vlen":
|
||||
continue
|
||||
t = theirs[name]
|
||||
o = ours[name]
|
||||
assert o.shape == t.shape, name
|
||||
assert o.dtype == t.dtype, f"{name}: {o.dtype} vs {t.dtype}"
|
||||
assert dict(o.dtype.metadata or {}) == dict(t.dtype.metadata or {}), name
|
||||
assert o.ndim == t.ndim and o.size == t.size, name
|
||||
assert o.maxshape == t.maxshape, name
|
||||
assert o.name == t.name, name
|
||||
if t.shape is None:
|
||||
assert_same(o[()], t[()], name)
|
||||
continue
|
||||
for key in keys_for(t.shape):
|
||||
what = f"{name}[{key!r}]"
|
||||
try:
|
||||
expected = t[key]
|
||||
except Exception as e: # noqa: BLE001 - h5py refuses: so must we
|
||||
with pytest.raises(type(e)):
|
||||
o[key]
|
||||
continue
|
||||
assert_same(o[key], expected, what)
|
||||
checked += 1
|
||||
assert checked > 500
|
||||
|
||||
|
||||
def test_vlen_big_endian(pair):
|
||||
ours, _, _ = pair
|
||||
ds = ours["vlen/f8_be"]
|
||||
assert ds.dtype.metadata["vlen"] == np.dtype(">f8")
|
||||
got = ds[()]
|
||||
expected = [np.linspace(0, 1, 5), np.array([2.5]), np.array([])]
|
||||
assert got.shape == (3,)
|
||||
for g, e in zip(got, expected):
|
||||
assert g.dtype == np.dtype(">f8")
|
||||
np.testing.assert_array_equal(g, e)
|
||||
np.testing.assert_array_equal(ds[1], [2.5])
|
||||
|
||||
|
||||
def test_errors_match_h5py(h5py, pair):
|
||||
ours, theirs, _ = pair
|
||||
for name in ["num/le_i4_1d", "num/be_f8_2d_gzip", "str/vlen", "num/scalar_f8"]:
|
||||
for key in ERROR_KEYS_1D:
|
||||
try:
|
||||
theirs[name][key]
|
||||
except Exception as e: # noqa: BLE001
|
||||
with pytest.raises(type(e)):
|
||||
ours[name][key]
|
||||
else:
|
||||
pass # valid for this shape; covered by the value test
|
||||
|
||||
|
||||
def test_compound_fields_match_h5py(pair):
|
||||
ours, theirs, _ = pair
|
||||
for name in ["cmp/padded", "cmp/padded_chunked", "cmp/nested_2d"]:
|
||||
t, o = theirs[name], ours[name]
|
||||
for field in t.dtype.names:
|
||||
assert_same(o[field], t[field], f"{name}[{field}]")
|
||||
assert_same(o[field, 1:3], t[field, 1:3], f"{name}[{field}, 1:3]")
|
||||
two = list(t.dtype.names[:2])
|
||||
expected = t[tuple(two)]
|
||||
got = o[tuple(two)]
|
||||
assert got.dtype.names == expected.dtype.names
|
||||
for field in two:
|
||||
np.testing.assert_array_equal(got[field], expected[field])
|
||||
with pytest.raises(ValueError):
|
||||
ours["cmp/padded"]["no_such_field"]
|
||||
with pytest.raises(ValueError):
|
||||
ours["num/le_i4_1d"]["id"]
|
||||
|
||||
|
||||
def test_numpy_asarray_and_len(pair):
|
||||
ours, theirs, _ = pair
|
||||
assert_same(np.asarray(ours["num/f8_3d"]), np.asarray(theirs["num/f8_3d"]))
|
||||
assert len(ours["num/f8_3d"]) == len(theirs["num/f8_3d"])
|
||||
with pytest.raises(TypeError):
|
||||
len(ours["num/scalar_f8"])
|
||||
|
||||
|
||||
def test_attributes_match_h5py(h5py, pair):
|
||||
ours, theirs, _ = pair
|
||||
for path in ["/", "num/le_f8_1d", "deep/er/est"]:
|
||||
t = theirs[path].attrs
|
||||
o = ours[path].attrs
|
||||
assert list(o.keys()) == sorted(t.keys()), path
|
||||
assert len(o) == len(t)
|
||||
for k in t.keys():
|
||||
assert k in o
|
||||
assert_same(o[k], t[k], f"{path}.attrs[{k}]")
|
||||
assert o.get("missing", 5) == 5
|
||||
with pytest.raises(KeyError):
|
||||
o["missing"]
|
||||
assert [k for k, _ in o.items()] == list(o.keys())
|
||||
|
||||
|
||||
def test_groups_match_h5py(h5py, pair):
|
||||
ours, theirs, _ = pair
|
||||
assert list(ours.keys()) == list(theirs.keys())
|
||||
assert len(ours) == len(theirs)
|
||||
for path in ["num", "deep", "deep/er", "deep/er/est", "empty_group"]:
|
||||
o, t = ours[path], theirs[path]
|
||||
assert isinstance(o, clawhdf5.Group)
|
||||
assert list(o.keys()) == list(t.keys()), path
|
||||
assert list(o) == list(t), path
|
||||
assert len(o) == len(t), path
|
||||
assert o.name == t.name
|
||||
g = ours["deep/er"]
|
||||
assert isinstance(g["est"], clawhdf5.Group)
|
||||
assert isinstance(g["est/leaf"], clawhdf5.Dataset)
|
||||
assert g["/deep/er/est/leaf"].name == "/deep/er/est/leaf"
|
||||
assert "est/leaf" in g and "/num" in g and "nope" not in g
|
||||
assert ours["/"].name == "/"
|
||||
assert "deep/er/est/leaf" in ours
|
||||
assert ours.get("nope") is None
|
||||
with pytest.raises(KeyError):
|
||||
ours["deep/nope"]
|
||||
names = [k for k, v in ours["deep/er/est"].items()]
|
||||
assert names == ["leaf"]
|
||||
assert isinstance(ours["deep/er/est"].values()[0], clawhdf5.Dataset)
|
||||
|
||||
|
||||
def test_unsupported_types_are_errors_not_data(pair):
|
||||
ours, _, _ = pair
|
||||
ds = ours["unsupported/refs"] # opening works
|
||||
with pytest.raises(TypeError):
|
||||
ds.dtype
|
||||
with pytest.raises(TypeError):
|
||||
ds[()]
|
||||
with pytest.raises(TypeError):
|
||||
ours["cmp/with_vlen"][()]
|
||||
|
||||
|
||||
def test_boolean_masks_are_refused(pair):
|
||||
ours, _, _ = pair
|
||||
with pytest.raises(TypeError):
|
||||
ours["num/le_i4_1d"][np.ones(37, dtype=bool)]
|
||||
|
||||
|
||||
def test_reads_hand_numpy_the_rust_buffer(pair):
|
||||
"""A fixed-size read is a view over the buffer the library filled, not a
|
||||
copy of it."""
|
||||
ours, _, _ = pair
|
||||
arr = ours["num/le_f8_2d_gzip"][2:9, 1:4]
|
||||
assert not arr.flags.owndata
|
||||
assert arr.base is not None
|
||||
assert arr.flags.aligned and arr.flags.c_contiguous
|
||||
|
||||
|
||||
def test_only_the_selected_chunks_are_read(h5py, tmp_path):
|
||||
"""Damage one chunk: a selection that avoids it still reads, one that
|
||||
touches it fails. Reading everything and slicing afterwards (what the
|
||||
bindings did before) failed both. (The library reads the whole dataset
|
||||
anyway when a selection's bounding box covers more than half of it, so
|
||||
the selections here stay below that.)"""
|
||||
path = str(tmp_path / "damaged.h5")
|
||||
data = np.arange(1000, dtype="<f8")
|
||||
with h5py.File(path, "w") as f:
|
||||
f.create_dataset("d", data=data, chunks=(100,), compression="gzip")
|
||||
with h5py.File(path, "r") as f:
|
||||
info = f["d"].id.get_chunk_info(9) # the last chunk
|
||||
with open(path, "r+b") as fh:
|
||||
fh.seek(info.byte_offset)
|
||||
fh.write(b"\xff" * info.size)
|
||||
with clawhdf5.File(path, "r") as f:
|
||||
ds = f["d"]
|
||||
np.testing.assert_array_equal(ds[0:400], data[0:400])
|
||||
np.testing.assert_array_equal(ds[805:900], data[805:900])
|
||||
np.testing.assert_array_equal(ds[5:450:7], data[5:450:7])
|
||||
np.testing.assert_array_equal(ds[[3, 450, 899]], data[[3, 450, 899]])
|
||||
with pytest.raises(Exception):
|
||||
ds[950]
|
||||
with pytest.raises(Exception):
|
||||
ds[:]
|
||||
|
||||
|
||||
def test_threads_read_the_same_file(pair):
|
||||
"""Reads from many threads at once (the GIL is released during each
|
||||
read) return exactly what h5py returns."""
|
||||
ours, theirs, _ = pair
|
||||
names = ["num/le_f8_2d_gzip", "num/i4_3d_shuffle", "str/vlen_2d_gzip", "cmp/padded_chunked", "num/be_i8_1d"]
|
||||
expected = {n: theirs[n][()] for n in names}
|
||||
errors = []
|
||||
barrier = threading.Barrier(8)
|
||||
|
||||
def work(i):
|
||||
barrier.wait()
|
||||
for r in range(40):
|
||||
n = names[(i + r) % len(names)]
|
||||
got = ours[n][()]
|
||||
try:
|
||||
assert_same(got, expected[n], n)
|
||||
except AssertionError as e:
|
||||
errors.append(e)
|
||||
|
||||
with ThreadPoolExecutor(8) as pool:
|
||||
list(pool.map(work, range(8)))
|
||||
assert not errors, errors[:3]
|
||||
@@ -87,7 +87,7 @@ def test_dataset_dtype(sample_read_file):
|
||||
def test_read_root_attrs(sample_read_file):
|
||||
with clawhdf5.File(sample_read_file, "r") as f:
|
||||
assert f.attrs["version"] == 1
|
||||
assert f.attrs["description"] == "test file"
|
||||
assert f.attrs["description"] == b"test file" # fixed-length string: numpy.bytes_, as in h5py
|
||||
|
||||
|
||||
def test_attrs_len(sample_read_file):
|
||||
@@ -132,7 +132,7 @@ def test_read_group_dataset(grouped_read_file):
|
||||
def test_read_group_attrs(grouped_read_file):
|
||||
with clawhdf5.File(grouped_read_file, "r") as f:
|
||||
grp = f["sensors"]
|
||||
assert grp.attrs["location"] == "lab"
|
||||
assert grp.attrs["location"] == b"lab"
|
||||
|
||||
|
||||
def test_nested_path_access(grouped_read_file):
|
||||
@@ -176,7 +176,7 @@ def test_write_with_attrs(tmp_h5):
|
||||
f.attrs["author"] = "test"
|
||||
f.attrs["count"] = 42
|
||||
with clawhdf5.File(tmp_h5, "r") as f:
|
||||
assert f.attrs["author"] == "test"
|
||||
assert f.attrs["author"] == b"test"
|
||||
assert f.attrs["count"] == 42
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user