feat: attrs() reports every attribute; unsigned arrays stay unsigned

attrs() silently omitted any attribute whose datatype had no AttrValue variant
— including every Python bool, which h5py stores as an enum — plus complex,
compound and reference attributes, and cast unsigned 64-bit arrays to
I64Array so values above i64::MAX came back negative.

- numpy/h5py-style booleans (an enum of exactly FALSE=0 / TRUE=1 over an
  integer base) decode as I64 / I64Array of 0/1.
- AttrValue::U64Array keeps unsigned arrays unsigned. Behaviour change: an
  unsigned array attribute no longer arrives as I64Array; the netCDF-4 CF
  helpers (_FillValue, valid_range) and the Python bindings handle it.
- AttrValue::Raw { datatype, shape, data } carries any other attribute
  verbatim (also used when a value fails to decode as its declared type), so
  the attribute list is always complete. Decodable with data_read against the
  datatype; Python receives {"dtype", "shape", "data"}.
- Both new variants are writable, so attributes round-trip between files.
  h5py interop tests cover reading 13 attribute kinds and h5py reading back a
  compound and a u64 attribute written by clawhdf5.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
osobh
2026-09-19 07:05:34 -07:00
co-authored by Claude Fable 5.1
parent 5dd95a6cf8
commit 97ab658c11
7 changed files with 281 additions and 16 deletions
+52 -9
View File
@@ -163,13 +163,53 @@ pub(crate) fn attrs_to_map(
) -> HashMap<String, AttrValue> {
let mut map = HashMap::new();
for attr in attrs {
if let Some(val) = decode_attr_value(attr, file_data, offset_size, length_size) {
map.insert(attr.name.clone(), val);
}
// Every attribute is reported. One that has no dedicated `AttrValue`
// variant, or that fails to decode as its declared type, is returned
// verbatim as `AttrValue::Raw` rather than dropped — a partial
// attribute list with no indication anything is missing is worse than
// an undecoded value.
let val =
decode_attr_value(attr, file_data, offset_size, length_size).unwrap_or_else(|| {
AttrValue::Raw {
datatype: attr.datatype.clone(),
shape: attr.dataspace.dimensions.clone(),
data: attr.raw_data.clone(),
}
});
map.insert(attr.name.clone(), val);
}
map
}
/// `Some(values)` if `attr` is a numpy/h5py-style boolean: an enumeration over
/// an integer base type whose members are exactly `FALSE` = 0 and `TRUE` = 1.
/// This is how `attrs["flag"] = True` is stored. Reported as 0/1 integers.
fn decode_bool_enum(attr: &clawhdf5_format::attribute::AttributeMessage) -> Option<Vec<i64>> {
use clawhdf5_format::datatype::Datatype;
let Datatype::Enumeration {
base_type, members, ..
} = &attr.datatype
else {
return None;
};
if members.len() != 2 {
return None;
}
let member_value = |name: &str| {
let m = members.iter().find(|m| m.name.eq_ignore_ascii_case(name))?;
clawhdf5_format::data_read::read_as_i64(&m.value, base_type)
.ok()?
.first()
.copied()
};
if member_value("FALSE")? != 0 || member_value("TRUE")? != 1 {
return None;
}
let values = clawhdf5_format::data_read::read_as_i64(&attr.raw_data, base_type).ok()?;
values.iter().all(|v| *v == 0 || *v == 1).then_some(values)
}
fn decode_attr_value(
attr: &clawhdf5_format::attribute::AttributeMessage,
file_data: &[u8],
@@ -200,12 +240,7 @@ fn decode_attr_value(
if vals.len() == 1 {
Some(AttrValue::U64(vals[0]))
} else {
// No U64Array variant, store as I64Array.
// NOTE: This cast is lossy for values > i64::MAX (bit 63 set).
// Those values will appear as negative i64. A dedicated U64Array
// variant would be needed to handle the full u64 range.
let i64_vals: Vec<i64> = vals.iter().map(|&v| v as i64).collect();
Some(AttrValue::I64Array(i64_vals))
Some(AttrValue::U64Array(vals))
}
}
Datatype::String { .. } => {
@@ -228,6 +263,14 @@ fn decode_attr_value(
Some(AttrValue::StringArray(strings))
}
}
Datatype::Enumeration { .. } => {
let vals = decode_bool_enum(attr)?;
if vals.len() == 1 {
Some(AttrValue::I64(vals[0]))
} else {
Some(AttrValue::I64Array(vals))
}
}
_ => None,
}
}
+129
View File
@@ -784,3 +784,132 @@ with h5py.File("links_{tag}.h5", "w"{kwargs}) as f:
);
}
}
// ---------------------------------------------------------------------------
// Attribute fidelity: nothing is dropped, unsigned stays unsigned
// ---------------------------------------------------------------------------
/// `attrs()` used to omit, without any error, every attribute whose datatype
/// had no `AttrValue` variant — including every Python `bool` (stored as an
/// enum) — and to wrap unsigned 64-bit arrays into negative `i64`s.
#[test]
fn h5py_attribute_kinds_are_all_reported() {
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("attrs_kinds.h5");
let path_str = path.display().to_string();
let script = format!(
r#"
import h5py, numpy as np
with h5py.File("{path_str}", "w") as f:
d = f.create_dataset("d", data=np.arange(3))
a = d.attrs
a["float"] = 1.5; a["int"] = 7; a["str"] = "hello"; a["int_array"] = np.arange(4)
a["str_list"] = ["a", "bc"]
a["uint64_array"] = np.array([2**63, 1], dtype="u8")
a["bool_true"] = True
a["bool_false"] = False
a["bool_array"] = np.array([True, False, True])
a["complex"] = 1 + 2j
a["compound"] = np.array([(1, 2.5)], dtype=[("a", "<i4"), ("b", "<f8")])
a["object_ref"] = d.ref
a["color"] = np.array(2, dtype=h5py.enum_dtype({{"RED": 0, "GREEN": 1, "BLUE": 2}}, basetype="i1"))
print(len(a))
"#
);
let written: usize = run_python_output(&script).trim().parse().unwrap();
let file = File::open(&path).unwrap();
let attrs = file.dataset("d").unwrap().attrs().unwrap();
assert_eq!(
attrs.len(),
written,
"every attribute is reported: {attrs:?}"
);
// Booleans decode as 0/1.
assert!(matches!(attrs["bool_true"], AttrValue::I64(1)));
assert!(matches!(attrs["bool_false"], AttrValue::I64(0)));
assert!(matches!(&attrs["bool_array"], AttrValue::I64Array(v) if v == &[1, 0, 1]));
// Unsigned stays unsigned.
assert!(matches!(&attrs["uint64_array"], AttrValue::U64Array(v) if v == &[1u64 << 63, 1]));
// A general enum is not a boolean: kept verbatim, member names intact.
match &attrs["color"] {
AttrValue::Raw { datatype, data, .. } => {
assert_eq!(data, &[2]);
assert!(format!("{datatype:?}").contains("BLUE"));
}
other => panic!("color: {other:?}"),
}
// A compound attribute is kept verbatim and decodes against its datatype.
match &attrs["compound"] {
AttrValue::Raw {
datatype,
shape,
data,
} => {
assert_eq!(shape, &[1]);
let fields = clawhdf5_format::data_read::read_compound_fields(data, datatype).unwrap();
assert_eq!(fields[0].name, "a");
let b =
clawhdf5_format::data_read::read_as_f64(&fields[1].raw_data, &fields[1].datatype)
.unwrap();
assert_eq!(b, vec![2.5]);
}
other => panic!("compound: {other:?}"),
}
for name in ["complex", "object_ref"] {
assert!(
matches!(attrs[name], AttrValue::Raw { .. }),
"{name}: {:?}",
attrs[name]
);
}
}
/// `Raw` and `U64Array` are writable, so an attribute read from one file can
/// be stored in another unchanged.
#[test]
fn clawhdf5_writes_raw_and_unsigned_attrs_h5py_reads() {
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src.h5");
let dst = dir.path().join("dst.h5");
let (src_str, dst_str) = (src.display().to_string(), dst.display().to_string());
run_python(&format!(
r#"
import h5py, numpy as np
with h5py.File("{src_str}", "w") as f:
d = f.create_dataset("d", data=np.arange(3))
d.attrs["compound"] = np.array([(1, 2.5), (3, 4.5)], dtype=[("a", "<i4"), ("b", "<f8")])
"#
));
let compound = File::open(&src)
.unwrap()
.dataset("d")
.unwrap()
.attrs()
.unwrap()["compound"]
.clone();
let mut builder = FileBuilder::new();
let ds = builder.create_dataset("d");
ds.with_f64_data(&[1.0]);
ds.set_attr("compound", compound);
ds.set_attr("big", AttrValue::U64Array(vec![u64::MAX, 0, 1 << 63]));
builder.write(&dst).unwrap();
let out = run_python_output(&format!(
r#"
import h5py
with h5py.File("{dst_str}", "r") as f:
a = f["d"].attrs
print(a["compound"].tolist(), a["compound"].dtype.names, a["big"].tolist(), a["big"].dtype)
"#
));
assert_eq!(
out.trim(),
"[(1, 2.5), (3, 4.5)] ('a', 'b') [18446744073709551615, 0, 9223372036854775808] uint64"
);
}