Files
clawhdf5/examples/wasm-viewer/test/make_fixture.py
T
osobhandClaude Opus 5.5 a42b646689 feat(wasm): clawhdf5-wasm, the reader for JavaScript via wasm-bindgen
open(bytes) -> H5File with kind/list/info/attrs/attrErrors/read/
readHyperslab. Numeric data comes back in the typed array of the
stored width (Int16Array for i16, BigInt64Array for i64, Float32Array
for f32/f16, ...), strings and enum names as string arrays, array
datatypes flattened with their dims appended to the shape. Compound,
reference, opaque and VL-sequence datasets are refused with an error
naming the type; nothing is returned as reinterpreted bytes.

The logic is in a plain-Rust core module, tested natively: unit tests,
and h5py_interop, which compares every dataset, hyperslab, listing and
attribute of an h5py- and a netCDF4-written file with what libhdf5
reads back (generator shared with the Node test of the built package).

No mmap, no threads; lz4 is on, zstd/szip (C) are not. A
wasm-release profile (opt-level s, LTO) serves the browser build.
ci-test.sh lints the crate for wasm32 and checks it builds no C.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:00:46 -05:00

185 lines
7.2 KiB
Python

"""Write HDF5 and NetCDF-4 test files with h5py/netCDF4, and what libhdf5
reads back from them, for the clawhdf5-wasm tests.
python make_fixture.py OUT_DIR
writes OUT_DIR/fixture.h5, OUT_DIR/fixture.nc and OUT_DIR/expected.json.
Both the Rust test (crates/clawhdf5-wasm/tests/h5py_interop.rs, native) and
the Node test (test.mjs, the built wasm package) compare against the same
expected.json, so the two check the same values.
Every expected value comes from h5py reading the file back (numpy slicing
for hyperslabs), never from the arrays that were written. Integers are
encoded as strings so JSON.parse keeps 64-bit values exact.
"""
import json
import sys
import warnings
from pathlib import Path
import h5py
import netCDF4
import numpy as np
# netCDF4 1.7 trips numpy 2.5's shape-setting deprecation on assignment.
warnings.filterwarnings("ignore", category=DeprecationWarning)
out = Path(sys.argv[1])
out.mkdir(parents=True, exist_ok=True)
h5 = out / "fixture.h5"
nc = out / "fixture.nc"
rng = np.random.default_rng(7)
with h5py.File(h5, "w") as f:
f.attrs["title"] = "wasm fixture"
f.attrs["version"] = np.int64(3)
f.attrs["scale"] = np.array([0.5, 2.0])
f.attrs["big"] = np.uint64(2**63 + 5)
f.attrs.create("vlen_note", "héllo", dtype=h5py.string_dtype())
f.create_dataset(
"grid", data=np.arange(60, dtype="<f8").reshape(6, 10) / 4,
chunks=(4, 3), compression="gzip", shuffle=True,
)
f.create_dataset("f32_be", data=rng.standard_normal(7).astype(">f4"))
f.create_dataset("f16", data=np.array([0.5, -1.25, 65504], dtype="<f2"))
f.create_dataset("i8", data=np.array([-128, -1, 0, 127], dtype="i1"))
f.create_dataset("i16_be", data=np.array([-32768, 5, 32767], dtype=">i2"))
f.create_dataset("u16", data=np.array([0, 40000, 65535], dtype="<u2"))
f.create_dataset("u32", data=np.array([0, 4_000_000_000], dtype="<u4"))
f.create_dataset("i64", data=np.array([-(2**63), 2**53 + 1, 7], dtype="<i8"))
f.create_dataset("u64", data=np.array([2**64 - 1, 1], dtype="<u8"))
f.create_dataset("scalar", data=np.float64(2.5))
f.create_dataset("fixed_str", data=np.array([b"alpha", b"be", b""], dtype="S5"))
f.create_dataset(
"vlen_str", data=["one", "двa", ""], dtype=h5py.string_dtype()
)
f.create_dataset("flags", data=np.array([True, False, True]))
# An array datatype: four elements, each an i4[2].
pairs = f.create_dataset("pairs", shape=(4,), dtype=np.dtype(("<i4", (2,))))
pairs[...] = np.arange(8, dtype="<i4").reshape(4, 2)
f.create_dataset(
"cube", data=np.arange(2 * 5 * 6, dtype="<i4").reshape(2, 5, 6),
chunks=(1, 2, 3), compression="gzip",
)
comp = np.zeros(2, dtype=[("x", "<f8"), ("n", "<i4")])
f.create_dataset("table", data=comp)
g = f.create_group("sensors")
g.attrs["location"] = "lab"
g.create_dataset("temp", data=np.array([21.5, 22.0, 22.25], dtype="<f4"))
g.create_group("empty")
f["alias"] = h5py.SoftLink("/sensors/temp")
with netCDF4.Dataset(nc, "w") as d:
d.title = "nc fixture"
d.createDimension("time", None)
d.createDimension("x", 4)
t = d.createVariable("time", "f8", ("time",))
t.units = "days since 2000-01-01"
v = d.createVariable("temp", "f4", ("time", "x"), zlib=True)
t[:] = np.arange(3)
v[:] = np.arange(12, dtype="f4").reshape(3, 4) + 0.5
def kind(dt):
"""The typed-array kind clawhdf5-wasm returns for a numpy dtype."""
if dt.kind == "b" or h5py.check_enum_dtype(dt) is not None:
return "strings"
if h5py.check_string_dtype(dt) is not None or dt.kind == "S":
return "strings"
if dt.subdtype is not None:
return kind(dt.subdtype[0])
if dt.kind == "f":
return "f64" if dt.itemsize == 8 else "f32"
if dt.kind in "iu":
return f"{dt.kind}{dt.itemsize * 8}"
raise ValueError(dt)
def flat(a, k):
a = np.asarray(a)
if k == "strings":
if a.dtype.kind == "b":
return ["TRUE" if x else "FALSE" for x in a.ravel()]
return [x.decode() if isinstance(x, bytes) else str(x) for x in a.ravel()]
if k.startswith(("i", "u")):
return [str(int(x)) for x in a.ravel()]
return [float(x) for x in a.ravel()]
def entry(ds, slab=None):
k = kind(ds.dtype)
data = ds[()]
e = {"kind": k, "shape": list(np.shape(data)), "values": flat(data, k)}
if slab:
start, count, stride = slab
idx = tuple(slice(s, s + (c - 1) * st + 1, st)
for s, c, st in zip(start, count, stride))
part = ds[idx]
e["slab"] = {"start": start, "count": count, "stride": stride,
"shape": list(part.shape), "values": flat(part, k)}
return e
def attr(v):
v = np.asarray(v) if not isinstance(v, (str, bytes)) else v
if isinstance(v, bytes):
return {"string": v.decode()}
if isinstance(v, str):
return {"string": v}
if v.dtype.kind in "iu":
return {"int": [str(int(x)) for x in v.ravel()], "scalar": v.ndim == 0}
if v.dtype.kind == "f":
return {"float": [float(x) for x in v.ravel()], "scalar": v.ndim == 0}
if v.dtype.kind in "OSU":
items = [x.decode() if isinstance(x, bytes) else str(x) for x in v.ravel()]
return {"string": items[0]} if v.ndim == 0 else {"strings": items}
raise ValueError(v.dtype)
# Attributes the reader returns but the comparison leaves out: netCDF-4's
# internal ones (a leading underscore), and dimension-scale bookkeeping.
SKIP_ATTRS = ["DIMENSION_LIST", "REFERENCE_LIST", "CLASS", "NAME"]
def describe(path):
expected = {"datasets": {}, "errors": {}, "attrs": {}, "lists": {},
"skip_attrs": SKIP_ATTRS}
with h5py.File(path, "r") as f:
def walk(key, obj):
expected["attrs"][key] = {
k: attr(obj.attrs[k]) for k in obj.attrs
if not k.startswith("_") and k not in SKIP_ATTRS}
if isinstance(obj, h5py.Group):
members = {n: obj.get(n) for n in obj}
groups = [n for n, o in members.items() if isinstance(o, h5py.Group)]
sets = [n for n, o in members.items() if isinstance(o, h5py.Dataset)]
expected["lists"][key] = {"groups": sorted(groups),
"datasets": sorted(sets)}
for n, o in members.items():
walk(key.rstrip("/") + "/" + n, o)
elif obj.dtype.names:
expected["errors"][key] = "compound"
else:
expected["datasets"][key] = entry(obj, slab_for(obj))
walk("/", f)
return expected
def slab_for(obj):
"""A strided hyperslab inside the dataset's extent, or None."""
if obj.ndim == 0 or obj.shape[0] < 2 or 0 in obj.shape:
return None
start = [1] + [0] * (obj.ndim - 1)
count = [max(1, (obj.shape[0] - 1) // 2)] + [
max(1, (n + 1) // 2) for n in obj.shape[1:]]
stride = [2 if obj.shape[0] > 2 else 1] + [2] * (obj.ndim - 1)
count = [min(c, (n - s - 1) // st + 1)
for c, s, st, n in zip(count, start, stride, obj.shape)]
return (start, count, stride)
json.dump({"fixture.h5": describe(h5), "fixture.nc": describe(nc)},
open(out / "expected.json", "w"), indent=1, ensure_ascii=False)