h5rs tools, browser reader, libhdf5 header checks, plugin filters, concurrency benchmark #14

Merged
osobh merged 60 commits from feat/p1-proof into main 2026-09-26 13:14:39 +00:00
5 changed files with 124 additions and 21 deletions
Showing only changes of commit c4d96c1390 - Show all commits
+4 -2
View File
@@ -123,8 +123,10 @@
- `h5rs dump [--json] [-A] [-p] [-d PATH] FILE` prints DDL text that is
byte-identical to h5dump 1.14.6's on the test files (all layouts and
chunk indexes, v1/v2 groups, compound, enum, strings, links, named
types, attributes), or JSON in the HDF Group's hdf5-json layout (schema
in the crate README).
types, attributes; null-padded strings show their NULs at any depth),
or JSON in the HDF Group's hdf5-json layout (schema in the crate
README). Nested compounds print inline and `long double` values as
errors (exit 1); both are listed in the README.
- `h5rs stat FILE` reports h5stat's object, link, rank, layout, filter,
attribute, raw-data and file-size figures (equal to h5stat's on the test
files); metadata space is one figure, not broken down.
+16 -4
View File
@@ -72,10 +72,22 @@ groups; integers of both byte orders, floats, compound with an array member,
enum, fixed and variable-length strings; soft, external and hard links; a
named datatype; compact and dense attributes) `h5rs dump` and `h5rs dump -A`
print the same bytes as h5dump 1.14.6 — `dump_matches_h5dump` in
`tests/h5rs_interop.rs` checks this. Not covered by that test: references,
opaque, bitfield, variable-length sequences and virtual datasets. Floats print at their own precision (a `float32` 0.1 prints as
`0.1`), which for some values is more digits than h5dump's `%g`. The `-p`
block is h5rs's own (it names the chunk index), not h5dump's.
`tests/h5rs_interop.rs` checks this, and `dump_shows_nul_padding_in_nested_strings`
that null-padded strings show their NULs (`"a\000b"`) at any depth, as
h5dump's do. Not covered by those tests: references, opaque, bitfield,
variable-length sequences and virtual datasets. Known differences from
h5dump:
- Floats print at their own precision (a `float32` 0.1 prints as `0.1`),
which for some values is more digits than h5dump's `%g`.
- A compound nested in a compound prints inline (`{ 1, 2.5 }`) where
h5dump prints it as an indented block, one member per line; only the
outer compound is a block.
- `long double` (x87 80-bit) and other floats wider than 64 bits: the
datatype is printed as an `H5T_FLOAT { ... }` block instead of h5dump's
one-line description, and each value as `<error: ...>`; `dump` then
exits 1. The library cannot convert them (see `docs/known-issues.md`).
- The `-p` block is h5rs's own (it names the chunk index), not h5dump's.
### JSON schema
+73 -15
View File
@@ -518,7 +518,9 @@ impl Dump<'_> {
errors += 1;
}
let comma = if i + 1 < n { "," } else { "" };
if let Value::Compound(members) = &v {
let esize = dt.type_size() as usize;
let eb = raw.get(i * esize..(i + 1) * esize).unwrap_or_default();
if let (Value::Compound(_), Datatype::Compound { members, .. }) = (&v, dt) {
// h5dump prints each compound element as a block, one
// member per line.
if !line.is_empty() {
@@ -526,25 +528,22 @@ impl Dump<'_> {
line.clear();
}
writeln!(out.o, "{pad}({}): {{", index_text(i as u64, &dims))?;
for (k, (_, m)) in members.iter().enumerate() {
for (k, m) in members.iter().enumerate() {
let sep = if k + 1 < members.len() { "," } else { "" };
writeln!(out.o, "{pad} {}{sep}", value::text(m, &paths))?;
let mb = usize::try_from(m.byte_offset)
.ok()
.and_then(|o| eb.get(o..))
.unwrap_or_default();
let t = ddl_text(&dec, &m.datatype, mb, &paths, 1);
writeln!(out.o, "{pad} {t}{sep}")?;
}
writeln!(out.o, "{pad} }}{comma}")?;
continue;
}
let t = match dt {
// h5dump shows a null-padded string's padding.
Datatype::String {
padding: StringPadding::NullPad,
size,
..
} if !matches!(v, Value::Error(_)) => {
let sz = *size as usize;
let b = raw.get(i * sz..(i + 1) * sz).unwrap_or_default();
format!("{}{comma}", value::quote_bytes(b))
}
_ => format!("{}{comma}", value::text(&v, &paths)),
let t = if matches!(v, Value::Error(_)) {
format!("{}{comma}", value::text(&v, &paths))
} else {
format!("{}{comma}", ddl_text(&dec, dt, eb, &paths, 0))
};
let row_start = i % last == 0;
if row_start || line.len() + 1 + t.len() > 77 {
@@ -863,6 +862,65 @@ const MAX_DDL_DEPTH: usize = 256;
const JSON_BYTES_PER_ELEMENT: u64 = 64;
/// `(i,j,k)` index of flat element `i` of an array with `dims`.
/// One element as h5dump prints it in a DATA block: [`value::text`], except
/// that a null-padded fixed string shows its padding (every byte, NULs as
/// `\000`) at any depth, in compound members and array elements too.
fn ddl_text(
dec: &Decoder,
dt: &Datatype,
b: &[u8],
paths: &dyn Fn(u64) -> Option<String>,
depth: u32,
) -> String {
let err = || value::text(&Value::Error("short element".into()), paths);
if depth > 32 {
return value::text(&dec.decode(dt, b, depth), paths);
}
match dt {
Datatype::String {
padding: StringPadding::NullPad,
size,
..
} => match b.get(..*size as usize) {
Some(s) => value::quote_bytes(s),
None => err(),
},
Datatype::Compound { members, .. } => format!(
"{{ {} }}",
members
.iter()
.map(
|m| match usize::try_from(m.byte_offset).ok().and_then(|o| b.get(o..)) {
Some(mb) => ddl_text(dec, &m.datatype, mb, paths, depth + 1),
None => err(),
}
)
.collect::<Vec<_>>()
.join(", ")
),
Datatype::Array {
base_type,
dimensions,
} => {
let bs = base_type.type_size() as usize;
let n = dimensions
.iter()
.try_fold(1usize, |a, &d| a.checked_mul(d as usize));
match n.filter(|n| n.checked_mul(bs).is_some_and(|t| t <= b.len())) {
Some(n) => format!(
"[ {} ]",
(0..n)
.map(|k| ddl_text(dec, base_type, &b[k * bs..], paths, depth + 1))
.collect::<Vec<_>>()
.join(", ")
),
None => value::text(&dec.decode(dt, b, depth), paths),
}
}
_ => value::text(&dec.decode(dt, b, depth), paths),
}
}
fn index_text(mut i: u64, dims: &[u64]) -> String {
let mut idx = vec![0u64; dims.len()];
for (k, &d) in dims.iter().enumerate().rev() {
+9
View File
@@ -149,6 +149,15 @@ for name, t in (("target1.h5", "/a"), ("target2.h5", "/b")):
f["s"] = h5py.SoftLink(t)
f["rel"] = h5py.SoftLink("a")
# Null-padded fixed strings with NULs, at the top level and nested in a
# compound and in an array member.
with h5py.File(os.path.join(out, "nulstrings.h5"), "w") as f:
f["top"] = np.array([b"", b"ab", b"a\x00b"], dtype="S3")
f["cmp"] = np.array(
[(b"", 1), (b"ab", 2), (b"a\x00b", 3)], dtype=[("s", "S3"), ("i", "i4")]
)
f["arr"] = np.array([([b"", b"x"],)], dtype=[("v", "(2,)S2")])
with h5py.File(os.path.join(out, "userblock.h5"), "w", userblock_size=1024) as f:
f["d"] = np.arange(10)
@@ -280,6 +280,28 @@ fn dump_matches_h5dump() {
);
}
/// A null-padded fixed string prints every byte (NULs as `\000`), as
/// h5dump prints it, also inside a compound and an array member.
#[test]
fn dump_shows_nul_padding_in_nested_strings() {
let Some(f) = generate() else { return };
let p = f.p("nulstrings.h5");
let ours = stdout(&h5rs(&["dump", &p]));
for want in [
r#"(0): "\000\000\000", "ab\000", "a\000b""#,
r#""\000\000\000","#,
r#""a\000b","#,
r#"[ "\000\000", "x\000" ]"#,
] {
assert!(ours.contains(want), "no {want} in\n{ours}");
}
if missing(tool_available("h5dump"), "h5dump") {
return;
}
let reference = run("h5dump", &[&p]);
assert_eq!(ours, stdout(&reference).replacen(&p, "nulstrings.h5", 1));
}
#[test]
fn dump_json_values_match_h5py() {
let Some(f) = generate() else { return };