clawhdf5: set_attr adds the Attribute Info message a version-2 header needs

libhdf5 counts a version-2 object header's attributes through its Attribute
Info message (0x15) and reports none when the header has none. set_attr gave
v110/latest groups, the root group and datasets without attributes an
attribute message only, so h5py listed the attribute but len(obj.attrs) and
H5Oget_info's num_attrs said 0, and stayed wrong after h5py r+ added more.

Like H5O__attr_create, the edit now adds the message when a version-2 header
lacks it, in the same planned edit: version 0, the header's creation-order
track/index flags, maximum creation index 0, undefined fractal heap and
B-tree addresses, message flag DONTSHARE — byte for byte what libhdf5
writes. It goes before the attribute (libhdf5's order) when free space
holds both, else after it, so a continuation chunk made for the attribute
also takes it.

Test: edit_interop attribute_count_in_version_2_headers — v110 and latest
files, attributes set on the root group, groups and datasets with and
without existing attributes: h5py's len/num_attrs/list/values, h5dump -A
and our reader agree, also after h5py r+ adds attributes up to and past the
compact limit. Fails on the previous editor (h5py len 0).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 14:12:35 -05:00
co-authored by Claude Opus 5.5
parent f7e2ab12f2
commit 485bea0f4f
4 changed files with 136 additions and 1 deletions
@@ -1315,3 +1315,90 @@ fn optional_filters_that_fail_are_skipped() {
assert!(o.status.success(), "h5rs check --data {p}:\n{}", text(&o));
}
}
/// libhdf5 counts a version-2 object header's attributes through its
/// Attribute Info message and reports none without one. The editor adds
/// that message, as `H5O__attr_create` does, when it gives a version-2
/// header (h5py `libver='v110'`/`'latest'`) its first attribute: afterwards
/// h5py lists, counts and reads every attribute, new and existing, h5dump
/// agrees, and h5py `r+` can add more (past the compact limit, into dense
/// storage) with the count still right.
#[test]
fn attribute_count_in_version_2_headers() {
if !tools_ok() {
return;
}
for (lv, dump) in [("'v110'", true), ("'latest'", false)] {
let dir = tmpdir();
let path = dir.path().join("acount.h5");
let p = path.to_str().unwrap();
py(&format!(
"import h5py, numpy as np\n\
with h5py.File({p:?}, 'w', libver={lv}) as f:\n\
\x20 f.create_group('g')\n\
\x20 f.create_group('has').attrs['old'] = 7\n\
\x20 f.create_dataset('d', data=np.arange(3, dtype='<i4'))\n\
\x20 f.create_dataset('e', data=np.arange(3, dtype='<i4')).attrs['old'] = 7\n"
));
let mut ed = FileEditor::open(&path).unwrap();
for obj in ["/", "g", "has", "d", "e"] {
for i in 0..3 {
ed.set_attr(obj, &format!("a{i}"), &AttrValue::I64(i))
.unwrap();
}
// Replacing one keeps the count.
ed.set_attr(obj, "a1", &AttrValue::F64(1.5)).unwrap();
}
drop(ed);
check_tools(&path, dump);
let check = |extra: usize| {
py(&format!(
"import h5py\n\
f = h5py.File({p:?}, 'r')\n\
for o in ['/', 'g', 'has', 'd', 'e']:\n\
\x20 a = f[o].attrs\n\
\x20 want = {{'a0': 0, 'a1': 1.5, 'a2': 2}}\n\
\x20 if o in ('has', 'e'): want['old'] = 7\n\
\x20 for i in range({extra}): want[f'h{{i}}'] = i\n\
\x20 assert len(a) == len(want), (o, len(a), list(a))\n\
\x20 assert h5py.h5o.get_info(f[o].id).num_attrs == len(want), o\n\
\x20 assert sorted(a) == sorted(want), (o, list(a))\n\
\x20 assert {{k: a[k] for k in a}} == want, (o, dict(a))\n"
));
let f = File::open(&path).unwrap();
for (o, n) in [("/", 3), ("g", 3), ("has", 4), ("d", 3), ("e", 4)] {
let attrs = match o {
"/" => f.root().attrs().unwrap(),
"d" | "e" => f.dataset(o).unwrap().attrs().unwrap(),
_ => f.group(o).unwrap().attrs().unwrap(),
};
assert_eq!(attrs.len(), n + extra, "{o}");
assert!(matches!(attrs.get("a1"), Some(AttrValue::F64(x)) if *x == 1.5));
}
if dump {
// Every object's attributes: 3 set by the editor on each of
// the five, 2 existing, `extra` from h5py on each.
let out = Command::new("h5dump").args(["-A", p]).output().unwrap();
assert!(out.status.success(), "h5dump -A:\n{}", text(&out));
let s = String::from_utf8_lossy(&out.stdout);
assert_eq!(
s.matches("ATTRIBUTE \"").count(),
5 * (3 + extra) + 2,
"h5dump -A:\n{s}"
);
}
};
check(0);
// libhdf5 adds more: to 7 or 8 (compact), then past its limit.
for extra in [4usize, 10] {
py(&format!(
"import h5py\n\
with h5py.File({p:?}, 'r+') as f:\n\
\x20 for o in ['/', 'g', 'has', 'd', 'e']:\n\
\x20 for i in range({extra}): f[o].attrs[f'h{{i}}'] = i\n"
));
check(extra);
check_tools(&path, dump);
}
}
}