New clawhdf5::FileEditor opens an HDF5 file (h5py-written at any libver, HDF5 2.0 format included, or clawhdf5-written) under an exclusive flock and changes only what an edit touches: - write_selection/write_all/write_values: compact, contiguous (also late-allocated) and chunked datasets, any selection. Chunks are decoded, updated and re-encoded; a filtered chunk that no longer fits moves to the end of the file unless it is the file's last structure, which grows in place. New chunks go into v1 B-tree, Extensible Array (paged data blocks included), Fixed Array and single-chunk indexes, created on first use. - resize: grow chunked datasets up to maxshape. - set_attr: add/replace compact attributes, in a NIL slot or a new continuation chunk. Each edit is planned in an in-memory image and refused whole (Error::Unsupported) when any part is unsupported (v2 B-tree / implicit new chunks, shrinking, vlen/reference data, dense or order-tracked attributes, cache images, paged/persistent free space). Commit writes and syncs new space before patching existing bytes. Layout v5 (HDF5 2.0) array indexes use 8-byte filtered chunk sizes, as libhdf5 does. Error gains Unsupported/InvalidArgument/Locked and is #[non_exhaustive]; the Python bindings map them. build_attr_message is public. Tests (h5py, h5dump, h5rs check --data after every round; h5py r+ afterwards): appends crossing EA super/data blocks and B-tree splits, the same B-tree node counts and EA statistics as libhdf5 for the same writes (in order, reversed and shuffled; paged blocks), every layout and chunk index overwritten under random selections, attributes to continuation chunks, random operations against a model, refused edits leave the file byte-identical, locking. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
1190 lines
43 KiB
Rust
1190 lines
43 KiB
Rust
//! `clawhdf5::FileEditor` against libhdf5: files written by h5py (default
|
|
//! `libver`, `v114`, and HDF5 2.0's `latest`) and by clawhdf5 are modified
|
|
//! in place — values overwritten, datasets grown and appended to many
|
|
//! times (crossing Extensible Array super-block / data-block boundaries and
|
|
//! version-1 B-tree node splits), attributes added and replaced — and after
|
|
//! each round h5py must read exactly the expected values, h5dump must read
|
|
//! the file, `h5rs check --data` must find nothing, and our reader must
|
|
//! agree. At the end h5py opens the file `r+` and modifies it further.
|
|
//!
|
|
//! Needs python3 with h5py and numpy (`CLAWHDF5_PYTHON`) and h5dump; skips
|
|
//! when they are missing, unless `CLAWHDF5_REQUIRE_INTEROP=1`.
|
|
|
|
use std::path::Path;
|
|
use std::process::{Command, Output};
|
|
|
|
use clawhdf5::{AttrValue, Error, File, FileBuilder, FileEditor, Selection};
|
|
|
|
fn python() -> String {
|
|
std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
|
|
}
|
|
|
|
fn interop_required() -> bool {
|
|
std::env::var("CLAWHDF5_REQUIRE_INTEROP").is_ok_and(|v| v == "1")
|
|
}
|
|
|
|
fn available(cmd: &str, args: &[&str]) -> bool {
|
|
Command::new(cmd)
|
|
.args(args)
|
|
.output()
|
|
.map(|o| o.status.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Whether the tools are here; false (skip) or a panic when interop is
|
|
/// required.
|
|
fn tools_ok() -> bool {
|
|
let ok =
|
|
available(&python(), &["-c", "import h5py, numpy"]) && available("h5dump", &["--version"]);
|
|
if !ok {
|
|
assert!(
|
|
!interop_required(),
|
|
"CLAWHDF5_REQUIRE_INTEROP=1 but h5py/numpy or h5dump is not available"
|
|
);
|
|
eprintln!("SKIP: h5py/numpy or h5dump not available");
|
|
}
|
|
ok
|
|
}
|
|
|
|
fn py(script: &str) -> String {
|
|
let o = Command::new(python())
|
|
.args(["-c", script])
|
|
.output()
|
|
.expect("run python");
|
|
assert!(
|
|
o.status.success(),
|
|
"python failed:\n{script}\nSTDOUT: {}\nSTDERR: {}",
|
|
String::from_utf8_lossy(&o.stdout),
|
|
String::from_utf8_lossy(&o.stderr)
|
|
);
|
|
String::from_utf8_lossy(&o.stdout).trim().to_string()
|
|
}
|
|
|
|
fn text(o: &Output) -> String {
|
|
format!(
|
|
"{}{}",
|
|
String::from_utf8_lossy(&o.stdout),
|
|
String::from_utf8_lossy(&o.stderr)
|
|
)
|
|
}
|
|
|
|
/// The file is valid for libhdf5's tools and for `h5rs check --data`.
|
|
/// HDF5 2.0 `latest` files are beyond h5dump 1.14.
|
|
fn check_tools(path: &Path, h5dump: bool) {
|
|
let p = path.to_str().unwrap();
|
|
let o = Command::new(env!("CARGO_BIN_EXE_h5rs"))
|
|
.args(["check", "--data", "-q", p])
|
|
.output()
|
|
.unwrap();
|
|
assert!(o.status.success(), "h5rs check --data {p}:\n{}", text(&o));
|
|
if h5dump {
|
|
let o = Command::new("h5dump")
|
|
.args(["-o", "/dev/null", p])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
o.status.success() && o.stderr.is_empty(),
|
|
"h5dump {p}:\n{}",
|
|
text(&o)
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A dataset's expected contents.
|
|
#[derive(Clone, Debug)]
|
|
struct Model {
|
|
shape: Vec<u64>,
|
|
/// Row-major values.
|
|
data: Vec<i32>,
|
|
}
|
|
|
|
impl Model {
|
|
fn new(shape: &[u64], f: impl Fn(u64) -> i32) -> Self {
|
|
let n: u64 = shape.iter().product();
|
|
Self {
|
|
shape: shape.to_vec(),
|
|
data: (0..n).map(f).collect(),
|
|
}
|
|
}
|
|
|
|
fn index(&self, c: &[u64]) -> usize {
|
|
c.iter()
|
|
.zip(&self.shape)
|
|
.fold(0u64, |a, (&x, &d)| a * d + x) as usize
|
|
}
|
|
|
|
/// Grow to `shape`, new elements `fill`.
|
|
fn resize(&mut self, shape: &[u64], fill: i32) {
|
|
let old = self.clone();
|
|
*self = Self::new(shape, |_| fill);
|
|
let n: u64 = old.shape.iter().product();
|
|
for flat in 0..n {
|
|
let mut c = vec![0u64; old.shape.len()];
|
|
let mut r = flat;
|
|
for d in (0..c.len()).rev() {
|
|
c[d] = r % old.shape[d];
|
|
r /= old.shape[d];
|
|
}
|
|
let i = self.index(&c);
|
|
self.data[i] = old.data[flat as usize];
|
|
}
|
|
}
|
|
|
|
/// Apply a hyperslab write of `vals` (row-major over the block).
|
|
fn write_block(&mut self, start: &[u64], count: &[u64], vals: &[i32]) {
|
|
let n: u64 = count.iter().product();
|
|
for flat in 0..n {
|
|
let mut c = vec![0u64; count.len()];
|
|
let mut r = flat;
|
|
for d in (0..c.len()).rev() {
|
|
c[d] = start[d] + r % count[d];
|
|
r /= count[d];
|
|
}
|
|
let i = self.index(&c);
|
|
self.data[i] = vals[flat as usize];
|
|
}
|
|
}
|
|
}
|
|
|
|
fn block(start: &[u64], count: &[u64]) -> Selection {
|
|
Selection::Hyperslab {
|
|
start: start.to_vec(),
|
|
stride: vec![1; start.len()],
|
|
count: count.to_vec(),
|
|
block: vec![1; start.len()],
|
|
}
|
|
}
|
|
|
|
/// h5py and our reader both read `m` from dataset `name`.
|
|
fn verify(path: &Path, name: &str, m: &Model) {
|
|
let f = File::open(path).unwrap();
|
|
let ds = f.dataset(name).unwrap();
|
|
assert_eq!(ds.shape().unwrap(), m.shape, "our shape of {name}");
|
|
assert!(ds.read_i32().unwrap() == m.data, "our values of {name}");
|
|
let exp = path.with_extension("expect");
|
|
let bytes: Vec<u8> = m.data.iter().flat_map(|v| v.to_le_bytes()).collect();
|
|
std::fs::write(&exp, bytes).unwrap();
|
|
let shape: Vec<String> = m.shape.iter().map(|d| d.to_string()).collect();
|
|
py(&format!(
|
|
"import h5py, numpy as np\n\
|
|
f = h5py.File({p:?}, 'r')\n\
|
|
a = f[{name:?}][()]\n\
|
|
e = np.fromfile({e:?}, dtype='<i4').reshape(({s},))\n\
|
|
assert a.shape == e.shape, (a.shape, e.shape)\n\
|
|
bad = np.argwhere(a != e)\n\
|
|
assert len(bad) == 0, ('mismatch at', bad[:5], a[tuple(bad[0])], e[tuple(bad[0])])\n",
|
|
p = path.to_str().unwrap(),
|
|
e = exp.to_str().unwrap(),
|
|
s = shape.join(",")
|
|
));
|
|
}
|
|
|
|
/// A deterministic pseudo-random sequence (SplitMix64).
|
|
struct Rng(u64);
|
|
impl Rng {
|
|
fn next(&mut self) -> u64 {
|
|
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
|
let mut z = self.0;
|
|
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
|
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
|
z ^ (z >> 31)
|
|
}
|
|
fn below(&mut self, n: u64) -> u64 {
|
|
self.next() % n
|
|
}
|
|
}
|
|
|
|
/// `h5py.File(..., libver=...)` argument for each flavour tested; the last
|
|
/// is HDF5 2.0's own format, which h5dump 1.14 cannot read.
|
|
const LIBVERS: &[(&str, bool)] = &[("'earliest'", true), ("'v114'", true), ("'latest'", false)];
|
|
|
|
fn tmpdir() -> tempfile::TempDir {
|
|
let base = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR"));
|
|
tempfile::TempDir::new_in(base).unwrap()
|
|
}
|
|
|
|
/// Append to a 1-D unlimited dataset hundreds of times, in runs of random
|
|
/// length: enough chunks to fill an Extensible Array's index block, direct
|
|
/// data blocks and several super blocks (paged data blocks included), and
|
|
/// to split version-1 B-tree nodes several levels deep.
|
|
fn append_many(libver: &str, h5dump: bool, compression: &str, tag: &str) {
|
|
let dir = tmpdir();
|
|
let path = dir.path().join(format!("append_{tag}.h5"));
|
|
py(&format!(
|
|
"import h5py, numpy as np\n\
|
|
with h5py.File({p:?}, 'w', libver={libver}) as f:\n\
|
|
\x20 f.create_dataset('x', shape=(5,), maxshape=(None,), chunks=(3,), dtype='<i4', \
|
|
data=np.arange(5, dtype='<i4'){compression})\n",
|
|
p = path.to_str().unwrap()
|
|
));
|
|
let mut m = Model::new(&[5], |i| i as i32);
|
|
verify(&path, "x", &m);
|
|
let mut rng = Rng(0x5eed ^ tag.len() as u64);
|
|
let mut ed = FileEditor::open(&path).unwrap();
|
|
for round in 0..600 {
|
|
let n = m.shape[0];
|
|
let add = 1 + rng.below(12);
|
|
ed.resize("x", &[n + add]).unwrap();
|
|
m.resize(&[n + add], 0);
|
|
let vals: Vec<i32> = (0..add).map(|k| (n + k) as i32 * 7 - 3).collect();
|
|
ed.write_values("x", &block(&[n], &[add]), &vals).unwrap();
|
|
m.write_block(&[n], &[add], &vals);
|
|
// Occasionally rewrite something earlier (in-place, or a filtered
|
|
// chunk that has to move).
|
|
if round % 7 == 0 {
|
|
let s = rng.below(m.shape[0]);
|
|
let c = 1 + rng.below((m.shape[0] - s).min(9));
|
|
let vals: Vec<i32> = (0..c).map(|_| rng.next() as i32).collect();
|
|
ed.write_values("x", &block(&[s], &[c]), &vals).unwrap();
|
|
m.write_block(&[s], &[c], &vals);
|
|
}
|
|
if round % 150 == 149 {
|
|
drop(ed);
|
|
verify(&path, "x", &m);
|
|
check_tools(&path, h5dump);
|
|
ed = FileEditor::open(&path).unwrap();
|
|
}
|
|
}
|
|
drop(ed);
|
|
verify(&path, "x", &m);
|
|
check_tools(&path, h5dump);
|
|
// h5py can go on appending to what we wrote.
|
|
py(&format!(
|
|
"import h5py, numpy as np\n\
|
|
with h5py.File({p:?}, 'r+') as f:\n\
|
|
\x20 d = f['x']\n\
|
|
\x20 n = d.shape[0]\n\
|
|
\x20 d.resize((n + 50,))\n\
|
|
\x20 d[n:] = np.arange(50, dtype='<i4') + 1000\n\
|
|
\x20 d[0] = -1\n",
|
|
p = path.to_str().unwrap()
|
|
));
|
|
let n = m.shape[0];
|
|
m.resize(&[n + 50], 0);
|
|
let vals: Vec<i32> = (0..50).map(|k| 1000 + k).collect();
|
|
m.write_block(&[n], &[50], &vals);
|
|
m.data[0] = -1;
|
|
verify(&path, "x", &m);
|
|
check_tools(&path, h5dump);
|
|
}
|
|
|
|
#[test]
|
|
fn append_many_unfiltered() {
|
|
if !tools_ok() {
|
|
return;
|
|
}
|
|
for (i, (lv, dump)) in LIBVERS.iter().enumerate() {
|
|
append_many(lv, *dump, "", &format!("plain{i}"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn append_many_gzip() {
|
|
if !tools_ok() {
|
|
return;
|
|
}
|
|
for (i, (lv, dump)) in LIBVERS.iter().enumerate() {
|
|
append_many(
|
|
lv,
|
|
*dump,
|
|
", compression='gzip', shuffle=True",
|
|
&format!("gzip{i}"),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Random operations — grow, hyperslab writes, point writes, attributes —
|
|
/// on a 2-D dataset with one unlimited dimension, checked against a model
|
|
/// after every few operations.
|
|
fn random_ops(libver: &str, h5dump: bool, extra: &str, tag: &str, seed: u64) {
|
|
let dir = tmpdir();
|
|
let path = dir.path().join(format!("rand_{tag}.h5"));
|
|
py(&format!(
|
|
"import h5py, numpy as np\n\
|
|
with h5py.File({p:?}, 'w', libver={libver}) as f:\n\
|
|
\x20 f.create_dataset('m', shape=(4, 7), maxshape=(None, 7), chunks=(3, 4), \
|
|
dtype='<i4', fillvalue=-9{extra})\n\
|
|
\x20 f['m'][1:3, 2:6] = 5\n",
|
|
p = path.to_str().unwrap()
|
|
));
|
|
let mut m = Model::new(&[4, 7], |_| -9);
|
|
m.write_block(&[1, 2], &[2, 4], &[5; 8]);
|
|
let mut attrs: Vec<(String, i64)> = Vec::new();
|
|
let mut rng = Rng(seed);
|
|
let mut ed = FileEditor::open(&path).unwrap();
|
|
for step in 0..120 {
|
|
match rng.below(10) {
|
|
0..=1 => {
|
|
let rows = m.shape[0] + 1 + rng.below(5);
|
|
ed.resize("m", &[rows, 7]).unwrap();
|
|
m.resize(&[rows, 7], -9);
|
|
}
|
|
2..=6 => {
|
|
let r0 = rng.below(m.shape[0]);
|
|
let c0 = rng.below(7);
|
|
let cnt = [
|
|
1 + rng.below((m.shape[0] - r0).min(6)),
|
|
1 + rng.below(7 - c0),
|
|
];
|
|
let n = cnt[0] * cnt[1];
|
|
let vals: Vec<i32> = (0..n).map(|_| (rng.next() % 100_000) as i32).collect();
|
|
ed.write_values("m", &block(&[r0, c0], &cnt), &vals)
|
|
.unwrap();
|
|
m.write_block(&[r0, c0], &cnt, &vals);
|
|
}
|
|
7 => {
|
|
let pts: Vec<Vec<u64>> = (0..1 + rng.below(4))
|
|
.map(|_| vec![rng.below(m.shape[0]), rng.below(7)])
|
|
.collect();
|
|
let vals: Vec<i32> = pts.iter().map(|_| rng.next() as i32).collect();
|
|
ed.write_values("m", &Selection::Points(pts.clone()), &vals)
|
|
.unwrap();
|
|
for (p, v) in pts.iter().zip(&vals) {
|
|
let i = m.index(p);
|
|
m.data[i] = *v;
|
|
}
|
|
}
|
|
_ => {
|
|
let k = rng.below(6);
|
|
let name = format!("a{k}");
|
|
let v = rng.next() as i64;
|
|
match ed.set_attr("m", &name, &AttrValue::I64(v)) {
|
|
Ok(()) => {
|
|
attrs.retain(|(n, _)| *n != name);
|
|
attrs.push((name, v));
|
|
}
|
|
Err(e) => panic!("set_attr {name}: {e}"),
|
|
}
|
|
}
|
|
}
|
|
if step % 30 == 29 {
|
|
drop(ed);
|
|
verify(&path, "m", &m);
|
|
check_tools(&path, h5dump);
|
|
check_attrs(&path, "m", &attrs);
|
|
ed = FileEditor::open(&path).unwrap();
|
|
}
|
|
}
|
|
drop(ed);
|
|
verify(&path, "m", &m);
|
|
check_attrs(&path, "m", &attrs);
|
|
py(&format!(
|
|
"import h5py, numpy as np\n\
|
|
with h5py.File({p:?}, 'r+') as f:\n\
|
|
\x20 d = f['m']\n\
|
|
\x20 n = d.shape[0]\n\
|
|
\x20 d.resize((n + 3, 7))\n\
|
|
\x20 d[n:, :] = 42\n\
|
|
\x20 d.attrs['from_h5py'] = 1.5\n",
|
|
p = path.to_str().unwrap()
|
|
));
|
|
let n = m.shape[0];
|
|
m.resize(&[n + 3, 7], -9);
|
|
m.write_block(&[n, 0], &[3, 7], &[42; 21]);
|
|
verify(&path, "m", &m);
|
|
check_tools(&path, h5dump);
|
|
}
|
|
|
|
fn check_attrs(path: &Path, obj: &str, attrs: &[(String, i64)]) {
|
|
let f = File::open(path).unwrap();
|
|
let got = f.dataset(obj).unwrap().attrs().unwrap();
|
|
for (n, v) in attrs {
|
|
match got.get(n) {
|
|
Some(AttrValue::I64(g)) => assert_eq!(g, v, "attribute {n}"),
|
|
other => panic!("attribute {n}: {other:?}"),
|
|
}
|
|
}
|
|
let want: Vec<String> = attrs.iter().map(|(n, v)| format!("{n:?}: {v}")).collect();
|
|
py(&format!(
|
|
"import h5py\n\
|
|
f = h5py.File({p:?}, 'r')\n\
|
|
want = {{{w}}}\n\
|
|
got = {{k: int(v) for k, v in f[{obj:?}].attrs.items() if k in want}}\n\
|
|
assert got == want, (got, want)\n",
|
|
p = path.to_str().unwrap(),
|
|
w = want.join(", ")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn random_operations_match_a_model() {
|
|
if !tools_ok() {
|
|
return;
|
|
}
|
|
let mut seed = 1;
|
|
for (i, (lv, dump)) in LIBVERS.iter().enumerate() {
|
|
// h5dump has no LZF decoder (h5py's own filter).
|
|
for (j, (extra, lzf)) in [
|
|
("", false),
|
|
(", compression='gzip', shuffle=True, fletcher32=True", false),
|
|
(", compression='lzf'", true),
|
|
]
|
|
.iter()
|
|
.enumerate()
|
|
{
|
|
seed += 1;
|
|
random_ops(lv, *dump && !lzf, extra, &format!("{i}_{j}"), seed);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn unsupported<T: std::fmt::Debug>(r: Result<T, Error>) {
|
|
match r {
|
|
Err(Error::Unsupported(_)) => {}
|
|
other => panic!("expected Error::Unsupported, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// The statistics an Extensible Array header keeps (super blocks and their
|
|
/// bytes, data blocks and their bytes, one past the highest index set,
|
|
/// elements realised), for the file's only Extensible Array.
|
|
fn ea_stats(path: &Path) -> [u64; 6] {
|
|
let b = std::fs::read(path).unwrap();
|
|
let at = b
|
|
.windows(4)
|
|
.position(|w| w == b"EAHD")
|
|
.expect("an EA header");
|
|
let mut s = [0u64; 6];
|
|
for (k, v) in s.iter_mut().enumerate() {
|
|
let o = at + 12 + 8 * k;
|
|
*v = u64::from_le_bytes(b[o..o + 8].try_into().unwrap());
|
|
}
|
|
s
|
|
}
|
|
|
|
/// Version-1 B-tree nodes in the file: (leaves, internal nodes).
|
|
fn btree1_nodes(path: &Path) -> (usize, usize) {
|
|
let b = std::fs::read(path).unwrap();
|
|
let mut out = (0, 0);
|
|
for i in 0..b.len().saturating_sub(6) {
|
|
if &b[i..i + 4] == b"TREE" && b[i + 4] == 1 {
|
|
if b[i + 5] == 0 {
|
|
out.0 += 1;
|
|
} else {
|
|
out.1 += 1;
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Growth one chunk at a time and in large steps: the editor's version-1
|
|
/// B-tree must split the way libhdf5's does (the same number of leaves and
|
|
/// internal nodes for the same insertions).
|
|
#[test]
|
|
fn btree1_splits_match_libhdf5() {
|
|
if !tools_ok() {
|
|
return;
|
|
}
|
|
let dir = tmpdir();
|
|
let mut steps: Vec<u64> = (1..=300).collect();
|
|
steps.extend([1000, 1001, 5000, 9000]);
|
|
let a = dir.path().join("bt_h5py.h5");
|
|
let b = dir.path().join("bt_edit.h5");
|
|
let create = |p: &Path, write: bool| {
|
|
py(&format!(
|
|
"import h5py, numpy as np\n\
|
|
with h5py.File({p:?}, 'w') as f:\n\
|
|
\x20 d = f.create_dataset('x', shape=(0,), maxshape=(None,), chunks=(2,), dtype='<i4')\n\
|
|
\x20 if {write}:\n\
|
|
\x20 n = 0\n\
|
|
\x20 for s in {steps:?}:\n\
|
|
\x20 d.resize((s,))\n\
|
|
\x20 d[n:s] = np.arange(n, s, dtype='<i4')\n\
|
|
\x20 n = s\n",
|
|
p = p.to_str().unwrap(),
|
|
write = if write { "True" } else { "False" },
|
|
))
|
|
};
|
|
create(&a, true);
|
|
create(&b, false);
|
|
let mut ed = FileEditor::open(&b).unwrap();
|
|
let mut n = 0u64;
|
|
for &s in &steps {
|
|
ed.resize("x", &[s]).unwrap();
|
|
let vals: Vec<i32> = (n..s).map(|v| v as i32).collect();
|
|
ed.write_values("x", &block(&[n], &[s - n]), &vals).unwrap();
|
|
n = s;
|
|
}
|
|
drop(ed);
|
|
verify(&b, "x", &Model::new(&[n], |i| i as i32));
|
|
check_tools(&b, true);
|
|
assert_eq!(
|
|
btree1_nodes(&b),
|
|
btree1_nodes(&a),
|
|
"B-tree shape differs from libhdf5's"
|
|
);
|
|
}
|
|
|
|
/// Enough chunks that the Extensible Array reaches its paged data blocks
|
|
/// (the first one holds element 131060 with h5py's parameters): the same
|
|
/// growth done by libhdf5 and by the editor must create the same blocks.
|
|
#[test]
|
|
fn extensible_array_paged_blocks_match_libhdf5() {
|
|
if !tools_ok() {
|
|
return;
|
|
}
|
|
let dir = tmpdir();
|
|
let steps = [5u64, 131_000, 131_070, 133_000, 140_000];
|
|
let a = dir.path().join("paged_h5py.h5");
|
|
let b = dir.path().join("paged_edit.h5");
|
|
let create = |p: &Path, write: bool| {
|
|
py(&format!(
|
|
"import h5py, numpy as np\n\
|
|
with h5py.File({p:?}, 'w', libver='v114') as f:\n\
|
|
\x20 d = f.create_dataset('x', shape=(0,), maxshape=(None,), chunks=(1,), dtype='<i4')\n\
|
|
\x20 if {write}:\n\
|
|
\x20 n = 0\n\
|
|
\x20 for s in {steps:?}:\n\
|
|
\x20 d.resize((s,))\n\
|
|
\x20 d[n:s] = np.arange(n, s, dtype='<i4')\n\
|
|
\x20 n = s\n",
|
|
p = p.to_str().unwrap(),
|
|
write = if write { "True" } else { "False" },
|
|
))
|
|
};
|
|
create(&a, true);
|
|
create(&b, false);
|
|
let mut ed = FileEditor::open(&b).unwrap();
|
|
let mut n = 0u64;
|
|
for &s in &steps {
|
|
ed.resize("x", &[s]).unwrap();
|
|
let vals: Vec<i32> = (n..s).map(|v| v as i32).collect();
|
|
ed.write_values("x", &block(&[n], &[s - n]), &vals).unwrap();
|
|
n = s;
|
|
}
|
|
drop(ed);
|
|
let m = Model::new(&[n], |i| i as i32);
|
|
verify(&b, "x", &m);
|
|
check_tools(&b, true);
|
|
assert_eq!(
|
|
ea_stats(&b),
|
|
ea_stats(&a),
|
|
"EA statistics differ from libhdf5's"
|
|
);
|
|
}
|
|
|
|
/// Every element of `sel` over `shape`, in selection order.
|
|
/// Every index of the box `ext`, row-major.
|
|
fn odometer(ext: &[u64]) -> Vec<Vec<u64>> {
|
|
let n: u64 = ext.iter().product();
|
|
(0..n)
|
|
.map(|flat| {
|
|
let mut c = vec![0u64; ext.len()];
|
|
let mut r = flat;
|
|
for d in (0..c.len()).rev() {
|
|
c[d] = r % ext[d];
|
|
r /= ext[d];
|
|
}
|
|
c
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn sel_coords(sel: &Selection, shape: &[u64]) -> Vec<Vec<u64>> {
|
|
match sel {
|
|
Selection::All => odometer(shape),
|
|
Selection::None => Vec::new(),
|
|
Selection::Points(p) => p.clone(),
|
|
Selection::Hyperslab {
|
|
start,
|
|
stride,
|
|
count,
|
|
block,
|
|
} => {
|
|
let ext: Vec<u64> = count.iter().zip(block).map(|(c, b)| c * b).collect();
|
|
odometer(&ext)
|
|
.into_iter()
|
|
.map(|j| {
|
|
(0..j.len())
|
|
.map(|d| start[d] + (j[d] / block[d]) * stride[d] + j[d] % block[d])
|
|
.collect()
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Model {
|
|
fn write_sel(&mut self, sel: &Selection, vals: &[i32]) {
|
|
let cs = sel_coords(sel, &self.shape);
|
|
assert_eq!(cs.len(), vals.len());
|
|
for (c, v) in cs.iter().zip(vals) {
|
|
let i = self.index(c);
|
|
self.data[i] = *v;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A random selection of `shape`: a block, a strided hyperslab, points,
|
|
/// or everything.
|
|
fn random_sel(rng: &mut Rng, shape: &[u64]) -> Selection {
|
|
let rank = shape.len();
|
|
match rng.below(4) {
|
|
0 => Selection::All,
|
|
1 => {
|
|
let pts = (0..1 + rng.below(5))
|
|
.map(|_| shape.iter().map(|&d| rng.below(d)).collect())
|
|
.collect();
|
|
Selection::Points(pts)
|
|
}
|
|
_ => {
|
|
let mut start = vec![0; rank];
|
|
let mut stride = vec![1; rank];
|
|
let mut count = vec![1; rank];
|
|
let mut block = vec![1; rank];
|
|
for d in 0..rank {
|
|
start[d] = rng.below(shape[d]);
|
|
let room = shape[d] - start[d];
|
|
block[d] = 1 + rng.below(room.min(3));
|
|
stride[d] = block[d] + rng.below(3);
|
|
// count * stride may overshoot as long as the last block fits.
|
|
count[d] = 1 + (room - block[d]) / stride[d];
|
|
count[d] = 1 + rng.below(count[d]);
|
|
}
|
|
Selection::Hyperslab {
|
|
start,
|
|
stride,
|
|
count,
|
|
block,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Datasets of every layout and chunk index h5py writes, overwritten under
|
|
/// random selections: contiguous (allocated, and never written — late
|
|
/// allocation), compact, chunked with a Fixed Array (paged, too), single
|
|
/// chunk (unfiltered and filtered), implicit (early allocation), version-1
|
|
/// B-tree (every chunked layout of the default libver), and version-2
|
|
/// B-tree (existing unfiltered chunks only).
|
|
#[test]
|
|
fn overwrite_every_layout() {
|
|
if !tools_ok() {
|
|
return;
|
|
}
|
|
let names: &[(&str, &[u64])] = &[
|
|
("contig", &[10, 4]),
|
|
("contig_late", &[6]),
|
|
("compact", &[3, 4]),
|
|
("fixed", &[10, 7]),
|
|
("fixed_gz", &[10, 7]),
|
|
("fa_paged", &[3000]),
|
|
("single", &[5, 6]),
|
|
("single_gz", &[5, 6]),
|
|
("implicit", &[8, 8]),
|
|
("fa_empty", &[10, 7]),
|
|
("fa_gz_empty", &[3000]),
|
|
("ea_gz_empty", &[20]),
|
|
("bt2", &[6, 6]),
|
|
];
|
|
for (li, (lv, dump)) in LIBVERS.iter().enumerate() {
|
|
let dir = tmpdir();
|
|
let path = dir.path().join(format!("overwrite_{li}.h5"));
|
|
py(&format!(
|
|
"import h5py, numpy as np\n\
|
|
from h5py import h5p, h5d, h5s, h5t\n\
|
|
def ar(*s): return np.arange(int(np.prod(s)), dtype='<i4').reshape(s)\n\
|
|
with h5py.File({p:?}, 'w', libver={lv}) as f:\n\
|
|
\x20 f.create_dataset('contig', data=ar(10, 4))\n\
|
|
\x20 f.create_dataset('contig_late', shape=(6,), dtype='<i4', fillvalue=7)\n\
|
|
\x20 dcpl = h5p.create(h5p.DATASET_CREATE)\n\
|
|
\x20 dcpl.set_layout(h5d.COMPACT)\n\
|
|
\x20 h5d.create(f.id, b'compact', h5t.STD_I32LE, h5s.create_simple((3, 4)), dcpl=dcpl)\n\
|
|
\x20 f['compact'][...] = ar(3, 4)\n\
|
|
\x20 d = f.create_dataset('fixed', shape=(10, 7), chunks=(3, 4), dtype='<i4', fillvalue=-1)\n\
|
|
\x20 d[0:3, 0:4] = 1\n\
|
|
\x20 d = f.create_dataset('fixed_gz', shape=(10, 7), chunks=(3, 4), dtype='<i4', compression='gzip')\n\
|
|
\x20 d[4:10, 3:7] = ar(6, 4)\n\
|
|
\x20 d = f.create_dataset('fa_paged', shape=(3000,), chunks=(1,), dtype='<i4')\n\
|
|
\x20 d[::7] = 3\n\
|
|
\x20 f.create_dataset('single', shape=(5, 6), chunks=(5, 6), dtype='<i4')\n\
|
|
\x20 f.create_dataset('single_gz', data=ar(5, 6), chunks=(5, 6), compression='gzip')\n\
|
|
\x20 dcpl = h5p.create(h5p.DATASET_CREATE)\n\
|
|
\x20 dcpl.set_chunk((4, 4))\n\
|
|
\x20 dcpl.set_alloc_time(h5d.ALLOC_TIME_EARLY)\n\
|
|
\x20 h5d.create(f.id, b'implicit', h5t.STD_I32LE, h5s.create_simple((8, 8)), dcpl=dcpl)\n\
|
|
\x20 f.create_dataset('fa_empty', shape=(10, 7), chunks=(3, 4), dtype='<i4', fillvalue=4)\n\
|
|
\x20 f.create_dataset('fa_gz_empty', shape=(3000,), chunks=(1,), dtype='<i4', compression='gzip')\n\
|
|
\x20 f.create_dataset('ea_gz_empty', shape=(20,), maxshape=(None,), chunks=(2,), dtype='<i4', compression='gzip')\n\
|
|
\x20 f.create_dataset('bt2', data=ar(6, 6), maxshape=(None, None), chunks=(2, 2))\n",
|
|
p = path.to_str().unwrap()
|
|
));
|
|
let mut models: Vec<Model> = vec![
|
|
Model::new(&[10, 4], |i| i as i32),
|
|
Model::new(&[6], |_| 7),
|
|
Model::new(&[3, 4], |i| i as i32),
|
|
{
|
|
let mut m = Model::new(&[10, 7], |_| -1);
|
|
m.write_block(&[0, 0], &[3, 4], &[1; 12]);
|
|
m
|
|
},
|
|
{
|
|
let mut m = Model::new(&[10, 7], |_| 0);
|
|
m.write_block(&[4, 3], &[6, 4], &(0..24).collect::<Vec<_>>());
|
|
m
|
|
},
|
|
Model::new(&[3000], |i| if i % 7 == 0 { 3 } else { 0 }),
|
|
Model::new(&[5, 6], |_| 0),
|
|
Model::new(&[5, 6], |i| i as i32),
|
|
Model::new(&[8, 8], |_| 0),
|
|
Model::new(&[10, 7], |_| 4),
|
|
Model::new(&[3000], |_| 0),
|
|
Model::new(&[20], |_| 0),
|
|
Model::new(&[6, 6], |i| i as i32),
|
|
];
|
|
let mut rng = Rng(77 + li as u64);
|
|
for round in 0..3 {
|
|
let mut ed = FileEditor::open(&path).unwrap();
|
|
for ((name, shape), m) in names.iter().zip(models.iter_mut()) {
|
|
for _ in 0..6 {
|
|
let sel = random_sel(&mut rng, shape);
|
|
let n = sel_coords(&sel, shape).len();
|
|
let vals: Vec<i32> = (0..n).map(|_| rng.next() as i32).collect();
|
|
ed.write_values(name, &sel, &vals)
|
|
.unwrap_or_else(|e| panic!("{name} round {round} {sel:?}: {e}"));
|
|
m.write_sel(&sel, &vals);
|
|
}
|
|
}
|
|
drop(ed);
|
|
for ((name, _), m) in names.iter().zip(&models) {
|
|
verify(&path, name, m);
|
|
}
|
|
check_tools(&path, *dump);
|
|
}
|
|
// A version-2 B-tree index can take new chunks only from libhdf5 for
|
|
// now: growing works, writing the new chunks is refused and changes
|
|
// nothing.
|
|
if *lv != "'earliest'" {
|
|
let mut ed = FileEditor::open(&path).unwrap();
|
|
ed.resize("bt2", &[8, 6]).unwrap();
|
|
let before = std::fs::read(&path).unwrap();
|
|
unsupported(ed.write_values("bt2", &block(&[6, 0], &[2, 6]), &[5; 12]));
|
|
assert!(
|
|
std::fs::read(&path).unwrap() == before,
|
|
"a refused edit changed the file"
|
|
);
|
|
models[12].resize(&[8, 6], 0);
|
|
}
|
|
// libhdf5 goes on modifying what we wrote.
|
|
py(&format!(
|
|
"import h5py, numpy as np\n\
|
|
with h5py.File({p:?}, 'r+') as f:\n\
|
|
\x20 for n in {names:?}:\n\
|
|
\x20 d = f[n]\n\
|
|
\x20 d[(0,) * d.ndim] = 123\n",
|
|
p = path.to_str().unwrap(),
|
|
names = names.iter().map(|(n, _)| *n).collect::<Vec<_>>()
|
|
));
|
|
for ((name, _), m) in names.iter().zip(models.iter_mut()) {
|
|
m.data[0] = 123;
|
|
verify(&path, name, m);
|
|
}
|
|
check_tools(&path, *dump);
|
|
}
|
|
}
|
|
|
|
/// Attributes added to and replaced on the root group, a group and a
|
|
/// dataset, until the headers need continuation chunks; then h5py adds,
|
|
/// changes and deletes attributes in the same headers.
|
|
#[test]
|
|
fn attributes_in_place() {
|
|
if !tools_ok() {
|
|
return;
|
|
}
|
|
for (li, (lv, dump)) in LIBVERS.iter().enumerate() {
|
|
let dir = tmpdir();
|
|
let path = dir.path().join(format!("attrs_{li}.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.attrs['title'] = 'root'\n\
|
|
\x20 g = f.create_group('g')\n\
|
|
\x20 g.attrs['n'] = 3\n\
|
|
\x20 d = f.create_dataset('d', data=np.arange(4, dtype='<i4'))\n\
|
|
\x20 d.attrs['units'] = 'm'\n\
|
|
\x20 d.attrs['scale'] = 0.5\n\
|
|
\x20 t = f.create_group('tracked', track_order=True)\n\
|
|
\x20 t.attrs['a'] = 1\n\
|
|
\x20 dense = f.create_group('dense')\n\
|
|
\x20 for i in range(12): dense.attrs[f'k{{i}}'] = i\n"
|
|
));
|
|
let mut want: Vec<(&str, String, AttrValue)> = Vec::new();
|
|
let mut ed = FileEditor::open(&path).unwrap();
|
|
// v2 headers hold 8 compact attributes by default.
|
|
let many = if *lv == "'earliest'" { 20 } else { 6 };
|
|
for obj in ["/", "g", "d"] {
|
|
for i in 0..many {
|
|
let name = format!("x{i}");
|
|
let v = AttrValue::I64Array((0..=i as i64).collect());
|
|
ed.set_attr(obj, &name, &v).unwrap();
|
|
want.push((obj, name, v));
|
|
}
|
|
// Replace one with something much larger (moves it).
|
|
let big = AttrValue::String("z".repeat(700));
|
|
ed.set_attr(obj, "x1", &big).unwrap();
|
|
want.retain(|(o, n, _)| !(*o == obj && n == "x1"));
|
|
want.push((obj, "x1".into(), big));
|
|
// And one smaller, in place.
|
|
ed.set_attr(obj, "x0", &AttrValue::F64(2.5)).unwrap();
|
|
want.retain(|(o, n, _)| !(*o == obj && n == "x0"));
|
|
want.push((obj, "x0".into(), AttrValue::F64(2.5)));
|
|
}
|
|
ed.set_attr("d", "units", &AttrValue::String("km".into()))
|
|
.unwrap();
|
|
want.push(("d", "units".into(), AttrValue::String("km".into())));
|
|
if *lv != "'earliest'" {
|
|
// Up to the compact limit (8) and no further; attributes in
|
|
// dense storage and tracked creation order are refused, and a
|
|
// refused edit writes nothing.
|
|
ed.set_attr("g", "eighth", &AttrValue::I64(8)).unwrap();
|
|
want.push(("g", "eighth".into(), AttrValue::I64(8)));
|
|
let before = std::fs::read(&path).unwrap();
|
|
unsupported(ed.set_attr("g", "ninth", &AttrValue::I64(9)));
|
|
unsupported(ed.set_attr("dense", "k0", &AttrValue::I64(1)));
|
|
unsupported(ed.set_attr("tracked", "b", &AttrValue::I64(1)));
|
|
assert!(
|
|
std::fs::read(&path).unwrap() == before,
|
|
"a refused edit changed the file"
|
|
);
|
|
}
|
|
drop(ed);
|
|
check_tools(&path, *dump);
|
|
let f = File::open(&path).unwrap();
|
|
let expect_py: Vec<String> = want
|
|
.iter()
|
|
.map(|(o, n, v)| {
|
|
let got = if *o == "/" {
|
|
f.root().attrs().unwrap()
|
|
} else if *o == "d" {
|
|
f.dataset(o).unwrap().attrs().unwrap()
|
|
} else {
|
|
f.group(o).unwrap().attrs().unwrap()
|
|
};
|
|
let g = got.get(n).unwrap_or_else(|| panic!("{o}/{n} missing"));
|
|
assert_eq!(format!("{g:?}"), format!("{v:?}"), "{o}/{n}");
|
|
let pv = match v {
|
|
AttrValue::I64Array(a) => format!("{a:?}"),
|
|
AttrValue::String(s) => format!("{s:?}"),
|
|
AttrValue::F64(x) => format!("{x:?}"),
|
|
AttrValue::I64(x) => format!("{x}"),
|
|
other => panic!("{other:?}"),
|
|
};
|
|
format!("({o:?}, {n:?}, {pv})")
|
|
})
|
|
.collect();
|
|
py(&format!(
|
|
"import h5py, numpy as np\n\
|
|
f = h5py.File({p:?}, 'r')\n\
|
|
for o, n, v in [{w}]:\n\
|
|
\x20 a = f[o].attrs[n]\n\
|
|
\x20 a = a.decode() if isinstance(a, bytes) else a\n\
|
|
\x20 a = a.tolist() if hasattr(a, 'tolist') else a\n\
|
|
\x20 assert a == v, (o, n, a, v)\n\
|
|
assert f['d'][()].tolist() == [0, 1, 2, 3]\n\
|
|
assert f['g'].attrs['n'] == 3 and f.attrs['title'] == 'root'\n",
|
|
w = expect_py.join(", ")
|
|
));
|
|
// libhdf5 modifies the headers we changed.
|
|
py(&format!(
|
|
"import h5py\n\
|
|
with h5py.File({p:?}, 'r+') as f:\n\
|
|
\x20 for o in ['/', 'd']:\n\
|
|
\x20 f[o].attrs['from_h5py'] = 'yes'\n\
|
|
\x20 f[o].attrs['x0'] = 9\n\
|
|
\x20 del f[o].attrs['x1']\n"
|
|
));
|
|
check_tools(&path, *dump);
|
|
let f = File::open(&path).unwrap();
|
|
for attrs in [
|
|
f.root().attrs().unwrap(),
|
|
f.dataset("d").unwrap().attrs().unwrap(),
|
|
] {
|
|
assert!(matches!(attrs.get("from_h5py"), Some(AttrValue::String(s)) if s == "yes"));
|
|
assert!(matches!(attrs.get("x0"), Some(AttrValue::I64(9))));
|
|
assert!(!attrs.contains_key("x1"));
|
|
assert!(attrs.contains_key("x2"));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Files written by clawhdf5's own writer: appended to, overwritten and
|
|
/// given attributes, then read by h5py.
|
|
#[test]
|
|
fn edit_clawhdf5_written_files() {
|
|
if !tools_ok() {
|
|
return;
|
|
}
|
|
let dir = tmpdir();
|
|
let path = dir.path().join("ours.h5");
|
|
let mut b = FileBuilder::new();
|
|
b.create_dataset("ext")
|
|
.with_i32_data(&(0..10).collect::<Vec<_>>())
|
|
.with_shape(&[10])
|
|
.with_maxshape(&[u64::MAX])
|
|
.with_chunks(&[4])
|
|
.with_deflate(4);
|
|
b.create_dataset("plain").with_i32_data(&[1, 2, 3, 4, 5, 6]);
|
|
b.create_dataset("grid")
|
|
.with_i32_data(&(0..24).collect::<Vec<_>>())
|
|
.with_shape(&[4, 6])
|
|
.with_maxshape(&[u64::MAX, 6])
|
|
.with_chunks(&[2, 3]);
|
|
b.set_attr("version", AttrValue::I64(1));
|
|
b.write(&path).unwrap();
|
|
let mut ext = Model::new(&[10], |i| i as i32);
|
|
let mut plain = Model::new(&[6], |i| i as i32 + 1);
|
|
let mut grid = Model::new(&[4, 6], |i| i as i32);
|
|
let mut ed = FileEditor::open(&path).unwrap();
|
|
let mut rng = Rng(9);
|
|
for _ in 0..200 {
|
|
let n = ext.shape[0];
|
|
let add = 1 + rng.below(9);
|
|
ed.resize("ext", &[n + add]).unwrap();
|
|
ext.resize(&[n + add], 0);
|
|
let vals: Vec<i32> = (0..add).map(|_| rng.next() as i32).collect();
|
|
ed.write_values("ext", &block(&[n], &[add]), &vals).unwrap();
|
|
ext.write_block(&[n], &[add], &vals);
|
|
}
|
|
for _ in 0..30 {
|
|
let rows = grid.shape[0] + rng.below(3);
|
|
ed.resize("grid", &[rows, 6]).unwrap();
|
|
grid.resize(&[rows, 6], 0);
|
|
let sel = random_sel(&mut rng, &grid.shape.clone());
|
|
let vals: Vec<i32> = (0..sel_coords(&sel, &grid.shape).len())
|
|
.map(|_| rng.next() as i32)
|
|
.collect();
|
|
ed.write_values("grid", &sel, &vals).unwrap();
|
|
grid.write_sel(&sel, &vals);
|
|
}
|
|
ed.write_values(
|
|
"plain",
|
|
&Selection::Points(vec![vec![5], vec![0]]),
|
|
&[60, 10],
|
|
)
|
|
.unwrap();
|
|
plain.write_sel(&Selection::Points(vec![vec![5], vec![0]]), &[60, 10]);
|
|
ed.set_attr("/", "version", &AttrValue::I64(2)).unwrap();
|
|
ed.set_attr("ext", "note", &AttrValue::String("appended".into()))
|
|
.unwrap();
|
|
drop(ed);
|
|
verify(&path, "ext", &ext);
|
|
verify(&path, "plain", &plain);
|
|
verify(&path, "grid", &grid);
|
|
check_tools(&path, true);
|
|
py(&format!(
|
|
"import h5py\n\
|
|
f = h5py.File({p:?}, 'r')\n\
|
|
assert f.attrs['version'] == 2\n\
|
|
assert f['ext'].attrs['note'] in ('appended', b'appended')\n",
|
|
p = path.to_str().unwrap()
|
|
));
|
|
}
|
|
|
|
/// One writer at a time: a second editor (or libhdf5 with file locking)
|
|
/// is refused until the first is dropped.
|
|
#[test]
|
|
fn editor_locks_the_file() {
|
|
let dir = tmpdir();
|
|
let path = dir.path().join("lock.h5");
|
|
let mut b = FileBuilder::new();
|
|
b.create_dataset("x").with_i32_data(&[1, 2, 3]);
|
|
b.write(&path).unwrap();
|
|
let ed = FileEditor::open(&path).unwrap();
|
|
assert!(matches!(FileEditor::open(&path), Err(Error::Locked(_))));
|
|
if available(&python(), &["-c", "import h5py"]) {
|
|
let o = Command::new(python())
|
|
.args([
|
|
"-c",
|
|
&format!("import h5py; h5py.File({:?}, 'r+')", path.to_str().unwrap()),
|
|
])
|
|
.env_remove("HDF5_USE_FILE_LOCKING")
|
|
.output()
|
|
.unwrap();
|
|
assert!(!o.status.success(), "h5py opened a locked file r+");
|
|
}
|
|
drop(ed);
|
|
FileEditor::open(&path).unwrap();
|
|
}
|
|
|
|
/// Refused edits leave the file byte for byte as it was.
|
|
#[test]
|
|
fn refused_edits_change_nothing() {
|
|
if !tools_ok() {
|
|
return;
|
|
}
|
|
let dir = tmpdir();
|
|
let path = dir.path().join("refuse.h5");
|
|
py(&format!(
|
|
"import h5py, numpy as np\n\
|
|
with h5py.File({p:?}, 'w') as f:\n\
|
|
\x20 f.create_dataset('s', data=['a', 'bb'], dtype=h5py.string_dtype())\n\
|
|
\x20 f.create_dataset('x', data=np.arange(6, dtype='<i4'), maxshape=(8,), chunks=(2,))\n\
|
|
\x20 f.create_dataset('c', data=np.arange(6, dtype='<i4'))\n",
|
|
p = path.to_str().unwrap()
|
|
));
|
|
let before = std::fs::read(&path).unwrap();
|
|
let mut ed = FileEditor::open(&path).unwrap();
|
|
unsupported(ed.write_all("s", &[0u8; 32]));
|
|
unsupported(ed.resize("x", &[4]));
|
|
unsupported(
|
|
ed.resize("c", &[6, 1])
|
|
.map_err(|_| Error::Unsupported(String::new())),
|
|
);
|
|
assert!(matches!(
|
|
ed.resize("x", &[9]),
|
|
Err(Error::InvalidArgument(_))
|
|
));
|
|
assert!(matches!(
|
|
ed.write_all("x", &[0u8; 5]),
|
|
Err(Error::InvalidArgument(_))
|
|
));
|
|
assert!(matches!(
|
|
ed.write_values("x", &block(&[5], &[2]), &[1i32, 2]),
|
|
Err(Error::InvalidArgument(_))
|
|
));
|
|
assert!(matches!(
|
|
ed.write_values("x", &Selection::All, &[1.0f64; 6]),
|
|
Err(Error::InvalidArgument(_))
|
|
));
|
|
drop(ed);
|
|
assert!(std::fs::read(&path).unwrap() == before);
|
|
}
|
|
|
|
/// Chunks created out of order, one edit each — descending (every insertion
|
|
/// below the tree's first key) and then shuffled — by libhdf5 and by the
|
|
/// editor: the same values, and for the version-1 B-tree (default libver)
|
|
/// the same node counts, for the Extensible Array (`v114`) the same
|
|
/// statistics.
|
|
#[test]
|
|
fn out_of_order_chunk_creation_matches_libhdf5() {
|
|
if !tools_ok() {
|
|
return;
|
|
}
|
|
let len = 1200u64;
|
|
let mut order: Vec<u64> = (900..len).rev().collect();
|
|
let mut rest: Vec<u64> = (0..900).collect();
|
|
let mut rng = Rng(3);
|
|
for i in (1..rest.len()).rev() {
|
|
rest.swap(i, rng.below(i as u64 + 1) as usize);
|
|
}
|
|
order.extend(rest.iter().take(500));
|
|
for (lv, tag) in [("'earliest'", "bt"), ("'v114'", "ea")] {
|
|
let dir = tmpdir();
|
|
let a = dir.path().join(format!("ooo_{tag}_h5py.h5"));
|
|
let b = dir.path().join(format!("ooo_{tag}_edit.h5"));
|
|
let create = |p: &Path, write: bool| {
|
|
py(&format!(
|
|
"import h5py, numpy as np\n\
|
|
with h5py.File({p:?}, 'w', libver={lv}) as f:\n\
|
|
\x20 d = f.create_dataset('x', shape=({len},), maxshape=(None,), chunks=(1,), dtype='<i4', fillvalue=-5)\n\
|
|
\x20 if {write}:\n\
|
|
\x20 for i in {order:?}:\n\
|
|
\x20 d[i] = i * 3\n",
|
|
p = p.to_str().unwrap(),
|
|
write = if write { "True" } else { "False" },
|
|
))
|
|
};
|
|
create(&a, true);
|
|
create(&b, false);
|
|
let mut ed = FileEditor::open(&b).unwrap();
|
|
for &i in &order {
|
|
ed.write_values("x", &block(&[i], &[1]), &[i as i32 * 3])
|
|
.unwrap();
|
|
}
|
|
drop(ed);
|
|
let mut m = Model::new(&[len], |_| -5);
|
|
for &i in &order {
|
|
m.data[i as usize] = i as i32 * 3;
|
|
}
|
|
verify(&b, "x", &m);
|
|
check_tools(&b, true);
|
|
if tag == "bt" {
|
|
assert_eq!(
|
|
btree1_nodes(&b),
|
|
btree1_nodes(&a),
|
|
"B-tree shape differs from libhdf5's"
|
|
);
|
|
} else {
|
|
assert_eq!(
|
|
ea_stats(&b),
|
|
ea_stats(&a),
|
|
"EA statistics differ from libhdf5's"
|
|
);
|
|
}
|
|
py(&format!(
|
|
"import h5py, numpy as np\n\
|
|
with h5py.File({p:?}, 'r+') as f:\n\
|
|
\x20 f['x'][:] = np.arange({len}, dtype='<i4')\n",
|
|
p = b.to_str().unwrap()
|
|
));
|
|
verify(&b, "x", &Model::new(&[len], |i| i as i32));
|
|
check_tools(&b, true);
|
|
}
|
|
}
|
|
|
|
/// Not a check: prints how much space an append workload leaks (the editor
|
|
/// never reuses space), against libhdf5 doing the same appends and against
|
|
/// `h5repack` of each. Run with `--ignored --nocapture`.
|
|
#[test]
|
|
#[ignore]
|
|
fn measure_append_waste() {
|
|
if !tools_ok() {
|
|
return;
|
|
}
|
|
let dir = tmpdir();
|
|
for (comp, chunk, add, rounds) in [
|
|
("", 1024u64, 100u64, 1000u64),
|
|
(", compression='gzip'", 1024, 100, 1000),
|
|
(", compression='gzip'", 4096, 10, 2000),
|
|
] {
|
|
let a = dir.path().join("waste_h5py.h5");
|
|
let b = dir.path().join("waste_edit.h5");
|
|
for (p, write) in [(&a, true), (&b, false)] {
|
|
py(&format!(
|
|
"import h5py, numpy as np\n\
|
|
rng = np.random.default_rng(1)\n\
|
|
with h5py.File({p:?}, 'w') as f:\n\
|
|
\x20 d = f.create_dataset('x', shape=(0,), maxshape=(None,), chunks=({chunk},), dtype='<f8'{comp})\n\
|
|
\x20 if {write}:\n\
|
|
\x20 for r in range({rounds}):\n\
|
|
\x20 n = d.shape[0]\n\
|
|
\x20 d.resize((n + {add},))\n\
|
|
\x20 d[n:] = np.round(np.sin(np.arange(n, n + {add}) / 50.0), 3)\n",
|
|
p = p.to_str().unwrap(),
|
|
write = if write { "True" } else { "False" },
|
|
));
|
|
}
|
|
let mut ed = FileEditor::open(&b).unwrap();
|
|
for r in 0..rounds {
|
|
let n = r * add;
|
|
ed.resize("x", &[n + add]).unwrap();
|
|
let vals: Vec<f64> = (n..n + add)
|
|
.map(|i| ((i as f64 / 50.0).sin() * 1000.0).round() / 1000.0)
|
|
.collect();
|
|
ed.write_values("x", &block(&[n], &[add]), &vals).unwrap();
|
|
}
|
|
drop(ed);
|
|
let size = |p: &Path| std::fs::metadata(p).unwrap().len();
|
|
let repacked = |p: &Path| {
|
|
let out = p.with_extension("repacked.h5");
|
|
let _ = std::fs::remove_file(&out);
|
|
let o = Command::new("h5repack")
|
|
.args([p.to_str().unwrap(), out.to_str().unwrap()])
|
|
.output()
|
|
.unwrap();
|
|
assert!(o.status.success(), "{}", text(&o));
|
|
size(&out)
|
|
};
|
|
println!(
|
|
"chunks {chunk}{comp}, {rounds} appends of {add}: editor {} bytes \
|
|
(repacked {}), libhdf5 {} bytes (repacked {})",
|
|
size(&b),
|
|
repacked(&b),
|
|
size(&a),
|
|
repacked(&a)
|
|
);
|
|
}
|
|
}
|