Merge pull request 'Fast contiguous and concurrent reads, VL data, nested groups and links, Python bindings' (#15) from feat/p2-perf-coverage into main
Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
@@ -38,7 +38,9 @@ jobs:
|
|||||||
# (clawhdf5-tools) interop tests compare against.
|
# (clawhdf5-tools) interop tests compare against.
|
||||||
apt-get install -y --no-install-recommends python3 python3-venv cmake hdf5-tools
|
apt-get install -y --no-install-recommends python3 python3-venv cmake hdf5-tools
|
||||||
python3 -m venv /opt/interop
|
python3 -m venv /opt/interop
|
||||||
/opt/interop/bin/pip install --no-cache-dir h5py numpy netCDF4 xarray hdf5plugin
|
# maturin + pytest: ci-test.sh builds the Python package
|
||||||
|
# (crates/clawhdf5-py) and runs its tests against h5py.
|
||||||
|
/opt/interop/bin/pip install --no-cache-dir h5py numpy netCDF4 xarray hdf5plugin maturin pytest
|
||||||
echo "/opt/interop/bin" >> "$GITHUB_PATH"
|
echo "/opt/interop/bin" >> "$GITHUB_PATH"
|
||||||
- name: Show interop library versions
|
- name: Show interop library versions
|
||||||
# h5dump's version too: the h5rs dump test requires its exact output
|
# h5dump's version too: the h5rs dump test requires its exact output
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ benchmarks/longmemeval/*.json
|
|||||||
# Local model weights (MiniLM etc.) — large, not committed
|
# Local model weights (MiniLM etc.) — large, not committed
|
||||||
weights/
|
weights/
|
||||||
.venv
|
.venv
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
|||||||
+51
-2
@@ -484,7 +484,52 @@ explain the slower windows.
|
|||||||
|
|
||||||
## Concurrent reads
|
## Concurrent reads
|
||||||
|
|
||||||
### Results (2026-09-26, tank)
|
### Results after the read fixes (2026-09-26, tank, `408f69e`)
|
||||||
|
|
||||||
|
Same machine, files and commands as the first run below, re-run on an idle
|
||||||
|
tank (load average 1.60 at the start; the 1-minute figure rose to about 5
|
||||||
|
during the clawhdf5 runs, mostly their own threads) after two fixes:
|
||||||
|
contiguous reads back their output with transparent huge pages and copy
|
||||||
|
hyperslabs run by run, and full chunked reads no longer queue behind a
|
||||||
|
one-thread rayon pool. h5py was re-run in the same session.
|
||||||
|
|
||||||
|
Each read decoding on its calling thread (`--decode-threads 1`, like h5py):
|
||||||
|
|
||||||
|
| layout | mode | threads | clawhdf5 MB/s (eff) | h5py threads MB/s (eff) | h5py processes MB/s (eff) |
|
||||||
|
|---|---|---:|---:|---:|---:|
|
||||||
|
| deflate | distinct | 1 | 606 (1.00) | 432 (1.00) | 421 (1.00) |
|
||||||
|
| deflate | distinct | 4 | 1816 (0.75) | 428 (0.25) | 1654 (0.98) |
|
||||||
|
| deflate | distinct | 8 | 2943 (0.61) | 428 (0.12) | 3042 (0.90) |
|
||||||
|
| deflate | distinct | 16 | 2142 (0.22) | 375 (0.05) | 3083 (0.46) |
|
||||||
|
| deflate | same | 1 | 154 (1.00) | 130 (1.00) | 129 (1.00) |
|
||||||
|
| deflate | same | 4 | 599 (0.98) | 129 (0.25) | 499 (0.97) |
|
||||||
|
| deflate | same | 16 | 1592 (0.65) | 128 (0.06) | 1399 (0.68) |
|
||||||
|
| contiguous | distinct | 1 | 13665 (1.00) | 9490 (1.00) | 8781 (1.00) |
|
||||||
|
| contiguous | distinct | 16 | 12674 (0.06) | 2285 (0.02) | 6942 (0.05) |
|
||||||
|
| contiguous | same | 1 | 31991 (1.00) | 5087 (1.00) | 5078 (1.00) |
|
||||||
|
| contiguous | same | 16 | 237151 (0.46) | 4304 (0.05) | 35772 (0.44) |
|
||||||
|
|
||||||
|
With the default rayon pool: deflate `distinct` 2117 MB/s at 1 thread (4.9x
|
||||||
|
h5py), 3163 at 4, 2341 at 16 (0.76x h5py processes); deflate `same` 1439 MB/s
|
||||||
|
at 16; contiguous as above within a few percent.
|
||||||
|
|
||||||
|
Before -> after for clawhdf5 (`--decode-threads 1` unless noted):
|
||||||
|
contiguous full read at 1 thread 2495 -> 13665 MB/s (0.25x -> 1.44x h5py);
|
||||||
|
contiguous 256 x 256 hyperslabs at 1 thread 624 -> 31991 MB/s (0.12x ->
|
||||||
|
6.3x); deflate full reads at 8 threads 887 -> 2943 MB/s; deflate
|
||||||
|
hyperslabs at 16 threads 1244 -> 1592 MB/s.
|
||||||
|
|
||||||
|
Read with care:
|
||||||
|
- `contiguous same` reads 1024 slabs of one 64 MiB dataset over and over, so
|
||||||
|
it mostly measures copies out of the CPU's caches (the 7800X3D has 96 MiB
|
||||||
|
of L3); the per-call overhead is what differs (h5py's is about 50 us).
|
||||||
|
- At 16 threads every tool dropped in this run (h5py threads on contiguous
|
||||||
|
data from 8002 to 2285 MB/s, processes from 12846 to 6942), so the
|
||||||
|
16-thread rows are noisier than the others.
|
||||||
|
- Still behind: full reads of chunked data at 16 threads (0.69x-0.76x h5py
|
||||||
|
processes). See `docs/known-issues.md`.
|
||||||
|
|
||||||
|
### First run, before the read fixes (2026-09-26, tank, `91644d8`)
|
||||||
|
|
||||||
Measured on tank (AMD Ryzen 7 7800X3D, 8 cores / 16 threads, 61 GiB, Linux
|
Measured on tank (AMD Ryzen 7 7800X3D, 8 cores / 16 threads, 61 GiB, Linux
|
||||||
7.0) at commit `91644d8`, load average 1.84 when the run started (the
|
7.0) at commit `91644d8`, load average 1.84 when the run started (the
|
||||||
@@ -528,7 +573,11 @@ What this shows:
|
|||||||
(about 880 MB/s) while h5py processes reach 4424 MB/s. Hyperslab
|
(about 880 MB/s) while h5py processes reach 4424 MB/s. Hyperslab
|
||||||
reads, which bypass the `File`'s chunk cache, keep scaling, so the
|
reads, which bypass the `File`'s chunk cache, keep scaling, so the
|
||||||
cache (one mutex and one 16 MiB budget per `File`, thrashed by 64 MiB
|
cache (one mutex and one 16 MiB budget per `File`, thrashed by 64 MiB
|
||||||
datasets) is the suspect.
|
datasets) is the suspect. The cause of the `--decode-threads 1`
|
||||||
|
ceiling was not the cache: every full read queued its chunks for the
|
||||||
|
pool's single rayon worker. That case was fixed after these
|
||||||
|
measurements (2026-09-26, not yet re-measured here). With the default
|
||||||
|
pool the gap to h5py processes remains (see `docs/known-issues.md`).
|
||||||
- *Contiguous reads are slow*: 2.5 GB/s for a single-threaded full read
|
- *Contiguous reads are slow*: 2.5 GB/s for a single-threaded full read
|
||||||
against h5py's 9.8 GB/s (0.25x), and 0.12x for 256 x 256 hyperslabs.
|
against h5py's 9.8 GB/s (0.25x), and 0.12x for 256 x 256 hyperslabs.
|
||||||
Threads close the gap (about 1.0x h5py at 16), but single-thread
|
Threads close the gap (about 1.0x h5py at 16), but single-thread
|
||||||
|
|||||||
+343
-2
@@ -2,6 +2,347 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Concurrent reads (2026-09-26)
|
||||||
|
- **Full reads of chunked datasets scale with threads again when rayon's
|
||||||
|
pool has one thread.** Each full read handed its chunks to rayon to
|
||||||
|
decode; with a one-thread pool (`RAYON_NUM_THREADS=1`, or
|
||||||
|
`concurrent_read --decode-threads 1`) every thread reading through a
|
||||||
|
`File` queued behind that single worker, so N readers decoded on one core
|
||||||
|
and throughput stopped at about 2x one thread. Such reads, and
|
||||||
|
`verify_provenance`'s uncached reader, now decode on the calling thread
|
||||||
|
(`clawhdf5_format::parallel_read::pool_can_parallelise`). The `File`'s
|
||||||
|
chunk cache, the suspect in `docs/known-issues.md`, was not the cause:
|
||||||
|
reads of datasets larger than its budget already skipped inserting, and
|
||||||
|
its lookups cost a few percent at 16 threads. Throughput with the default
|
||||||
|
pool is unchanged, and still short of an h5py process pool.
|
||||||
|
|
||||||
|
### Contiguous read speed (2026-09-26)
|
||||||
|
- **Large read buffers are backed by transparent huge pages.** A full read
|
||||||
|
of a contiguous dataset was one `memcpy` from the mapped file, yet ran at
|
||||||
|
a quarter of h5py's speed on one thread: the fresh output `Vec` took a
|
||||||
|
page fault (and a kernel page clear) for every 4 KiB page it was written
|
||||||
|
to, 16384 of them for 64 MiB, and those cost several times the copy.
|
||||||
|
numpy, and so h5py, asks for transparent huge pages on every allocation of
|
||||||
|
4 MiB or more; clawhdf5-format's read buffers now do too
|
||||||
|
(`madvise(MADV_HUGEPAGE)` on Linux, `libc` added as a Linux-only
|
||||||
|
dependency; a no-op elsewhere or when THP is disabled). It applies to the
|
||||||
|
typed readers' output (`read_f32`, `read_f64`, `read_i32`, `read_i64`,
|
||||||
|
`read_u64`, both byte orders), the raw contiguous read and the chunk
|
||||||
|
assembly buffer. Values are unchanged; new h5py comparison
|
||||||
|
`crates/clawhdf5/tests/contiguous_read_interop.rs` covers every 1-8-byte
|
||||||
|
integer and float type in both byte orders, ranks 1-4, and datasets past
|
||||||
|
the 4 MiB threshold.
|
||||||
|
- **Hyperslab and point reads of contiguous data copy runs, not elements.**
|
||||||
|
A 256 x 256 hyperslab of a contiguous `f32` dataset read at an eighth of
|
||||||
|
h5py's speed: the selection's bounding box was copied out of the file,
|
||||||
|
then walked element by element (a recursive call and two bounds checks per
|
||||||
|
element) into a second buffer, which `read_f32_selection` converted into
|
||||||
|
a third. Selections of contiguous data are now copied straight from the
|
||||||
|
file, one `memcpy` per run of elements that is contiguous in the file
|
||||||
|
(a block along the last dimension, blocks that touch, and whole rows when
|
||||||
|
the inner dimensions are selected in full, merged), with no zero-filled
|
||||||
|
intermediate; a selection covering most of the dataset no longer makes a
|
||||||
|
full copy first. The typed selection readers (`read_f32_selection`,
|
||||||
|
`read_f64_selection`, `read_i32_selection`, `read_i64_selection`) copy
|
||||||
|
directly into their output when the dataset stores that type natively,
|
||||||
|
and convert as before otherwise (big-endian, other widths). The chunked
|
||||||
|
paths use the same run-based extraction. New public
|
||||||
|
`clawhdf5_format::data_read::read_selection_native` and the sealed
|
||||||
|
`NativeElement` trait (also used by the `read_as_*` fast paths, which
|
||||||
|
gained one for native `u64`). Values are unchanged: checked against h5py
|
||||||
|
by `contiguous_read_interop.rs` (strided, blocked, adjacent-block and
|
||||||
|
whole-row hyperslabs, points, empty selections; every type, both byte
|
||||||
|
orders, ranks 1-4).
|
||||||
|
|
||||||
|
### Variable-length data (2026-09-26)
|
||||||
|
- **VL values in files with 4-byte offsets** (`sizeof_addr = 4`). A VL
|
||||||
|
string attribute came back as `AttrValue::Raw`, a VL member of a compound
|
||||||
|
failed with `GlobalHeapObjectNotFound`, and VL datasets failed with a
|
||||||
|
size mismatch. Two causes: `Datatype::type_size()` reported 16 for every
|
||||||
|
VL type (the element is 4 + offset size + 4 bytes: 12 here), and the
|
||||||
|
global heap was parsed without the padding libhdf5 puts after its
|
||||||
|
collection and object headers (`H5HG_SIZEOF_HDR`/`H5HG_SIZEOF_OBJHDR`
|
||||||
|
round up to 8), so with 4-byte lengths every object was looked up 4
|
||||||
|
bytes early. `Datatype::VariableLength` now carries the element `size`
|
||||||
|
stored in the datatype message (**breaking** for code that builds or
|
||||||
|
exhaustively destructures that variant; patterns with `..` are
|
||||||
|
unaffected), and it is written back as stored. Tested against h5py
|
||||||
|
(`crates/clawhdf5/tests/vl_offset4_interop.rs`).
|
||||||
|
- **Wrong data: VL strings with an embedded NUL, and VL elements whose heap
|
||||||
|
object has the wrong size.** libhdf5 hands VL strings over as C strings,
|
||||||
|
so h5py reads `"a\0b"` as `"a"`; `read_vl_strings` returned the NUL and
|
||||||
|
what followed. An element whose heap object is not exactly
|
||||||
|
`length × base size` bytes is refused by libhdf5 ("Expected global heap
|
||||||
|
object size does not match"); we returned the object cut or padded to
|
||||||
|
the length. Both now behave as libhdf5, and a heap address of 0 is a null
|
||||||
|
element (empty) whatever its length. The new
|
||||||
|
`clawhdf5_format::vl_data::VlResolver` does this and parses each global
|
||||||
|
heap collection once per read: `read_vl_strings` parsed the whole
|
||||||
|
collection again for every element. `vl_data::check_element_size` refuses
|
||||||
|
a VL datatype whose stored size is not 4 + offset size + 4 (libhdf5
|
||||||
|
ignores the stored size). The conformance probe resolves VL elements
|
||||||
|
with `VlResolver` too; conformance unchanged at 575 of 697.
|
||||||
|
- **VL data through the facade.** VL-string datasets (h5py's default `str`
|
||||||
|
dtype) failed `read_string` with "type mismatch: expected String, got
|
||||||
|
VariableLength". `Dataset::read_string` now reads fixed- and
|
||||||
|
variable-length strings; new `read_string_bytes` (a VL string's exact
|
||||||
|
bytes, as h5py's `Dataset[()]` returns them), `read_string_selection`,
|
||||||
|
`read_vlen::<T>()` / `read_vlen_selection::<T>()` for VL sequences of
|
||||||
|
numbers (`T` = `f64`, `f32`, `i64`, `i32`, `u64`; converted like the
|
||||||
|
other typed readers), and `File::decode_strings` / `decode_string_bytes`
|
||||||
|
/ `decode_vlen` for VL values in compound fields and `AttrValue::Raw`
|
||||||
|
attributes. `MmapDataset` and `LazyDataset` gain `read_string` for VL
|
||||||
|
strings, `read_string_bytes` and `read_vlen`. Checked against h5py with
|
||||||
|
8- and 4-byte offsets: scalar and 1-/2-D, ASCII and UTF-8, empty strings,
|
||||||
|
contiguous, compact, chunked with gzip/shuffle, never-written and
|
||||||
|
partly written chunks, hyperslab selections, VL members of compound
|
||||||
|
datasets and attributes (`crates/clawhdf5/tests/vl_data_interop.rs`).
|
||||||
|
NetCDF-4 `string` variables now read through
|
||||||
|
`clawhdf5_netcdf4::Variable::read_string` (checked against netCDF4-python
|
||||||
|
in `crates/clawhdf5-netcdf4/tests/interop_tests.rs`).
|
||||||
|
|
||||||
|
- **Crafted global heaps could exhaust memory.** `VlResolver` kept an owned
|
||||||
|
copy of every object of every heap collection it read, so collections
|
||||||
|
nested inside each other's object data made a 744 KB file take 1.58 GB
|
||||||
|
(and `read_vl_strings` before it did the same). The cache now records
|
||||||
|
where objects lie instead of copying them, is dropped past a 32 MiB
|
||||||
|
budget, and a collection overlapping one already read is an error
|
||||||
|
(libhdf5 never writes one). New `GlobalHeapCollection::parse_index`
|
||||||
|
locates a collection's objects without copying them; `parse` and
|
||||||
|
`parse_index` refuse a collection running past the end of the file or an
|
||||||
|
object running past its collection. Conformance unchanged at 575 of 697
|
||||||
|
(`crates/clawhdf5-format/tests/vl_heap_bounds.rs`).
|
||||||
|
|
||||||
|
- **Every reader resolves VL data the same way.** `h5rs` (`dump`, `ls`,
|
||||||
|
`diff`, `check --data`) had its own lenient VL decoder: a heap object
|
||||||
|
longer than the element's length was cut to it (h5py refuses it), a null
|
||||||
|
string printed `""` where h5dump prints `NULL`, the stored element size
|
||||||
|
was trusted, and each heap collection was kept as a copy for the whole
|
||||||
|
run. It now resolves through `VlResolver`, so `dump` matches h5dump byte
|
||||||
|
for byte on VL strings (`"a\0b"` as `"a"`, null as `NULL`), VL sequences
|
||||||
|
and 4-byte-offset files, `dump --json` gives h5py's values, and
|
||||||
|
`check --data` reports any heap object whose size is not exactly the
|
||||||
|
element's length × base size. `clawhdf5-wasm` already resolved VL strings
|
||||||
|
with `read_vl_strings`; it now uses `VlResolver` and refuses a VL type
|
||||||
|
whose stored element size disagrees with the file, as `File` does
|
||||||
|
(`crates/clawhdf5-tools/tests/h5rs_interop.rs`,
|
||||||
|
`crates/clawhdf5-wasm/tests/vl_strings.rs`). New
|
||||||
|
`VlResolver::element` / `string_element` resolve one element in place.
|
||||||
|
|
||||||
|
- **A VL element at the undefined heap address is an error**, as in
|
||||||
|
libhdf5 ("addr undefined"). One of length 0 read as `""` in every reader
|
||||||
|
(`File`, `h5rs`, `clawhdf5-wasm`, `read_vl_strings`, `read_vl_bytes`).
|
||||||
|
libhdf5 writes a null element with heap address 0, which still reads as
|
||||||
|
empty, and h5py writes `""` as a zero-size heap object at a real address,
|
||||||
|
so no file libhdf5 or h5py writes is affected
|
||||||
|
(`a_vl_element_at_the_undefined_heap_address_fails_like_h5py` in
|
||||||
|
`crates/clawhdf5/tests/vl_data_interop.rs`). `read_vl_bytes` now also
|
||||||
|
treats address 0 as null whatever the length, as `VlResolver` does.
|
||||||
|
|
||||||
|
### Writer: groups and links (2026-09-26)
|
||||||
|
- **Nested groups, to any depth.** `FileWriter`/`FileBuilder` wrote the root
|
||||||
|
group plus one level, and refused path-like names. Now a name may be a path
|
||||||
|
(`create_dataset("a/b/x")`, `create_group("a/b")`, a leading `/` at the
|
||||||
|
root) and missing intermediate groups are created, as h5py does; groups
|
||||||
|
also nest through the new `GroupBuilder::create_group`/`add_group`. A group
|
||||||
|
added at a path that already holds a group is merged into it (h5py's
|
||||||
|
`require_group`); a name used twice otherwise, an empty or `"."`
|
||||||
|
component (`"a//b"`, `"a/"`) or an absolute path below the root is an
|
||||||
|
error. Datasets, attributes, dense attribute storage and dense link
|
||||||
|
storage work at every level.
|
||||||
|
- **Soft, hard and external links at any depth:** `add_soft_link(name,
|
||||||
|
target)` (h5py's `SoftLink`; the target may dangle),
|
||||||
|
`add_hard_link(name, target)` (h5py's `f[name] = f[target]`; the target
|
||||||
|
path is resolved when the file is written, may go through other hard
|
||||||
|
links, and a missing target, a soft link on the way or a cycle of
|
||||||
|
hard-link paths is an error) and `add_external_link`, on `FileWriter`,
|
||||||
|
`FileBuilder` and `GroupBuilder`. An object with several hard links gets
|
||||||
|
an Object Reference Count message, so libhdf5 can delete one of the links
|
||||||
|
without freeing the object.
|
||||||
|
- **Link creation order:** `track_order(true)` on a `GroupBuilder`, or on
|
||||||
|
`FileWriter`/`FileBuilder` for every group that does not set its own,
|
||||||
|
tracks and indexes link creation order (h5py's `track_order=True`): the
|
||||||
|
Link Info message carries the flags, each link its order, and a dense
|
||||||
|
group a creation-order B-tree (type 6). h5py then lists members in
|
||||||
|
insertion order. Attribute creation order is not tracked.
|
||||||
|
- A group holds at most 65 535 links (its link index is one B-tree leaf),
|
||||||
|
and in a group of more than 8 links (dense storage) each link message
|
||||||
|
must be at most 65 515 bytes (one fractal heap block; huge heap objects
|
||||||
|
are not written); more is an error. Measured at the limit: 65 535 links
|
||||||
|
with 100-byte names (a 7 MB heap) read in h5py, h5dump and clawhdf5, and
|
||||||
|
h5py can add to the group. `GroupBuilder`'s fields changed (they were
|
||||||
|
crate-private); `FinishedGroup` is unchanged for callers.
|
||||||
|
- Files that use one level of groups and no new link kinds are laid out as
|
||||||
|
before: byte-identical to the writer with the Group Info fix below
|
||||||
|
(compared on simple, mixed dense/chunked/compact/external-link and paged
|
||||||
|
files). Tests: h5py and clawhdf5 read the same
|
||||||
|
tree (every path, attribute and value) from a 5-level file; soft, hard,
|
||||||
|
external and cyclic hard links; 10 000, 20 000 and 65 535 links in one
|
||||||
|
group, with and without creation order; libhdf5 adding and deleting links
|
||||||
|
in our groups;
|
||||||
|
`h5rs check` passes and `h5rs dump` equals h5dump
|
||||||
|
(`crates/clawhdf5/tests/writer_groups_interop.rs`,
|
||||||
|
`crates/clawhdf5-tools/tests/h5rs_interop.rs`).
|
||||||
|
- **Big dense groups and attribute sets were unreadable.** The fractal heap
|
||||||
|
holding dense links or attributes wrote every doubling-table row as
|
||||||
|
direct blocks, but past the 512 KiB the root's direct blocks hold, rows
|
||||||
|
are child indirect blocks, and libhdf5 and `h5rs check` read them as
|
||||||
|
such: a group with 20 000 links of 20-byte names was written without
|
||||||
|
error and h5py could not list it ("incorrect metadata checksum"); 150
|
||||||
|
dense attributes of up to 56 KB could not be opened. This was in 2.7.0
|
||||||
|
too. The heap writer now writes child indirect blocks, nested as deep as
|
||||||
|
needed. Found on the way: an object bigger than the next block's space
|
||||||
|
was cut off (it now goes in the first block big enough), and h5py adding
|
||||||
|
a link to a heap over 64 KiB overwrote its first block (the header's
|
||||||
|
next-block offset was 0).
|
||||||
|
- **h5py crashed adding a link to a group of more than about 47 700
|
||||||
|
links** (35 000 with creation order tracked). The link index leaf's node size gave libhdf5 room for more than
|
||||||
|
65 535 records, which overflows the leaf's 2-byte count. The node is now
|
||||||
|
capped at 65 535 records. Dense attributes use the same index builder:
|
||||||
|
more than 65 535 on one object used to be written with the count modulo
|
||||||
|
65 536, and are now an error.
|
||||||
|
- **A dense link or attribute message over 65 515 bytes** (e.g. a soft link
|
||||||
|
with a long target in a group of more than 8 links) was written cut off,
|
||||||
|
and libhdf5 could not list the group ("object overruns end of direct
|
||||||
|
block"). It is now an error.
|
||||||
|
- **Chained hard links took exponential time to resolve.** A hard-link
|
||||||
|
target going through other hard links resolved them again on every path
|
||||||
|
through them: 26 links whose targets each named the previous one twice
|
||||||
|
took 46 s. Each hard link is now resolved once, and a cycle is reported
|
||||||
|
by the link's name.
|
||||||
|
- **A dataset attribute set twice read back as its first value**, as for
|
||||||
|
groups below (h5py listed the name twice). The later value now replaces
|
||||||
|
the earlier one; a hand-set attribute named like a provenance attribute
|
||||||
|
is replaced by the computed one.
|
||||||
|
- **A group attribute set twice read back as its first value.** Setting a
|
||||||
|
group (or root) attribute again wrote a second attribute message with the
|
||||||
|
same name, and h5py returned the first value. The later value now replaces
|
||||||
|
the earlier one, as `attrs[name] = v` does in h5py — also when a group is
|
||||||
|
merged from two builders.
|
||||||
|
- **Non-ASCII link names were marked ASCII.** A group or dataset name such as
|
||||||
|
`größe` was written with the ASCII character set flag (h5py reported
|
||||||
|
`cset` 0 for it); it is now flagged UTF-8, as h5py writes it.
|
||||||
|
- **libhdf5 could not add links to groups we wrote.** h5py in `"r+"` mode
|
||||||
|
failed with "Unable to create link (message type not found)" on every
|
||||||
|
group `FileWriter` wrote: libhdf5 reads a group's Group Info message before
|
||||||
|
inserting a link, and none was written. Every group now carries one
|
||||||
|
(version 0, default thresholds: 6 more bytes per group header, so files
|
||||||
|
are not byte-identical to earlier versions). Regression test:
|
||||||
|
`crates/clawhdf5/tests/writer_groups_interop.rs`.
|
||||||
|
|
||||||
|
### Python bindings (2026-09-26)
|
||||||
|
- **Panic: selections of v4 implicit-index chunked datasets** (pre-existing,
|
||||||
|
facade `Dataset::read_selection`, Rust callers too). A hyperslab whose
|
||||||
|
bounding box covered more than half of a chunked dataset with the implicit
|
||||||
|
index (`libver='latest'`, early allocation, no filters) panicked with
|
||||||
|
"index out of bounds" in `generate_implicit_chunks`: the fallback in
|
||||||
|
`data_read::read_raw_data_selection` passed the layout's chunk dimensions,
|
||||||
|
element-size dimension included, and then decoded the whole dataset
|
||||||
|
anyway. That arm now decodes and extracts directly, for every chunk index.
|
||||||
|
`crates/clawhdf5/tests/v4_chunk_index_selection.rs` reads small and large
|
||||||
|
hyperslabs of all five v4 indexes (single chunk, implicit, fixed array,
|
||||||
|
extensible array, B-tree v2) and compares them with h5py; it panicked
|
||||||
|
before. The Python bindings made this easy to reach (`ds[0:3]` on
|
||||||
|
libhdf5's `h5fc_ext*.h5` test files).
|
||||||
|
- **`pip install` / `maturin develop` now gives `import clawhdf5`.** The
|
||||||
|
distribution in `crates/clawhdf5-py/pyproject.toml` was still called
|
||||||
|
`rustyhdf5` while the extension module was `clawhdf5`, and the package's
|
||||||
|
tests imported `rustyhdf5`, so they failed at collection. Distribution,
|
||||||
|
module and tests now all say `clawhdf5`, and the module has
|
||||||
|
`__version__`.
|
||||||
|
- **h5py-style reads that read the selection, not the dataset.** `ds[...]`
|
||||||
|
used to read the whole dataset and slice it in numpy, and knew six
|
||||||
|
dtypes. Now
|
||||||
|
integers (negative from the end), slices with positive steps, `...`, one
|
||||||
|
increasing list of integers per key and compound field names map onto the
|
||||||
|
facade's hyperslab selection (a list is read one group of neighbouring
|
||||||
|
chunks at a time and picked from in memory), with h5py's results (numpy scalar for an all-integer
|
||||||
|
key, 0-d array for `scalar[...]`) and h5py's errors for everything else
|
||||||
|
(negative steps, `None`, boolean masks, out-of-range indices).
|
||||||
|
`Dataset.dtype` is the numpy dtype h5py reports, for every integer and
|
||||||
|
IEEE float width (incl. `float16`) in either byte order, `bool`, enums
|
||||||
|
(base integer with `metadata['enum']`), complex (`r`/`i` compounds),
|
||||||
|
fixed strings (`S<n>`), variable-length strings (`object` of `bytes`, as
|
||||||
|
h5py), variable-length sequences (`object` of arrays), opaque (`V<n>`),
|
||||||
|
HDF5 array types and compounds (numpy structured, offsets and padding
|
||||||
|
kept, nested). The bytes the library returns become the numpy array's
|
||||||
|
buffer without a copy. Types the mapping cannot describe exactly
|
||||||
|
(references, bitfields, time, non-IEEE floats, integers with padding
|
||||||
|
bits, variable-length members inside compounds) raise `TypeError` rather
|
||||||
|
than return guessed data. Attributes come back as h5py returns them
|
||||||
|
(numpy scalars and arrays with the stored dtype, `str` for
|
||||||
|
variable-length strings, `numpy.bytes_` for fixed ones — **a change**:
|
||||||
|
string attributes written by this package are fixed-length and used to
|
||||||
|
come back as `str` — and `clawhdf5.Empty` for a null dataspace, which
|
||||||
|
datasets return too). `Group`/`File` gain `get`, `values`, `items`,
|
||||||
|
iteration, `len`, `name`, absolute and relative paths (`g['/a/b']`,
|
||||||
|
`g['c/d']`, `f['/']`); `Dataset` gains `ndim`, `size`, `maxshape`,
|
||||||
|
`name`, `len()` and `numpy.asarray(ds)`. File access and decoding run
|
||||||
|
with the GIL released, so Python threads read in parallel.
|
||||||
|
`crates/clawhdf5-py/tests/test_read_vs_h5py.py` compares every read with
|
||||||
|
h5py 3.16 (HDF5 2.0) on a file h5py writes. One difference is h5py's:
|
||||||
|
it returns variable-length sequences of big-endian floats unswapped; this
|
||||||
|
package returns the stored values.
|
||||||
|
- **A panic in the library is an ordinary Python exception.** PyO3 turns a
|
||||||
|
Rust panic into `PanicException`, a `BaseException` that `except
|
||||||
|
Exception` does not catch. Every call from the bindings into the library
|
||||||
|
is now guarded and a panic becomes `clawhdf5.InternalError` (a
|
||||||
|
`RuntimeError`) naming the object; with the implicit-index panic above
|
||||||
|
restored, `ds[0:30]` raises it.
|
||||||
|
- **Wrong data: uninitialised padding in compound results of index lists.**
|
||||||
|
`ds[[0, 3, 6]]` joined one read per run with `np.concatenate`, which
|
||||||
|
copies structured dtypes field by field into an `np.empty` result, so the
|
||||||
|
padding bytes held whatever was in memory (pointers were seen) and leaked
|
||||||
|
through `tobytes()`, hashes and write-backs. The runs' bytes are now joined
|
||||||
|
in Rust, whole elements at a time, so the result carries the bytes read
|
||||||
|
from the file (h5py's, zero for files it wrote) and stays zero-copy.
|
||||||
|
The h5py comparisons now also compare every byte of structured values
|
||||||
|
(`test_compound_padding_bytes_match_h5py` and `assert_same`).
|
||||||
|
- **Index lists no longer decode the same chunks once per run.** A list
|
||||||
|
index was one uncached hyperslab read per run of consecutive indices, so
|
||||||
|
on a chunked, compressed dataset every run decoded its chunk again:
|
||||||
|
`d[list(range(0, 200000, 40))]` over 20 gzip chunks took 8 s (h5py:
|
||||||
|
0.014 s). The list is now read in groups — for a chunked dataset a group
|
||||||
|
ends only where a whole chunk holds no selected index, so each chunk is
|
||||||
|
decoded once; otherwise at a gap of more than 64 KiB — and the selected
|
||||||
|
rows are picked from each group in Rust. The same read now takes 3.8 ms
|
||||||
|
(h5py 4.1 ms; release build on tank, best of 5).
|
||||||
|
`test_a_long_index_list_decodes_each_chunk_once` compares 1-D, 2-D and
|
||||||
|
contiguous cases with h5py under a 2 s bound (5.8 s before, debug build).
|
||||||
|
- **Groups and datasets remember where they are.** Every `ds[...]`, and
|
||||||
|
every `g[k]`, resolved its path from the root again (two or three times
|
||||||
|
per open), and in a large group each resolution scans the group's links,
|
||||||
|
so visiting a group was quadratic: 4000 scalar datasets in one group took
|
||||||
|
39 s (`libver='earliest'`) and 131 s (`'latest'`) to list, read and
|
||||||
|
re-read in `test_big_groups_are_not_quadratic`; now 0.3 s each (debug
|
||||||
|
build). A `Dataset` keeps its object's address, and a `Group` (and the
|
||||||
|
file's root) its address and, once listed, its link table. New facade
|
||||||
|
API: `File::dataset_at(address)` opens a dataset without resolving a
|
||||||
|
path. libhdf5's `h5stat_newgrat.h5` (35001 members in the root): listing
|
||||||
|
takes 0.03 s and 2000 opens 1 ms (h5py: 0.022 s).
|
||||||
|
- **`ds[np.array(1)]` is an integer index**, as in h5py; a 0-d integer
|
||||||
|
array went down the index-list path and raised a confusing `TypeError`.
|
||||||
|
The h5py comparison keys now include 0-d arrays on every axis.
|
||||||
|
- **Tests that would notice a held GIL, and our extra errors.**
|
||||||
|
`test_reads_release_the_gil` times a Python thread spinning while another
|
||||||
|
reads: with the read made to hold the GIL it stalls for the whole read
|
||||||
|
(0.062 s of a 0.064 s read) and the test fails; released, its longest
|
||||||
|
stall is about 3 ms. (The existing threads test only checked values.)
|
||||||
|
`test_errors_match_h5py` now also requires that every key h5py reads
|
||||||
|
reads here too, with the same result, and covers more keys (0-d arrays,
|
||||||
|
repeated and empty lists, `()`, `...`).
|
||||||
|
- **Docs say when a selection reads more than itself.** The README and
|
||||||
|
the package README said `ds[...]` reads only the selected elements,
|
||||||
|
without condition. The library decodes the whole dataset when the
|
||||||
|
selection's bounding box covers more than half of it, and for compact,
|
||||||
|
virtual, unwritten and non-default-fill chunked datasets; the READMEs,
|
||||||
|
the facade's `read_selection` docs and `docs/known-issues.md` now say so.
|
||||||
|
- **CI builds and tests the Python package.** It was excluded from CI.
|
||||||
|
`scripts/ci-test.sh` now lints `clawhdf5-py`, builds the wheel with
|
||||||
|
maturin, unpacks it under `target/` and runs the pytest suite; skipped
|
||||||
|
without maturin/pytest in `$CLAWHDF5_PYTHON`, a failure then under
|
||||||
|
`CLAWHDF5_REQUIRE_INTEROP=1`. The CI interop venv installs both.
|
||||||
|
|
||||||
### Plugin filters (2026-09-26)
|
### Plugin filters (2026-09-26)
|
||||||
- **LZF, bitshuffle, bzip2 and Blosc read and write, in pure Rust.** Files
|
- **LZF, bitshuffle, bzip2 and Blosc read and write, in pure Rust.** Files
|
||||||
written by h5py with `compression="lzf"`, or with hdf5plugin's
|
written by h5py with `compression="lzf"`, or with hdf5plugin's
|
||||||
@@ -205,10 +546,10 @@
|
|||||||
printed with its address; exit 1 when there are any. libhdf5's h5check
|
printed with its address; exit 1 when there are any. libhdf5's h5check
|
||||||
reads only the 1.8 format. On the conformance corpus it passes all 418
|
reads only the 1.8 format. On the conformance corpus it passes all 418
|
||||||
files that both clawhdf5 and h5py read in full, and `check --data` flags
|
files that both clawhdf5 and h5py read in full, and `check --data` flags
|
||||||
134 of the 150 CVE and fuzzer files of the `cve_hdf5` corpus (tank,
|
135 of the 150 CVE and fuzzer files of the `cve_hdf5` corpus (tank,
|
||||||
2026-09-26). `--data` also follows variable-length data into its global
|
2026-09-26). `--data` also follows variable-length data into its global
|
||||||
heap collections and reports a damaged one at its address. It inherits
|
heap collections and reports a damaged one at its address. It inherits
|
||||||
the library's tolerance, though: 9 of the 16 it passes are files h5dump
|
the library's tolerance, though: 8 of the 15 it passes are files h5dump
|
||||||
1.14.6 rejects (see `docs/known-issues.md`, header checks).
|
1.14.6 rejects (see `docs/known-issues.md`, header checks).
|
||||||
- Values over `--max-bytes` (default 1 GiB) are reported instead of read;
|
- Values over `--max-bytes` (default 1 GiB) are reported instead of read;
|
||||||
a panic is caught and reported as an internal error (exit 3).
|
a panic is caught and reported as an internal error (exit 3).
|
||||||
|
|||||||
+4
-4
@@ -13,15 +13,15 @@ fatal. This file is generated by `conformance/run.sh`; do not edit it by hand.
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| date | 2026-09-26 06:50 UTC |
|
| date | 2026-09-26 14:18 UTC |
|
||||||
| clawhdf5 commit | `72306c601399748616bc9d061be2ebc4c1bea9e0` |
|
| clawhdf5 commit | `73a01f1256fb9bf1b1e7601f755af9e8273cec4e` |
|
||||||
| machine | `tank`: AMD Ryzen 7 7800X3D 8-Core Processor, 16 CPUs, 61 GiB, Linux 7.0.0-34-generic x86_64 |
|
| machine | `tank`: AMD Ryzen 7 7800X3D 8-Core Processor, 16 CPUs, 61 GiB, Linux 7.0.0-34-generic x86_64 |
|
||||||
| command | `conformance/run.sh --no-fetch --update-baseline` |
|
| command | `conformance/run.sh --no-fetch --update-baseline` |
|
||||||
| rustc | rustc 1.98.1 (48a229cea 2026-09-01) |
|
| rustc | rustc 1.98.1 (48a229cea 2026-09-01) |
|
||||||
| reference | h5py 3.16.0, HDF5 2.0.0, numpy 2.5.3, hdf5plugin 7.1.0, Python 3.14.4 |
|
| reference | h5py 3.16.0, HDF5 2.0.0, numpy 2.5.3, hdf5plugin 7.1.0, Python 3.14.4 |
|
||||||
| h5dump | Version 1.14.6 (CVE corpus only) |
|
| h5dump | Version 1.14.6 (CVE corpus only) |
|
||||||
| limits | 20 s timeout (SIGKILL), 4096 MiB address space, per process; 16 files in parallel |
|
| limits | 20 s timeout (SIGKILL), 4096 MiB address space, per process; 16 files in parallel |
|
||||||
| runtime | 22 s probing + comparing (0 s fetch/build before it) |
|
| runtime | 23 s probing + comparing (0 s fetch/build before it) |
|
||||||
|
|
||||||
## Results
|
## Results
|
||||||
|
|
||||||
@@ -191,7 +191,7 @@ columns are.
|
|||||||
| cvefiles/cve-2024-32606.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
| cvefiles/cve-2024-32606.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
||||||
| cvefiles/cve-2024-32607-1.h5 | ok | read 10 obj | read 10 obj | ok |
|
| cvefiles/cve-2024-32607-1.h5 | ok | read 10 obj | read 10 obj | ok |
|
||||||
| cvefiles/cve-2024-32607-2.h5 | error exit | read 9 obj, 1 errors | read 9 obj, 1 errors | ok |
|
| cvefiles/cve-2024-32607-2.h5 | error exit | read 9 obj, 1 errors | read 9 obj, 1 errors | ok |
|
||||||
| cvefiles/cve-2024-32608.h5 | error exit | read 6 obj, 1 errors | read 6 obj | ok |
|
| cvefiles/cve-2024-32608.h5 | error exit | read 6 obj, 1 errors | read 6 obj, 1 errors | ok |
|
||||||
| cvefiles/cve-2024-32609.h5 | error exit | SIGSEGV | read 3 obj, 1 errors | h5py-cannot-read |
|
| cvefiles/cve-2024-32609.h5 | error exit | SIGSEGV | read 3 obj, 1 errors | h5py-cannot-read |
|
||||||
| cvefiles/cve-2024-32610.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
| cvefiles/cve-2024-32610.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
|
||||||
| cvefiles/cve-2024-32611.h5 | ok | read 6 obj | read 6 obj | ok |
|
| cvefiles/cve-2024-32611.h5 | ok | read 6 obj | read 6 obj | ok |
|
||||||
|
|||||||
@@ -73,8 +73,9 @@ breaking change, are in [CHANGELOG.md](CHANGELOG.md).
|
|||||||
- Default fusion weights are now the measured 0.4 / 0.6 (v2.5.0). Re-ranking had
|
- Default fusion weights are now the measured 0.4 / 0.6 (v2.5.0). Re-ranking had
|
||||||
been discarding the retrieval score, costing the Markdown backend 40.6pp of
|
been discarding the retrieval score, costing the Markdown backend 40.6pp of
|
||||||
Hit@1; fixed in v2.6.0.
|
Hit@1; fixed in v2.6.0.
|
||||||
- Selection reads decode only the chunks they touch (a 64×64 window: 105 ms to
|
- Selection reads whose bounding box covers at most half the dataset decode
|
||||||
0.39 ms), and full reads are 1.2–1.9× faster (v2.5.0).
|
only the chunks they touch (a 64×64 window: 105 ms to 0.39 ms), and full
|
||||||
|
reads are 1.2–1.9× faster (v2.5.0).
|
||||||
|
|
||||||
**Memory**
|
**Memory**
|
||||||
- A loaded store holds ~30% less (embeddings stored once, v2.6.0), and the
|
- A loaded store holds ~30% less (embeddings stored once, v2.6.0), and the
|
||||||
@@ -407,6 +408,79 @@ let values = ds.read_f64()?;
|
|||||||
assert_eq!(values, vec![22.5, 23.1, 21.8]);
|
assert_eq!(values, vec![22.5, 23.1, 21.8]);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Groups and links
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use clawhdf5::{AttrValue, FileBuilder};
|
||||||
|
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
// A path creates its missing intermediate groups, as in h5py.
|
||||||
|
b.create_dataset("run/2026/temps").with_f64_data(&[22.5, 23.1]);
|
||||||
|
// Builders nest; a group added at an existing path is merged into it.
|
||||||
|
let mut run = b.create_group("run");
|
||||||
|
run.set_attr("operator", AttrValue::String("ana".into()));
|
||||||
|
let mut cal = run.create_group("calibration");
|
||||||
|
cal.track_order(true); // h5py lists members in insertion order
|
||||||
|
cal.create_dataset("offset").with_f64_data(&[0.1]);
|
||||||
|
run.add_group(cal.finish());
|
||||||
|
b.add_group(run.finish());
|
||||||
|
b.add_soft_link("latest", "/run/2026"); // h5py.SoftLink
|
||||||
|
b.add_hard_link("temps", "/run/2026/temps"); // f["temps"] = f["run/2026/temps"]
|
||||||
|
b.add_external_link("raw", "raw.h5", "/data");
|
||||||
|
b.write("groups.h5")?;
|
||||||
|
```
|
||||||
|
|
||||||
|
A group holds at most 65 535 links; more is an error, as is a link over
|
||||||
|
65 515 bytes (a very long soft-link target) in a group of more than 8 links.
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
`crates/clawhdf5-py` is a Python package (PyO3 + numpy) that reads HDF5 with
|
||||||
|
an h5py-shaped API and no libhdf5. It is not on PyPI; build it with
|
||||||
|
[maturin](https://www.maturin.rs) into a virtualenv:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv && . .venv/bin/activate
|
||||||
|
pip install maturin numpy
|
||||||
|
maturin develop --release -m crates/clawhdf5-py/Cargo.toml
|
||||||
|
python -c "import clawhdf5; print(clawhdf5.__version__)"
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
import clawhdf5
|
||||||
|
|
||||||
|
with clawhdf5.File("data.h5", "r") as f:
|
||||||
|
print(list(f.keys())) # sorted member names, like h5py
|
||||||
|
ds = f["group/temperatures"] # relative or absolute ("/group/...") paths
|
||||||
|
print(ds.shape, ds.dtype) # dtype is the numpy dtype h5py reports
|
||||||
|
block = ds[100:200, ::4] # a small selection reads only its chunks
|
||||||
|
row = ds[-1] # integers drop the axis
|
||||||
|
picked = ds[[1, 5, 9], :] # one increasing index list per key
|
||||||
|
units = ds.attrs["units"] # attributes come back as h5py returns them
|
||||||
|
everything = np.asarray(ds)
|
||||||
|
|
||||||
|
records = f["table"] # compound -> numpy structured array
|
||||||
|
ids = records["id"] # one field
|
||||||
|
```
|
||||||
|
|
||||||
|
Reads cover integers and IEEE floats of every width in either byte order,
|
||||||
|
`bool`, enums, complex, fixed and variable-length strings, variable-length
|
||||||
|
sequences, opaque, HDF5 array types and compounds; other types (references,
|
||||||
|
bitfields, ...) raise `TypeError` instead of returning guessed data. Keys
|
||||||
|
follow h5py (negative steps, `None` and boolean masks are refused). The
|
||||||
|
read itself runs with the GIL released, so Python threads read in parallel.
|
||||||
|
A selection whose bounding box covers at most half the dataset decodes only
|
||||||
|
the chunks (or contiguous rows) that box overlaps; a larger one — including
|
||||||
|
a strided slice across the whole dataset — decodes the whole dataset, as
|
||||||
|
do datasets that are compact, virtual, unwritten, or chunked with a
|
||||||
|
non-default fill value (`docs/known-issues.md`). An index list is read one
|
||||||
|
group of neighbouring chunks at a time.
|
||||||
|
Writing (`File(path, "w")`, `create_dataset`, `create_group`, `attrs[...] =`)
|
||||||
|
covers `float64`, `float32`, `int64`, `int32` and `uint8` arrays. The tests
|
||||||
|
in `crates/clawhdf5-py/tests` compare every read with h5py; run them with
|
||||||
|
`pip install pytest h5py && pytest crates/clawhdf5-py/tests`.
|
||||||
|
|
||||||
### Agent Memory
|
### Agent Memory
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"comment": "conformance/run.sh fails if the ok count drops below `ok` or a file in `ok_files` stops being ok. Regenerate with `conformance/run.sh --update-baseline` after an intended change.",
|
"comment": "conformance/run.sh fails if the ok count drops below `ok` or a file in `ok_files` stops being ok. Regenerate with `conformance/run.sh --update-baseline` after an intended change.",
|
||||||
"commit": "72306c601399748616bc9d061be2ebc4c1bea9e0",
|
"commit": "73a01f1256fb9bf1b1e7601f755af9e8273cec4e",
|
||||||
"date": "2026-09-26 06:50 UTC",
|
"date": "2026-09-26 14:18 UTC",
|
||||||
"reference": "h5py 3.16.0 / HDF5 2.0.0",
|
"reference": "h5py 3.16.0 / HDF5 2.0.0",
|
||||||
"files": 697,
|
"files": 697,
|
||||||
"ok": 575,
|
"ok": 575,
|
||||||
|
|||||||
Generated
+1
@@ -64,6 +64,7 @@ dependencies = [
|
|||||||
"bzip2",
|
"bzip2",
|
||||||
"flate2",
|
"flate2",
|
||||||
"libaec-sys",
|
"libaec-sys",
|
||||||
|
"libc",
|
||||||
"lz4_flex",
|
"lz4_flex",
|
||||||
"pco",
|
"pco",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
|
|||||||
@@ -19,9 +19,8 @@
|
|||||||
//! with its message, location and the clawhdf5 frames of its backtrace.
|
//! with its message, location and the clawhdf5 frames of its backtrace.
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::HashSet;
|
||||||
use std::panic::{self, AssertUnwindSafe};
|
use std::panic::{self, AssertUnwindSafe};
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use clawhdf5_format::attribute::extract_attributes_full;
|
use clawhdf5_format::attribute::extract_attributes_full;
|
||||||
use clawhdf5_format::data_layout::DataLayout;
|
use clawhdf5_format::data_layout::DataLayout;
|
||||||
@@ -29,7 +28,6 @@ use clawhdf5_format::data_read;
|
|||||||
use clawhdf5_format::dataspace::{Dataspace, DataspaceType};
|
use clawhdf5_format::dataspace::{Dataspace, DataspaceType};
|
||||||
use clawhdf5_format::datatype::{Datatype, DatatypeByteOrder};
|
use clawhdf5_format::datatype::{Datatype, DatatypeByteOrder};
|
||||||
use clawhdf5_format::filter_pipeline::FilterPipeline;
|
use clawhdf5_format::filter_pipeline::FilterPipeline;
|
||||||
use clawhdf5_format::global_heap::GlobalHeapCollection;
|
|
||||||
use clawhdf5_format::group_v1::{self, GroupEntry};
|
use clawhdf5_format::group_v1::{self, GroupEntry};
|
||||||
use clawhdf5_format::group_v2;
|
use clawhdf5_format::group_v2;
|
||||||
use clawhdf5_format::message_type::MessageType;
|
use clawhdf5_format::message_type::MessageType;
|
||||||
@@ -37,6 +35,7 @@ use clawhdf5_format::object_header::ObjectHeader;
|
|||||||
use clawhdf5_format::signature;
|
use clawhdf5_format::signature;
|
||||||
use clawhdf5_format::superblock::Superblock;
|
use clawhdf5_format::superblock::Superblock;
|
||||||
use clawhdf5_format::symbol_table::SymbolTableMessage;
|
use clawhdf5_format::symbol_table::SymbolTableMessage;
|
||||||
|
use clawhdf5_format::vl_data::{VlResolver, check_element_size};
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
@@ -111,7 +110,10 @@ struct Ctx<'a> {
|
|||||||
os: u8,
|
os: u8,
|
||||||
ls: u8,
|
ls: u8,
|
||||||
base_dir: std::path::PathBuf,
|
base_dir: std::path::PathBuf,
|
||||||
heaps: RefCell<HashMap<u64, Result<Rc<GlobalHeapCollection>, String>>>,
|
/// Resolves variable-length elements as the library does (null
|
||||||
|
/// elements, strings cut at a NUL, heap objects of the wrong size
|
||||||
|
/// refused), caching each heap collection.
|
||||||
|
vl: RefCell<VlResolver<'a>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Ctx<'a> {
|
impl<'a> Ctx<'a> {
|
||||||
@@ -130,33 +132,6 @@ impl<'a> Ctx<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn heap_obj(&self, addr: u64, idx: u32) -> Result<Vec<u8>, String> {
|
|
||||||
let coll = {
|
|
||||||
let mut cache = self.heaps.borrow_mut();
|
|
||||||
cache
|
|
||||||
.entry(addr)
|
|
||||||
.or_insert_with(|| {
|
|
||||||
GlobalHeapCollection::parse(self.data, addr as usize, self.ls)
|
|
||||||
.map(Rc::new)
|
|
||||||
.map_err(e)
|
|
||||||
})
|
|
||||||
.clone()?
|
|
||||||
};
|
|
||||||
coll.get_object(idx as u16)
|
|
||||||
.map(|o| o.data.clone())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
format!("GlobalHeapObjectNotFound {{ collection_address: {addr}, index: {idx} }}")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_offset(&self, b: &[u8]) -> u64 {
|
|
||||||
let mut v = 0u64;
|
|
||||||
for (i, x) in b.iter().take(self.os as usize).enumerate() {
|
|
||||||
v |= (*x as u64) << (8 * i);
|
|
||||||
}
|
|
||||||
v
|
|
||||||
}
|
|
||||||
|
|
||||||
fn canon(&self, dt: &Datatype, b: &[u8], out: &mut Vec<u8>) -> Result<(), String> {
|
fn canon(&self, dt: &Datatype, b: &[u8], out: &mut Vec<u8>) -> Result<(), String> {
|
||||||
let size = dt.type_size() as usize;
|
let size = dt.type_size() as usize;
|
||||||
if b.len() < size {
|
if b.len() < size {
|
||||||
@@ -204,41 +179,27 @@ impl<'a> Ctx<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Datatype::VariableLength {
|
Datatype::VariableLength {
|
||||||
|
size: vl_size,
|
||||||
is_string,
|
is_string,
|
||||||
base_type,
|
base_type,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let len = u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize;
|
check_element_size(*vl_size, self.os).map_err(e)?;
|
||||||
let addr = self.read_offset(&b[4..]);
|
let el = &b[..size];
|
||||||
let idx_off = 4 + self.os as usize;
|
|
||||||
let idx = u32::from_le_bytes([
|
|
||||||
b[idx_off],
|
|
||||||
b[idx_off + 1],
|
|
||||||
b[idx_off + 2],
|
|
||||||
b[idx_off + 3],
|
|
||||||
]);
|
|
||||||
let obj = if len == 0 || addr == 0 || addr == u64::MAX >> (64 - 8 * self.os as u32)
|
|
||||||
{
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
self.heap_obj(addr, idx)?
|
|
||||||
};
|
|
||||||
if *is_string {
|
if *is_string {
|
||||||
let l = len.min(obj.len());
|
let s = self.vl.borrow_mut().string_bytes(el).map_err(e)?;
|
||||||
canon_str(&obj[..l], out);
|
canon_str(&s[0], out);
|
||||||
} else {
|
} else {
|
||||||
let bs = base_type.type_size() as usize;
|
let bs = base_type.type_size() as usize;
|
||||||
if bs == 0 {
|
// The borrow ends here: the base type may itself be
|
||||||
return Err("canon: VL base size 0".into());
|
// variable-length.
|
||||||
}
|
let seq = self.vl.borrow_mut().sequences(el, bs).map_err(e)?;
|
||||||
let need = len.checked_mul(bs).ok_or("canon: VL overflow")?;
|
let seq = &seq[0];
|
||||||
if len > 0 && obj.len() < need {
|
let len = seq.len() / bs;
|
||||||
return Err(format!("canon: VL object {} < {need}", obj.len()));
|
|
||||||
}
|
|
||||||
out.push(b'V');
|
out.push(b'V');
|
||||||
out.extend_from_slice(&(len as u32).to_le_bytes());
|
out.extend_from_slice(&(len as u32).to_le_bytes());
|
||||||
for i in 0..len {
|
for i in 0..len {
|
||||||
self.canon(base_type, &obj[i * bs..], out)?;
|
self.canon(base_type, &seq[i * bs..], out)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -744,7 +705,7 @@ fn main() {
|
|||||||
.parent()
|
.parent()
|
||||||
.map(|p| p.to_path_buf())
|
.map(|p| p.to_path_buf())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
heaps: RefCell::new(HashMap::new()),
|
vl: RefCell::new(VlResolver::new(hdf5, sb.offset_size, sb.length_size)),
|
||||||
};
|
};
|
||||||
let mut objects: Vec<Value> = Vec::new();
|
let mut objects: Vec<Value> = Vec::new();
|
||||||
let mut visited = HashSet::new();
|
let mut visited = HashSet::new();
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ ruzstd = { version = "0.9", optional = true }
|
|||||||
bzip2 = { version = "0.6", optional = true }
|
bzip2 = { version = "0.6", optional = true }
|
||||||
snap = { version = "1", optional = true }
|
snap = { version = "1", optional = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
# madvise(MADV_HUGEPAGE) for large read buffers (see src/bulk_alloc.rs).
|
||||||
|
libc = { version = "0.2", default-features = false }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
half = { workspace = true }
|
half = { workspace = true }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//! Large output buffers backed by transparent huge pages where the OS offers
|
||||||
|
//! them.
|
||||||
|
//!
|
||||||
|
//! A fresh multi-megabyte `Vec` is mapped lazily by the kernel: the first
|
||||||
|
//! write to each 4 KiB page takes a page fault, and the kernel zeroes the page
|
||||||
|
//! before handing it over. For a 64 MiB read that is 16384 faults, and they
|
||||||
|
//! cost far more than the copy that fills the buffer — single-threaded
|
||||||
|
//! contiguous reads ran at about a quarter of h5py's speed because of them.
|
||||||
|
//! numpy (so h5py) avoids this by asking for transparent huge pages
|
||||||
|
//! (`madvise(MADV_HUGEPAGE)`) on every allocation of 4 MiB or more, which
|
||||||
|
//! turns 512 faults into one; this module does the same.
|
||||||
|
//!
|
||||||
|
//! The advice only changes how the pages are backed, never their contents, so
|
||||||
|
//! it is harmless when it cannot be honoured (THP disabled, not Linux, a
|
||||||
|
//! region that is part of the heap): the buffer is then exactly what it would
|
||||||
|
//! have been without it.
|
||||||
|
|
||||||
|
#[cfg(not(feature = "std"))]
|
||||||
|
use alloc::vec::Vec;
|
||||||
|
|
||||||
|
/// Buffers smaller than this are left alone (numpy uses the same threshold).
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
|
pub(crate) const HUGE_PAGE_THRESHOLD: usize = 4 << 20;
|
||||||
|
|
||||||
|
/// Advise the kernel to back `[ptr, ptr + len)` with transparent huge pages,
|
||||||
|
/// when `len` is large enough to benefit. Call it before the first write so
|
||||||
|
/// the faults happen at huge-page granularity.
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn advise_huge_pages(ptr: *const u8, len: usize) {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if len >= HUGE_PAGE_THRESHOLD {
|
||||||
|
const PAGE: usize = 4096;
|
||||||
|
let start = (ptr as usize).next_multiple_of(PAGE);
|
||||||
|
let end = (ptr as usize + len) & !(PAGE - 1);
|
||||||
|
if end > start {
|
||||||
|
// SAFETY: `[start, end)` lies inside an allocation of `len` bytes
|
||||||
|
// at `ptr` that the caller owns, and is page aligned as madvise
|
||||||
|
// requires. MADV_HUGEPAGE does not change the memory's contents or
|
||||||
|
// validity; on failure (EINVAL when THP is compiled out, etc.) the
|
||||||
|
// region is simply left as it was, so the result is ignored.
|
||||||
|
unsafe {
|
||||||
|
libc::madvise(start as *mut libc::c_void, end - start, libc::MADV_HUGEPAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let _ = (ptr, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Vec::with_capacity(count)` for a buffer about to be filled in bulk, with
|
||||||
|
/// huge-page advice when it is large (see the module docs).
|
||||||
|
#[inline]
|
||||||
|
pub(crate) fn vec_for_bulk<T>(count: usize) -> Vec<T> {
|
||||||
|
let v: Vec<T> = Vec::with_capacity(count);
|
||||||
|
advise_huge_pages(
|
||||||
|
v.as_ptr().cast::<u8>(),
|
||||||
|
v.capacity().saturating_mul(core::mem::size_of::<T>()),
|
||||||
|
);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bulk_vec_is_an_ordinary_vec() {
|
||||||
|
for count in [0usize, 1, 1000, HUGE_PAGE_THRESHOLD / 4 + 3] {
|
||||||
|
let mut v: Vec<u32> = vec_for_bulk(count);
|
||||||
|
assert!(v.capacity() >= count);
|
||||||
|
v.extend((0..count as u32).map(|i| i.wrapping_mul(2654435761)));
|
||||||
|
assert!(
|
||||||
|
v.iter()
|
||||||
|
.enumerate()
|
||||||
|
.all(|(i, &x)| x == (i as u32).wrapping_mul(2654435761))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ fn decompress_all_chunks(
|
|||||||
{
|
{
|
||||||
if let Some(pl) = pipeline
|
if let Some(pl) = pipeline
|
||||||
&& parallel_read::should_use_parallel(chunks.len())
|
&& parallel_read::should_use_parallel(chunks.len())
|
||||||
|
&& parallel_read::pool_can_parallelise()
|
||||||
{
|
{
|
||||||
// Seed from the first chunk's address and count for determinism.
|
// Seed from the first chunk's address and count for determinism.
|
||||||
let seed = chunks.first().map(|c| c.address).unwrap_or(0) ^ (chunks.len() as u64);
|
let seed = chunks.first().map(|c| c.address).unwrap_or(0) ^ (chunks.len() as u64);
|
||||||
@@ -277,6 +278,8 @@ pub(crate) fn alloc_output(len: usize) -> Result<Vec<u8>, FormatError> {
|
|||||||
if ptr.is_null() {
|
if ptr.is_null() {
|
||||||
return Err(failed());
|
return Err(failed());
|
||||||
}
|
}
|
||||||
|
// Before anything writes to it, so a large buffer faults in huge pages.
|
||||||
|
crate::bulk_alloc::advise_huge_pages(ptr, len);
|
||||||
// SAFETY: `ptr` came from the global allocator with the layout of
|
// SAFETY: `ptr` came from the global allocator with the layout of
|
||||||
// `[u8; len]`, which is exactly what `Vec<u8>` with capacity `len` frees;
|
// `[u8; len]`, which is exactly what `Vec<u8>` with capacity `len` frees;
|
||||||
// all `len` bytes are initialised (zero).
|
// all `len` bytes are initialised (zero).
|
||||||
@@ -510,6 +513,10 @@ fn collect_chunk_info_inner(
|
|||||||
///
|
///
|
||||||
/// Chunks are stored contiguously starting at `base_address`. No stored index;
|
/// Chunks are stored contiguously starting at `base_address`. No stored index;
|
||||||
/// addresses are computed from the chunk position.
|
/// addresses are computed from the chunk position.
|
||||||
|
///
|
||||||
|
/// `chunk_dimensions` are the spatial chunk dimensions, one per entry of
|
||||||
|
/// `dataset_dims` — not the layout message's list, which carries the element
|
||||||
|
/// size as an extra last dimension.
|
||||||
pub fn generate_implicit_chunks(
|
pub fn generate_implicit_chunks(
|
||||||
base_address: u64,
|
base_address: u64,
|
||||||
dataset_dims: &[u64],
|
dataset_dims: &[u64],
|
||||||
@@ -1056,7 +1063,9 @@ pub fn read_chunked_data_cached(
|
|||||||
|
|
||||||
// Decompress what the cache didn't have, a bounded batch at a time — in
|
// Decompress what the cache didn't have, a bounded batch at a time — in
|
||||||
// parallel with the `parallel` feature (this path, the one the facade
|
// parallel with the `parallel` feature (this path, the one the facade
|
||||||
// uses, was sequential; only the uncached reader was parallel). Chunks are
|
// uses, was sequential; only the uncached reader was parallel), unless the
|
||||||
|
// pool has one thread: then every reading thread would queue behind that
|
||||||
|
// one worker, so each decodes its own chunks instead. Chunks are
|
||||||
// cached only when the whole dataset fits: pushing a larger dataset
|
// cached only when the whole dataset fits: pushing a larger dataset
|
||||||
// through the cache just evicts each chunk moments after inserting it.
|
// through the cache just evicts each chunk moments after inserting it.
|
||||||
let cache_them = total_bytes <= cache.max_bytes();
|
let cache_them = total_bytes <= cache.max_bytes();
|
||||||
@@ -1073,7 +1082,8 @@ pub fn read_chunked_data_cached(
|
|||||||
};
|
};
|
||||||
for batch in misses.chunks(DECODE_BATCH) {
|
for batch in misses.chunks(DECODE_BATCH) {
|
||||||
#[cfg(feature = "parallel")]
|
#[cfg(feature = "parallel")]
|
||||||
let decoded: Vec<Result<Vec<u8>, FormatError>> = if batch.len() >= 4 {
|
let decoded: Vec<Result<Vec<u8>, FormatError>> =
|
||||||
|
if batch.len() >= 4 && parallel_read::pool_can_parallelise() {
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
batch.par_iter().map(decode).collect()
|
batch.par_iter().map(decode).collect()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -180,7 +180,9 @@ fn read_raw_data_full_impl(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
ensure_len(file_data, addr, sz)?;
|
ensure_len(file_data, addr, sz)?;
|
||||||
Ok(file_data[addr..addr + sz].to_vec())
|
let mut out = crate::bulk_alloc::vec_for_bulk(sz);
|
||||||
|
out.extend_from_slice(&file_data[addr..addr + sz]);
|
||||||
|
Ok(out)
|
||||||
}
|
}
|
||||||
DataLayout::Chunked { .. } => read_chunked_data(
|
DataLayout::Chunked { .. } => read_chunked_data(
|
||||||
file_data,
|
file_data,
|
||||||
@@ -286,9 +288,11 @@ pub fn read_raw_data_indexed(
|
|||||||
|
|
||||||
/// Read raw bytes for only the selected elements of a dataset.
|
/// Read raw bytes for only the selected elements of a dataset.
|
||||||
///
|
///
|
||||||
/// For chunked layouts, only chunks that intersect the selection are read
|
/// When the selection's bounding box covers at most half the dataset, only
|
||||||
/// and decompressed. For compact/contiguous layouts, the full data is read
|
/// that box is materialised — the overlapping rows of a contiguous dataset,
|
||||||
/// and then the selection is extracted.
|
/// the overlapping chunks of a chunked one, whatever its chunk index (see
|
||||||
|
/// [`crate::partial_read`]). Otherwise, and for compact and virtual
|
||||||
|
/// layouts, the whole dataset is decoded and the selection extracted.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn read_raw_data_selection(
|
pub fn read_raw_data_selection(
|
||||||
file_data: &[u8],
|
file_data: &[u8],
|
||||||
@@ -356,85 +360,17 @@ pub fn read_raw_data_selection(
|
|||||||
}
|
}
|
||||||
DataLayout::Chunked {
|
DataLayout::Chunked {
|
||||||
chunk_dimensions,
|
chunk_dimensions,
|
||||||
btree_address,
|
|
||||||
version,
|
version,
|
||||||
chunk_index_type,
|
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
|
// `partial_read` declined (a bounding box covering most of the
|
||||||
|
// dataset, or a selection it doesn't box), so decode every chunk
|
||||||
|
// and pick the selection out, whatever the chunk index. This arm
|
||||||
|
// used to enumerate the chunks first — passing the layout's
|
||||||
|
// chunk dimensions, element-size dimension included, to the
|
||||||
|
// implicit-index generator, which then indexed past the rank and
|
||||||
|
// panicked — only to decode the full dataset anyway.
|
||||||
crate::chunked_read::chunk_geometry(chunk_dimensions, *version, dataspace, elem_size)?;
|
crate::chunked_read::chunk_geometry(chunk_dimensions, *version, dataspace, elem_size)?;
|
||||||
// For chunked data, only read chunks that intersect the selection
|
|
||||||
let chunk_dims: Vec<u64> = chunk_dimensions.iter().map(|&d| d as u64).collect();
|
|
||||||
let rank = dims.len();
|
|
||||||
|
|
||||||
// Collect chunk info from B-tree
|
|
||||||
let chunks = if *version == 4 {
|
|
||||||
match chunk_index_type {
|
|
||||||
Some(2) => {
|
|
||||||
// Implicit index
|
|
||||||
crate::chunked_read::generate_implicit_chunks(
|
|
||||||
btree_address.unwrap_or(0),
|
|
||||||
dims,
|
|
||||||
chunk_dimensions,
|
|
||||||
elem_size as u32,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if let Some(_addr) = btree_address {
|
|
||||||
// Use extensible array or fixed array
|
|
||||||
// Fall back to full read for complex v4 index types
|
|
||||||
let full_data = read_raw_data_full(
|
|
||||||
file_data,
|
|
||||||
layout,
|
|
||||||
dataspace,
|
|
||||||
datatype,
|
|
||||||
pipeline,
|
|
||||||
offset_size,
|
|
||||||
length_size,
|
|
||||||
)?;
|
|
||||||
return extract_selection_from_buffer(
|
|
||||||
&full_data, dims, elem_size, selection,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// v3: B-tree v1
|
|
||||||
if let Some(addr) = btree_address {
|
|
||||||
crate::chunked_read::collect_chunk_info_checked(
|
|
||||||
file_data,
|
|
||||||
*addr,
|
|
||||||
chunk_dimensions,
|
|
||||||
offset_size,
|
|
||||||
length_size,
|
|
||||||
)?
|
|
||||||
} else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter chunks to only those that intersect the selection
|
|
||||||
let intersecting: Vec<_> = chunks
|
|
||||||
.iter()
|
|
||||||
.filter(|ci| {
|
|
||||||
let offsets: Vec<u64> = ci.offsets.iter().take(rank).copied().collect();
|
|
||||||
selection.intersects_chunk(&offsets, &chunk_dims[..rank])
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if intersecting.is_empty() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decompress only the intersecting chunks
|
|
||||||
let _chunk_total_bytes: usize =
|
|
||||||
chunk_dims.iter().map(|&d| d as usize).product::<usize>() * elem_size;
|
|
||||||
let _element_size_u32 = elem_size as u32;
|
|
||||||
|
|
||||||
// First, assemble only the intersecting chunks into a partial buffer,
|
|
||||||
// then extract the selection. For simplicity, we assemble into a full
|
|
||||||
// dataset buffer and extract (same as contiguous path).
|
|
||||||
let full_data = read_raw_data_full(
|
let full_data = read_raw_data_full(
|
||||||
file_data,
|
file_data,
|
||||||
layout,
|
layout,
|
||||||
@@ -529,6 +465,11 @@ pub fn extract_selection_from_buffer(
|
|||||||
block,
|
block,
|
||||||
} => {
|
} => {
|
||||||
let rank = dims.len();
|
let rank = dims.len();
|
||||||
|
if [start.len(), stride.len(), count.len(), block.len()] != [rank; 4] {
|
||||||
|
return Err(FormatError::SelectionOutOfBounds(format!(
|
||||||
|
"hyperslab rank does not match dataset rank {rank}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
let output_elements = count
|
let output_elements = count
|
||||||
.iter()
|
.iter()
|
||||||
.zip(block.iter())
|
.zip(block.iter())
|
||||||
@@ -538,96 +479,40 @@ pub fn extract_selection_from_buffer(
|
|||||||
crate::chunked_read::checked_byte_len(output_elements, elem_size)?,
|
crate::chunked_read::checked_byte_len(output_elements, elem_size)?,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Compute dataset strides (row-major)
|
// One copy per run of elements contiguous in `full_data`
|
||||||
let mut ds_strides = vec![1usize; rank];
|
// (`gather`'s runs). Coordinates past the extent are skipped and
|
||||||
for i in (0..rank.saturating_sub(1)).rev() {
|
// runs past the end of `full_data` left as zeros, element by
|
||||||
ds_strides[i] = ds_strides[i + 1] * dims[i + 1] as usize;
|
// element, as this extractor always did; validated selections
|
||||||
}
|
// never hit either.
|
||||||
|
let mut out_at = 0usize;
|
||||||
// Compute output shape and strides
|
crate::gather::hyperslab_runs(dims, start, stride, count, block, |first, n| {
|
||||||
let output_dims: Vec<usize> = count
|
let big = |v: u64| usize::try_from(v).unwrap_or(usize::MAX);
|
||||||
.iter()
|
let (first, n) = (big(first), big(n));
|
||||||
.zip(block.iter())
|
let len = n.saturating_mul(elem_size);
|
||||||
.map(|(&c, &b)| (c * b) as usize)
|
let src = first.saturating_mul(elem_size);
|
||||||
.collect();
|
let out_end = out_at.saturating_add(len);
|
||||||
let mut out_strides = vec![1usize; rank];
|
if let (Some(from), Some(to)) = (
|
||||||
for i in (0..rank.saturating_sub(1)).rev() {
|
full_data.get(src..src.saturating_add(len)),
|
||||||
out_strides[i] = out_strides[i + 1] * output_dims[i + 1];
|
output.get_mut(out_at..out_end),
|
||||||
}
|
|
||||||
|
|
||||||
// Iterate over all selected elements
|
|
||||||
// For each block in the hyperslab, copy the elements
|
|
||||||
let mut out_linear = 0usize;
|
|
||||||
let _block_coords = vec![0u64; rank];
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn iterate_hyperslab(
|
|
||||||
d: usize,
|
|
||||||
rank: usize,
|
|
||||||
start: &[u64],
|
|
||||||
stride: &[u64],
|
|
||||||
count: &[u64],
|
|
||||||
block: &[u64],
|
|
||||||
dims: &[u64],
|
|
||||||
ds_strides: &[usize],
|
|
||||||
elem_size: usize,
|
|
||||||
full_data: &[u8],
|
|
||||||
output: &mut [u8],
|
|
||||||
out_linear: &mut usize,
|
|
||||||
current_ds_offset: usize,
|
|
||||||
) {
|
) {
|
||||||
if d == rank {
|
to.copy_from_slice(from);
|
||||||
// Copy one element
|
} else {
|
||||||
let src = current_ds_offset * elem_size;
|
for k in 0..n {
|
||||||
let dst = *out_linear * elem_size;
|
let s = first.saturating_add(k).saturating_mul(elem_size);
|
||||||
if src + elem_size <= full_data.len() && dst + elem_size <= output.len() {
|
let o = out_at.saturating_add(k.saturating_mul(elem_size));
|
||||||
output[dst..dst + elem_size]
|
if o >= output.len() {
|
||||||
.copy_from_slice(&full_data[src..src + elem_size]);
|
break;
|
||||||
}
|
}
|
||||||
*out_linear += 1;
|
if let (Some(from), Some(to)) = (
|
||||||
return;
|
full_data.get(s..s.saturating_add(elem_size)),
|
||||||
}
|
output.get_mut(o..o.saturating_add(elem_size)),
|
||||||
|
) {
|
||||||
for bi in 0..count[d] {
|
to.copy_from_slice(from);
|
||||||
let block_start = start[d] + bi * stride[d];
|
|
||||||
for bj in 0..block[d] {
|
|
||||||
let coord = block_start + bj;
|
|
||||||
if coord < dims[d] {
|
|
||||||
iterate_hyperslab(
|
|
||||||
d + 1,
|
|
||||||
rank,
|
|
||||||
start,
|
|
||||||
stride,
|
|
||||||
count,
|
|
||||||
block,
|
|
||||||
dims,
|
|
||||||
ds_strides,
|
|
||||||
elem_size,
|
|
||||||
full_data,
|
|
||||||
output,
|
|
||||||
out_linear,
|
|
||||||
current_ds_offset + coord as usize * ds_strides[d],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
out_at = out_end;
|
||||||
|
});
|
||||||
iterate_hyperslab(
|
|
||||||
0,
|
|
||||||
rank,
|
|
||||||
start,
|
|
||||||
stride,
|
|
||||||
count,
|
|
||||||
block,
|
|
||||||
dims,
|
|
||||||
&ds_strides,
|
|
||||||
elem_size,
|
|
||||||
full_data,
|
|
||||||
&mut output,
|
|
||||||
&mut out_linear,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
@@ -755,22 +640,76 @@ fn get_size(dt: &Datatype) -> usize {
|
|||||||
dt.type_size() as usize
|
dt.type_size() as usize
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reinterpret little-endian bytes as `count` native values of `T` on a
|
mod sealed {
|
||||||
/// little-endian target, in one copy.
|
pub trait Sealed {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A numeric type whose values can be copied straight out of a dataset's
|
||||||
|
/// bytes when the dataset stores exactly that type in the target's byte
|
||||||
|
/// order: `u8`, `i32`, `i64`, `u64`, `f32` and `f64`.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
///
|
||||||
|
/// Implementors have no padding and no invalid bit patterns, so a buffer of
|
||||||
|
/// them may be filled by copying bytes. The trait is sealed.
|
||||||
|
pub unsafe trait NativeElement: sealed::Sealed + Copy + 'static {
|
||||||
|
/// Whether `datatype`'s stored bytes are this type's native in-memory
|
||||||
|
/// representation (same size, byte order, signedness, full precision,
|
||||||
|
/// IEEE layout), so reading needs a copy and no conversion.
|
||||||
|
fn is_native(datatype: &Datatype) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A full-width fixed-point type of `size` bytes and the given signedness in
|
||||||
|
/// the target's byte order.
|
||||||
|
fn is_native_int(datatype: &Datatype, size: u32, want_signed: bool) -> bool {
|
||||||
|
let order = if cfg!(target_endian = "little") {
|
||||||
|
DatatypeByteOrder::LittleEndian
|
||||||
|
} else {
|
||||||
|
DatatypeByteOrder::BigEndian
|
||||||
|
};
|
||||||
|
matches!(
|
||||||
|
datatype,
|
||||||
|
Datatype::FixedPoint { size: s, signed, byte_order, .. }
|
||||||
|
if *s == size && *signed == want_signed && (size == 1 || *byte_order == order)
|
||||||
|
) && is_full_width(datatype)
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! native_element {
|
||||||
|
($($t:ty => |$dt:ident| $check:expr;)*) => {$(
|
||||||
|
impl sealed::Sealed for $t {}
|
||||||
|
// SAFETY: a primitive integer or float: no padding, and every bit
|
||||||
|
// pattern is a valid value.
|
||||||
|
unsafe impl NativeElement for $t {
|
||||||
|
fn is_native($dt: &Datatype) -> bool {
|
||||||
|
$check
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)*};
|
||||||
|
}
|
||||||
|
|
||||||
|
native_element! {
|
||||||
|
u8 => |dt| is_native_int(dt, 1, false);
|
||||||
|
i32 => |dt| is_native_int(dt, 4, true);
|
||||||
|
i64 => |dt| is_native_int(dt, 8, true);
|
||||||
|
u64 => |dt| is_native_int(dt, 8, false);
|
||||||
|
f32 => |dt| cfg!(target_endian = "little") && is_native_le_float(dt, FloatFormat::Single);
|
||||||
|
f64 => |dt| cfg!(target_endian = "little") && is_native_le_float(dt, FloatFormat::Double);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy `count` values of `T` out of `raw`, which holds them in `T`'s native
|
||||||
|
/// representation (see [`NativeElement::is_native`]), in one copy.
|
||||||
///
|
///
|
||||||
/// The buffer is allocated uninitialised and filled by the copy. It used to be
|
/// The buffer is allocated uninitialised and filled by the copy. It used to be
|
||||||
/// `vec![0; count]` first, which for a large dataset meant writing every page
|
/// `vec![0; count]` first, which for a large dataset meant writing every page
|
||||||
/// twice (zero it, then overwrite it) — about as expensive as the copy itself.
|
/// twice (zero it, then overwrite it) — about as expensive as the copy itself.
|
||||||
#[cfg(target_endian = "little")]
|
fn native_to_vec<T: NativeElement>(raw: &[u8], count: usize) -> Vec<T> {
|
||||||
fn native_le_to_vec<T: Copy>(raw: &[u8], count: usize) -> Vec<T> {
|
|
||||||
let bytes = count * core::mem::size_of::<T>();
|
let bytes = count * core::mem::size_of::<T>();
|
||||||
debug_assert!(bytes <= raw.len());
|
assert!(bytes <= raw.len(), "native_to_vec: source too short");
|
||||||
let mut result: Vec<T> = Vec::with_capacity(count);
|
let mut result: Vec<T> = crate::bulk_alloc::vec_for_bulk(count);
|
||||||
// SAFETY: `result` has capacity for `count` values of `T`, i.e. `bytes`
|
// SAFETY: `result` has capacity for `count` values of `T`, i.e. `bytes`
|
||||||
// bytes; `raw` holds at least `bytes` bytes (callers derive `count` from
|
// bytes; `raw` holds at least `bytes` bytes (asserted); the regions
|
||||||
// `raw.len() / size_of::<T>()`); the regions cannot overlap because
|
// cannot overlap because `result` was just allocated. `T: NativeElement`
|
||||||
// `result` was just allocated. Every `T` used here (f32/f64/i32/i64) is
|
// is valid for any bit pattern, so after the copy all `count` values are
|
||||||
// valid for any bit pattern, so after the copy all `count` values are
|
|
||||||
// initialised and `set_len` is sound.
|
// initialised and `set_len` is sound.
|
||||||
unsafe {
|
unsafe {
|
||||||
core::ptr::copy_nonoverlapping(raw.as_ptr(), result.as_mut_ptr().cast::<u8>(), bytes);
|
core::ptr::copy_nonoverlapping(raw.as_ptr(), result.as_mut_ptr().cast::<u8>(), bytes);
|
||||||
@@ -779,6 +718,44 @@ fn native_le_to_vec<T: Copy>(raw: &[u8], count: usize) -> Vec<T> {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read `selection` of a dataset whose raw bytes (all of them, row-major, of
|
||||||
|
/// shape `dims`) are `raw` — typically a contiguous dataset's bytes borrowed
|
||||||
|
/// from the file — straight into a `Vec<T>`, copying each contiguous run of
|
||||||
|
/// selected elements once.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(None)` when `datatype` is not `T`'s native representation
|
||||||
|
/// ([`NativeElement::is_native`]); the caller then converts through
|
||||||
|
/// [`read_raw_data_selection`] and the `read_as_*` functions. The selection is
|
||||||
|
/// validated like every selection read: out-of-range coordinates are
|
||||||
|
/// [`FormatError::SelectionOutOfBounds`].
|
||||||
|
pub fn read_selection_native<T: NativeElement>(
|
||||||
|
raw: &[u8],
|
||||||
|
dims: &[u64],
|
||||||
|
datatype: &Datatype,
|
||||||
|
selection: &crate::selection::Selection,
|
||||||
|
) -> Result<Option<Vec<T>>, FormatError> {
|
||||||
|
if !T::is_native(datatype) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let elem_size = core::mem::size_of::<T>();
|
||||||
|
let total = dims
|
||||||
|
.iter()
|
||||||
|
.try_fold(1u64, |acc, &d| acc.checked_mul(d))
|
||||||
|
.ok_or_else(|| FormatError::Overflow("dataset shape overflows".into()))?;
|
||||||
|
let expected = crate::chunked_read::checked_byte_len(total, elem_size)?;
|
||||||
|
if raw.len() != expected {
|
||||||
|
return Err(FormatError::DataSizeMismatch {
|
||||||
|
expected,
|
||||||
|
actual: raw.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let crate::selection::Selection::All = selection {
|
||||||
|
return Ok(Some(native_to_vec(raw, expected / elem_size)));
|
||||||
|
}
|
||||||
|
crate::partial_read::validate(selection, dims)?;
|
||||||
|
crate::gather::gather::<T>(raw, dims, elem_size, selection).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert raw bytes to `f64` values.
|
/// Convert raw bytes to `f64` values.
|
||||||
pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatError> {
|
pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatError> {
|
||||||
// Array datatypes read as a flat sequence of their base elements, and
|
// Array datatypes read as a flat sequence of their base elements, and
|
||||||
@@ -797,13 +774,12 @@ pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatEr
|
|||||||
let count = raw.len() / elem_size;
|
let count = raw.len() / elem_size;
|
||||||
|
|
||||||
// Fast path: native-endian f64 — single bulk memcpy
|
// Fast path: native-endian f64 — single bulk memcpy
|
||||||
#[cfg(target_endian = "little")]
|
if f64::is_native(datatype) {
|
||||||
if is_native_le_float(datatype, FloatFormat::Double) {
|
return Ok(native_to_vec::<f64>(raw, count));
|
||||||
return Ok(native_le_to_vec::<f64>(raw, count));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let order = get_byte_order(datatype);
|
let order = get_byte_order(datatype);
|
||||||
let mut result = Vec::with_capacity(count);
|
let mut result = crate::bulk_alloc::vec_for_bulk(count);
|
||||||
if let Datatype::FloatingPoint { .. } = datatype {
|
if let Datatype::FloatingPoint { .. } = datatype {
|
||||||
let format = FloatFormat::of(datatype)?;
|
let format = FloatFormat::of(datatype)?;
|
||||||
for chunk in raw.chunks_exact(elem_size) {
|
for chunk in raw.chunks_exact(elem_size) {
|
||||||
@@ -939,23 +915,12 @@ pub fn read_as_i64(raw: &[u8], datatype: &Datatype) -> Result<Vec<i64>, FormatEr
|
|||||||
let count = raw.len() / elem_size;
|
let count = raw.len() / elem_size;
|
||||||
|
|
||||||
// Fast path: native LE i64 — single bulk memcpy
|
// Fast path: native LE i64 — single bulk memcpy
|
||||||
#[cfg(target_endian = "little")]
|
if i64::is_native(datatype) {
|
||||||
if elem_size == 8
|
return Ok(native_to_vec::<i64>(raw, count));
|
||||||
&& is_full_width(datatype)
|
|
||||||
&& matches!(
|
|
||||||
datatype,
|
|
||||||
Datatype::FixedPoint {
|
|
||||||
byte_order: DatatypeByteOrder::LittleEndian,
|
|
||||||
signed: true,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return Ok(native_le_to_vec::<i64>(raw, count));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let order = get_byte_order(datatype);
|
let order = get_byte_order(datatype);
|
||||||
let mut result = Vec::with_capacity(count);
|
let mut result = crate::bulk_alloc::vec_for_bulk(count);
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
let chunk = &raw[i * elem_size..(i + 1) * elem_size];
|
let chunk = &raw[i * elem_size..(i + 1) * elem_size];
|
||||||
result.push(decode_scalar(chunk, datatype, &order)?.to_i64());
|
result.push(decode_scalar(chunk, datatype, &order)?.to_i64());
|
||||||
@@ -984,8 +949,14 @@ pub fn read_as_u64(raw: &[u8], datatype: &Datatype) -> Result<Vec<u64>, FormatEr
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let count = raw.len() / elem_size;
|
let count = raw.len() / elem_size;
|
||||||
|
|
||||||
|
// Fast path: native u64 — single bulk memcpy
|
||||||
|
if u64::is_native(datatype) {
|
||||||
|
return Ok(native_to_vec::<u64>(raw, count));
|
||||||
|
}
|
||||||
|
|
||||||
let order = get_byte_order(datatype);
|
let order = get_byte_order(datatype);
|
||||||
let mut result = Vec::with_capacity(count);
|
let mut result = crate::bulk_alloc::vec_for_bulk(count);
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
let chunk = &raw[i * elem_size..(i + 1) * elem_size];
|
let chunk = &raw[i * elem_size..(i + 1) * elem_size];
|
||||||
result.push(decode_scalar(chunk, datatype, &order)?.to_u64());
|
result.push(decode_scalar(chunk, datatype, &order)?.to_u64());
|
||||||
@@ -1011,21 +982,23 @@ pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatEr
|
|||||||
let count = raw.len() / elem_size;
|
let count = raw.len() / elem_size;
|
||||||
|
|
||||||
// Fast path: native-endian f32 — single bulk memcpy
|
// Fast path: native-endian f32 — single bulk memcpy
|
||||||
#[cfg(target_endian = "little")]
|
if f32::is_native(datatype) {
|
||||||
if is_native_le_float(datatype, FloatFormat::Single) {
|
return Ok(native_to_vec::<f32>(raw, count));
|
||||||
return Ok(native_le_to_vec::<f32>(raw, count));
|
|
||||||
}
|
}
|
||||||
// Little-endian IEEE half precision (numpy float16): widen directly.
|
// Little-endian IEEE half precision (numpy float16): widen directly.
|
||||||
if is_native_le_float(datatype, FloatFormat::Half) {
|
if is_native_le_float(datatype, FloatFormat::Half) {
|
||||||
let (halves, _) = raw[..count * 2].as_chunks::<2>();
|
let (halves, _) = raw[..count * 2].as_chunks::<2>();
|
||||||
return Ok(halves
|
let mut result = crate::bulk_alloc::vec_for_bulk(count);
|
||||||
|
result.extend(
|
||||||
|
halves
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&b| f16_bits_to_f32(u16::from_le_bytes(b)))
|
.map(|&b| f16_bits_to_f32(u16::from_le_bytes(b))),
|
||||||
.collect());
|
);
|
||||||
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
let order = get_byte_order(datatype);
|
let order = get_byte_order(datatype);
|
||||||
let mut result = Vec::with_capacity(count);
|
let mut result = crate::bulk_alloc::vec_for_bulk(count);
|
||||||
if let Datatype::FloatingPoint { .. } = datatype {
|
if let Datatype::FloatingPoint { .. } = datatype {
|
||||||
let format = FloatFormat::of(datatype)?;
|
let format = FloatFormat::of(datatype)?;
|
||||||
for chunk in raw.chunks_exact(elem_size) {
|
for chunk in raw.chunks_exact(elem_size) {
|
||||||
@@ -1098,23 +1071,12 @@ pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatEr
|
|||||||
let count = raw.len() / elem_size;
|
let count = raw.len() / elem_size;
|
||||||
|
|
||||||
// Fast path: native LE i32 — single bulk memcpy
|
// Fast path: native LE i32 — single bulk memcpy
|
||||||
#[cfg(target_endian = "little")]
|
if i32::is_native(datatype) {
|
||||||
if elem_size == 4
|
return Ok(native_to_vec::<i32>(raw, count));
|
||||||
&& is_full_width(datatype)
|
|
||||||
&& matches!(
|
|
||||||
datatype,
|
|
||||||
Datatype::FixedPoint {
|
|
||||||
byte_order: DatatypeByteOrder::LittleEndian,
|
|
||||||
signed: true,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return Ok(native_le_to_vec::<i32>(raw, count));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let order = get_byte_order(datatype);
|
let order = get_byte_order(datatype);
|
||||||
let mut result = Vec::with_capacity(count);
|
let mut result = crate::bulk_alloc::vec_for_bulk(count);
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
let chunk = &raw[i * elem_size..(i + 1) * elem_size];
|
let chunk = &raw[i * elem_size..(i + 1) * elem_size];
|
||||||
result.push(decode_scalar(chunk, datatype, &order)?.to_i32());
|
result.push(decode_scalar(chunk, datatype, &order)?.to_i32());
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ pub enum Datatype {
|
|||||||
},
|
},
|
||||||
/// Class 9: Variable-length type.
|
/// Class 9: Variable-length type.
|
||||||
VariableLength {
|
VariableLength {
|
||||||
|
/// Size of one element as stored in the file: a sequence length (4
|
||||||
|
/// bytes), a global heap collection address (the file's
|
||||||
|
/// `offset_size`) and an object index (4 bytes) — 16 in a file with
|
||||||
|
/// 8-byte offsets, 12 with 4-byte offsets.
|
||||||
|
size: u32,
|
||||||
is_string: bool,
|
is_string: bool,
|
||||||
padding: Option<StringPadding>,
|
padding: Option<StringPadding>,
|
||||||
charset: Option<CharacterSet>,
|
charset: Option<CharacterSet>,
|
||||||
@@ -771,6 +776,7 @@ impl Datatype {
|
|||||||
pos += consumed;
|
pos += consumed;
|
||||||
Ok((
|
Ok((
|
||||||
Datatype::VariableLength {
|
Datatype::VariableLength {
|
||||||
|
size,
|
||||||
is_string,
|
is_string,
|
||||||
padding,
|
padding,
|
||||||
charset,
|
charset,
|
||||||
@@ -1017,6 +1023,7 @@ impl Datatype {
|
|||||||
Self::build_header(3, 1, [bf0, 0, 0], *size)
|
Self::build_header(3, 1, [bf0, 0, 0], *size)
|
||||||
}
|
}
|
||||||
Datatype::VariableLength {
|
Datatype::VariableLength {
|
||||||
|
size,
|
||||||
is_string,
|
is_string,
|
||||||
padding,
|
padding,
|
||||||
charset,
|
charset,
|
||||||
@@ -1039,7 +1046,7 @@ impl Datatype {
|
|||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
let mut buf = Self::build_header(9, 1, [bf0, bf1, 0], 16);
|
let mut buf = Self::build_header(9, 1, [bf0, bf1, 0], *size);
|
||||||
buf.extend_from_slice(&base_type.serialize());
|
buf.extend_from_slice(&base_type.serialize());
|
||||||
buf
|
buf
|
||||||
}
|
}
|
||||||
@@ -1208,7 +1215,7 @@ impl Datatype {
|
|||||||
Datatype::Compound { size, .. } => *size,
|
Datatype::Compound { size, .. } => *size,
|
||||||
Datatype::Reference { size, .. } => *size,
|
Datatype::Reference { size, .. } => *size,
|
||||||
Datatype::Enumeration { size, .. } => *size,
|
Datatype::Enumeration { size, .. } => *size,
|
||||||
Datatype::VariableLength { .. } => 16, // typically pointer + length
|
Datatype::VariableLength { size, .. } => *size,
|
||||||
Datatype::Array {
|
Datatype::Array {
|
||||||
base_type,
|
base_type,
|
||||||
dimensions,
|
dimensions,
|
||||||
@@ -1889,11 +1896,13 @@ mod tests {
|
|||||||
let (dt, _) = Datatype::parse(&buf).unwrap();
|
let (dt, _) = Datatype::parse(&buf).unwrap();
|
||||||
match dt {
|
match dt {
|
||||||
Datatype::VariableLength {
|
Datatype::VariableLength {
|
||||||
|
size,
|
||||||
is_string,
|
is_string,
|
||||||
padding,
|
padding,
|
||||||
charset,
|
charset,
|
||||||
base_type,
|
base_type,
|
||||||
} => {
|
} => {
|
||||||
|
assert_eq!(size, 16);
|
||||||
assert!(is_string);
|
assert!(is_string);
|
||||||
assert_eq!(padding, Some(StringPadding::NullTerminate));
|
assert_eq!(padding, Some(StringPadding::NullTerminate));
|
||||||
assert_eq!(charset, Some(CharacterSet::Utf8));
|
assert_eq!(charset, Some(CharacterSet::Utf8));
|
||||||
@@ -1914,11 +1923,13 @@ mod tests {
|
|||||||
let (dt, _) = Datatype::parse(&buf).unwrap();
|
let (dt, _) = Datatype::parse(&buf).unwrap();
|
||||||
match dt {
|
match dt {
|
||||||
Datatype::VariableLength {
|
Datatype::VariableLength {
|
||||||
|
size,
|
||||||
is_string,
|
is_string,
|
||||||
padding,
|
padding,
|
||||||
charset,
|
charset,
|
||||||
base_type,
|
base_type,
|
||||||
} => {
|
} => {
|
||||||
|
assert_eq!(size, 16);
|
||||||
assert!(!is_string);
|
assert!(!is_string);
|
||||||
assert_eq!(padding, None);
|
assert_eq!(padding, None);
|
||||||
assert_eq!(charset, None);
|
assert_eq!(charset, None);
|
||||||
@@ -1928,6 +1939,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn variable_length_size_is_the_stored_size() {
|
||||||
|
// A file with 4-byte offsets stores 12-byte VL elements (length 4 +
|
||||||
|
// address 4 + index 4); the type used to report 16 regardless, so
|
||||||
|
// every read laid the elements out 16 bytes apart.
|
||||||
|
let mut buf = build_dt_header(9, 1, [0x01, 0x00, 0], 12);
|
||||||
|
buf.extend_from_slice(&build_fixed_point(1, false, false, 0, 8));
|
||||||
|
let (dt, _) = Datatype::parse(&buf).unwrap();
|
||||||
|
assert_eq!(dt.type_size(), 12);
|
||||||
|
// And it is written back as stored.
|
||||||
|
assert_eq!(dt.serialize()[4..8], 12u32.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_array_2d() {
|
fn test_array_2d() {
|
||||||
// Array [3][4] of i32 LE, version 3
|
// Array [3][4] of i32 LE, version 3
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,343 @@
|
|||||||
|
//! Copying a selection out of a row-major buffer one contiguous run at a time.
|
||||||
|
//!
|
||||||
|
//! A selection's elements, in output order, fall into runs that are adjacent
|
||||||
|
//! in the source: a whole block along the last dimension, blocks that touch
|
||||||
|
//! (`stride == block`), and whole rows when the inner dimensions are selected
|
||||||
|
//! in full. Copying run by run turns a 256 x 256 hyperslab of a 1024-wide
|
||||||
|
//! dataset into 256 `memcpy`s of 1 KiB, where the old extractor recursed and
|
||||||
|
//! bounds-checked once per element.
|
||||||
|
|
||||||
|
#[cfg(not(feature = "std"))]
|
||||||
|
use alloc::{vec, vec::Vec};
|
||||||
|
|
||||||
|
use crate::data_read::NativeElement;
|
||||||
|
use crate::error::FormatError;
|
||||||
|
use crate::selection::Selection;
|
||||||
|
|
||||||
|
/// Row-major element strides of `dims` (the last dimension has stride 1).
|
||||||
|
fn strides(dims: &[u64]) -> Vec<u64> {
|
||||||
|
let mut s = vec![1u64; dims.len()];
|
||||||
|
for d in (0..dims.len().saturating_sub(1)).rev() {
|
||||||
|
s[d] = s[d + 1].wrapping_mul(dims[d + 1]);
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges adjacent runs before handing them on.
|
||||||
|
struct Coalesce<F: FnMut(u64, u64)> {
|
||||||
|
start: u64,
|
||||||
|
len: u64,
|
||||||
|
emit: F,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<F: FnMut(u64, u64)> Coalesce<F> {
|
||||||
|
#[inline]
|
||||||
|
fn push(&mut self, start: u64, len: u64) {
|
||||||
|
if len == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.len > 0 && self.start.wrapping_add(self.len) == start {
|
||||||
|
self.len += len;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.flush();
|
||||||
|
self.start = start;
|
||||||
|
self.len = len;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) {
|
||||||
|
if self.len > 0 {
|
||||||
|
(self.emit)(self.start, self.len);
|
||||||
|
self.len = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call `emit(first_element, element_count)` for each run of a hyperslab's
|
||||||
|
/// elements that is contiguous in a row-major dataset of shape `dims`, in
|
||||||
|
/// the order the selection returns them. Adjacent runs are merged.
|
||||||
|
///
|
||||||
|
/// Coordinates at or past a dimension's extent are skipped, as the
|
||||||
|
/// element-wise extractor always did; callers that want them to be an error
|
||||||
|
/// validate the selection first. The four vectors must have `dims.len()`
|
||||||
|
/// entries.
|
||||||
|
pub(crate) fn hyperslab_runs(
|
||||||
|
dims: &[u64],
|
||||||
|
start: &[u64],
|
||||||
|
stride: &[u64],
|
||||||
|
count: &[u64],
|
||||||
|
block: &[u64],
|
||||||
|
emit: impl FnMut(u64, u64),
|
||||||
|
) {
|
||||||
|
let rank = dims.len();
|
||||||
|
let mut out = Coalesce {
|
||||||
|
start: 0,
|
||||||
|
len: 0,
|
||||||
|
emit,
|
||||||
|
};
|
||||||
|
if rank == 0 {
|
||||||
|
out.push(0, 1);
|
||||||
|
out.flush();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (0..rank).any(|d| count[d] == 0 || block[d] == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let strides = strides(dims);
|
||||||
|
let last = rank - 1;
|
||||||
|
// Odometer over the outer dimensions: (block index, offset in block).
|
||||||
|
let mut ci = vec![0u64; last];
|
||||||
|
let mut bi = vec![0u64; last];
|
||||||
|
'outer: loop {
|
||||||
|
// Base offset of this row, or skip it if a coordinate is out of range.
|
||||||
|
let mut base = 0u64;
|
||||||
|
let mut in_range = true;
|
||||||
|
for d in 0..last {
|
||||||
|
let coord = start[d]
|
||||||
|
.saturating_add(ci[d].saturating_mul(stride[d]))
|
||||||
|
.saturating_add(bi[d]);
|
||||||
|
if coord >= dims[d] {
|
||||||
|
in_range = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
base = base.wrapping_add(coord.wrapping_mul(strides[d]));
|
||||||
|
}
|
||||||
|
if in_range && (stride[last] == block[last] || count[last] == 1) {
|
||||||
|
// Blocks that touch (the common unit-stride case: block 1,
|
||||||
|
// stride 1) are one range; don't split it into per-element runs.
|
||||||
|
let s = start[last];
|
||||||
|
let e = s
|
||||||
|
.saturating_add(count[last].saturating_mul(block[last]))
|
||||||
|
.min(dims[last]);
|
||||||
|
if s < e {
|
||||||
|
out.push(base.wrapping_add(s), e - s);
|
||||||
|
}
|
||||||
|
} else if in_range {
|
||||||
|
for c in 0..count[last] {
|
||||||
|
let s = start[last].saturating_add(c.saturating_mul(stride[last]));
|
||||||
|
if s >= dims[last] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let e = s.saturating_add(block[last]).min(dims[last]);
|
||||||
|
out.push(base.wrapping_add(s), e - s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Advance the odometer, last outer dimension fastest.
|
||||||
|
let mut d = last;
|
||||||
|
loop {
|
||||||
|
if d == 0 {
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
d -= 1;
|
||||||
|
bi[d] += 1;
|
||||||
|
if bi[d] < block[d] {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
bi[d] = 0;
|
||||||
|
ci[d] += 1;
|
||||||
|
if ci[d] < count[d] {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ci[d] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The selected elements of `src` — a row-major dataset of shape `dims` and
|
||||||
|
/// `elem_size`-byte elements — copied into a fresh `Vec<T>`, one `memcpy` per
|
||||||
|
/// contiguous run, with no zero-filling of the output first.
|
||||||
|
///
|
||||||
|
/// For `T` other than `u8`, `elem_size` must equal `size_of::<T>()`. The
|
||||||
|
/// selection must be a validated hyperslab, point list or `None` (`All` is the
|
||||||
|
/// caller's to handle); `src` must hold exactly the dataset. Anything that
|
||||||
|
/// would read outside `src` is an error, never a partial result.
|
||||||
|
pub(crate) fn gather<T: NativeElement>(
|
||||||
|
src: &[u8],
|
||||||
|
dims: &[u64],
|
||||||
|
elem_size: usize,
|
||||||
|
selection: &Selection,
|
||||||
|
) -> Result<Vec<T>, FormatError> {
|
||||||
|
let t_size = core::mem::size_of::<T>();
|
||||||
|
if elem_size == 0 || (t_size != 1 && t_size != elem_size) {
|
||||||
|
return Err(FormatError::DataSizeMismatch {
|
||||||
|
expected: t_size,
|
||||||
|
actual: elem_size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let n_elements = match selection {
|
||||||
|
Selection::None => 0,
|
||||||
|
Selection::Hyperslab { count, block, .. } => count
|
||||||
|
.iter()
|
||||||
|
.zip(block)
|
||||||
|
.try_fold(1u64, |acc, (&c, &b)| acc.checked_mul(c.checked_mul(b)?))
|
||||||
|
.ok_or_else(|| FormatError::Overflow("hyperslab count x block overflows".into()))?,
|
||||||
|
Selection::Points(points) => points.len() as u64,
|
||||||
|
Selection::All => {
|
||||||
|
return Err(FormatError::SelectionOutOfBounds(
|
||||||
|
"gather does not take Selection::All".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let out_bytes = crate::chunked_read::checked_byte_len(n_elements, elem_size)?;
|
||||||
|
let out_len = out_bytes / t_size;
|
||||||
|
let mut out: Vec<T> = crate::bulk_alloc::vec_for_bulk(out_len);
|
||||||
|
let dst = out.as_mut_ptr().cast::<u8>();
|
||||||
|
let mut written = 0usize;
|
||||||
|
let mut failed = false;
|
||||||
|
let mut copy_run = |first: u64, n: u64| {
|
||||||
|
if failed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let range = usize::try_from(first)
|
||||||
|
.ok()
|
||||||
|
.and_then(|f| f.checked_mul(elem_size))
|
||||||
|
.zip(
|
||||||
|
usize::try_from(n)
|
||||||
|
.ok()
|
||||||
|
.and_then(|n| n.checked_mul(elem_size)),
|
||||||
|
)
|
||||||
|
.and_then(|(at, len)| Some((at, len, at.checked_add(len)?)));
|
||||||
|
match range {
|
||||||
|
Some((at, len, end)) if end <= src.len() && written + len <= out_bytes => {
|
||||||
|
// SAFETY: `src[at..end]` is in bounds (checked above), and
|
||||||
|
// `dst + written .. + len` lies within `out`'s capacity of
|
||||||
|
// `out_bytes` bytes (checked above); `out` is a fresh
|
||||||
|
// allocation, so the regions do not overlap.
|
||||||
|
unsafe {
|
||||||
|
core::ptr::copy_nonoverlapping(src.as_ptr().add(at), dst.add(written), len)
|
||||||
|
};
|
||||||
|
written += len;
|
||||||
|
}
|
||||||
|
_ => failed = true,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut bad_point = false;
|
||||||
|
match selection {
|
||||||
|
Selection::Hyperslab {
|
||||||
|
start,
|
||||||
|
stride,
|
||||||
|
count,
|
||||||
|
block,
|
||||||
|
} => {
|
||||||
|
let rank = dims.len();
|
||||||
|
if [start.len(), stride.len(), count.len(), block.len()] != [rank; 4] {
|
||||||
|
return Err(FormatError::SelectionOutOfBounds(
|
||||||
|
"hyperslab rank does not match dataset rank".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
hyperslab_runs(dims, start, stride, count, block, &mut copy_run);
|
||||||
|
}
|
||||||
|
Selection::Points(points) => {
|
||||||
|
let strides = strides(dims);
|
||||||
|
let mut runs = Coalesce {
|
||||||
|
start: 0,
|
||||||
|
len: 0,
|
||||||
|
emit: &mut copy_run,
|
||||||
|
};
|
||||||
|
for p in points {
|
||||||
|
if p.len() != dims.len() || p.iter().zip(dims).any(|(c, n)| c >= n) {
|
||||||
|
bad_point = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let at = p
|
||||||
|
.iter()
|
||||||
|
.zip(&strides)
|
||||||
|
.fold(0u64, |acc, (c, s)| acc.wrapping_add(c.wrapping_mul(*s)));
|
||||||
|
runs.push(at, 1);
|
||||||
|
}
|
||||||
|
runs.flush();
|
||||||
|
}
|
||||||
|
Selection::None | Selection::All => {}
|
||||||
|
}
|
||||||
|
if failed || bad_point || written != out_bytes {
|
||||||
|
return Err(FormatError::SelectionOutOfBounds(
|
||||||
|
"selection addresses elements outside the dataset".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// SAFETY: all `out_bytes` bytes, i.e. `out_len` values of `T`, were
|
||||||
|
// written above, and every bit pattern is a valid `T` (`NativeElement`).
|
||||||
|
unsafe { out.set_len(out_len) };
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn runs(dims: &[u64], sel: [&[u64]; 4]) -> Vec<(u64, u64)> {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
hyperslab_runs(dims, sel[0], sel[1], sel[2], sel[3], |s, n| v.push((s, n)));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runs_merge_blocks_and_whole_rows() {
|
||||||
|
// A box: one run per row.
|
||||||
|
assert_eq!(
|
||||||
|
runs(&[4, 10], [&[1, 2], &[1, 1], &[2, 3], &[1, 1]]),
|
||||||
|
vec![(12, 3), (22, 3)]
|
||||||
|
);
|
||||||
|
// Whole rows: one run.
|
||||||
|
assert_eq!(
|
||||||
|
runs(&[4, 10], [&[1, 0], &[1, 1], &[3, 10], &[1, 1]]),
|
||||||
|
vec![(10, 30)]
|
||||||
|
);
|
||||||
|
// stride == block: blocks merge.
|
||||||
|
assert_eq!(
|
||||||
|
runs(&[1, 10], [&[0, 1], &[1, 2], &[1, 4], &[1, 2]]),
|
||||||
|
vec![(1, 8)]
|
||||||
|
);
|
||||||
|
// Strided with blocks along both dimensions.
|
||||||
|
assert_eq!(
|
||||||
|
runs(&[6, 10], [&[0, 1], &[3, 4], &[2, 2], &[2, 2]]),
|
||||||
|
vec![
|
||||||
|
(1, 2),
|
||||||
|
(5, 2),
|
||||||
|
(11, 2),
|
||||||
|
(15, 2),
|
||||||
|
(31, 2),
|
||||||
|
(35, 2),
|
||||||
|
(41, 2),
|
||||||
|
(45, 2)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// Empty.
|
||||||
|
assert!(runs(&[4, 10], [&[0, 0], &[1, 1], &[0, 3], &[1, 1]]).is_empty());
|
||||||
|
// Scalar.
|
||||||
|
assert_eq!(runs(&[], [&[], &[], &[], &[]]), vec![(0, 1)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gather_matches_element_order_and_rejects_out_of_range() {
|
||||||
|
let dims = [3u64, 4];
|
||||||
|
let src: Vec<u8> = (0..12u16).flat_map(|v| v.to_le_bytes()).collect();
|
||||||
|
let sel = Selection::Hyperslab {
|
||||||
|
start: vec![0, 1],
|
||||||
|
stride: vec![2, 2],
|
||||||
|
count: vec![2, 2],
|
||||||
|
block: vec![1, 1],
|
||||||
|
};
|
||||||
|
let got: Vec<u8> = gather(&src, &dims, 2, &sel).unwrap();
|
||||||
|
let want: Vec<u8> = [1u16, 3, 9, 11]
|
||||||
|
.iter()
|
||||||
|
.flat_map(|v| v.to_le_bytes())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(got, want);
|
||||||
|
let pts = Selection::Points(vec![vec![2, 3], vec![0, 0], vec![0, 1]]);
|
||||||
|
let got: Vec<u8> = gather(&src, &dims, 2, &pts).unwrap();
|
||||||
|
let want: Vec<u8> = [11u16, 0, 1].iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||||
|
assert_eq!(got, want);
|
||||||
|
// Past the extent, or a source shorter than the dataset: an error.
|
||||||
|
let bad = Selection::Points(vec![vec![3, 0]]);
|
||||||
|
assert!(gather::<u8>(&src, &dims, 2, &bad).is_err());
|
||||||
|
let past = Selection::Hyperslab {
|
||||||
|
start: vec![2, 0],
|
||||||
|
stride: vec![1, 1],
|
||||||
|
count: vec![2, 4],
|
||||||
|
block: vec![1, 1],
|
||||||
|
};
|
||||||
|
assert!(gather::<u8>(&src, &dims, 2, &past).is_err());
|
||||||
|
assert!(gather::<u8>(&src[..20], &dims, 2, &pts).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
//! HDF5 Global Heap collection parsing.
|
//! HDF5 Global Heap collection parsing.
|
||||||
|
|
||||||
#[cfg(not(feature = "std"))]
|
#[cfg(not(feature = "std"))]
|
||||||
use alloc::vec::Vec;
|
use alloc::{format, string::String, vec::Vec};
|
||||||
|
|
||||||
use crate::error::FormatError;
|
use crate::error::FormatError;
|
||||||
|
|
||||||
@@ -52,11 +52,42 @@ fn read_length(data: &[u8], offset: usize, length_size: u8) -> Result<u64, Forma
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn object_overrun_msg(index: u16, size: usize, collection_size: u64) -> String {
|
||||||
|
format!(
|
||||||
|
"global heap object {index} ({size} bytes) runs past the end of its \
|
||||||
|
{collection_size}-byte collection"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Round up to next multiple of 8.
|
/// Round up to next multiple of 8.
|
||||||
fn pad8(x: usize) -> usize {
|
fn pad8(x: usize) -> usize {
|
||||||
(x + 7) & !7
|
(x + 7) & !7
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where one object of a global heap collection lies in the file, without
|
||||||
|
/// its data: see [`GlobalHeapCollection::parse_index`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct GlobalHeapObjectRef {
|
||||||
|
/// Object index (1-based; 0 is the free space marker).
|
||||||
|
pub index: u16,
|
||||||
|
/// Reference count.
|
||||||
|
pub reference_count: u16,
|
||||||
|
/// Offset of the object's data in the file data the collection was
|
||||||
|
/// parsed from.
|
||||||
|
pub offset: usize,
|
||||||
|
/// Size of the object's data in bytes.
|
||||||
|
pub size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A global heap collection's objects, located but not copied.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GlobalHeapIndex {
|
||||||
|
/// Total size of this collection including header.
|
||||||
|
pub collection_size: u64,
|
||||||
|
/// The objects, in file order.
|
||||||
|
pub objects: Vec<GlobalHeapObjectRef>,
|
||||||
|
}
|
||||||
|
|
||||||
impl GlobalHeapCollection {
|
impl GlobalHeapCollection {
|
||||||
/// Parse a global heap collection at the given offset in the file data.
|
/// Parse a global heap collection at the given offset in the file data.
|
||||||
pub fn parse(
|
pub fn parse(
|
||||||
@@ -64,8 +95,38 @@ impl GlobalHeapCollection {
|
|||||||
offset: usize,
|
offset: usize,
|
||||||
length_size: u8,
|
length_size: u8,
|
||||||
) -> Result<GlobalHeapCollection, FormatError> {
|
) -> Result<GlobalHeapCollection, FormatError> {
|
||||||
// signature(4) + version(1) + reserved(3) + collection_size(length_size)
|
let index = Self::parse_index(file_data, offset, length_size)?;
|
||||||
let header_size = 8 + length_size as usize;
|
Ok(GlobalHeapCollection {
|
||||||
|
collection_size: index.collection_size,
|
||||||
|
objects: index
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.map(|o| GlobalHeapObject {
|
||||||
|
index: o.index,
|
||||||
|
reference_count: o.reference_count,
|
||||||
|
data: file_data[o.offset..o.offset + o.size].to_vec(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate the objects of the global heap collection at `offset` without
|
||||||
|
/// copying their data, so a caller can keep many collections indexed
|
||||||
|
/// for the cost of their object headers.
|
||||||
|
///
|
||||||
|
/// The collection must lie inside `file_data`, and every object inside
|
||||||
|
/// the collection, as libhdf5 lays them out; an object that runs past
|
||||||
|
/// its collection is an error.
|
||||||
|
pub fn parse_index(
|
||||||
|
file_data: &[u8],
|
||||||
|
offset: usize,
|
||||||
|
length_size: u8,
|
||||||
|
) -> Result<GlobalHeapIndex, FormatError> {
|
||||||
|
// signature(4) + version(1) + reserved(3) + collection_size(length_size),
|
||||||
|
// padded to a multiple of 8 as libhdf5 lays it out (`H5HG_SIZEOF_HDR`).
|
||||||
|
// With 8-byte lengths the padding is 0; with 4-byte lengths it is 4,
|
||||||
|
// and reading without it put every object 4 bytes early.
|
||||||
|
let header_size = pad8(8 + length_size as usize);
|
||||||
ensure_len(file_data, offset, header_size)?;
|
ensure_len(file_data, offset, header_size)?;
|
||||||
|
|
||||||
if file_data[offset..offset + 4] != GCOL_SIGNATURE {
|
if file_data[offset..offset + 4] != GCOL_SIGNATURE {
|
||||||
@@ -78,25 +139,25 @@ impl GlobalHeapCollection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let collection_size = read_length(file_data, offset + 8, length_size)?;
|
let collection_size = read_length(file_data, offset + 8, length_size)?;
|
||||||
let collection_size_usize =
|
let collection_end = usize::try_from(collection_size)
|
||||||
usize::try_from(collection_size).map_err(|_| FormatError::UnexpectedEof {
|
.ok()
|
||||||
expected: u64::MAX as usize,
|
.and_then(|size| offset.checked_add(size))
|
||||||
available: file_data.len(),
|
|
||||||
})?;
|
|
||||||
let collection_end =
|
|
||||||
offset
|
|
||||||
.checked_add(collection_size_usize)
|
|
||||||
.ok_or(FormatError::UnexpectedEof {
|
.ok_or(FormatError::UnexpectedEof {
|
||||||
expected: usize::MAX,
|
expected: usize::MAX,
|
||||||
available: file_data.len(),
|
available: file_data.len(),
|
||||||
})?;
|
})?;
|
||||||
|
if collection_end > file_data.len() {
|
||||||
|
return Err(FormatError::UnexpectedEof {
|
||||||
|
expected: collection_end,
|
||||||
|
available: file_data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let mut pos = offset + header_size;
|
let mut pos = offset + header_size;
|
||||||
let mut objects = Vec::new();
|
let mut objects = Vec::new();
|
||||||
|
|
||||||
// Parse objects until we hit index 0 (free space) or run out of space
|
// Parse objects until we hit index 0 (free space) or run out of space
|
||||||
while pos + 2 <= collection_end {
|
while pos + 2 <= collection_end {
|
||||||
ensure_len(file_data, pos, 2)?;
|
|
||||||
let object_index = u16::from_le_bytes([file_data[pos], file_data[pos + 1]]);
|
let object_index = u16::from_le_bytes([file_data[pos], file_data[pos + 1]]);
|
||||||
|
|
||||||
if object_index == 0 {
|
if object_index == 0 {
|
||||||
@@ -104,28 +165,39 @@ impl GlobalHeapCollection {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// object_index(2) + reference_count(2) + reserved(4) + object_size(length_size)
|
// object_index(2) + reference_count(2) + reserved(4) +
|
||||||
let obj_header_size = 8 + length_size as usize;
|
// object_size(length_size), padded to 8 (`H5HG_SIZEOF_OBJHDR`).
|
||||||
ensure_len(file_data, pos, obj_header_size)?;
|
let obj_header_size = pad8(8 + length_size as usize);
|
||||||
|
ensure_len(&file_data[..collection_end], pos, obj_header_size)?;
|
||||||
|
|
||||||
let reference_count = u16::from_le_bytes([file_data[pos + 2], file_data[pos + 3]]);
|
let reference_count = u16::from_le_bytes([file_data[pos + 2], file_data[pos + 3]]);
|
||||||
let object_size = read_length(file_data, pos + 8, length_size)? as usize;
|
let object_size = usize::try_from(read_length(file_data, pos + 8, length_size)?)
|
||||||
|
.map_err(|_| FormatError::Overflow("global heap object size".into()))?;
|
||||||
|
|
||||||
pos += obj_header_size;
|
pos += obj_header_size;
|
||||||
ensure_len(file_data, pos, object_size)?;
|
if pos
|
||||||
let data = file_data[pos..pos + object_size].to_vec();
|
.checked_add(object_size)
|
||||||
|
.is_none_or(|end| end > collection_end)
|
||||||
|
{
|
||||||
|
return Err(FormatError::VlDataError(object_overrun_msg(
|
||||||
|
object_index,
|
||||||
|
object_size,
|
||||||
|
collection_size,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
objects.push(GlobalHeapObject {
|
objects.push(GlobalHeapObjectRef {
|
||||||
index: object_index,
|
index: object_index,
|
||||||
reference_count,
|
reference_count,
|
||||||
data,
|
offset: pos,
|
||||||
|
size: object_size,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Advance past data + padding to 8-byte boundary
|
// Advance past data + padding to 8-byte boundary
|
||||||
pos += pad8(object_size);
|
pos = pos.saturating_add(pad8(object_size));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(GlobalHeapCollection {
|
Ok(GlobalHeapIndex {
|
||||||
collection_size,
|
collection_size,
|
||||||
objects,
|
objects,
|
||||||
})
|
})
|
||||||
@@ -149,10 +221,11 @@ mod tests {
|
|||||||
let ls = length_size as usize;
|
let ls = length_size as usize;
|
||||||
|
|
||||||
// Calculate total size
|
// Calculate total size
|
||||||
let header_size = 8 + ls;
|
// libhdf5 pads both headers to a multiple of 8.
|
||||||
|
let header_size = pad8(8 + ls);
|
||||||
let mut obj_size_total = 0usize;
|
let mut obj_size_total = 0usize;
|
||||||
for (_, _, data) in objects {
|
for (_, _, data) in objects {
|
||||||
let obj_header = 8 + ls;
|
let obj_header = pad8(8 + ls);
|
||||||
obj_size_total += obj_header + pad8(data.len());
|
obj_size_total += obj_header + pad8(data.len());
|
||||||
}
|
}
|
||||||
// Free space marker (2 bytes for index 0)
|
// Free space marker (2 bytes for index 0)
|
||||||
@@ -170,6 +243,7 @@ mod tests {
|
|||||||
8 => buf.extend_from_slice(&(collection_size as u64).to_le_bytes()),
|
8 => buf.extend_from_slice(&(collection_size as u64).to_le_bytes()),
|
||||||
_ => panic!("unsupported length_size"),
|
_ => panic!("unsupported length_size"),
|
||||||
}
|
}
|
||||||
|
buf.resize(header_size, 0);
|
||||||
|
|
||||||
// Objects
|
// Objects
|
||||||
for (index, ref_count, data) in objects {
|
for (index, ref_count, data) in objects {
|
||||||
@@ -181,6 +255,7 @@ mod tests {
|
|||||||
8 => buf.extend_from_slice(&(data.len() as u64).to_le_bytes()),
|
8 => buf.extend_from_slice(&(data.len() as u64).to_le_bytes()),
|
||||||
_ => panic!("unsupported"),
|
_ => panic!("unsupported"),
|
||||||
}
|
}
|
||||||
|
buf.resize(buf.len() + (pad8(8 + ls) - (8 + ls)), 0);
|
||||||
buf.extend_from_slice(data);
|
buf.extend_from_slice(data);
|
||||||
// Pad to 8 bytes
|
// Pad to 8 bytes
|
||||||
let padded = pad8(data.len());
|
let padded = pad8(data.len());
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ pub mod attribute;
|
|||||||
pub mod attribute_info;
|
pub mod attribute_info;
|
||||||
pub mod btree_v1;
|
pub mod btree_v1;
|
||||||
pub mod btree_v2;
|
pub mod btree_v2;
|
||||||
|
mod bulk_alloc;
|
||||||
pub mod checksum;
|
pub mod checksum;
|
||||||
pub mod chunk_cache;
|
pub mod chunk_cache;
|
||||||
mod chunk_grid;
|
mod chunk_grid;
|
||||||
@@ -93,6 +94,7 @@ mod filters_szip;
|
|||||||
pub mod fixed_array;
|
pub mod fixed_array;
|
||||||
pub mod float16;
|
pub mod float16;
|
||||||
pub mod fractal_heap;
|
pub mod fractal_heap;
|
||||||
|
mod gather;
|
||||||
pub mod global_heap;
|
pub mod global_heap;
|
||||||
pub mod group_info;
|
pub mod group_info;
|
||||||
pub mod group_v1;
|
pub mod group_v1;
|
||||||
@@ -130,6 +132,7 @@ mod test_fuzz;
|
|||||||
pub mod type_builders;
|
pub mod type_builders;
|
||||||
pub mod vds;
|
pub mod vds;
|
||||||
pub mod vl_data;
|
pub mod vl_data;
|
||||||
|
mod writer_tree;
|
||||||
|
|
||||||
#[cfg(feature = "provenance")]
|
#[cfg(feature = "provenance")]
|
||||||
pub mod provenance;
|
pub mod provenance;
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ pub fn should_use_parallel(chunk_count: usize) -> bool {
|
|||||||
chunk_count > PARALLEL_THRESHOLD
|
chunk_count > PARALLEL_THRESHOLD
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether handing a read's chunks to rayon can decode them faster than the
|
||||||
|
/// calling thread would alone.
|
||||||
|
///
|
||||||
|
/// `false` when the pool the work would go to (the current pool inside a
|
||||||
|
/// rayon worker, else the global one) has a single thread. Handing work to
|
||||||
|
/// that pool is then worse than useless: the caller blocks while the one
|
||||||
|
/// worker decodes, and every other thread reading at the same time queues
|
||||||
|
/// behind the same worker, so N reader threads decode on one core. (That is
|
||||||
|
/// how full reads with `--decode-threads 1` stopped scaling at about 2x in
|
||||||
|
/// the `concurrent_read` benchmark.)
|
||||||
|
pub fn pool_can_parallelise() -> bool {
|
||||||
|
rayon::current_num_threads() > 1
|
||||||
|
}
|
||||||
|
|
||||||
/// Decompress chunks in parallel using lane-partitioned assignment.
|
/// Decompress chunks in parallel using lane-partitioned assignment.
|
||||||
///
|
///
|
||||||
/// Instead of naive `par_iter`, chunks are deterministically assigned to lanes
|
/// Instead of naive `par_iter`, chunks are deterministically assigned to lanes
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
//!
|
//!
|
||||||
//! [`crate::data_read::read_raw_data_selection`] used to decode the *entire*
|
//! [`crate::data_read::read_raw_data_selection`] used to decode the *entire*
|
||||||
//! dataset and then pick elements out of it, so reading a 64x64 window of a
|
//! dataset and then pick elements out of it, so reading a 64x64 window of a
|
||||||
//! large dataset took about as long as reading all of it. Here the selection's
|
//! large dataset took about as long as reading all of it. A contiguous
|
||||||
//! bounding box is materialised instead — only the rows of a contiguous
|
//! dataset's selection is now copied straight out of the file, one `memcpy`
|
||||||
//! dataset, or only the chunks, that overlap it — and the existing extractor
|
//! per contiguous run of selected elements (`crate::gather`). For chunked
|
||||||
//! runs over that small buffer with the selection translated to the box's
|
//! data the selection's bounding box is materialised — only the chunks that
|
||||||
//! origin. Extraction semantics are therefore exactly the full-read ones.
|
//! overlap it — and the extractor runs over that small buffer with the
|
||||||
|
//! selection translated to the box's origin. Extraction semantics are
|
||||||
|
//! therefore exactly the full-read ones.
|
||||||
|
|
||||||
#[cfg(not(feature = "std"))]
|
#[cfg(not(feature = "std"))]
|
||||||
use alloc::string as alloc_or_std;
|
use alloc::string as alloc_or_std;
|
||||||
@@ -250,10 +252,33 @@ pub fn read_selection(
|
|||||||
if dims.is_empty() || elem_size == 0 {
|
if dims.is_empty() || elem_size == 0 {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
let total = dataspace.checked_num_elements()?;
|
||||||
|
// Contiguous data is addressable in place: copy the selection's runs
|
||||||
|
// straight out of it, whatever fraction of the dataset it covers, with no
|
||||||
|
// intermediate box (and no full copy for a large selection).
|
||||||
|
if let (
|
||||||
|
DataLayout::Contiguous {
|
||||||
|
address: Some(address),
|
||||||
|
..
|
||||||
|
},
|
||||||
|
Selection::Hyperslab { .. } | Selection::Points(_),
|
||||||
|
) = (layout, selection)
|
||||||
|
{
|
||||||
|
validate(selection, dims)?;
|
||||||
|
let base = usize::try_from(*address)
|
||||||
|
.map_err(|_| FormatError::Overflow("data address exceeds usize".into()))?;
|
||||||
|
let data = file_data
|
||||||
|
.get(base..)
|
||||||
|
.and_then(|d| d.get(..checked_byte_len(total, elem_size).ok()?))
|
||||||
|
.ok_or(FormatError::UnexpectedEof {
|
||||||
|
expected: base,
|
||||||
|
available: file_data.len(),
|
||||||
|
})?;
|
||||||
|
return crate::gather::gather::<u8>(data, dims, elem_size, selection).map(Some);
|
||||||
|
}
|
||||||
let Some((box_start, box_extent)) = bounding_box(selection, dims) else {
|
let Some((box_start, box_extent)) = bounding_box(selection, dims) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let total = dataspace.checked_num_elements()?;
|
|
||||||
let box_elements = box_extent
|
let box_elements = box_extent
|
||||||
.iter()
|
.iter()
|
||||||
.try_fold(1u64, |acc, &e| acc.checked_mul(e))
|
.try_fold(1u64, |acc, &e| acc.checked_mul(e))
|
||||||
@@ -265,30 +290,6 @@ pub fn read_selection(
|
|||||||
let mut boxed = alloc_output(checked_byte_len(box_elements, elem_size)?)?;
|
let mut boxed = alloc_output(checked_byte_len(box_elements, elem_size)?)?;
|
||||||
|
|
||||||
match layout {
|
match layout {
|
||||||
DataLayout::Contiguous {
|
|
||||||
address: Some(address),
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let base = usize::try_from(*address)
|
|
||||||
.map_err(|_| FormatError::Overflow("data address exceeds usize".into()))?;
|
|
||||||
let data = file_data
|
|
||||||
.get(base..)
|
|
||||||
.and_then(|d| d.get(..checked_byte_len(total, elem_size).ok()?))
|
|
||||||
.ok_or(FormatError::UnexpectedEof {
|
|
||||||
expected: base,
|
|
||||||
available: file_data.len(),
|
|
||||||
})?;
|
|
||||||
let origin = vec![0u64; dims.len()];
|
|
||||||
copy_overlap(
|
|
||||||
data,
|
|
||||||
&origin,
|
|
||||||
dims,
|
|
||||||
&mut boxed,
|
|
||||||
&box_start,
|
|
||||||
&box_extent,
|
|
||||||
elem_size,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
DataLayout::Chunked {
|
DataLayout::Chunked {
|
||||||
btree_address: Some(_),
|
btree_address: Some(_),
|
||||||
..
|
..
|
||||||
|
|||||||
@@ -695,8 +695,13 @@ impl DatasetBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set attribute `name`. Setting it again replaces the earlier value,
|
||||||
|
/// as `attrs[name] = v` does in h5py.
|
||||||
pub fn set_attr(&mut self, name: &str, value: AttrValue) -> &mut Self {
|
pub fn set_attr(&mut self, name: &str, value: AttrValue) -> &mut Self {
|
||||||
self.attrs.push((name.to_string(), value));
|
match self.attrs.iter_mut().find(|(n, _)| n == name) {
|
||||||
|
Some(slot) => slot.1 = value,
|
||||||
|
None => self.attrs.push((name.to_string(), value)),
|
||||||
|
}
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -903,34 +908,117 @@ impl DatasetBuilder {
|
|||||||
|
|
||||||
// ---- Group builder ----
|
// ---- Group builder ----
|
||||||
|
|
||||||
/// Builder for groups.
|
/// One entry of a [`GroupBuilder`], kept in the order it was added (the
|
||||||
|
/// order a group that tracks creation order lists its links in).
|
||||||
|
pub(crate) enum GroupItem {
|
||||||
|
Dataset(Box<DatasetBuilder>),
|
||||||
|
Group(GroupBuilder),
|
||||||
|
/// A soft link: `name` resolves to whatever `target` names when read.
|
||||||
|
Soft {
|
||||||
|
name: String,
|
||||||
|
target: String,
|
||||||
|
},
|
||||||
|
/// An extra hard link to the object at `target` (a path in this file).
|
||||||
|
Hard {
|
||||||
|
name: String,
|
||||||
|
target: String,
|
||||||
|
},
|
||||||
|
/// An external link to `path` in the file `file`.
|
||||||
|
External {
|
||||||
|
name: String,
|
||||||
|
file: String,
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builder for a group: its datasets, subgroups, links and attributes.
|
||||||
|
///
|
||||||
|
/// Names are paths relative to the group: `create_dataset("a/b/x")` creates
|
||||||
|
/// the groups `a` and `a/b` as needed, as h5py does. A group added where a
|
||||||
|
/// group of the same path already exists (added by another builder, or
|
||||||
|
/// created as an intermediate group) is merged into it, like h5py's
|
||||||
|
/// `require_group`; any other name used twice in a group is an error when the
|
||||||
|
/// file is written. A path component must not be empty or `"."`.
|
||||||
pub struct GroupBuilder {
|
pub struct GroupBuilder {
|
||||||
pub(crate) name: String,
|
pub(crate) name: String,
|
||||||
pub(crate) datasets: Vec<DatasetBuilder>,
|
pub(crate) items: Vec<GroupItem>,
|
||||||
pub(crate) attrs: Vec<(String, AttrValue)>,
|
pub(crate) attrs: Vec<(String, AttrValue)>,
|
||||||
/// (link_name, target_file, target_path)
|
/// Track (and index) link creation order; `None` follows the file's
|
||||||
pub(crate) external_links: Vec<(String, String, String)>,
|
/// default (`FileWriter::track_order`).
|
||||||
|
pub(crate) track_order: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GroupBuilder {
|
impl GroupBuilder {
|
||||||
pub(crate) fn new(name: &str) -> Self {
|
pub(crate) fn new(name: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
datasets: Vec::new(),
|
items: Vec::new(),
|
||||||
attrs: Vec::new(),
|
attrs: Vec::new(),
|
||||||
external_links: Vec::new(),
|
track_order: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a dataset in this group. `name` may be a relative path
|
||||||
|
/// (`"a/b/x"`); missing intermediate groups are created.
|
||||||
pub fn create_dataset(&mut self, name: &str) -> &mut DatasetBuilder {
|
pub fn create_dataset(&mut self, name: &str) -> &mut DatasetBuilder {
|
||||||
self.datasets.push(DatasetBuilder::new(name));
|
self.items
|
||||||
self.datasets.last_mut().unwrap()
|
.push(GroupItem::Dataset(Box::new(DatasetBuilder::new(name))));
|
||||||
|
match self.items.last_mut() {
|
||||||
|
Some(GroupItem::Dataset(d)) => d,
|
||||||
|
_ => unreachable!("just pushed a dataset"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a subgroup of this group. Like `FileWriter::create_group`, the
|
||||||
|
/// builder is detached: fill it, then pass `finish()`'s result to
|
||||||
|
/// [`Self::add_group`]. `name` may be a relative path.
|
||||||
|
pub fn create_group(&self, name: &str) -> GroupBuilder {
|
||||||
|
GroupBuilder::new(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a finished subgroup to this group.
|
||||||
|
pub fn add_group(&mut self, group: FinishedGroup) -> &mut Self {
|
||||||
|
self.items.push(GroupItem::Group(group.group));
|
||||||
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_attr(&mut self, name: &str, value: AttrValue) {
|
pub fn set_attr(&mut self, name: &str, value: AttrValue) {
|
||||||
self.attrs.push((name.to_string(), value));
|
self.attrs.push((name.to_string(), value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Track the creation order of this group's links, and index it, as
|
||||||
|
/// h5py's `track_order=True` does: libhdf5 (and h5py) then list the
|
||||||
|
/// group's members in the order they were added rather than by name.
|
||||||
|
/// Applies to links only, not to attributes.
|
||||||
|
pub fn track_order(&mut self, track: bool) -> &mut Self {
|
||||||
|
self.track_order = Some(track);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a soft link `name` to the path `target` (absolute, or relative to
|
||||||
|
/// this group), like h5py's `grp[name] = h5py.SoftLink(target)`. The
|
||||||
|
/// target need not exist.
|
||||||
|
pub fn add_soft_link(&mut self, name: &str, target: &str) -> &mut Self {
|
||||||
|
self.items.push(GroupItem::Soft {
|
||||||
|
name: name.to_string(),
|
||||||
|
target: target.to_string(),
|
||||||
|
});
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add another hard link `name` to the group or dataset at `target`
|
||||||
|
/// (absolute, or relative to this group), like h5py's
|
||||||
|
/// `grp[name] = f[target]`. The target must be written in the same file;
|
||||||
|
/// its path may go through other hard links, but not through soft or
|
||||||
|
/// external links.
|
||||||
|
pub fn add_hard_link(&mut self, name: &str, target: &str) -> &mut Self {
|
||||||
|
self.items.push(GroupItem::Hard {
|
||||||
|
name: name.to_string(),
|
||||||
|
target: target.to_string(),
|
||||||
|
});
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Add an external link: a named pointer to an object in another HDF5 file.
|
/// Add an external link: a named pointer to an object in another HDF5 file.
|
||||||
pub fn add_external_link(
|
pub fn add_external_link(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -938,30 +1026,21 @@ impl GroupBuilder {
|
|||||||
target_file: &str,
|
target_file: &str,
|
||||||
target_path: &str,
|
target_path: &str,
|
||||||
) -> &mut Self {
|
) -> &mut Self {
|
||||||
self.external_links.push((
|
self.items.push(GroupItem::External {
|
||||||
name.to_string(),
|
name: name.to_string(),
|
||||||
target_file.to_string(),
|
file: target_file.to_string(),
|
||||||
target_path.to_string(),
|
path: target_path.to_string(),
|
||||||
));
|
});
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consume the builder, returning a FinishedGroup to add to FileWriter.
|
/// Consume the builder, returning a FinishedGroup to add to FileWriter.
|
||||||
pub fn finish(self) -> FinishedGroup {
|
pub fn finish(self) -> FinishedGroup {
|
||||||
FinishedGroup {
|
FinishedGroup { group: self }
|
||||||
name: self.name,
|
|
||||||
datasets: self.datasets,
|
|
||||||
attrs: self.attrs,
|
|
||||||
external_links: self.external_links,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A finished group ready for the file writer.
|
/// A finished group ready for the file writer.
|
||||||
pub struct FinishedGroup {
|
pub struct FinishedGroup {
|
||||||
pub(crate) name: String,
|
pub(crate) group: GroupBuilder,
|
||||||
pub(crate) datasets: Vec<DatasetBuilder>,
|
|
||||||
pub(crate) attrs: Vec<(String, AttrValue)>,
|
|
||||||
/// (link_name, target_file, target_path)
|
|
||||||
pub(crate) external_links: Vec<(String, String, String)>,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
//! `sequence_length(4 LE) + collection_address(offset_size LE) + object_index(4 LE)`.
|
//! `sequence_length(4 LE) + collection_address(offset_size LE) + object_index(4 LE)`.
|
||||||
|
|
||||||
#[cfg(not(feature = "std"))]
|
#[cfg(not(feature = "std"))]
|
||||||
use alloc::{string::String, vec::Vec};
|
use alloc::{collections::BTreeMap, format, string::String, vec, vec::Vec};
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use crate::error::FormatError;
|
use crate::error::FormatError;
|
||||||
use crate::global_heap::GlobalHeapCollection;
|
use crate::global_heap::{GlobalHeapCollection, GlobalHeapIndex};
|
||||||
|
|
||||||
/// A parsed variable-length element reference (global heap ID).
|
/// A parsed variable-length element reference (global heap ID).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -109,7 +111,218 @@ fn is_undefined_address(addr: u64, offset_size: u8) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The size of one variable-length element in a file with `offset_size`-byte
|
||||||
|
/// addresses: a sequence length (4), a global heap collection address and an
|
||||||
|
/// object index (4). libhdf5 computes it this way rather than trusting the
|
||||||
|
/// datatype message (`H5T_set_loc`).
|
||||||
|
pub fn element_size(offset_size: u8) -> usize {
|
||||||
|
4 + offset_size as usize + 4
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuse a variable-length datatype whose stored element size is not the
|
||||||
|
/// one this file's offset size implies. Its elements would be laid out with
|
||||||
|
/// a stride libhdf5 does not use, so every value after the first would be
|
||||||
|
/// read from the wrong place.
|
||||||
|
pub fn check_element_size(stored_size: u32, offset_size: u8) -> Result<(), FormatError> {
|
||||||
|
let expected = element_size(offset_size);
|
||||||
|
if stored_size as usize != expected {
|
||||||
|
return Err(FormatError::VlDataError(format!(
|
||||||
|
"variable-length datatype stores {stored_size}-byte elements; a file with \
|
||||||
|
{offset_size}-byte offsets uses {expected}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A collection's objects, located in the file data but not copied:
|
||||||
|
/// `(index, offset, size)` of the first object with each index, sorted by
|
||||||
|
/// index.
|
||||||
|
struct CachedCollection {
|
||||||
|
objects: Vec<(u16, usize, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CachedCollection {
|
||||||
|
fn new(index: GlobalHeapIndex) -> Self {
|
||||||
|
let mut objects: Vec<(u16, usize, usize)> = index
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.map(|o| (o.index, o.offset, o.size))
|
||||||
|
.collect();
|
||||||
|
// Stable, so the first object with a repeated index is kept.
|
||||||
|
objects.sort_by_key(|o| o.0);
|
||||||
|
objects.dedup_by_key(|o| o.0);
|
||||||
|
Self { objects }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What this entry costs to keep, in bytes (roughly).
|
||||||
|
fn cost(&self) -> usize {
|
||||||
|
64 + self.objects.len() * core::mem::size_of::<(u16, usize, usize)>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, index: u32) -> Option<(usize, usize)> {
|
||||||
|
let index = u16::try_from(index).ok()?;
|
||||||
|
let i = self.objects.binary_search_by_key(&index, |o| o.0).ok()?;
|
||||||
|
Some((self.objects[i].1, self.objects[i].2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many bytes of collection indexes a [`VlResolver`] keeps before it
|
||||||
|
/// drops them and starts again. Values are never copied into the cache, so
|
||||||
|
/// this bounds what a read retains however many collections it visits.
|
||||||
|
const CACHE_BUDGET: usize = 32 << 20;
|
||||||
|
|
||||||
|
/// Resolves variable-length elements against a file's global heap, parsing
|
||||||
|
/// each heap collection once however many elements point into it.
|
||||||
|
///
|
||||||
|
/// Values follow libhdf5: an element whose heap address is 0 is null (an
|
||||||
|
/// empty string or sequence), and an element whose heap object is not
|
||||||
|
/// exactly `length × base size` bytes is an error ("Expected global heap
|
||||||
|
/// object size does not match"), not a truncated or padded value.
|
||||||
|
///
|
||||||
|
/// Memory stays bounded on hostile files: the cache holds where each
|
||||||
|
/// object lies, not a copy of it, up to a fixed budget; and collections
|
||||||
|
/// that overlap one another are refused (libhdf5 never writes them), so a
|
||||||
|
/// file cannot make the resolver parse the same bytes as the objects of
|
||||||
|
/// many collections.
|
||||||
|
pub struct VlResolver<'a> {
|
||||||
|
file_data: &'a [u8],
|
||||||
|
offset_size: u8,
|
||||||
|
length_size: u8,
|
||||||
|
cache: BTreeMap<u64, CachedCollection>,
|
||||||
|
cached_bytes: usize,
|
||||||
|
budget: usize,
|
||||||
|
/// Start → end of every collection parsed so far (kept when the cache
|
||||||
|
/// is dropped, to check overlaps).
|
||||||
|
extents: BTreeMap<usize, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> VlResolver<'a> {
|
||||||
|
/// A resolver over `file_data` (the file from its superblock on), with
|
||||||
|
/// the superblock's offset and length sizes.
|
||||||
|
pub fn new(file_data: &'a [u8], offset_size: u8, length_size: u8) -> Self {
|
||||||
|
Self {
|
||||||
|
file_data,
|
||||||
|
offset_size,
|
||||||
|
length_size,
|
||||||
|
cache: BTreeMap::new(),
|
||||||
|
cached_bytes: 0,
|
||||||
|
budget: CACHE_BUDGET,
|
||||||
|
extents: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The size of one element in this file (see [`element_size`]).
|
||||||
|
pub fn element_size(&self) -> usize {
|
||||||
|
element_size(self.offset_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `raw` into elements; its length must be a whole number of them.
|
||||||
|
fn elements(&self, raw: &[u8]) -> Result<Vec<VlElement>, FormatError> {
|
||||||
|
let size = self.element_size();
|
||||||
|
if !raw.len().is_multiple_of(size) {
|
||||||
|
return Err(FormatError::VlDataError(format!(
|
||||||
|
"{} bytes is not a whole number of {size}-byte variable-length elements",
|
||||||
|
raw.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
parse_vl_references(raw, (raw.len() / size) as u64, self.offset_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bytes of one element: `length × base_size` bytes from the heap,
|
||||||
|
/// or `None` for a null element.
|
||||||
|
fn resolve(
|
||||||
|
&mut self,
|
||||||
|
vl: &VlElement,
|
||||||
|
base_size: usize,
|
||||||
|
) -> Result<Option<&'a [u8]>, FormatError> {
|
||||||
|
let addr = vl.collection_address;
|
||||||
|
if addr == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let data = self.object(vl)?;
|
||||||
|
let expected = (vl.length as usize)
|
||||||
|
.checked_mul(base_size)
|
||||||
|
.ok_or_else(|| FormatError::Overflow("variable-length element size".into()))?;
|
||||||
|
if data.len() != expected {
|
||||||
|
return Err(FormatError::VlDataError(format!(
|
||||||
|
"global heap object {} in the collection at {addr} holds {} bytes; the element \
|
||||||
|
says {} × {base_size}",
|
||||||
|
vl.object_index,
|
||||||
|
data.len(),
|
||||||
|
vl.length
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Some(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One element (the first [`element_size`](Self::element_size) bytes of
|
||||||
|
/// `elem`) of a variable-length sequence whose base type is `base_size`
|
||||||
|
/// bytes: its `length × base_size` bytes, or `None` for a null element
|
||||||
|
/// (heap address 0).
|
||||||
|
pub fn element(
|
||||||
|
&mut self,
|
||||||
|
elem: &[u8],
|
||||||
|
base_size: usize,
|
||||||
|
) -> Result<Option<&'a [u8]>, FormatError> {
|
||||||
|
let vl = parse_vl_references(elem, 1, self.offset_size)?;
|
||||||
|
self.resolve(&vl[0], base_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One variable-length string element: its bytes up to the first NUL,
|
||||||
|
/// or `None` for a null element (h5dump prints it as `NULL`, h5py
|
||||||
|
/// returns it as empty).
|
||||||
|
pub fn string_element(&mut self, elem: &[u8]) -> Result<Option<&'a [u8]>, FormatError> {
|
||||||
|
Ok(self.element(elem, 1)?.map(cut_at_nul))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The strings of the variable-length string elements in `raw`, as
|
||||||
|
/// bytes. A string ends at its first NUL, as libhdf5 returns it (it
|
||||||
|
/// converts each to a C string); a null element is empty.
|
||||||
|
pub fn string_bytes(&mut self, raw: &[u8]) -> Result<Vec<Vec<u8>>, FormatError> {
|
||||||
|
self.elements(raw)?
|
||||||
|
.iter()
|
||||||
|
.map(|vl| Ok(self.resolve(vl, 1)?.map(cut_at_nul).unwrap_or(&[]).to_vec()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The strings of the variable-length string elements in `raw`, decoded
|
||||||
|
/// as UTF-8 with invalid sequences replaced by U+FFFD (see
|
||||||
|
/// [`string_bytes`](Self::string_bytes) for the exact bytes).
|
||||||
|
pub fn strings(&mut self, raw: &[u8]) -> Result<Vec<String>, FormatError> {
|
||||||
|
Ok(self
|
||||||
|
.string_bytes(raw)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|b| match String::from_utf8(b) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sequences of the variable-length sequence elements in `raw`, each
|
||||||
|
/// as its `length × base_size` bytes in the base type's encoding.
|
||||||
|
pub fn sequences(&mut self, raw: &[u8], base_size: usize) -> Result<Vec<Vec<u8>>, FormatError> {
|
||||||
|
if base_size == 0 {
|
||||||
|
return Err(FormatError::VlDataError(
|
||||||
|
"variable-length sequence of a zero-size base type".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.elements(raw)?
|
||||||
|
.iter()
|
||||||
|
.map(|vl| Ok(self.resolve(vl, base_size)?.unwrap_or(&[]).to_vec()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A string's bytes up to its first NUL.
|
||||||
|
fn cut_at_nul(s: &[u8]) -> &[u8] {
|
||||||
|
&s[..s.iter().position(|&b| b == 0).unwrap_or(s.len())]
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve VL strings from raw data by looking up each element in the global heap.
|
/// Resolve VL strings from raw data by looking up each element in the global heap.
|
||||||
|
///
|
||||||
|
/// Reads the first `num_elements` elements of `raw`. Strings end at their
|
||||||
|
/// first NUL and invalid UTF-8 is replaced, as in [`VlResolver::strings`].
|
||||||
pub fn read_vl_strings(
|
pub fn read_vl_strings(
|
||||||
file_data: &[u8],
|
file_data: &[u8],
|
||||||
raw_data: &[u8],
|
raw_data: &[u8],
|
||||||
@@ -117,35 +330,23 @@ pub fn read_vl_strings(
|
|||||||
offset_size: u8,
|
offset_size: u8,
|
||||||
length_size: u8,
|
length_size: u8,
|
||||||
) -> Result<Vec<String>, FormatError> {
|
) -> Result<Vec<String>, FormatError> {
|
||||||
let refs = parse_vl_references(raw_data, num_elements, offset_size)?;
|
let raw = first_elements(raw_data, num_elements, offset_size)?;
|
||||||
let mut result = Vec::with_capacity(refs.len());
|
VlResolver::new(file_data, offset_size, length_size).strings(raw)
|
||||||
|
}
|
||||||
|
|
||||||
for vl in &refs {
|
/// The first `num_elements` elements of `raw`, or an error if it is shorter.
|
||||||
if vl.length == 0 && is_undefined_address(vl.collection_address, offset_size) {
|
fn first_elements(raw: &[u8], num_elements: u64, offset_size: u8) -> Result<&[u8], FormatError> {
|
||||||
result.push(String::new());
|
let total = usize::try_from(num_elements)
|
||||||
continue;
|
.ok()
|
||||||
}
|
.and_then(|n| n.checked_mul(element_size(offset_size)))
|
||||||
if vl.length == 0 && vl.collection_address == 0 {
|
.ok_or(FormatError::UnexpectedEof {
|
||||||
result.push(String::new());
|
expected: usize::MAX,
|
||||||
continue;
|
available: raw.len(),
|
||||||
}
|
})?;
|
||||||
|
raw.get(..total).ok_or(FormatError::UnexpectedEof {
|
||||||
let coll =
|
expected: total,
|
||||||
GlobalHeapCollection::parse(file_data, vl.collection_address as usize, length_size)?;
|
available: raw.len(),
|
||||||
let obj = coll.get_object(vl.object_index as u16).ok_or(
|
})
|
||||||
FormatError::GlobalHeapObjectNotFound {
|
|
||||||
collection_address: vl.collection_address,
|
|
||||||
index: vl.object_index as u16,
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// The object data is the raw string bytes
|
|
||||||
let len = (vl.length as usize).min(obj.data.len());
|
|
||||||
let s = String::from_utf8_lossy(&obj.data[..len]).into_owned();
|
|
||||||
result.push(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve VL sequences from raw data, returning each element's bytes.
|
/// Resolve VL sequences from raw data, returning each element's bytes.
|
||||||
@@ -153,7 +354,9 @@ pub fn read_vl_strings(
|
|||||||
/// Each element is the sequence's full encoding — element count × base type
|
/// Each element is the sequence's full encoding — element count × base type
|
||||||
/// size bytes, in the base type's byte order — so a sequence of `i32` yields
|
/// size bytes, in the base type's byte order — so a sequence of `i32` yields
|
||||||
/// four bytes per value. Decode it with the base type (e.g.
|
/// four bytes per value. Decode it with the base type (e.g.
|
||||||
/// [`crate::data_read::read_as_i64`]).
|
/// [`crate::data_read::read_as_i64`]). This does not know the base type, so
|
||||||
|
/// it returns each heap object whole; [`VlResolver::sequences`] also checks
|
||||||
|
/// the object's size against the element's length.
|
||||||
pub fn read_vl_bytes(
|
pub fn read_vl_bytes(
|
||||||
file_data: &[u8],
|
file_data: &[u8],
|
||||||
raw_data: &[u8],
|
raw_data: &[u8],
|
||||||
@@ -162,35 +365,97 @@ pub fn read_vl_bytes(
|
|||||||
length_size: u8,
|
length_size: u8,
|
||||||
) -> Result<Vec<Vec<u8>>, FormatError> {
|
) -> Result<Vec<Vec<u8>>, FormatError> {
|
||||||
let refs = parse_vl_references(raw_data, num_elements, offset_size)?;
|
let refs = parse_vl_references(raw_data, num_elements, offset_size)?;
|
||||||
|
let mut resolver = VlResolver::new(file_data, offset_size, length_size);
|
||||||
let mut result = Vec::with_capacity(refs.len());
|
let mut result = Vec::with_capacity(refs.len());
|
||||||
|
|
||||||
for vl in &refs {
|
for vl in &refs {
|
||||||
if vl.length == 0
|
// A heap address of 0 is a null element, as in VlResolver.
|
||||||
&& (is_undefined_address(vl.collection_address, offset_size)
|
if vl.collection_address == 0 {
|
||||||
|| vl.collection_address == 0)
|
|
||||||
{
|
|
||||||
result.push(Vec::new());
|
result.push(Vec::new());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let coll =
|
|
||||||
GlobalHeapCollection::parse(file_data, vl.collection_address as usize, length_size)?;
|
|
||||||
let obj = coll.get_object(vl.object_index as u16).ok_or(
|
|
||||||
FormatError::GlobalHeapObjectNotFound {
|
|
||||||
collection_address: vl.collection_address,
|
|
||||||
index: vl.object_index as u16,
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// The heap object holds the whole sequence. `vl.length` counts
|
// The heap object holds the whole sequence. `vl.length` counts
|
||||||
// elements, not bytes, so it is only the byte length when the base
|
// elements, not bytes, so it is only the byte length when the base
|
||||||
// type is one byte wide.
|
// type is one byte wide.
|
||||||
result.push(obj.data.clone());
|
let obj = resolver.object(vl)?;
|
||||||
|
result.push(obj.to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'a> VlResolver<'a> {
|
||||||
|
/// The heap object `vl` points to, whatever its size; its collection is
|
||||||
|
/// parsed on first use.
|
||||||
|
fn object(&mut self, vl: &VlElement) -> Result<&'a [u8], FormatError> {
|
||||||
|
let addr = vl.collection_address;
|
||||||
|
// libhdf5 writes a null element with address 0, never the undefined
|
||||||
|
// address, and fails to read one ("addr undefined") even when its
|
||||||
|
// length is 0; we returned an empty value.
|
||||||
|
if is_undefined_address(addr, self.offset_size) {
|
||||||
|
return Err(FormatError::VlDataError(format!(
|
||||||
|
"variable-length element (length {}) has the undefined global heap address",
|
||||||
|
vl.length
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !self.cache.contains_key(&addr) {
|
||||||
|
let offset = usize::try_from(addr).map_err(|_| FormatError::UnexpectedEof {
|
||||||
|
expected: usize::MAX,
|
||||||
|
available: self.file_data.len(),
|
||||||
|
})?;
|
||||||
|
let index =
|
||||||
|
GlobalHeapCollection::parse_index(self.file_data, offset, self.length_size)?;
|
||||||
|
// parse_index checked that the collection lies in the file.
|
||||||
|
let end = offset + index.collection_size as usize;
|
||||||
|
self.check_overlap(offset, end)?;
|
||||||
|
let coll = CachedCollection::new(index);
|
||||||
|
if self.cached_bytes.saturating_add(coll.cost()) > self.budget {
|
||||||
|
self.cache.clear();
|
||||||
|
self.cached_bytes = 0;
|
||||||
|
}
|
||||||
|
self.cached_bytes += coll.cost();
|
||||||
|
self.cache.insert(addr, coll);
|
||||||
|
}
|
||||||
|
let (start, size) = self.cache[&addr].get(vl.object_index).ok_or(
|
||||||
|
FormatError::GlobalHeapObjectNotFound {
|
||||||
|
collection_address: addr,
|
||||||
|
index: vl.object_index as u16,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(&self.file_data[start..start + size])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the collection at `start..end`, refusing one that overlaps a
|
||||||
|
/// collection already read. libhdf5 allocates each collection its own
|
||||||
|
/// block; overlapping ones only come from a crafted file, where they let
|
||||||
|
/// every byte be parsed again as the objects of each collection.
|
||||||
|
fn check_overlap(&mut self, start: usize, end: usize) -> Result<(), FormatError> {
|
||||||
|
if let Some(&known) = self.extents.get(&start) {
|
||||||
|
return if known == end {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(FormatError::VlDataError(format!(
|
||||||
|
"global heap collection at {start} changed size"
|
||||||
|
)))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let before = self.extents.range(..start).next_back();
|
||||||
|
let after = self.extents.range(start..).next();
|
||||||
|
let clash = match (before, after) {
|
||||||
|
(Some((&s, &e)), _) if e > start => Some(s),
|
||||||
|
(_, Some((&s, _))) if s < end => Some(s),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(other) = clash {
|
||||||
|
return Err(FormatError::VlDataError(format!(
|
||||||
|
"global heap collection at {start} overlaps the one at {other}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.extents.insert(start, end);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -285,16 +550,27 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn null_vl_element_empty_string() {
|
fn an_undefined_heap_address_is_an_error_even_at_length_0() {
|
||||||
// length=0, address=undefined
|
// libhdf5 fails the read ("addr undefined"); h5py and libhdf5 write
|
||||||
let mut raw = Vec::new();
|
// a null element with address 0. We returned "".
|
||||||
raw.extend_from_slice(&0u32.to_le_bytes()); // length=0
|
let mut file_data = vec![0u8; 256];
|
||||||
raw.extend_from_slice(&u64::MAX.to_le_bytes()); // undefined address
|
build_gcol_at(&mut file_data, 64, &[(1, b"x")]);
|
||||||
raw.extend_from_slice(&0u32.to_le_bytes()); // index
|
for (os, undef) in [(8u8, u64::MAX), (4, 0xFFFF_FFFF)] {
|
||||||
|
for length in [0, 1] {
|
||||||
let file_data = vec![0u8; 16];
|
let mut raw = element(1, 64, 1, os);
|
||||||
let strings = read_vl_strings(&file_data, &raw, 1, 8, 8).unwrap();
|
raw.extend(element(length, undef, 1, os));
|
||||||
assert_eq!(strings, vec![""]);
|
let mut r = VlResolver::new(&file_data, os, 8);
|
||||||
|
let e = r.string_bytes(&raw).unwrap_err().to_string();
|
||||||
|
assert!(e.contains("undefined"), "{e}");
|
||||||
|
assert!(r.sequences(&raw, 1).is_err());
|
||||||
|
assert!(r.string_element(&raw[raw.len() / 2..]).is_err());
|
||||||
|
let n = 2;
|
||||||
|
assert!(read_vl_strings(&file_data, &raw, n, os, 8).is_err());
|
||||||
|
assert!(read_vl_bytes(&file_data, &raw, n, os, 8).is_err());
|
||||||
|
// The defined element alone still reads.
|
||||||
|
assert_eq!(r.strings(&raw[..raw.len() / 2]).unwrap(), ["x"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -333,6 +609,126 @@ mod tests {
|
|||||||
assert_eq!(bytes, vec![vec![0xDE, 0xAD], vec![0xBE, 0xEF, 0xCA]]);
|
assert_eq!(bytes, vec![vec![0xDE, 0xAD], vec![0xBE, 0xEF, 0xCA]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn element(length: u32, addr: u64, index: u32, offset_size: u8) -> Vec<u8> {
|
||||||
|
let mut raw = length.to_le_bytes().to_vec();
|
||||||
|
raw.extend_from_slice(&addr.to_le_bytes()[..offset_size as usize]);
|
||||||
|
raw.extend_from_slice(&index.to_le_bytes());
|
||||||
|
raw
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strings_end_at_the_first_nul() {
|
||||||
|
// libhdf5 hands each VL string over as a C string, so h5py sees
|
||||||
|
// "a\0b" as "a"; we used to return the NUL and what followed.
|
||||||
|
let mut file_data = vec![0u8; 512];
|
||||||
|
build_gcol_at(&mut file_data, 64, &[(1, b"a\0b"), (2, b"cd")]);
|
||||||
|
let mut raw = element(3, 64, 1, 8);
|
||||||
|
raw.extend(element(2, 64, 2, 8));
|
||||||
|
let mut r = VlResolver::new(&file_data, 8, 8);
|
||||||
|
assert_eq!(
|
||||||
|
r.string_bytes(&raw).unwrap(),
|
||||||
|
vec![b"a".to_vec(), b"cd".to_vec()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_vl_strings(&file_data, &raw, 2, 8, 8).unwrap(),
|
||||||
|
["a", "cd"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_heap_object_of_the_wrong_size_is_an_error() {
|
||||||
|
// libhdf5: "Expected global heap object size does not match". We
|
||||||
|
// used to return the object cut to the element's length.
|
||||||
|
let mut file_data = vec![0u8; 512];
|
||||||
|
build_gcol_at(&mut file_data, 64, &[(1, b"cdefgh"), (2, &[1, 0, 0, 0])]);
|
||||||
|
let mut r = VlResolver::new(&file_data, 8, 8);
|
||||||
|
assert!(r.string_bytes(&element(3, 64, 1, 8)).is_err());
|
||||||
|
assert!(r.string_bytes(&element(9, 64, 1, 8)).is_err());
|
||||||
|
assert!(read_vl_strings(&file_data, &element(3, 64, 1, 8), 1, 8, 8).is_err());
|
||||||
|
// A sequence of one i32 is 4 bytes; of two, 8.
|
||||||
|
assert_eq!(
|
||||||
|
r.sequences(&element(1, 64, 2, 8), 4).unwrap(),
|
||||||
|
vec![vec![1, 0, 0, 0]]
|
||||||
|
);
|
||||||
|
assert!(r.sequences(&element(2, 64, 2, 8), 4).is_err());
|
||||||
|
assert!(r.sequences(&element(1, 64, 2, 8), 0).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn address_zero_is_null_whatever_the_length() {
|
||||||
|
// libhdf5 treats a heap address of 0 as a null element.
|
||||||
|
let file_data = vec![0u8; 64];
|
||||||
|
let mut r = VlResolver::new(&file_data, 8, 8);
|
||||||
|
assert_eq!(
|
||||||
|
r.string_bytes(&element(5, 0, 1, 8)).unwrap(),
|
||||||
|
vec![Vec::<u8>::new()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
r.sequences(&element(5, 0, 1, 8), 4).unwrap(),
|
||||||
|
vec![Vec::<u8>::new()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn four_byte_offsets_use_twelve_byte_elements() {
|
||||||
|
let mut file_data = vec![0u8; 512];
|
||||||
|
build_gcol_at(&mut file_data, 64, &[(1, b"one"), (2, b""), (3, b"three")]);
|
||||||
|
let mut raw = element(3, 64, 1, 4);
|
||||||
|
raw.extend(element(0, 64, 2, 4));
|
||||||
|
raw.extend(element(5, 64, 3, 4));
|
||||||
|
assert_eq!(raw.len(), 36);
|
||||||
|
let mut r = VlResolver::new(&file_data, 4, 8);
|
||||||
|
assert_eq!(r.element_size(), 12);
|
||||||
|
assert_eq!(r.strings(&raw).unwrap(), ["one", "", "three"]);
|
||||||
|
// Not a whole number of elements.
|
||||||
|
assert!(r.strings(&raw[..30]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_cache_stays_within_its_budget_and_rereads_what_it_dropped() {
|
||||||
|
// Twenty collections of three objects each; a budget that holds
|
||||||
|
// about two of them. Reading every element twice must still return
|
||||||
|
// the right strings after the cache is dropped.
|
||||||
|
let mut file_data = vec![0u8; 64];
|
||||||
|
let mut raw = Vec::new();
|
||||||
|
for c in 0..20u64 {
|
||||||
|
let at = file_data.len();
|
||||||
|
let names: Vec<String> = (0..3).map(|i| format!("c{c}o{i}")).collect();
|
||||||
|
let objs: Vec<(u16, &[u8])> = names
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, n)| (i as u16 + 1, n.as_bytes()))
|
||||||
|
.collect();
|
||||||
|
build_gcol_at(&mut file_data, at, &objs);
|
||||||
|
for (i, n) in names.iter().enumerate() {
|
||||||
|
raw.extend(element(n.len() as u32, at as u64, i as u32 + 1, 8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw.extend(raw.clone());
|
||||||
|
let mut r = VlResolver::new(&file_data, 8, 8);
|
||||||
|
let one = CachedCollection {
|
||||||
|
objects: vec![(0, 0, 0); 3],
|
||||||
|
}
|
||||||
|
.cost();
|
||||||
|
r.budget = 2 * one + 1;
|
||||||
|
let want: Vec<String> = (0..2)
|
||||||
|
.flat_map(|_| (0..20).flat_map(|c| (0..3).map(move |i| format!("c{c}o{i}"))))
|
||||||
|
.collect();
|
||||||
|
for (k, chunk) in raw.chunks(16).enumerate() {
|
||||||
|
assert_eq!(r.strings(chunk).unwrap(), [want[k].clone()]);
|
||||||
|
assert!(r.cached_bytes <= r.budget);
|
||||||
|
assert!(r.cache.len() <= 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn element_size_is_checked_against_the_offset_size() {
|
||||||
|
assert!(check_element_size(16, 8).is_ok());
|
||||||
|
assert!(check_element_size(12, 4).is_ok());
|
||||||
|
assert!(check_element_size(16, 4).is_err());
|
||||||
|
assert!(check_element_size(524_304, 8).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_vl_references_truncated_error() {
|
fn parse_vl_references_truncated_error() {
|
||||||
let raw = vec![0u8; 10]; // too short for 1 element with offset_size=8
|
let raw = vec![0u8; 10]; // too short for 1 element with offset_size=8
|
||||||
|
|||||||
@@ -0,0 +1,494 @@
|
|||||||
|
//! The group hierarchy `FileWriter` writes: builders flattened into a tree
|
||||||
|
//! of groups, datasets and links, with path names expanded into
|
||||||
|
//! intermediate groups, hard links resolved to objects, reference counts
|
||||||
|
//! counted, and everything put in layout order.
|
||||||
|
|
||||||
|
#[cfg(not(feature = "std"))]
|
||||||
|
use alloc::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
format,
|
||||||
|
string::{String, ToString},
|
||||||
|
vec,
|
||||||
|
vec::Vec,
|
||||||
|
};
|
||||||
|
#[cfg(feature = "std")]
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::error::FormatError;
|
||||||
|
use crate::type_builders::{AttrValue, DatasetBuilder, GroupBuilder, GroupItem};
|
||||||
|
|
||||||
|
/// Depth of the chain of unresolved hard links followed while resolving one
|
||||||
|
/// hard-link target path (a bound on recursion; cycles are found exactly).
|
||||||
|
const MAX_LINK_DEPTH: usize = 64;
|
||||||
|
|
||||||
|
fn err(msg: String) -> FormatError {
|
||||||
|
FormatError::SerializationError(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A link name must be one path component: not empty, not ".", and without
|
||||||
|
/// '/' (a '/' separates components, so it cannot be part of a name).
|
||||||
|
fn check_link_name(name: &str, path: &str) -> Result<(), FormatError> {
|
||||||
|
if name.is_empty() || name == "." || name.contains('/') {
|
||||||
|
return Err(err(format!(
|
||||||
|
"invalid object name {path:?}: every path component must be a \
|
||||||
|
non-empty name other than \".\""
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a link in the final tree points at.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub(crate) enum LinkTo {
|
||||||
|
/// A group, by index into [`Tree::groups`] (layout order).
|
||||||
|
Group(usize),
|
||||||
|
/// A dataset, by index into [`Tree::datasets`] (layout order).
|
||||||
|
Dataset(usize),
|
||||||
|
Soft(String),
|
||||||
|
External {
|
||||||
|
file: String,
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct Link {
|
||||||
|
pub(crate) name: String,
|
||||||
|
pub(crate) to: LinkTo,
|
||||||
|
/// Set when the group tracks creation order.
|
||||||
|
pub(crate) creation_order: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct Group {
|
||||||
|
pub(crate) attrs: Vec<(String, AttrValue)>,
|
||||||
|
/// Links in the order they are written.
|
||||||
|
pub(crate) links: Vec<Link>,
|
||||||
|
pub(crate) track_order: bool,
|
||||||
|
/// Number of hard links to this group (the root counts one for the
|
||||||
|
/// superblock's reference).
|
||||||
|
pub(crate) refcount: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The flattened file: groups (root first) and datasets, both in the order
|
||||||
|
/// they are laid out in the file.
|
||||||
|
pub(crate) struct Tree {
|
||||||
|
pub(crate) groups: Vec<Group>,
|
||||||
|
pub(crate) datasets: Vec<(DatasetBuilder, u32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- construction ----
|
||||||
|
|
||||||
|
enum Target {
|
||||||
|
Group(usize),
|
||||||
|
Dataset(usize),
|
||||||
|
Soft(String),
|
||||||
|
Hard(String),
|
||||||
|
External { file: String, path: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BuildGroup {
|
||||||
|
/// Full path, for messages.
|
||||||
|
path: String,
|
||||||
|
attrs: Vec<(String, AttrValue)>,
|
||||||
|
links: Vec<(String, Target)>,
|
||||||
|
by_name: BTreeMap<String, usize>,
|
||||||
|
track_order: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Builder {
|
||||||
|
groups: Vec<BuildGroup>,
|
||||||
|
datasets: Vec<DatasetBuilder>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join(parent: &str, name: &str) -> String {
|
||||||
|
if parent == "/" {
|
||||||
|
format!("/{name}")
|
||||||
|
} else {
|
||||||
|
format!("{parent}/{name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Builder {
|
||||||
|
fn new_group(&mut self, path: String) -> usize {
|
||||||
|
self.groups.push(BuildGroup {
|
||||||
|
path,
|
||||||
|
attrs: Vec::new(),
|
||||||
|
links: Vec::new(),
|
||||||
|
by_name: BTreeMap::new(),
|
||||||
|
track_order: None,
|
||||||
|
});
|
||||||
|
self.groups.len() - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `path` (relative to group `g`) into the group holding its last
|
||||||
|
/// component, creating missing intermediate groups, and that component.
|
||||||
|
fn parent_of<'p>(&mut self, g: usize, path: &'p str) -> Result<(usize, &'p str), FormatError> {
|
||||||
|
// An absolute path is accepted at the root only.
|
||||||
|
let rel = match path.strip_prefix('/') {
|
||||||
|
Some(rest) if g == 0 => rest,
|
||||||
|
Some(_) => {
|
||||||
|
return Err(err(format!(
|
||||||
|
"invalid object name {path:?} in {}: absolute paths are accepted \
|
||||||
|
only at the root",
|
||||||
|
self.groups[g].path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
None => path,
|
||||||
|
};
|
||||||
|
let mut comps: Vec<&str> = rel.split('/').collect();
|
||||||
|
let last = comps.pop().unwrap_or("");
|
||||||
|
check_link_name(last, path)?;
|
||||||
|
let mut cur = g;
|
||||||
|
for c in comps {
|
||||||
|
check_link_name(c, path)?;
|
||||||
|
cur = match self.groups[cur].by_name.get(c).copied() {
|
||||||
|
Some(i) => match self.groups[cur].links[i].1 {
|
||||||
|
Target::Group(child) => child,
|
||||||
|
_ => {
|
||||||
|
return Err(err(format!(
|
||||||
|
"cannot create {path:?} in {}: {c:?} exists and is not a group",
|
||||||
|
self.groups[g].path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
let child = self.new_group(join(&self.groups[cur].path, c));
|
||||||
|
self.push_link(cur, c, Target::Group(child))?;
|
||||||
|
child
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok((cur, last))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_link(&mut self, g: usize, name: &str, to: Target) -> Result<(), FormatError> {
|
||||||
|
let grp = &mut self.groups[g];
|
||||||
|
if grp.by_name.contains_key(name) {
|
||||||
|
return Err(err(format!("{:?} already exists", join(&grp.path, name))));
|
||||||
|
}
|
||||||
|
grp.by_name.insert(name.to_string(), grp.links.len());
|
||||||
|
grp.links.push((name.to_string(), to));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add `item` to group `g`.
|
||||||
|
fn add_item(&mut self, g: usize, item: GroupItem) -> Result<(), FormatError> {
|
||||||
|
match item {
|
||||||
|
GroupItem::Dataset(db) => {
|
||||||
|
let (parent, name) = self.parent_of(g, &db.name)?;
|
||||||
|
let name = name.to_string();
|
||||||
|
self.push_link(parent, &name, Target::Dataset(self.datasets.len()))?;
|
||||||
|
self.datasets.push(*db);
|
||||||
|
}
|
||||||
|
GroupItem::Group(gb) => self.add_group(g, gb)?,
|
||||||
|
GroupItem::Soft { name, target } => {
|
||||||
|
if target.is_empty() {
|
||||||
|
return Err(err(format!("soft link {name:?} has an empty target")));
|
||||||
|
}
|
||||||
|
let (parent, last) = self.parent_of(g, &name)?;
|
||||||
|
self.push_link(parent, last, Target::Soft(target))?;
|
||||||
|
}
|
||||||
|
GroupItem::Hard { name, target } => {
|
||||||
|
let (parent, last) = self.parent_of(g, &name)?;
|
||||||
|
self.push_link(parent, last, Target::Hard(target))?;
|
||||||
|
}
|
||||||
|
GroupItem::External { name, file, path } => {
|
||||||
|
if file.is_empty() || path.is_empty() {
|
||||||
|
return Err(err(format!(
|
||||||
|
"external link {name:?} needs a file name and an object path"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let (parent, last) = self.parent_of(g, &name)?;
|
||||||
|
self.push_link(parent, last, Target::External { file, path })?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add the group `gb` (named by a path relative to group `g`), merging it
|
||||||
|
/// into a group already at that path.
|
||||||
|
fn add_group(&mut self, g: usize, gb: GroupBuilder) -> Result<(), FormatError> {
|
||||||
|
let (parent, last) = self.parent_of(g, &gb.name)?;
|
||||||
|
let idx = match self.groups[parent].by_name.get(last).copied() {
|
||||||
|
Some(i) => match self.groups[parent].links[i].1 {
|
||||||
|
Target::Group(child) => child,
|
||||||
|
_ => {
|
||||||
|
return Err(err(format!(
|
||||||
|
"{:?} already exists and is not a group",
|
||||||
|
join(&self.groups[parent].path, last)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
let child = self.new_group(join(&self.groups[parent].path, last));
|
||||||
|
self.push_link(parent, last, Target::Group(child))?;
|
||||||
|
child
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.merge_into(idx, gb)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge a builder's attributes, setting and items into group `idx`.
|
||||||
|
fn merge_into(&mut self, idx: usize, gb: GroupBuilder) -> Result<(), FormatError> {
|
||||||
|
// An attribute set again (by this builder or a merged one) takes the
|
||||||
|
// new value, as assigning `attrs[name]` in h5py does.
|
||||||
|
for (name, value) in gb.attrs {
|
||||||
|
let attrs = &mut self.groups[idx].attrs;
|
||||||
|
match attrs.iter_mut().find(|(n, _)| *n == name) {
|
||||||
|
Some(slot) => slot.1 = value,
|
||||||
|
None => attrs.push((name, value)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(t) = gb.track_order {
|
||||||
|
match self.groups[idx].track_order {
|
||||||
|
Some(old) if old != t => {
|
||||||
|
return Err(err(format!(
|
||||||
|
"conflicting track_order settings for {}",
|
||||||
|
self.groups[idx].path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
_ => self.groups[idx].track_order = Some(t),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for item in gb.items {
|
||||||
|
self.add_item(idx, item)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The object a hard link's `target` path names, from group `from`.
|
||||||
|
///
|
||||||
|
/// Hard links met on the way are resolved once and remembered in
|
||||||
|
/// `memo` (by group and link index), so a target that goes through
|
||||||
|
/// other hard links costs time linear in the links, not exponential; a
|
||||||
|
/// hard link met again while it is being resolved is a cycle.
|
||||||
|
fn resolve(
|
||||||
|
&self,
|
||||||
|
memo: &mut [Vec<Resolution>],
|
||||||
|
from: usize,
|
||||||
|
target: &str,
|
||||||
|
depth: usize,
|
||||||
|
) -> Result<Obj, FormatError> {
|
||||||
|
if depth > MAX_LINK_DEPTH {
|
||||||
|
return Err(err(format!(
|
||||||
|
"hard link target {target:?}: more than {MAX_LINK_DEPTH} hard links \
|
||||||
|
to follow"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let (mut cur, rest) = match target.strip_prefix('/') {
|
||||||
|
Some(rest) => (0, rest),
|
||||||
|
None => (from, target),
|
||||||
|
};
|
||||||
|
if target.is_empty() {
|
||||||
|
return Err(err("a hard link needs a target path".to_string()));
|
||||||
|
}
|
||||||
|
let comps: Vec<&str> = rest
|
||||||
|
.split('/')
|
||||||
|
.filter(|c| !c.is_empty() && *c != ".")
|
||||||
|
.collect();
|
||||||
|
let mut obj = Obj::Group(cur);
|
||||||
|
for (i, c) in comps.iter().enumerate() {
|
||||||
|
let Obj::Group(g) = obj else {
|
||||||
|
return Err(err(format!(
|
||||||
|
"hard link target {target:?}: {:?} is not a group",
|
||||||
|
comps[..i].join("/")
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
cur = g;
|
||||||
|
let grp = &self.groups[cur];
|
||||||
|
let Some(&li) = grp.by_name.get(*c) else {
|
||||||
|
return Err(err(format!(
|
||||||
|
"hard link target {target:?} does not exist in the file"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
obj = match &grp.links[li].1 {
|
||||||
|
Target::Group(child) => Obj::Group(*child),
|
||||||
|
Target::Dataset(d) => Obj::Dataset(*d),
|
||||||
|
Target::Hard(p) => match memo[cur][li] {
|
||||||
|
Resolution::Done(o) => o,
|
||||||
|
Resolution::InProgress => {
|
||||||
|
return Err(err(format!(
|
||||||
|
"hard link target {target:?}: the hard link {:?} leads \
|
||||||
|
back to itself (a cycle)",
|
||||||
|
join(&grp.path, c)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Resolution::Todo => {
|
||||||
|
memo[cur][li] = Resolution::InProgress;
|
||||||
|
let o = self.resolve(memo, cur, p, depth + 1)?;
|
||||||
|
memo[cur][li] = Resolution::Done(o);
|
||||||
|
o
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Target::Soft(_) | Target::External { .. } => {
|
||||||
|
return Err(err(format!(
|
||||||
|
"hard link target {target:?} goes through a soft or external \
|
||||||
|
link ({:?}); name the object by its hard-link path",
|
||||||
|
join(&grp.path, c)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(obj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where resolving one hard link has got to.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Resolution {
|
||||||
|
Todo,
|
||||||
|
InProgress,
|
||||||
|
Done(Obj),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Obj {
|
||||||
|
Group(usize),
|
||||||
|
Dataset(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flatten the root group builder into a [`Tree`]. `default_track_order`
|
||||||
|
/// applies to every group that does not set its own.
|
||||||
|
pub(crate) fn build(root: GroupBuilder, default_track_order: bool) -> Result<Tree, FormatError> {
|
||||||
|
let mut b = Builder {
|
||||||
|
groups: Vec::new(),
|
||||||
|
datasets: Vec::new(),
|
||||||
|
};
|
||||||
|
b.new_group("/".to_string());
|
||||||
|
b.merge_into(0, root)?;
|
||||||
|
|
||||||
|
// Resolve hard links and count references.
|
||||||
|
let mut group_refs = vec![0u32; b.groups.len()];
|
||||||
|
let mut ds_refs = vec![0u32; b.datasets.len()];
|
||||||
|
group_refs[0] = 1; // the superblock's reference to the root
|
||||||
|
let mut memo: Vec<Vec<Resolution>> = b
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.map(|g| vec![Resolution::Todo; g.links.len()])
|
||||||
|
.collect();
|
||||||
|
let mut resolved: Vec<Vec<Option<Obj>>> = Vec::with_capacity(b.groups.len());
|
||||||
|
for (gi, g) in b.groups.iter().enumerate() {
|
||||||
|
let mut row = Vec::with_capacity(g.links.len());
|
||||||
|
for (li, (_, t)) in g.links.iter().enumerate() {
|
||||||
|
let obj = match t {
|
||||||
|
Target::Group(i) => Some(Obj::Group(*i)),
|
||||||
|
Target::Dataset(d) => Some(Obj::Dataset(*d)),
|
||||||
|
Target::Hard(p) => Some(match memo[gi][li] {
|
||||||
|
Resolution::Done(o) => o,
|
||||||
|
_ => {
|
||||||
|
memo[gi][li] = Resolution::InProgress;
|
||||||
|
let o = b.resolve(&mut memo, gi, p, 0)?;
|
||||||
|
memo[gi][li] = Resolution::Done(o);
|
||||||
|
o
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Target::Soft(_) | Target::External { .. } => None,
|
||||||
|
};
|
||||||
|
match obj {
|
||||||
|
Some(Obj::Group(i)) => group_refs[i] += 1,
|
||||||
|
Some(Obj::Dataset(d)) => ds_refs[d] += 1,
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
row.push(obj);
|
||||||
|
}
|
||||||
|
resolved.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The order each group's links are written in: creation order when
|
||||||
|
// tracked; otherwise datasets, then groups, then other links (the order
|
||||||
|
// earlier versions wrote, so one-level files keep their layout).
|
||||||
|
let tracked: Vec<bool> = b
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.map(|g| g.track_order.unwrap_or(default_track_order))
|
||||||
|
.collect();
|
||||||
|
let link_order: Vec<Vec<usize>> = b
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(gi, g)| {
|
||||||
|
let mut idx: Vec<usize> = (0..g.links.len()).collect();
|
||||||
|
if !tracked[gi] {
|
||||||
|
idx.sort_by_key(|&i| match g.links[i].1 {
|
||||||
|
Target::Dataset(_) => 0,
|
||||||
|
Target::Group(_) => 1,
|
||||||
|
_ => 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
idx
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Layout order: groups depth-first from the root, following the links
|
||||||
|
// that created them; datasets group by group in that order.
|
||||||
|
let mut group_order = Vec::with_capacity(b.groups.len());
|
||||||
|
let mut stack = vec![0usize];
|
||||||
|
while let Some(g) = stack.pop() {
|
||||||
|
group_order.push(g);
|
||||||
|
let children: Vec<usize> = link_order[g]
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&i| match b.groups[g].links[i].1 {
|
||||||
|
Target::Group(c) => Some(c),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
stack.extend(children.into_iter().rev());
|
||||||
|
}
|
||||||
|
let mut ds_order = Vec::with_capacity(b.datasets.len());
|
||||||
|
for &g in &group_order {
|
||||||
|
for &i in &link_order[g] {
|
||||||
|
if let Target::Dataset(d) = b.groups[g].links[i].1 {
|
||||||
|
ds_order.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut group_pos = vec![0usize; b.groups.len()];
|
||||||
|
for (pos, &g) in group_order.iter().enumerate() {
|
||||||
|
group_pos[g] = pos;
|
||||||
|
}
|
||||||
|
let mut ds_pos = vec![0usize; b.datasets.len()];
|
||||||
|
for (pos, &d) in ds_order.iter().enumerate() {
|
||||||
|
ds_pos[d] = pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut groups_by_id: Vec<Option<BuildGroup>> = b.groups.into_iter().map(Some).collect();
|
||||||
|
let mut groups = Vec::with_capacity(group_order.len());
|
||||||
|
for &g in &group_order {
|
||||||
|
let bg = groups_by_id[g].take().expect("each group is laid out once");
|
||||||
|
let mut targets: Vec<Option<(String, Target)>> = bg.links.into_iter().map(Some).collect();
|
||||||
|
let links = link_order[g]
|
||||||
|
.iter()
|
||||||
|
.map(|&i| {
|
||||||
|
let (name, t) = targets[i].take().expect("each link is written once");
|
||||||
|
let to = match (resolved[g][i], t) {
|
||||||
|
(Some(Obj::Group(c)), _) => LinkTo::Group(group_pos[c]),
|
||||||
|
(Some(Obj::Dataset(d)), _) => LinkTo::Dataset(ds_pos[d]),
|
||||||
|
(None, Target::Soft(s)) => LinkTo::Soft(s),
|
||||||
|
(None, Target::External { file, path }) => LinkTo::External { file, path },
|
||||||
|
(None, _) => unreachable!("hard links are resolved"),
|
||||||
|
};
|
||||||
|
Link {
|
||||||
|
name,
|
||||||
|
to,
|
||||||
|
creation_order: tracked[g].then_some(i as u64),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
groups.push(Group {
|
||||||
|
attrs: bg.attrs,
|
||||||
|
links,
|
||||||
|
track_order: tracked[g],
|
||||||
|
refcount: group_refs[g],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut ds_by_id: Vec<Option<DatasetBuilder>> = b.datasets.into_iter().map(Some).collect();
|
||||||
|
let datasets = ds_order
|
||||||
|
.iter()
|
||||||
|
.map(|&d| {
|
||||||
|
(
|
||||||
|
ds_by_id[d].take().expect("each dataset is laid out once"),
|
||||||
|
ds_refs[d],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Tree { groups, datasets })
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
//! Crafted files cannot make variable-length reads retain memory, or take
|
||||||
|
//! time, out of proportion to the file.
|
||||||
|
//!
|
||||||
|
//! `VlResolver` used to keep an owned copy of every object of every
|
||||||
|
//! collection it parsed, for the whole read. A file whose global heap
|
||||||
|
//! collections nest inside each other's object data — each element
|
||||||
|
//! pointing at a different one — then made retained memory O(K × file
|
||||||
|
//! size): a 744 KB file took 1.58 GB. The same nesting, with every
|
||||||
|
//! collection's object chain jumping to one shared run of tiny objects,
|
||||||
|
//! made the parse time O(K × M) as well. libhdf5 never writes overlapping
|
||||||
|
//! collections; they are now refused, and the cache holds only where
|
||||||
|
//! objects lie.
|
||||||
|
//!
|
||||||
|
//! Peak heap use is measured with a counting global allocator, so the
|
||||||
|
//! cases run one after another in a single test.
|
||||||
|
|
||||||
|
use std::alloc::{GlobalAlloc, Layout, System};
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use clawhdf5_format::vl_data::VlResolver;
|
||||||
|
|
||||||
|
struct Counting;
|
||||||
|
|
||||||
|
static CURRENT: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static PEAK: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
unsafe impl GlobalAlloc for Counting {
|
||||||
|
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||||
|
let p = unsafe { System.alloc(layout) };
|
||||||
|
if !p.is_null() {
|
||||||
|
let now = CURRENT.fetch_add(layout.size(), Ordering::Relaxed) + layout.size();
|
||||||
|
PEAK.fetch_max(now, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||||
|
unsafe { System.dealloc(ptr, layout) };
|
||||||
|
CURRENT.fetch_sub(layout.size(), Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[global_allocator]
|
||||||
|
static ALLOC: Counting = Counting;
|
||||||
|
|
||||||
|
/// Bytes allocated at the peak of `f`, above what was live when it started.
|
||||||
|
fn peak_during<T>(f: impl FnOnce() -> T) -> (T, usize) {
|
||||||
|
let base = CURRENT.load(Ordering::Relaxed);
|
||||||
|
PEAK.store(base, Ordering::Relaxed);
|
||||||
|
let out = f();
|
||||||
|
(out, PEAK.load(Ordering::Relaxed) - base)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_header(file: &mut [u8], at: usize, size: u64) {
|
||||||
|
file[at..at + 4].copy_from_slice(b"GCOL");
|
||||||
|
file[at + 4] = 1;
|
||||||
|
file[at + 8..at + 16].copy_from_slice(&size.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_object(file: &mut [u8], at: usize, index: u16, size: u64) {
|
||||||
|
file[at..at + 2].copy_from_slice(&index.to_le_bytes());
|
||||||
|
file[at + 2..at + 4].copy_from_slice(&1u16.to_le_bytes());
|
||||||
|
file[at + 8..at + 16].copy_from_slice(&size.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn element(length: u32, addr: u64, index: u32) -> Vec<u8> {
|
||||||
|
let mut e = length.to_le_bytes().to_vec();
|
||||||
|
e.extend_from_slice(&addr.to_le_bytes());
|
||||||
|
e.extend_from_slice(&index.to_le_bytes());
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
/// K collections 32 bytes apart, each running to the end of the file with
|
||||||
|
/// one object covering the rest of it (and so every later collection).
|
||||||
|
/// Element i is that object of collection i.
|
||||||
|
fn nested(k: usize) -> (Vec<u8>, Vec<u8>) {
|
||||||
|
let base = 64;
|
||||||
|
let end = base + 32 * k + 64;
|
||||||
|
let mut file = vec![0u8; end];
|
||||||
|
let mut raw = Vec::new();
|
||||||
|
for i in 0..k {
|
||||||
|
let at = base + 32 * i;
|
||||||
|
put_header(&mut file, at, (end - at) as u64);
|
||||||
|
let obj = (end - at - 32) as u64;
|
||||||
|
put_object(&mut file, at + 16, 1, obj);
|
||||||
|
raw.extend(element(obj as u32, at as u64, 1));
|
||||||
|
}
|
||||||
|
(file, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// K collections 32 bytes apart, each with a first object that jumps over
|
||||||
|
/// the later collections to one shared run of M empty objects, so parsing
|
||||||
|
/// every collection walks all M.
|
||||||
|
fn shared_tail(k: usize, m: usize) -> (Vec<u8>, Vec<u8>) {
|
||||||
|
let base = 64;
|
||||||
|
let tail = base + 32 * k + 32;
|
||||||
|
let end = tail + 16 * m + 16;
|
||||||
|
let mut file = vec![0u8; end];
|
||||||
|
let mut raw = Vec::new();
|
||||||
|
for i in 0..k {
|
||||||
|
let at = base + 32 * i;
|
||||||
|
put_header(&mut file, at, (end - at) as u64);
|
||||||
|
let jump = (tail - at - 32) as u64;
|
||||||
|
put_object(&mut file, at + 16, 1, jump);
|
||||||
|
raw.extend(element(jump as u32, at as u64, 1));
|
||||||
|
}
|
||||||
|
for j in 0..m {
|
||||||
|
put_object(&mut file, tail + 16 * j, (j % 65_000 + 2) as u16, 0);
|
||||||
|
}
|
||||||
|
(file, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overlapping_collections_are_refused_in_bounded_memory_and_time() {
|
||||||
|
for (name, (file, raw)) in [
|
||||||
|
("nested", nested(2000)),
|
||||||
|
("shared tail", shared_tail(500, 10_000)),
|
||||||
|
] {
|
||||||
|
let start = Instant::now();
|
||||||
|
let (result, peak) = peak_during(|| {
|
||||||
|
let mut r = VlResolver::new(&file, 8, 8);
|
||||||
|
(r.string_bytes(&raw), r.sequences(&raw, 1).map(|s| s.len()))
|
||||||
|
});
|
||||||
|
let took = start.elapsed();
|
||||||
|
// libhdf5 never writes overlapping collections, and refuses these
|
||||||
|
// files; so do we, rather than returning what they claim.
|
||||||
|
let (strings, sequences) = result;
|
||||||
|
let e = strings.expect_err(name).to_string();
|
||||||
|
assert!(e.contains("overlaps"), "{name}: {e}");
|
||||||
|
assert!(sequences.is_err(), "{name}");
|
||||||
|
// Measured before the fix: 129 MB ("nested", 64 KB file) and 350 MB
|
||||||
|
// ("shared tail", 176 KB file) live at the peak; after, 97 KB and
|
||||||
|
// 0.9 MB.
|
||||||
|
assert!(
|
||||||
|
peak < 4 * file.len() + (1 << 20),
|
||||||
|
"{name}: peak {peak} bytes for a {}-byte file",
|
||||||
|
file.len()
|
||||||
|
);
|
||||||
|
assert!(took < Duration::from_secs(5), "{name}: took {took:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collections that do not overlap still read, however many elements point
|
||||||
|
/// into them, and the first object of a collection is returned for its
|
||||||
|
/// index (as before).
|
||||||
|
#[test]
|
||||||
|
fn separate_collections_still_read() {
|
||||||
|
let mut file = vec![0u8; 64 + 3 * 64];
|
||||||
|
let mut raw = Vec::new();
|
||||||
|
for i in 0..3usize {
|
||||||
|
let at = 64 + 64 * i;
|
||||||
|
put_header(&mut file, at, 64);
|
||||||
|
put_object(&mut file, at + 16, 1, 3);
|
||||||
|
file[at + 32..at + 35].copy_from_slice(format!("s{i}!").as_bytes());
|
||||||
|
raw.extend(element(3, at as u64, 1));
|
||||||
|
}
|
||||||
|
raw.extend(element(3, 64, 1));
|
||||||
|
let mut r = VlResolver::new(&file, 8, 8);
|
||||||
|
assert_eq!(r.strings(&raw).unwrap(), ["s0!", "s1!", "s2!", "s0!"]);
|
||||||
|
}
|
||||||
@@ -528,33 +528,50 @@ fn h5py_reads_all_attributes_next_to_an_empty_string() {
|
|||||||
// ---- 6. path-like names ----
|
// ---- 6. path-like names ----
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn slash_in_a_group_or_dataset_name_is_an_error() {
|
fn path_names_create_nested_groups() {
|
||||||
// Measured: create_group("a/b") wrote one link literally named "a/b",
|
// create_group("a/b") used to write one link literally named "a/b",
|
||||||
// which h5py cannot reach ("component not found"). The writer has no
|
// which h5py cannot reach ("component not found"); then such names were
|
||||||
// nested groups, so such names are refused.
|
// refused. Now a path creates its missing intermediate groups, as h5py
|
||||||
|
// does.
|
||||||
let mut fw = FileWriter::new();
|
let mut fw = FileWriter::new();
|
||||||
let mut g = fw.create_group("a/b");
|
let mut g = fw.create_group("a/b");
|
||||||
g.create_dataset("c").with_f64_data(&[1.0]);
|
g.create_dataset("c").with_f64_data(&[1.0]);
|
||||||
fw.add_group(g.finish());
|
fw.add_group(g.finish());
|
||||||
assert!(fw.finish().is_err());
|
fw.create_dataset("x/y").with_f64_data(&[2.0]);
|
||||||
|
fw.create_dataset("/a/b/z").with_f64_data(&[3.0]);
|
||||||
let mut fw = FileWriter::new();
|
|
||||||
fw.create_dataset("x/y").with_f64_data(&[1.0]);
|
|
||||||
assert!(fw.finish().is_err());
|
|
||||||
|
|
||||||
let mut fw = FileWriter::new();
|
|
||||||
let mut g = fw.create_group("g");
|
let mut g = fw.create_group("g");
|
||||||
g.create_dataset("x/y").with_f64_data(&[1.0]);
|
g.create_dataset("x/y").with_f64_data(&[4.0]);
|
||||||
fw.add_group(g.finish());
|
fw.add_group(g.finish());
|
||||||
assert!(fw.finish().is_err());
|
let bytes = fw.finish().unwrap();
|
||||||
|
for path in ["a", "a/b", "a/b/c", "a/b/z", "x", "x/y", "g/x", "g/x/y"] {
|
||||||
|
header_at(&bytes, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for bad in ["", "."] {
|
#[test]
|
||||||
|
fn names_that_are_not_valid_link_names_are_errors() {
|
||||||
|
for bad in ["", ".", "a//b", "a/", "a/./b", "/"] {
|
||||||
let mut fw = FileWriter::new();
|
let mut fw = FileWriter::new();
|
||||||
fw.create_dataset(bad).with_f64_data(&[1.0]);
|
fw.create_dataset(bad).with_f64_data(&[1.0]);
|
||||||
assert!(fw.finish().is_err(), "{bad:?}");
|
assert!(fw.finish().is_err(), "{bad:?}");
|
||||||
}
|
}
|
||||||
|
// An absolute path inside a group, and a name used twice.
|
||||||
|
let mut fw = FileWriter::new();
|
||||||
|
let mut g = fw.create_group("g");
|
||||||
|
g.create_dataset("/x").with_f64_data(&[1.0]);
|
||||||
|
fw.add_group(g.finish());
|
||||||
|
assert!(fw.finish().is_err());
|
||||||
|
let mut fw = FileWriter::new();
|
||||||
|
fw.create_dataset("x").with_f64_data(&[1.0]);
|
||||||
|
fw.create_dataset("x").with_f64_data(&[1.0]);
|
||||||
|
assert!(fw.finish().is_err());
|
||||||
|
// A dataset in the way of a path.
|
||||||
|
let mut fw = FileWriter::new();
|
||||||
|
fw.create_dataset("x").with_f64_data(&[1.0]);
|
||||||
|
fw.create_dataset("x/y").with_f64_data(&[1.0]);
|
||||||
|
assert!(fw.finish().is_err());
|
||||||
|
|
||||||
// One level of groups still works, and '/' stays legal in attribute names.
|
// '/' stays legal in attribute names.
|
||||||
let mut fw = FileWriter::new();
|
let mut fw = FileWriter::new();
|
||||||
let mut g = fw.create_group("g");
|
let mut g = fw.create_group("g");
|
||||||
g.create_dataset("c").with_f64_data(&[1.0]);
|
g.create_dataset("c").with_f64_data(&[1.0]);
|
||||||
|
|||||||
@@ -350,3 +350,30 @@ ds.close()
|
|||||||
let press_vals = press_var.read_raw_f32().unwrap();
|
let press_vals = press_var.read_raw_f32().unwrap();
|
||||||
assert_eq!(press_vals, vec![1000.0f32, 850.0, 500.0, 200.0]);
|
assert_eq!(press_vals, vec![1000.0f32, 850.0, 500.0, 200.0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn netcdf4_python_string_variable_clawhdf5_reads() {
|
||||||
|
// NC_STRING variables are HDF5 variable-length strings, which
|
||||||
|
// `read_string` refused ("expected String, got VariableLength") until
|
||||||
|
// 2026-09-26.
|
||||||
|
skip_if_no_netcdf4!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("strings.nc");
|
||||||
|
let path_str = path.display().to_string();
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
import netCDF4 as nc
|
||||||
|
import numpy as np
|
||||||
|
ds = nc.Dataset("{path_str}", "w", format="NETCDF4")
|
||||||
|
ds.createDimension("station", 4)
|
||||||
|
v = ds.createVariable("name", str, ("station",))
|
||||||
|
v[:] = np.array(["Oslo", "", "São Paulo", "x"], dtype=object)
|
||||||
|
ds.close()
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
run_python(&script);
|
||||||
|
|
||||||
|
let file = NetCDF4File::open(&path).unwrap();
|
||||||
|
let names = file.variable("name").unwrap().read_string().unwrap();
|
||||||
|
assert_eq!(names, vec!["Oslo", "", "São Paulo", "x"]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name = "clawhdf5-py"
|
|||||||
version = "2.7.0"
|
version = "2.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
description = "Python bindings for rustyhdf5 — a pure-Rust HDF5 library"
|
description = "Python bindings for clawhdf5 — a pure-Rust HDF5 library"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
repository = "https://git.redclaw.dev/quantumclaw/clawhdf5"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|||||||
@@ -3,23 +3,81 @@
|
|||||||
[](https://crates.io/crates/clawhdf5-py)
|
[](https://crates.io/crates/clawhdf5-py)
|
||||||
[](https://docs.rs/clawhdf5-py)
|
[](https://docs.rs/clawhdf5-py)
|
||||||
|
|
||||||
Python bindings for clawhdf5 — a pure-Rust HDF5 library.
|
Python bindings for clawhdf5 — a pure-Rust HDF5 library. The package is
|
||||||
|
`clawhdf5` (`import clawhdf5`); it needs numpy and no libhdf5.
|
||||||
|
|
||||||
## Features
|
## Install
|
||||||
|
|
||||||
- h5py-compatible API (`File`, `Group`, `Dataset`)
|
Not on PyPI yet. Build it into a virtualenv with [maturin](https://www.maturin.rs):
|
||||||
- NumPy array integration
|
|
||||||
- Read and write HDF5 files from Python with no C dependencies
|
|
||||||
|
|
||||||
## Usage
|
```bash
|
||||||
|
pip install maturin numpy
|
||||||
|
cd crates/clawhdf5-py
|
||||||
|
maturin develop --release
|
||||||
|
python -c "import clawhdf5; print(clawhdf5.__version__)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reading
|
||||||
|
|
||||||
|
The read API follows h5py:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
import numpy as np
|
||||||
import clawhdf5
|
import clawhdf5
|
||||||
|
|
||||||
with clawhdf5.File('data.h5', 'r') as f:
|
with clawhdf5.File("data.h5", "r") as f:
|
||||||
data = f['/dataset'][:]
|
f.keys(), f["group"].items(), "group/data" in f
|
||||||
|
ds = f["group/data"] # or f["/group/data"], f["group"]["data"]
|
||||||
|
ds.shape, ds.dtype, ds.attrs["units"]
|
||||||
|
ds[10:20, ::2] # a small selection reads only its chunks
|
||||||
|
ds[-1], ds[..., 0], ds[[1, 4, 7]]
|
||||||
|
np.asarray(ds)
|
||||||
|
f["table"]["id"] # a compound field
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `Dataset.dtype` is the numpy dtype h5py reports: integers and IEEE floats
|
||||||
|
of every width in either byte order, `bool`, enums (with
|
||||||
|
`dtype.metadata['enum']`), complex, `S<n>` fixed strings, `object` for
|
||||||
|
variable-length strings (`bytes` values) and sequences (array values),
|
||||||
|
`V<n>` opaque, array types, and compounds as structured dtypes.
|
||||||
|
Other types raise `TypeError`.
|
||||||
|
- Keys are h5py's: integers, slices with a positive step, `...`, one
|
||||||
|
increasing list of integers, compound field names. Each maps onto a
|
||||||
|
hyperslab selection. `None`, negative steps and boolean masks are refused
|
||||||
|
with h5py's errors.
|
||||||
|
- What is read from the file: a selection whose bounding box covers at
|
||||||
|
most half the dataset decodes only the chunks (or contiguous rows) the box
|
||||||
|
overlaps. The library decodes the whole dataset for a larger box
|
||||||
|
(including a strided slice such as `ds[::100]` across a chunked dataset),
|
||||||
|
and for compact, virtual and unwritten datasets and chunked ones with a
|
||||||
|
non-default fill value. An index list is read one group of neighbouring
|
||||||
|
chunks at a time (a new group only past a chunk with no selected index),
|
||||||
|
so each chunk is decoded once. `ds[()]`, `ds[...]` and `np.asarray(ds)`
|
||||||
|
use the file's chunk cache; other selections do not.
|
||||||
|
- The bytes the library reads become the numpy array's buffer without a
|
||||||
|
copy, and the read runs with the GIL released, so threads read in
|
||||||
|
parallel. A bug in the library (a Rust panic) raises
|
||||||
|
`clawhdf5.InternalError`, a `RuntimeError`.
|
||||||
|
- Attributes return what h5py returns; `clawhdf5.Empty` stands for a null
|
||||||
|
dataspace (h5py's `Empty`).
|
||||||
|
|
||||||
|
## Writing
|
||||||
|
|
||||||
|
`clawhdf5.File(path, "w")` with `create_dataset(name, data=array,
|
||||||
|
chunks=..., compression="gzip")`, `create_group` and `attrs[...] = ...`
|
||||||
|
writes `float64`, `float32`, `int64`, `int32` and `uint8` arrays; the file is
|
||||||
|
written on `close()`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pytest h5py
|
||||||
|
pytest crates/clawhdf5-py/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
`tests/test_read_vs_h5py.py` compares every read with h5py on a file h5py
|
||||||
|
writes. `scripts/ci-test.sh` builds the wheel and runs these in CI.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ requires = ["maturin>=1.0,<2.0"]
|
|||||||
build-backend = "maturin"
|
build-backend = "maturin"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "rustyhdf5"
|
name = "clawhdf5"
|
||||||
version = "2.7.0"
|
version = "2.7.0"
|
||||||
description = "Python bindings for rustyhdf5 — a pure-Rust HDF5 library"
|
description = "Python bindings for clawhdf5 — a pure-Rust HDF5 library"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
dependencies = ["numpy"]
|
dependencies = ["numpy"]
|
||||||
|
|
||||||
[tool.maturin]
|
[tool.maturin]
|
||||||
features = ["extension-module"]
|
features = ["extension-module"]
|
||||||
|
# The extension module is `clawhdf5` (the cdylib's [lib] name): the
|
||||||
|
# distribution, the import name and the #[pymodule] all agree.
|
||||||
|
module-name = "clawhdf5"
|
||||||
|
|||||||
+106
-45
@@ -1,24 +1,31 @@
|
|||||||
//! PyAttrs — dict-like access to HDF5 attributes.
|
//! PyAttrs — dict-like access to HDF5 attributes.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use clawhdf5_format::attribute::AttributeMessage;
|
||||||
|
use pyo3::exceptions::{PyKeyError, PyTypeError, PyValueError};
|
||||||
use pyo3::prelude::*;
|
use pyo3::prelude::*;
|
||||||
use pyo3::types::PyList;
|
use pyo3::types::{PyList, PyTuple};
|
||||||
|
|
||||||
use crate::{OwnedAttrValue, attr_value_to_py, py_to_attr_value};
|
use crate::convert::{Converter, Elements, resolve_vl};
|
||||||
|
use crate::{OwnedAttrValue, PyEmpty, attr_value_to_py, node, py_to_attr_value};
|
||||||
|
|
||||||
/// Backing storage for attributes.
|
/// Backing storage for attributes.
|
||||||
enum AttrsInner {
|
enum AttrsInner {
|
||||||
/// Read-only attributes from an existing HDF5 object.
|
/// Attributes of an object in a file opened for reading, sorted by name.
|
||||||
Read(HashMap<String, clawhdf5_rs::AttrValue>),
|
Read {
|
||||||
|
file: Arc<clawhdf5_rs::File>,
|
||||||
|
attrs: Vec<AttributeMessage>,
|
||||||
|
},
|
||||||
/// Writable attribute list shared with a parent (PyFile or PyGroup).
|
/// Writable attribute list shared with a parent (PyFile or PyGroup).
|
||||||
Write(Arc<Mutex<Vec<(String, OwnedAttrValue)>>>),
|
Write(Arc<Mutex<Vec<(String, OwnedAttrValue)>>>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dict-like access to HDF5 attributes.
|
/// Dict-like access to HDF5 attributes.
|
||||||
///
|
///
|
||||||
/// In read mode, provides immutable access to attribute key/value pairs.
|
/// In read mode, values are what h5py returns: numpy scalars for scalar
|
||||||
|
/// attributes, numpy arrays otherwise, `str` for variable-length strings,
|
||||||
|
/// `numpy.bytes_` for fixed-length ones, and `Empty` for a null dataspace.
|
||||||
/// In write mode, attributes set here are accumulated and written when
|
/// In write mode, attributes set here are accumulated and written when
|
||||||
/// the parent file is closed.
|
/// the parent file is closed.
|
||||||
#[pyclass(name = "Attrs")]
|
#[pyclass(name = "Attrs")]
|
||||||
@@ -27,11 +34,13 @@ pub struct PyAttrs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PyAttrs {
|
impl PyAttrs {
|
||||||
/// Create a read-only attrs from an existing attribute map.
|
/// The attributes of the object at `addr` (whose path is `path`) in a
|
||||||
pub(crate) fn from_read(map: HashMap<String, clawhdf5_rs::AttrValue>) -> Self {
|
/// file opened for reading.
|
||||||
Self {
|
pub(crate) fn read(file: Arc<clawhdf5_rs::File>, addr: u64, path: &str) -> PyResult<Self> {
|
||||||
inner: AttrsInner::Read(map),
|
let attrs = node::attributes(&file, addr, path)?;
|
||||||
}
|
Ok(Self {
|
||||||
|
inner: AttrsInner::Read { file, attrs },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a writable attrs that shares storage with a parent object.
|
/// Create a writable attrs that shares storage with a parent object.
|
||||||
@@ -46,11 +55,11 @@ impl PyAttrs {
|
|||||||
impl PyAttrs {
|
impl PyAttrs {
|
||||||
fn __getitem__(&self, py: Python<'_>, key: &str) -> PyResult<Py<PyAny>> {
|
fn __getitem__(&self, py: Python<'_>, key: &str) -> PyResult<Py<PyAny>> {
|
||||||
match &self.inner {
|
match &self.inner {
|
||||||
AttrsInner::Read(map) => match map.get(key) {
|
AttrsInner::Read { file, attrs } => match attrs.iter().find(|a| a.name == key) {
|
||||||
Some(val) => Ok(attr_value_to_py(py, val)),
|
Some(attr) => Ok(attr_to_py(py, file, attr)?.unbind()),
|
||||||
None => Err(PyErr::new::<pyo3::exceptions::PyKeyError, _>(
|
None => Err(PyKeyError::new_err(format!(
|
||||||
key.to_string(),
|
"Can't open attribute (can't locate attribute: '{key}')"
|
||||||
)),
|
))),
|
||||||
},
|
},
|
||||||
AttrsInner::Write(store) => {
|
AttrsInner::Write(store) => {
|
||||||
let guard = store.lock().unwrap();
|
let guard = store.lock().unwrap();
|
||||||
@@ -60,16 +69,14 @@ impl PyAttrs {
|
|||||||
return Ok(attr_value_to_py(py, &attr_val));
|
return Ok(attr_value_to_py(py, &attr_val));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(PyErr::new::<pyo3::exceptions::PyKeyError, _>(
|
Err(PyKeyError::new_err(key.to_string()))
|
||||||
key.to_string(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn __setitem__(&self, key: &str, value: &Bound<'_, PyAny>) -> PyResult<()> {
|
fn __setitem__(&self, key: &str, value: &Bound<'_, PyAny>) -> PyResult<()> {
|
||||||
match &self.inner {
|
match &self.inner {
|
||||||
AttrsInner::Read(_) => Err(PyErr::new::<pyo3::exceptions::PyIOError, _>(
|
AttrsInner::Read { .. } => Err(PyErr::new::<pyo3::exceptions::PyIOError, _>(
|
||||||
"cannot set attributes on a read-only file",
|
"cannot set attributes on a read-only file",
|
||||||
)),
|
)),
|
||||||
AttrsInner::Write(store) => {
|
AttrsInner::Write(store) => {
|
||||||
@@ -88,14 +95,14 @@ impl PyAttrs {
|
|||||||
|
|
||||||
fn __len__(&self) -> usize {
|
fn __len__(&self) -> usize {
|
||||||
match &self.inner {
|
match &self.inner {
|
||||||
AttrsInner::Read(map) => map.len(),
|
AttrsInner::Read { attrs, .. } => attrs.len(),
|
||||||
AttrsInner::Write(store) => store.lock().unwrap().len(),
|
AttrsInner::Write(store) => store.lock().unwrap().len(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn __contains__(&self, key: &str) -> bool {
|
fn __contains__(&self, key: &str) -> bool {
|
||||||
match &self.inner {
|
match &self.inner {
|
||||||
AttrsInner::Read(map) => map.contains_key(key),
|
AttrsInner::Read { attrs, .. } => attrs.iter().any(|a| a.name == key),
|
||||||
AttrsInner::Write(store) => store.lock().unwrap().iter().any(|(k, _)| k == key),
|
AttrsInner::Write(store) => store.lock().unwrap().iter().any(|(k, _)| k == key),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,10 +118,20 @@ impl PyAttrs {
|
|||||||
format!("<HDF5 Attrs ({n} members)>")
|
format!("<HDF5 Attrs ({n} members)>")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The value of `key`, or `default` if there is no such attribute.
|
||||||
|
#[pyo3(signature = (key, default=None))]
|
||||||
|
fn get(&self, py: Python<'_>, key: &str, default: Option<Py<PyAny>>) -> PyResult<Py<PyAny>> {
|
||||||
|
if self.__contains__(key) {
|
||||||
|
self.__getitem__(py, key)
|
||||||
|
} else {
|
||||||
|
Ok(default.unwrap_or_else(|| py.None()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Return attribute names as a list.
|
/// Return attribute names as a list.
|
||||||
fn keys(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
fn keys(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||||
let names: Vec<String> = match &self.inner {
|
let names: Vec<String> = match &self.inner {
|
||||||
AttrsInner::Read(map) => map.keys().cloned().collect(),
|
AttrsInner::Read { attrs, .. } => attrs.iter().map(|a| a.name.clone()).collect(),
|
||||||
AttrsInner::Write(store) => store
|
AttrsInner::Write(store) => store
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -129,7 +146,10 @@ impl PyAttrs {
|
|||||||
/// Return attribute values as a list.
|
/// Return attribute values as a list.
|
||||||
fn values(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
fn values(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||||
let vals: Vec<Py<PyAny>> = match &self.inner {
|
let vals: Vec<Py<PyAny>> = match &self.inner {
|
||||||
AttrsInner::Read(map) => map.values().map(|v| attr_value_to_py(py, v)).collect(),
|
AttrsInner::Read { file, attrs } => attrs
|
||||||
|
.iter()
|
||||||
|
.map(|a| attr_to_py(py, file, a).map(Bound::unbind))
|
||||||
|
.collect::<PyResult<_>>()?,
|
||||||
AttrsInner::Write(store) => store
|
AttrsInner::Write(store) => store
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -147,10 +167,10 @@ impl PyAttrs {
|
|||||||
/// Return attribute (key, value) pairs as a list of tuples.
|
/// Return attribute (key, value) pairs as a list of tuples.
|
||||||
fn items(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
fn items(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||||
let pairs: Vec<(String, Py<PyAny>)> = match &self.inner {
|
let pairs: Vec<(String, Py<PyAny>)> = match &self.inner {
|
||||||
AttrsInner::Read(map) => map
|
AttrsInner::Read { file, attrs } => attrs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(k, v)| (k.clone(), attr_value_to_py(py, v)))
|
.map(|a| Ok((a.name.clone(), attr_to_py(py, file, a)?.unbind())))
|
||||||
.collect(),
|
.collect::<PyResult<_>>()?,
|
||||||
AttrsInner::Write(store) => store
|
AttrsInner::Write(store) => store
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -166,28 +186,69 @@ impl PyAttrs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An attribute's value as h5py returns it.
|
||||||
|
fn attr_to_py<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
file: &clawhdf5_rs::File,
|
||||||
|
attr: &AttributeMessage,
|
||||||
|
) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
crate::no_panic(|| {
|
||||||
|
let sb = file.superblock();
|
||||||
|
let conv = Converter::new(py, &attr.datatype, sb.offset_size)
|
||||||
|
.map_err(|e| prefix_err(py, &attr.name, e))?;
|
||||||
|
if node::is_null(&attr.dataspace) {
|
||||||
|
return Ok(PyEmpty::new(conv.dtype).into_pyobject(py)?.into_any());
|
||||||
|
}
|
||||||
|
let shape: Vec<usize> = attr
|
||||||
|
.dataspace
|
||||||
|
.dimensions
|
||||||
|
.iter()
|
||||||
|
.map(|&d| d as usize)
|
||||||
|
.collect();
|
||||||
|
let n: usize = shape.iter().product();
|
||||||
|
let data = if conv.is_vl() {
|
||||||
|
let want = n * conv.elem_size;
|
||||||
|
if attr.raw_data.len() < want {
|
||||||
|
return Err(PyValueError::new_err(format!(
|
||||||
|
"attribute {}: {} bytes of variable-length references, expected {want}",
|
||||||
|
attr.name,
|
||||||
|
attr.raw_data.len(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let raw = &attr.raw_data[..want];
|
||||||
|
let file_data = file.as_bytes();
|
||||||
|
let (osz, lsz, unit) = (sb.offset_size, sb.length_size, conv.vl_unit);
|
||||||
|
Elements::Vl(
|
||||||
|
py.detach(|| resolve_vl(file_data, raw, n, osz, lsz, unit))
|
||||||
|
.map_err(|e| PyValueError::new_err(format!("attribute {}: {e}", attr.name)))?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Elements::Bytes(attr.raw_data.clone())
|
||||||
|
};
|
||||||
|
let arr = conv
|
||||||
|
.to_array(py, data, &shape, true)
|
||||||
|
.map_err(|e| prefix_err(py, &attr.name, e))?;
|
||||||
|
if shape.is_empty() {
|
||||||
|
// A scalar dataspace: h5py returns the element itself.
|
||||||
|
return arr.get_item(PyTuple::empty(py));
|
||||||
|
}
|
||||||
|
Ok(arr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefix_err(py: Python<'_>, name: &str, e: PyErr) -> PyErr {
|
||||||
|
let msg = format!("attribute {name}: {}", e.value(py));
|
||||||
|
if e.is_instance_of::<PyTypeError>(py) {
|
||||||
|
PyTypeError::new_err(msg)
|
||||||
|
} else {
|
||||||
|
PyValueError::new_err(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_attrs_len() {
|
|
||||||
let mut map = HashMap::new();
|
|
||||||
map.insert("a".into(), clawhdf5_rs::AttrValue::I64(1));
|
|
||||||
map.insert("b".into(), clawhdf5_rs::AttrValue::F64(2.0));
|
|
||||||
let attrs = PyAttrs::from_read(map);
|
|
||||||
assert_eq!(attrs.__len__(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_attrs_contains() {
|
|
||||||
let mut map = HashMap::new();
|
|
||||||
map.insert("x".into(), clawhdf5_rs::AttrValue::String("hello".into()));
|
|
||||||
let attrs = PyAttrs::from_read(map);
|
|
||||||
assert!(attrs.__contains__("x"));
|
|
||||||
assert!(!attrs.__contains__("y"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn write_attrs_len() {
|
fn write_attrs_len() {
|
||||||
let store = Arc::new(Mutex::new(Vec::new()));
|
let store = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|||||||
@@ -0,0 +1,600 @@
|
|||||||
|
//! HDF5 datatypes as numpy dtypes, and element bytes as numpy arrays.
|
||||||
|
//!
|
||||||
|
//! The dtype a file's datatype maps to is the one h5py reports for it
|
||||||
|
//! (byte order kept, compound offsets and padding kept, `r`/`i` compounds as
|
||||||
|
//! complex, the `FALSE`/`TRUE` enum as `bool`, fixed strings as `S<n>`,
|
||||||
|
//! variable-length data as `object`). For every fixed-size type that dtype
|
||||||
|
//! describes the file's element bytes exactly, so the bytes the library
|
||||||
|
//! returns become the array's buffer as they are: the `Vec<u8>` is handed to
|
||||||
|
//! numpy without a copy and viewed as the dtype.
|
||||||
|
//!
|
||||||
|
//! Anything this mapping cannot describe exactly — non-IEEE floats, integers
|
||||||
|
//! with padding bits, VAX byte order, references, bitfields, time, and
|
||||||
|
//! variable-length members inside compounds or arrays — is a `TypeError`,
|
||||||
|
//! never a best-effort guess.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use clawhdf5_format::datatype::{CharacterSet, Datatype, DatatypeByteOrder};
|
||||||
|
use clawhdf5_format::global_heap::GlobalHeapCollection;
|
||||||
|
use numpy::PyArray1;
|
||||||
|
use pyo3::exceptions::{PyTypeError, PyValueError};
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
use pyo3::types::{PyBytes, PyDict, PyList, PyString, PyTuple};
|
||||||
|
|
||||||
|
/// How the elements of a datatype become Python values.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub(crate) enum Layout {
|
||||||
|
/// Fixed-size elements numpy reads as they are.
|
||||||
|
Fixed,
|
||||||
|
/// A top-level HDF5 array type: elements are viewed as the base dtype and
|
||||||
|
/// the array's dimensions are appended to the shape (as h5py does).
|
||||||
|
Subarray(Vec<usize>),
|
||||||
|
/// Variable-length string: a global heap reference per element.
|
||||||
|
VlString { utf8: bool },
|
||||||
|
/// Variable-length sequence of a fixed-size base type.
|
||||||
|
VlSequence,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything needed to turn a dataset's or attribute's bytes into numpy.
|
||||||
|
pub(crate) struct Converter {
|
||||||
|
/// The dtype reported to Python (`Dataset.dtype`).
|
||||||
|
pub dtype: Py<PyAny>,
|
||||||
|
/// The dtype the element bytes are viewed as: `dtype` itself, the base
|
||||||
|
/// of a subarray, or the base of a variable-length sequence.
|
||||||
|
pub view: Py<PyAny>,
|
||||||
|
pub layout: Layout,
|
||||||
|
/// Bytes per element in the raw buffer the library returns.
|
||||||
|
pub elem_size: usize,
|
||||||
|
/// For variable-length data, bytes per unit of an element's stored
|
||||||
|
/// length: 1 for strings, the base type's size for sequences.
|
||||||
|
pub vl_unit: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unsupported(what: impl std::fmt::Display) -> PyErr {
|
||||||
|
PyTypeError::new_err(format!(
|
||||||
|
"clawhdf5 cannot read this datatype into numpy: {what}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byte_order_char(order: &DatatypeByteOrder, size: u32) -> PyResult<&'static str> {
|
||||||
|
if size == 1 {
|
||||||
|
return Ok("|");
|
||||||
|
}
|
||||||
|
match order {
|
||||||
|
DatatypeByteOrder::LittleEndian => Ok("<"),
|
||||||
|
DatatypeByteOrder::BigEndian => Ok(">"),
|
||||||
|
DatatypeByteOrder::Vax => Err(unsupported("VAX byte order")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The numpy format string of an integer type, if it is a plain one.
|
||||||
|
fn int_format(dt: &Datatype) -> PyResult<String> {
|
||||||
|
match dt {
|
||||||
|
Datatype::FixedPoint {
|
||||||
|
size,
|
||||||
|
byte_order,
|
||||||
|
signed,
|
||||||
|
bit_offset,
|
||||||
|
bit_precision,
|
||||||
|
} => {
|
||||||
|
if !matches!(size, 1 | 2 | 4 | 8) {
|
||||||
|
return Err(unsupported(format!("{size}-byte integer")));
|
||||||
|
}
|
||||||
|
if *bit_offset != 0 || u32::from(*bit_precision) != size * 8 {
|
||||||
|
return Err(unsupported(format!(
|
||||||
|
"integer with {bit_precision} significant bits at offset {bit_offset} in {size} bytes"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let kind = if *signed { 'i' } else { 'u' };
|
||||||
|
Ok(format!(
|
||||||
|
"{}{kind}{size}",
|
||||||
|
byte_order_char(byte_order, *size)?
|
||||||
|
))
|
||||||
|
}
|
||||||
|
other => Err(unsupported(format!("{other:?} is not an integer"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The numpy format string of an IEEE 754 binary16/32/64 type.
|
||||||
|
fn float_format(dt: &Datatype) -> PyResult<String> {
|
||||||
|
let Datatype::FloatingPoint {
|
||||||
|
size,
|
||||||
|
byte_order,
|
||||||
|
bit_offset,
|
||||||
|
bit_precision,
|
||||||
|
exponent_location,
|
||||||
|
exponent_size,
|
||||||
|
mantissa_location,
|
||||||
|
mantissa_size,
|
||||||
|
exponent_bias,
|
||||||
|
} = dt
|
||||||
|
else {
|
||||||
|
return Err(unsupported(format!("{dt:?} is not a float")));
|
||||||
|
};
|
||||||
|
// (exponent location, exponent size, mantissa size, bias) of IEEE 754.
|
||||||
|
let ieee = match size {
|
||||||
|
2 => (10, 5, 10, 15),
|
||||||
|
4 => (23, 8, 23, 127),
|
||||||
|
8 => (52, 11, 52, 1023),
|
||||||
|
_ => return Err(unsupported(format!("{size}-byte float"))),
|
||||||
|
};
|
||||||
|
let layout = (
|
||||||
|
*exponent_location,
|
||||||
|
*exponent_size,
|
||||||
|
*mantissa_size,
|
||||||
|
*exponent_bias,
|
||||||
|
);
|
||||||
|
if *bit_offset != 0
|
||||||
|
|| u32::from(*bit_precision) != size * 8
|
||||||
|
|| *mantissa_location != 0
|
||||||
|
|| layout != ieee
|
||||||
|
{
|
||||||
|
return Err(unsupported(format!(
|
||||||
|
"non-IEEE {size}-byte float (exponent {exponent_size} bits at {exponent_location}, \
|
||||||
|
mantissa {mantissa_size} bits at {mantissa_location}, bias {exponent_bias})"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(format!("{}f{size}", byte_order_char(byte_order, *size)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `r`/`i` compounds of two identical IEEE floats are complex numbers in h5py.
|
||||||
|
fn complex_format(
|
||||||
|
size: u32,
|
||||||
|
members: &[clawhdf5_format::datatype::CompoundMember],
|
||||||
|
) -> Option<String> {
|
||||||
|
let [re, im] = members else { return None };
|
||||||
|
if re.name != "r" || im.name != "i" || re.datatype != im.datatype {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let Datatype::FloatingPoint {
|
||||||
|
size: fsize,
|
||||||
|
byte_order,
|
||||||
|
..
|
||||||
|
} = &re.datatype
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
if !matches!(fsize, 4 | 8)
|
||||||
|
|| re.byte_offset != 0
|
||||||
|
|| im.byte_offset != u64::from(*fsize)
|
||||||
|
|| size != 2 * fsize
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
float_format(&re.datatype).ok()?;
|
||||||
|
let order = byte_order_char(byte_order, *fsize).ok()?;
|
||||||
|
Some(format!("{order}c{}", 2 * fsize))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The members of an enum as `{name: value}`.
|
||||||
|
fn enum_members<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
base: &Datatype,
|
||||||
|
members: &[clawhdf5_format::datatype::EnumMember],
|
||||||
|
) -> PyResult<Bound<'py, PyDict>> {
|
||||||
|
let signed = matches!(base, Datatype::FixedPoint { signed: true, .. });
|
||||||
|
let dict = PyDict::new(py);
|
||||||
|
for m in members {
|
||||||
|
let value: Py<PyAny> = if signed {
|
||||||
|
let v = clawhdf5_format::data_read::read_as_i64(&m.value, base)
|
||||||
|
.map_err(|e| PyValueError::new_err(format!("enum member {}: {e}", m.name)))?;
|
||||||
|
let v = *v.first().ok_or_else(|| {
|
||||||
|
PyValueError::new_err(format!("enum member {} has no value", m.name))
|
||||||
|
})?;
|
||||||
|
v.into_pyobject(py)?.into_any().unbind()
|
||||||
|
} else {
|
||||||
|
let v = clawhdf5_format::data_read::read_as_u64(&m.value, base)
|
||||||
|
.map_err(|e| PyValueError::new_err(format!("enum member {}: {e}", m.name)))?;
|
||||||
|
let v = *v.first().ok_or_else(|| {
|
||||||
|
PyValueError::new_err(format!("enum member {} has no value", m.name))
|
||||||
|
})?;
|
||||||
|
v.into_pyobject(py)?.into_any().unbind()
|
||||||
|
};
|
||||||
|
dict.set_item(&m.name, value)?;
|
||||||
|
}
|
||||||
|
Ok(dict)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an enum is h5py's boolean: a one-byte integer with exactly the
|
||||||
|
/// members `FALSE` = 0 and `TRUE` = 1.
|
||||||
|
fn is_h5py_bool(base: &Datatype, members: &[clawhdf5_format::datatype::EnumMember]) -> bool {
|
||||||
|
if base.type_size() != 1 || members.len() != 2 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let value = |name: &str| {
|
||||||
|
members
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.name == name)
|
||||||
|
.and_then(|m| m.value.first().copied())
|
||||||
|
};
|
||||||
|
value("FALSE") == Some(0) && value("TRUE") == Some(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn np_dtype<'py>(py: Python<'py>, spec: impl IntoPyObject<'py>) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
py.import("numpy")?.getattr("dtype")?.call1((spec,))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn np_dtype_with_metadata<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
spec: impl IntoPyObject<'py>,
|
||||||
|
metadata: Bound<'py, PyDict>,
|
||||||
|
) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let kwargs = PyDict::new(py);
|
||||||
|
kwargs.set_item("metadata", metadata)?;
|
||||||
|
py.import("numpy")?
|
||||||
|
.getattr("dtype")?
|
||||||
|
.call((spec,), Some(&kwargs))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The numpy dtype of a fixed-size datatype, whose element bytes numpy can
|
||||||
|
/// read as they are.
|
||||||
|
pub(crate) fn fixed_dtype<'py>(py: Python<'py>, dt: &Datatype) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
match dt {
|
||||||
|
Datatype::FixedPoint { .. } => np_dtype(py, int_format(dt)?),
|
||||||
|
Datatype::FloatingPoint { .. } => np_dtype(py, float_format(dt)?),
|
||||||
|
Datatype::String { size, charset, .. } => {
|
||||||
|
if *size == 0 {
|
||||||
|
return Err(unsupported("zero-length fixed string"));
|
||||||
|
}
|
||||||
|
let meta = PyDict::new(py);
|
||||||
|
let enc = match charset {
|
||||||
|
CharacterSet::Ascii => "ascii",
|
||||||
|
CharacterSet::Utf8 => "utf-8",
|
||||||
|
};
|
||||||
|
meta.set_item("h5py_encoding", enc)?;
|
||||||
|
np_dtype_with_metadata(py, format!("S{size}"), meta)
|
||||||
|
}
|
||||||
|
Datatype::Opaque { size, .. } => {
|
||||||
|
if *size == 0 {
|
||||||
|
return Err(unsupported("zero-length opaque type"));
|
||||||
|
}
|
||||||
|
np_dtype(py, format!("V{size}"))
|
||||||
|
}
|
||||||
|
Datatype::Enumeration {
|
||||||
|
base_type, members, ..
|
||||||
|
} => {
|
||||||
|
let base = int_format(base_type)?;
|
||||||
|
if is_h5py_bool(base_type, members) {
|
||||||
|
return np_dtype(py, "?");
|
||||||
|
}
|
||||||
|
let meta = PyDict::new(py);
|
||||||
|
meta.set_item("enum", enum_members(py, base_type, members)?)?;
|
||||||
|
np_dtype_with_metadata(py, base, meta)
|
||||||
|
}
|
||||||
|
Datatype::Compound { size, members } => {
|
||||||
|
if let Some(c) = complex_format(*size, members) {
|
||||||
|
return np_dtype(py, c);
|
||||||
|
}
|
||||||
|
let names = PyList::empty(py);
|
||||||
|
let formats = PyList::empty(py);
|
||||||
|
let offsets = PyList::empty(py);
|
||||||
|
for m in members {
|
||||||
|
let end = m.byte_offset.checked_add(u64::from(m.datatype.type_size()));
|
||||||
|
if end.is_none_or(|end| end > u64::from(*size)) {
|
||||||
|
return Err(PyValueError::new_err(format!(
|
||||||
|
"compound member {} lies outside the {size}-byte compound",
|
||||||
|
m.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
names.append(&m.name)?;
|
||||||
|
formats.append(fixed_dtype(py, &m.datatype).map_err(|e| {
|
||||||
|
unsupported(format!("compound member {}: {}", m.name, e.value(py)))
|
||||||
|
})?)?;
|
||||||
|
offsets.append(m.byte_offset)?;
|
||||||
|
}
|
||||||
|
let spec = PyDict::new(py);
|
||||||
|
spec.set_item("names", names)?;
|
||||||
|
spec.set_item("formats", formats)?;
|
||||||
|
spec.set_item("offsets", offsets)?;
|
||||||
|
spec.set_item("itemsize", size)?;
|
||||||
|
np_dtype(py, spec)
|
||||||
|
}
|
||||||
|
Datatype::Array {
|
||||||
|
base_type,
|
||||||
|
dimensions,
|
||||||
|
} => {
|
||||||
|
let base = fixed_dtype(py, base_type)?;
|
||||||
|
let dims = PyTuple::new(py, dimensions)?;
|
||||||
|
np_dtype(py, (base, dims))
|
||||||
|
}
|
||||||
|
Datatype::VariableLength { is_string, .. } => Err(unsupported(if *is_string {
|
||||||
|
"variable-length string inside a compound or array type"
|
||||||
|
} else {
|
||||||
|
"variable-length sequence inside a compound or array type"
|
||||||
|
})),
|
||||||
|
Datatype::Reference { .. } => Err(unsupported("object/region references")),
|
||||||
|
Datatype::BitField { .. } => Err(unsupported("bitfield")),
|
||||||
|
Datatype::Time { .. } => Err(unsupported("time")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Converter {
|
||||||
|
/// The converter for a dataset's or attribute's datatype.
|
||||||
|
pub(crate) fn new(py: Python<'_>, dt: &Datatype, offset_size: u8) -> PyResult<Self> {
|
||||||
|
match dt {
|
||||||
|
Datatype::VariableLength {
|
||||||
|
is_string: true,
|
||||||
|
charset,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let utf8 = matches!(charset, Some(CharacterSet::Utf8));
|
||||||
|
let meta = PyDict::new(py);
|
||||||
|
if utf8 {
|
||||||
|
meta.set_item("vlen", py.get_type::<PyString>())?;
|
||||||
|
} else {
|
||||||
|
meta.set_item("vlen", py.get_type::<PyBytes>())?;
|
||||||
|
}
|
||||||
|
let dtype = np_dtype_with_metadata(py, "O", meta)?;
|
||||||
|
Ok(Self {
|
||||||
|
view: dtype.clone().unbind(),
|
||||||
|
dtype: dtype.unbind(),
|
||||||
|
layout: Layout::VlString { utf8 },
|
||||||
|
elem_size: vl_ref_size(offset_size)?,
|
||||||
|
vl_unit: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Datatype::VariableLength {
|
||||||
|
is_string: false,
|
||||||
|
base_type,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let base = fixed_dtype(py, base_type)?;
|
||||||
|
let meta = PyDict::new(py);
|
||||||
|
meta.set_item("vlen", &base)?;
|
||||||
|
let dtype = np_dtype_with_metadata(py, "O", meta)?;
|
||||||
|
Ok(Self {
|
||||||
|
dtype: dtype.unbind(),
|
||||||
|
view: base.unbind(),
|
||||||
|
layout: Layout::VlSequence,
|
||||||
|
elem_size: vl_ref_size(offset_size)?,
|
||||||
|
vl_unit: base_type.type_size() as usize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Datatype::Array {
|
||||||
|
base_type,
|
||||||
|
dimensions,
|
||||||
|
} => {
|
||||||
|
let dtype = fixed_dtype(py, dt)?;
|
||||||
|
let base = fixed_dtype(py, base_type)?;
|
||||||
|
Ok(Self {
|
||||||
|
dtype: dtype.unbind(),
|
||||||
|
view: base.unbind(),
|
||||||
|
layout: Layout::Subarray(dimensions.iter().map(|&d| d as usize).collect()),
|
||||||
|
elem_size: dt.type_size() as usize,
|
||||||
|
vl_unit: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let dtype = fixed_dtype(py, dt)?;
|
||||||
|
Ok(Self {
|
||||||
|
view: dtype.clone().unbind(),
|
||||||
|
dtype: dtype.unbind(),
|
||||||
|
layout: Layout::Fixed,
|
||||||
|
elem_size: dt.type_size() as usize,
|
||||||
|
vl_unit: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_vl(&self) -> bool {
|
||||||
|
matches!(self.layout, Layout::VlString { .. } | Layout::VlSequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty array of `shape` (some dimension is zero).
|
||||||
|
pub(crate) fn empty<'py>(
|
||||||
|
&self,
|
||||||
|
py: Python<'py>,
|
||||||
|
shape: &[usize],
|
||||||
|
) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let np = py.import("numpy")?;
|
||||||
|
match &self.layout {
|
||||||
|
Layout::Subarray(dims) => {
|
||||||
|
let mut full = shape.to_vec();
|
||||||
|
full.extend_from_slice(dims);
|
||||||
|
np.call_method1("empty", (PyTuple::new(py, full)?, self.view.bind(py)))
|
||||||
|
}
|
||||||
|
_ => np.call_method1("empty", (PyTuple::new(py, shape)?, self.dtype.bind(py))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn decoded element data into a numpy array of `shape`.
|
||||||
|
///
|
||||||
|
/// `str_values` decodes variable-length strings to `str` (what h5py
|
||||||
|
/// does for attributes) instead of `bytes` (what it does for datasets).
|
||||||
|
pub(crate) fn to_array<'py>(
|
||||||
|
&self,
|
||||||
|
py: Python<'py>,
|
||||||
|
data: Elements,
|
||||||
|
shape: &[usize],
|
||||||
|
str_values: bool,
|
||||||
|
) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let n: usize = shape.iter().product();
|
||||||
|
match (data, &self.layout) {
|
||||||
|
(Elements::Bytes(bytes), Layout::Fixed) => {
|
||||||
|
bytes_as_array(py, bytes, self.view.bind(py), shape)
|
||||||
|
}
|
||||||
|
(Elements::Bytes(bytes), Layout::Subarray(dims)) => {
|
||||||
|
let mut full = shape.to_vec();
|
||||||
|
full.extend_from_slice(dims);
|
||||||
|
bytes_as_array(py, bytes, self.view.bind(py), &full)
|
||||||
|
}
|
||||||
|
(Elements::Vl(items), Layout::VlString { .. }) => {
|
||||||
|
check_count(items.len(), n)?;
|
||||||
|
let mut objs: Vec<Py<PyAny>> = Vec::with_capacity(items.len());
|
||||||
|
for item in items {
|
||||||
|
let obj = if str_values {
|
||||||
|
PyBytes::new(py, &item)
|
||||||
|
.call_method1("decode", ("utf-8", "surrogateescape"))?
|
||||||
|
.unbind()
|
||||||
|
} else {
|
||||||
|
PyBytes::new(py, &item).into_any().unbind()
|
||||||
|
};
|
||||||
|
objs.push(obj);
|
||||||
|
}
|
||||||
|
object_array(py, objs, shape)
|
||||||
|
}
|
||||||
|
(Elements::Vl(items), Layout::VlSequence) => {
|
||||||
|
check_count(items.len(), n)?;
|
||||||
|
let base = self.view.bind(py);
|
||||||
|
let itemsize: usize = base.getattr("itemsize")?.extract()?;
|
||||||
|
let mut objs: Vec<Py<PyAny>> = Vec::with_capacity(items.len());
|
||||||
|
for item in items {
|
||||||
|
if item.len() % itemsize != 0 {
|
||||||
|
return Err(PyValueError::new_err(format!(
|
||||||
|
"variable-length element of {} bytes is not a whole number of {itemsize}-byte values",
|
||||||
|
item.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let len = item.len() / itemsize;
|
||||||
|
objs.push(bytes_as_array(py, item, base, &[len])?.unbind());
|
||||||
|
}
|
||||||
|
object_array(py, objs, shape)
|
||||||
|
}
|
||||||
|
_ => Err(PyValueError::new_err(
|
||||||
|
"internal error: element data does not match the datatype",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Element data as read, before it becomes numpy.
|
||||||
|
pub(crate) enum Elements {
|
||||||
|
/// The elements' bytes, back to back.
|
||||||
|
Bytes(Vec<u8>),
|
||||||
|
/// Each variable-length element's bytes, resolved from the global heap.
|
||||||
|
Vl(Vec<Vec<u8>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vl_ref_size(offset_size: u8) -> PyResult<usize> {
|
||||||
|
// The library sizes a variable-length element as 16 bytes (a length, an
|
||||||
|
// 8-byte heap address and an index) whatever the file's offset size.
|
||||||
|
// Refuse the other sizes rather than read misaligned references.
|
||||||
|
if offset_size != 8 {
|
||||||
|
return Err(unsupported(format!(
|
||||||
|
"variable-length data in a file with {offset_size}-byte offsets"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(4 + usize::from(offset_size) + 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_count(got: usize, want: usize) -> PyResult<()> {
|
||||||
|
if got != want {
|
||||||
|
return Err(PyValueError::new_err(format!(
|
||||||
|
"read {got} elements, expected {want}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A numpy array over `bytes` without copying them: the `Vec` becomes the
|
||||||
|
/// array's buffer and is viewed as `dtype` with `shape`.
|
||||||
|
pub(crate) fn bytes_as_array<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
dtype: &Bound<'py, PyAny>,
|
||||||
|
shape: &[usize],
|
||||||
|
) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let itemsize: usize = dtype.getattr("itemsize")?.extract()?;
|
||||||
|
let n: usize = shape.iter().product();
|
||||||
|
if n.checked_mul(itemsize) != Some(bytes.len()) {
|
||||||
|
return Err(PyValueError::new_err(format!(
|
||||||
|
"read {} bytes, expected {n} elements of {itemsize} bytes",
|
||||||
|
bytes.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let shape = PyTuple::new(py, shape)?;
|
||||||
|
if n == 0 {
|
||||||
|
return py.import("numpy")?.call_method1("empty", (shape, dtype));
|
||||||
|
}
|
||||||
|
let raw = PyArray1::from_vec(py, bytes);
|
||||||
|
let arr = raw
|
||||||
|
.call_method1("view", (dtype,))?
|
||||||
|
.call_method1("reshape", (shape,))?;
|
||||||
|
// A `Vec<u8>` carries no alignment promise. numpy copes with unaligned
|
||||||
|
// arrays, but slowly and not in every routine, so hand out an aligned
|
||||||
|
// copy in the (allocator-dependent, rare) case the buffer is not.
|
||||||
|
if !arr
|
||||||
|
.getattr("flags")?
|
||||||
|
.getattr("aligned")?
|
||||||
|
.extract::<bool>()?
|
||||||
|
{
|
||||||
|
return arr.call_method0("copy");
|
||||||
|
}
|
||||||
|
Ok(arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn object_array<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
objs: Vec<Py<PyAny>>,
|
||||||
|
shape: &[usize],
|
||||||
|
) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let arr = PyArray1::from_vec(py, objs);
|
||||||
|
arr.call_method1("reshape", (PyTuple::new(py, shape)?,))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve variable-length elements (global heap references in `raw`) to
|
||||||
|
/// their bytes: each element's stored length times `unit` (1 for strings,
|
||||||
|
/// the base type's size for sequences). Pure Rust, so it runs without the
|
||||||
|
/// GIL.
|
||||||
|
pub(crate) fn resolve_vl(
|
||||||
|
file_data: &[u8],
|
||||||
|
raw: &[u8],
|
||||||
|
count: usize,
|
||||||
|
offset_size: u8,
|
||||||
|
length_size: u8,
|
||||||
|
unit: usize,
|
||||||
|
) -> Result<Vec<Vec<u8>>, String> {
|
||||||
|
let refs = clawhdf5_format::vl_data::parse_vl_references(raw, count as u64, offset_size)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let undefined = match offset_size {
|
||||||
|
2 => 0xFFFF,
|
||||||
|
4 => 0xFFFF_FFFF,
|
||||||
|
_ => u64::MAX,
|
||||||
|
};
|
||||||
|
let mut collections: HashMap<u64, GlobalHeapCollection> = HashMap::new();
|
||||||
|
let mut out = Vec::with_capacity(refs.len());
|
||||||
|
for vl in &refs {
|
||||||
|
if vl.collection_address == 0 || vl.collection_address == undefined {
|
||||||
|
if vl.length != 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"variable-length element of length {} has no heap address",
|
||||||
|
vl.length
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push(Vec::new());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let coll = match collections.entry(vl.collection_address) {
|
||||||
|
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
|
||||||
|
std::collections::hash_map::Entry::Vacant(e) => {
|
||||||
|
let addr = usize::try_from(vl.collection_address)
|
||||||
|
.map_err(|_| "global heap address out of range".to_string())?;
|
||||||
|
e.insert(
|
||||||
|
GlobalHeapCollection::parse(file_data, addr, length_size)
|
||||||
|
.map_err(|e| e.to_string())?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let index = u16::try_from(vl.object_index)
|
||||||
|
.map_err(|_| format!("global heap object index {} out of range", vl.object_index))?;
|
||||||
|
let obj = coll.get_object(index).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"global heap object {index} not found in the collection at {}",
|
||||||
|
vl.collection_address
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let need = (vl.length as usize)
|
||||||
|
.checked_mul(unit)
|
||||||
|
.ok_or("variable-length element too long")?;
|
||||||
|
if need > obj.data.len() {
|
||||||
|
return Err(format!(
|
||||||
|
"variable-length element of {need} bytes in a {}-byte heap object",
|
||||||
|
obj.data.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push(obj.data[..need].to_vec());
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
+328
-196
@@ -1,241 +1,373 @@
|
|||||||
//! PyDataset — read access to HDF5 datasets with numpy integration.
|
//! PyDataset — h5py-style read access to HDF5 datasets.
|
||||||
|
//!
|
||||||
|
//! `ds[key]` parses the key into hyperslab selections (see `select`) and
|
||||||
|
//! reads them through the facade's `read_selection`, which decodes only the
|
||||||
|
//! chunks a small selection touches (see its docs for when it decodes the
|
||||||
|
//! whole dataset instead); the
|
||||||
|
//! bytes it returns become the numpy array's buffer without a copy (see
|
||||||
|
//! `convert`). All file access and decoding runs with the GIL released, so
|
||||||
|
//! Python threads reading the same or different datasets run in parallel.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use numpy::PyArrayDyn;
|
use clawhdf5_format::datatype::Datatype;
|
||||||
use numpy::ndarray::{ArrayD, IxDyn};
|
use clawhdf5_format::object_header::ObjectHeader;
|
||||||
|
use pyo3::exceptions::{PyTypeError, PyValueError};
|
||||||
use pyo3::prelude::*;
|
use pyo3::prelude::*;
|
||||||
use pyo3::types::PyList;
|
use pyo3::types::{PyList, PyTuple};
|
||||||
|
|
||||||
use clawhdf5_rs::DType;
|
|
||||||
|
|
||||||
use crate::attrs::PyAttrs;
|
use crate::attrs::PyAttrs;
|
||||||
use crate::to_py_err;
|
use crate::convert::{Converter, Elements, resolve_vl};
|
||||||
|
use crate::select::{self, Plan};
|
||||||
|
use crate::{PyEmpty, node, to_py_err};
|
||||||
|
|
||||||
/// A handle to an HDF5 dataset (read mode).
|
/// A dataset in a file opened for reading.
|
||||||
///
|
///
|
||||||
/// Supports numpy-style indexing via `__getitem__`:
|
|
||||||
/// ```python
|
/// ```python
|
||||||
/// ds = f['dataset_name']
|
/// ds = f['group/dataset']
|
||||||
/// data = ds[:] # read all data as numpy array
|
/// ds.shape, ds.dtype, ds.attrs['units']
|
||||||
/// shape = ds.shape
|
/// block = ds[10:20, ::2] # a small selection reads only its chunks
|
||||||
/// dtype = ds.dtype
|
|
||||||
/// ```
|
/// ```
|
||||||
#[pyclass(name = "Dataset")]
|
#[pyclass(name = "Dataset")]
|
||||||
pub struct PyDataset {
|
pub struct PyDataset {
|
||||||
file: Arc<clawhdf5_rs::File>,
|
file: Arc<clawhdf5_rs::File>,
|
||||||
path: String,
|
path: String,
|
||||||
cached_shape: Vec<u64>,
|
/// Where the dataset's object header is: reads open it from here rather
|
||||||
cached_dtype: DType,
|
/// than resolve `path` again.
|
||||||
|
addr: u64,
|
||||||
|
/// `None` for a dataset with a null dataspace (h5py's `Empty`).
|
||||||
|
shape: Option<Vec<u64>>,
|
||||||
|
/// The chunk shape, for a chunked dataset.
|
||||||
|
chunks: Option<Vec<u64>>,
|
||||||
|
datatype: Datatype,
|
||||||
|
/// Why the datatype cannot be read into numpy, if it cannot.
|
||||||
|
conv: Result<Converter, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PyDataset {
|
impl PyDataset {
|
||||||
pub fn new(file: Arc<clawhdf5_rs::File>, path: String) -> PyResult<Self> {
|
pub(crate) fn open(
|
||||||
let ds = file.dataset(&path).map_err(to_py_err)?;
|
py: Python<'_>,
|
||||||
let cached_shape = ds.shape().map_err(to_py_err)?;
|
file: Arc<clawhdf5_rs::File>,
|
||||||
let cached_dtype = ds.dtype().map_err(to_py_err)?;
|
path: String,
|
||||||
|
addr: u64,
|
||||||
|
hdr: &ObjectHeader,
|
||||||
|
) -> PyResult<Self> {
|
||||||
|
crate::no_panic(|| {
|
||||||
|
let null = node::is_null(&node::dataspace(&file, hdr)?);
|
||||||
|
let (shape, datatype) = {
|
||||||
|
let ds = file.dataset_at(addr).map_err(to_py_err)?;
|
||||||
|
let shape = if null {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(ds.shape().map_err(to_py_err)?)
|
||||||
|
};
|
||||||
|
(shape, ds.raw_datatype().map_err(to_py_err)?)
|
||||||
|
};
|
||||||
|
let conv = Converter::new(py, &datatype, file.superblock().offset_size)
|
||||||
|
.map_err(|e| e.value(py).to_string());
|
||||||
|
let chunks = shape
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| node::chunk_shape(&file, hdr, s.len()));
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
file,
|
file,
|
||||||
path,
|
path,
|
||||||
cached_shape,
|
addr,
|
||||||
cached_dtype,
|
shape,
|
||||||
|
chunks,
|
||||||
|
datatype,
|
||||||
|
conv,
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn converter(&self) -> PyResult<&Converter> {
|
||||||
|
self.conv
|
||||||
|
.as_ref()
|
||||||
|
.map_err(|msg| PyTypeError::new_err(format!("{}: {msg}", node::name(&self.path))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the selection described by `plan` into a numpy array.
|
||||||
|
fn read_plan<'py>(&self, py: Python<'py>, plan: &Plan) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let conv = self.converter()?;
|
||||||
|
let dims = self.shape.as_deref().unwrap_or(&[]);
|
||||||
|
let out_shape = plan.out_shape();
|
||||||
|
|
||||||
|
let arr = if plan.is_empty() {
|
||||||
|
conv.empty(py, &out_shape)?
|
||||||
|
} else {
|
||||||
|
let (vl, elem_size, unit) = (conv.is_vl(), conv.elem_size, conv.vl_unit);
|
||||||
|
let list_axis = plan.list_axis();
|
||||||
|
let chunk_len = match (&self.chunks, list_axis) {
|
||||||
|
(Some(c), Some(a)) => c.get(a).copied(),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let (reads, list_axis) = plan.reads(dims, chunk_len, elem_size);
|
||||||
|
let read_shape = plan.read_shape();
|
||||||
|
let file = &*self.file;
|
||||||
|
let addr = self.addr;
|
||||||
|
// Everything below touches only Rust data: release the GIL.
|
||||||
|
let read = || -> Result<Elements, ReadError> {
|
||||||
|
let ds = file.dataset_at(addr)?;
|
||||||
|
let mut blocks = Vec::with_capacity(reads.len());
|
||||||
|
for read in reads {
|
||||||
|
let raw = ds.read_selection(&read.sel)?;
|
||||||
|
let mut shape = read.shape;
|
||||||
|
let want = shape.iter().product::<usize>() * elem_size;
|
||||||
|
if raw.len() != want {
|
||||||
|
return Err(ReadError::Other(format!(
|
||||||
|
"read {} bytes, expected {want}",
|
||||||
|
raw.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let raw = match (&read.pick, list_axis) {
|
||||||
|
(Some(pick), Some(axis)) => {
|
||||||
|
let kept = select::gather_along(&raw, &shape, axis, pick, elem_size);
|
||||||
|
shape[axis] = pick.len();
|
||||||
|
kept
|
||||||
|
}
|
||||||
|
_ => raw,
|
||||||
|
};
|
||||||
|
blocks.push((raw, shape));
|
||||||
|
}
|
||||||
|
// Several blocks only for a list index: join their bytes
|
||||||
|
// (every byte of every element, padding included) along
|
||||||
|
// that axis before anything becomes numpy.
|
||||||
|
let raw = match (blocks.len(), list_axis) {
|
||||||
|
(1, _) => blocks.pop().expect("one block").0,
|
||||||
|
(_, Some(axis)) => select::join_along(&blocks, axis, elem_size),
|
||||||
|
_ => {
|
||||||
|
return Err(ReadError::Other(
|
||||||
|
"several reads without an index list".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !vl {
|
||||||
|
return Ok(Elements::Bytes(raw));
|
||||||
|
}
|
||||||
|
let sb = file.superblock();
|
||||||
|
let n = read_shape.iter().product();
|
||||||
|
resolve_vl(
|
||||||
|
file.as_bytes(),
|
||||||
|
&raw,
|
||||||
|
n,
|
||||||
|
sb.offset_size,
|
||||||
|
sb.length_size,
|
||||||
|
unit,
|
||||||
|
)
|
||||||
|
.map(Elements::Vl)
|
||||||
|
.map_err(ReadError::Other)
|
||||||
|
};
|
||||||
|
let data = py
|
||||||
|
.detach(|| {
|
||||||
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(read))
|
||||||
|
.unwrap_or_else(|p| Err(ReadError::Panic(crate::panic_text(&*p))))
|
||||||
|
})
|
||||||
|
.map_err(|e| e.into_py(&self.path))?;
|
||||||
|
let joined = conv.to_array(py, data, &read_shape, false)?;
|
||||||
|
// Drop the axes indexed by an integer (length 1 in the blocks).
|
||||||
|
let mut shape = out_shape.clone();
|
||||||
|
if let crate::convert::Layout::Subarray(sub) = &conv.layout {
|
||||||
|
shape.extend_from_slice(sub);
|
||||||
|
}
|
||||||
|
joined.call_method1("reshape", (PyTuple::new(py, shape)?,))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let arr = select_fields(py, arr, &plan.fields)?;
|
||||||
|
if plan.scalar {
|
||||||
|
return arr.get_item(PyTuple::empty(py));
|
||||||
|
}
|
||||||
|
Ok(arr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a `DType` to a numpy dtype string.
|
/// An error from the read closure, turned into a Python error with the GIL.
|
||||||
fn dtype_to_numpy_str(dt: &DType) -> &'static str {
|
enum ReadError {
|
||||||
match dt {
|
Lib(clawhdf5_rs::Error),
|
||||||
DType::F64 => "float64",
|
Other(String),
|
||||||
DType::F32 => "float32",
|
Panic(String),
|
||||||
DType::I64 => "int64",
|
}
|
||||||
DType::I32 => "int32",
|
|
||||||
DType::I16 => "int16",
|
impl From<clawhdf5_rs::Error> for ReadError {
|
||||||
DType::I8 => "int8",
|
fn from(e: clawhdf5_rs::Error) -> Self {
|
||||||
DType::U64 => "uint64",
|
ReadError::Lib(e)
|
||||||
DType::U32 => "uint32",
|
|
||||||
DType::U16 => "uint16",
|
|
||||||
DType::U8 => "uint8",
|
|
||||||
DType::String | DType::VariableLengthString => "object",
|
|
||||||
_ => "object",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ReadError {
|
||||||
|
fn into_py(self, path: &str) -> PyErr {
|
||||||
|
match self {
|
||||||
|
ReadError::Lib(e) => to_py_err(e),
|
||||||
|
ReadError::Other(msg) => PyValueError::new_err(format!("{}: {msg}", node::name(path))),
|
||||||
|
ReadError::Panic(msg) => crate::InternalError::new_err(format!(
|
||||||
|
"{}: clawhdf5 internal error (please report it): {msg}",
|
||||||
|
node::name(path)
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep only the named compound fields, as h5py's `ds['x']` / `ds['x', 'y']`.
|
||||||
|
fn select_fields<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
arr: Bound<'py, PyAny>,
|
||||||
|
fields: &[String],
|
||||||
|
) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
if fields.is_empty() {
|
||||||
|
return Ok(arr);
|
||||||
|
}
|
||||||
|
let names = arr.getattr("dtype")?.getattr("names")?;
|
||||||
|
if names.is_none() {
|
||||||
|
return Err(PyValueError::new_err(
|
||||||
|
"Field names only allowed for compound types",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let names: Vec<String> = names.extract()?;
|
||||||
|
for f in fields {
|
||||||
|
if !names.contains(f) {
|
||||||
|
return Err(PyValueError::new_err(format!(
|
||||||
|
"Field {f} does not appear in this type."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let np = py.import("numpy")?;
|
||||||
|
if let [one] = fields {
|
||||||
|
return np.call_method1("ascontiguousarray", (arr.get_item(one)?,));
|
||||||
|
}
|
||||||
|
let picked = arr.get_item(PyList::new(py, fields)?)?;
|
||||||
|
py.import("numpy.lib.recfunctions")?
|
||||||
|
.call_method1("repack_fields", (picked,))
|
||||||
|
}
|
||||||
|
|
||||||
#[pymethods]
|
#[pymethods]
|
||||||
impl PyDataset {
|
impl PyDataset {
|
||||||
/// The shape of the dataset as a tuple.
|
/// The shape of the dataset (`None` for an empty/null dataspace).
|
||||||
#[getter]
|
#[getter]
|
||||||
fn shape(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
fn shape<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
|
||||||
let tuple = pyo3::types::PyTuple::new(py, self.cached_shape.iter().map(|&d| d as usize))?;
|
match &self.shape {
|
||||||
Ok(tuple.into_any().unbind())
|
Some(s) => Ok(PyTuple::new(py, s)?.into_any()),
|
||||||
|
None => Ok(py.None().into_bound(py)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The numpy dtype string of the dataset.
|
/// The maximum shape (`None` per unlimited dimension), like h5py.
|
||||||
#[getter]
|
#[getter]
|
||||||
fn dtype(&self) -> &'static str {
|
fn maxshape<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
|
||||||
dtype_to_numpy_str(&self.cached_dtype)
|
crate::no_panic(|| {
|
||||||
|
let Some(shape) = &self.shape else {
|
||||||
|
return Ok(py.None().into_bound(py));
|
||||||
|
};
|
||||||
|
let max = self
|
||||||
|
.file
|
||||||
|
.dataset_at(self.addr)
|
||||||
|
.and_then(|ds| ds.max_dimensions())
|
||||||
|
.map_err(to_py_err)?
|
||||||
|
.unwrap_or_else(|| shape.clone());
|
||||||
|
let items: Vec<Option<u64>> = max
|
||||||
|
.into_iter()
|
||||||
|
.map(|d| (d != u64::MAX).then_some(d))
|
||||||
|
.collect();
|
||||||
|
Ok(PyTuple::new(py, items)?.into_any())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attribute access (read-only).
|
/// The dataset's numpy dtype, as h5py reports it.
|
||||||
|
#[getter]
|
||||||
|
fn dtype<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
Ok(self.converter()?.dtype.bind(py).clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[getter]
|
||||||
|
fn ndim(&self) -> usize {
|
||||||
|
self.shape.as_ref().map_or(0, Vec::len)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of elements (`None` for an empty/null dataspace, as h5py).
|
||||||
|
#[getter]
|
||||||
|
fn size(&self) -> Option<u64> {
|
||||||
|
self.shape.as_ref().map(|s| s.iter().product())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dataset's full name, e.g. `/group/data`.
|
||||||
|
#[getter]
|
||||||
|
fn name(&self) -> String {
|
||||||
|
node::name(&self.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dataset's attributes (read-only, dict-like).
|
||||||
#[getter]
|
#[getter]
|
||||||
fn attrs(&self) -> PyResult<PyAttrs> {
|
fn attrs(&self) -> PyResult<PyAttrs> {
|
||||||
let ds = self.file.dataset(&self.path).map_err(to_py_err)?;
|
PyAttrs::read(Arc::clone(&self.file), self.addr, &self.path)
|
||||||
let map = ds.attrs().map_err(to_py_err)?;
|
|
||||||
Ok(PyAttrs::from_read(map))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read data via indexing. Supports `ds[:]`, `ds[0]`, `ds[0:5]`, etc.
|
/// Read with h5py indexing: integers, slices with positive steps,
|
||||||
///
|
/// `...`, one increasing list of integers, and compound field names.
|
||||||
/// The full dataset is always read from the underlying file; the index
|
/// A selection whose bounding box covers at most half the dataset reads
|
||||||
/// is then applied on the resulting numpy array.
|
/// only the chunks (or contiguous rows) it overlaps.
|
||||||
fn __getitem__<'py>(&self, py: Python<'py>, key: &Bound<'py, PyAny>) -> PyResult<Py<PyAny>> {
|
fn __getitem__<'py>(
|
||||||
let arr = self.read_as_numpy(py)?;
|
&self,
|
||||||
let indexed = arr.get_item(key)?;
|
py: Python<'py>,
|
||||||
Ok(indexed.unbind())
|
key: &Bound<'py, PyAny>,
|
||||||
|
) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let Some(dims) = &self.shape else {
|
||||||
|
let is_empty_tuple = key.cast::<PyTuple>().is_ok_and(|t| t.is_empty());
|
||||||
|
let is_ellipsis = key.is_instance_of::<pyo3::types::PyEllipsis>();
|
||||||
|
if is_empty_tuple || is_ellipsis {
|
||||||
|
let empty = PyEmpty::new(self.converter()?.dtype.clone_ref(py));
|
||||||
|
return Ok(empty.into_pyobject(py)?.into_any());
|
||||||
|
}
|
||||||
|
return Err(PyValueError::new_err("Empty datasets cannot be sliced"));
|
||||||
|
};
|
||||||
|
let plan = select::parse(key, dims)?;
|
||||||
|
self.read_plan(py, &plan)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn __repr__(&self) -> String {
|
/// `numpy.asarray(ds)` reads the whole dataset.
|
||||||
|
#[pyo3(signature = (dtype=None, copy=None))]
|
||||||
|
fn __array__<'py>(
|
||||||
|
&self,
|
||||||
|
py: Python<'py>,
|
||||||
|
dtype: Option<&Bound<'py, PyAny>>,
|
||||||
|
copy: Option<bool>,
|
||||||
|
) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
let _ = copy; // every read is a fresh array
|
||||||
|
let Some(dims) = &self.shape else {
|
||||||
|
return Err(PyValueError::new_err("an empty dataset has no array value"));
|
||||||
|
};
|
||||||
|
let ellipsis = pyo3::types::PyEllipsis::get(py).to_owned().into_any();
|
||||||
|
let plan = select::parse(&ellipsis, dims)?;
|
||||||
|
let arr = self.read_plan(py, &plan)?;
|
||||||
|
match dtype {
|
||||||
|
Some(dt) => arr.call_method1("astype", (dt,)),
|
||||||
|
None => Ok(arr),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn __len__(&self) -> PyResult<usize> {
|
||||||
|
match self.shape.as_deref() {
|
||||||
|
Some([first, ..]) => Ok(*first as usize),
|
||||||
|
_ => Err(PyTypeError::new_err(
|
||||||
|
"Attempt to take len() of scalar dataset",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn __repr__(&self, py: Python<'_>) -> String {
|
||||||
|
let dtype = match &self.conv {
|
||||||
|
Ok(c) => c
|
||||||
|
.dtype
|
||||||
|
.bind(py)
|
||||||
|
.str()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
Err(_) => format!("{:?}", self.datatype),
|
||||||
|
};
|
||||||
|
let shape = match &self.shape {
|
||||||
|
Some(s) => format!("{s:?}"),
|
||||||
|
None => "None".to_string(),
|
||||||
|
};
|
||||||
format!(
|
format!(
|
||||||
"<HDF5 Dataset \"{}\": shape {:?}, dtype {}>",
|
"<HDF5 dataset \"{}\": shape {shape}, type \"{dtype}\">",
|
||||||
self.path,
|
node::name(&self.path)
|
||||||
self.cached_shape,
|
|
||||||
dtype_to_numpy_str(&self.cached_dtype),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn __len__(&self) -> usize {
|
|
||||||
self.cached_shape.first().copied().unwrap_or(0) as usize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PyDataset {
|
|
||||||
/// Read the full dataset and return it as a numpy array (or list for strings).
|
|
||||||
///
|
|
||||||
/// For numeric types, the Rust I/O (file reading + decompression) is
|
|
||||||
/// performed inside `py.detach()` so that the GIL is released
|
|
||||||
/// during the potentially expensive operation. The numpy array
|
|
||||||
/// construction still happens with the GIL held.
|
|
||||||
fn read_as_numpy<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
|
|
||||||
let file = &self.file;
|
|
||||||
let path = &self.path;
|
|
||||||
let shape: Vec<usize> = self.cached_shape.iter().map(|&d| d as usize).collect();
|
|
||||||
|
|
||||||
match &self.cached_dtype {
|
|
||||||
DType::F64 => {
|
|
||||||
let data = py
|
|
||||||
.detach(|| file.dataset(path).and_then(|ds| ds.read_f64()))
|
|
||||||
.map_err(to_py_err)?;
|
|
||||||
let nd = ArrayD::from_shape_vec(IxDyn(&shape), data)
|
|
||||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
|
|
||||||
let arr = PyArrayDyn::from_owned_array(py, nd);
|
|
||||||
Ok(arr.into_any())
|
|
||||||
}
|
|
||||||
DType::F32 => {
|
|
||||||
let data = py
|
|
||||||
.detach(|| file.dataset(path).and_then(|ds| ds.read_f32()))
|
|
||||||
.map_err(to_py_err)?;
|
|
||||||
let nd = ArrayD::from_shape_vec(IxDyn(&shape), data)
|
|
||||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
|
|
||||||
let arr = PyArrayDyn::from_owned_array(py, nd);
|
|
||||||
Ok(arr.into_any())
|
|
||||||
}
|
|
||||||
DType::I32 => {
|
|
||||||
let data = py
|
|
||||||
.detach(|| file.dataset(path).and_then(|ds| ds.read_i32()))
|
|
||||||
.map_err(to_py_err)?;
|
|
||||||
let nd = ArrayD::from_shape_vec(IxDyn(&shape), data)
|
|
||||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
|
|
||||||
let arr = PyArrayDyn::from_owned_array(py, nd);
|
|
||||||
Ok(arr.into_any())
|
|
||||||
}
|
|
||||||
DType::I64 => {
|
|
||||||
let data = py
|
|
||||||
.detach(|| file.dataset(path).and_then(|ds| ds.read_i64()))
|
|
||||||
.map_err(to_py_err)?;
|
|
||||||
let nd = ArrayD::from_shape_vec(IxDyn(&shape), data)
|
|
||||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
|
|
||||||
let arr = PyArrayDyn::from_owned_array(py, nd);
|
|
||||||
Ok(arr.into_any())
|
|
||||||
}
|
|
||||||
DType::U8 => {
|
|
||||||
// Try zero-copy first (contiguous layout), fall back to
|
|
||||||
// read_u64 + cast for chunked/compact datasets.
|
|
||||||
let data: Vec<u8> = py
|
|
||||||
.detach(|| {
|
|
||||||
let ds = file.dataset(path)?;
|
|
||||||
match ds.read_u8_zerocopy() {
|
|
||||||
Ok(slice) => Ok(slice.to_vec()),
|
|
||||||
Err(_) => {
|
|
||||||
let raw = ds.read_u64()?;
|
|
||||||
Ok(raw.iter().map(|&v| v as u8).collect())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.map_err(to_py_err)?;
|
|
||||||
let nd = ArrayD::from_shape_vec(IxDyn(&shape), data)
|
|
||||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
|
|
||||||
let arr = PyArrayDyn::from_owned_array(py, nd);
|
|
||||||
Ok(arr.into_any())
|
|
||||||
}
|
|
||||||
DType::U64 => {
|
|
||||||
let data = py
|
|
||||||
.detach(|| file.dataset(path).and_then(|ds| ds.read_u64()))
|
|
||||||
.map_err(to_py_err)?;
|
|
||||||
let nd = ArrayD::from_shape_vec(IxDyn(&shape), data)
|
|
||||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
|
|
||||||
let arr = PyArrayDyn::from_owned_array(py, nd);
|
|
||||||
Ok(arr.into_any())
|
|
||||||
}
|
|
||||||
DType::String | DType::VariableLengthString => {
|
|
||||||
// String reads need the GIL for PyList construction, but we
|
|
||||||
// release it during the Rust I/O portion.
|
|
||||||
let data = py
|
|
||||||
.detach(|| file.dataset(path).and_then(|ds| ds.read_string()))
|
|
||||||
.map_err(to_py_err)?;
|
|
||||||
let list = PyList::new(py, &data)?;
|
|
||||||
Ok(list.into_any())
|
|
||||||
}
|
|
||||||
other => Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(format!(
|
|
||||||
"unsupported dataset dtype for reading: {other}"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dtype_mapping() {
|
|
||||||
assert_eq!(dtype_to_numpy_str(&DType::F64), "float64");
|
|
||||||
assert_eq!(dtype_to_numpy_str(&DType::F32), "float32");
|
|
||||||
assert_eq!(dtype_to_numpy_str(&DType::I32), "int32");
|
|
||||||
assert_eq!(dtype_to_numpy_str(&DType::I64), "int64");
|
|
||||||
assert_eq!(dtype_to_numpy_str(&DType::U8), "uint8");
|
|
||||||
assert_eq!(dtype_to_numpy_str(&DType::String), "object");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dataset_from_file() {
|
|
||||||
let mut b = clawhdf5_rs::FileBuilder::new();
|
|
||||||
b.create_dataset("vals").with_f64_data(&[1.0, 2.0, 3.0]);
|
|
||||||
let bytes = b.finish().unwrap();
|
|
||||||
let file = Arc::new(clawhdf5_rs::File::from_bytes(bytes).unwrap());
|
|
||||||
let ds = PyDataset::new(file, "vals".into()).unwrap();
|
|
||||||
assert_eq!(ds.cached_shape, vec![3]);
|
|
||||||
assert_eq!(ds.cached_dtype, DType::F64);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dataset_len() {
|
|
||||||
let mut b = clawhdf5_rs::FileBuilder::new();
|
|
||||||
b.create_dataset("data")
|
|
||||||
.with_i32_data(&[10, 20, 30, 40])
|
|
||||||
.with_shape(&[2, 2]);
|
|
||||||
let bytes = b.finish().unwrap();
|
|
||||||
let file = Arc::new(clawhdf5_rs::File::from_bytes(bytes).unwrap());
|
|
||||||
let ds = PyDataset::new(file, "data".into()).unwrap();
|
|
||||||
assert_eq!(ds.__len__(), 2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ use std::path::PathBuf;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use pyo3::prelude::*;
|
use pyo3::prelude::*;
|
||||||
|
use pyo3::types::PyList;
|
||||||
|
|
||||||
use crate::attrs::PyAttrs;
|
use crate::attrs::PyAttrs;
|
||||||
use crate::dataset::PyDataset;
|
use crate::group::{PyGroup, ReadGroup, WriteGroupState, finalize_write_group};
|
||||||
use crate::group::{PyGroup, WriteGroupState, finalize_write_group};
|
|
||||||
use crate::{DatasetSpec, OwnedAttrValue, apply_dataset_spec, extract_numpy_data, to_py_err};
|
use crate::{DatasetSpec, OwnedAttrValue, apply_dataset_spec, extract_numpy_data, to_py_err};
|
||||||
|
|
||||||
/// Internal state for write mode.
|
/// Internal state for write mode.
|
||||||
@@ -35,10 +35,12 @@ struct WriteState {
|
|||||||
#[pyclass(name = "File")]
|
#[pyclass(name = "File")]
|
||||||
pub struct PyFile {
|
pub struct PyFile {
|
||||||
inner: Option<FileInner>,
|
inner: Option<FileInner>,
|
||||||
|
filename: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum FileInner {
|
enum FileInner {
|
||||||
Read(Arc<clawhdf5_rs::File>),
|
/// The root group; it holds the file.
|
||||||
|
Read(ReadGroup),
|
||||||
Write(WriteState),
|
Write(WriteState),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,15 +53,20 @@ impl PyFile {
|
|||||||
/// mode: 'r' for read (default), 'w' for write
|
/// mode: 'r' for read (default), 'w' for write
|
||||||
#[new]
|
#[new]
|
||||||
#[pyo3(signature = (path, mode="r"))]
|
#[pyo3(signature = (path, mode="r"))]
|
||||||
fn new(path: &str, mode: &str) -> PyResult<Self> {
|
fn new(py: Python<'_>, path: &str, mode: &str) -> PyResult<Self> {
|
||||||
|
let filename = path.to_string();
|
||||||
match mode {
|
match mode {
|
||||||
"r" => {
|
"r" => {
|
||||||
let file = clawhdf5_rs::File::open(path).map_err(to_py_err)?;
|
let file = py.detach(|| {
|
||||||
|
crate::no_panic(|| clawhdf5_rs::File::open(path).map_err(to_py_err))
|
||||||
|
})?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: Some(FileInner::Read(Arc::new(file))),
|
inner: Some(FileInner::Read(root_group(Arc::new(file)))),
|
||||||
|
filename,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
"w" => Ok(Self {
|
"w" => Ok(Self {
|
||||||
|
filename,
|
||||||
inner: Some(FileInner::Write(WriteState {
|
inner: Some(FileInner::Write(WriteState {
|
||||||
path: PathBuf::from(path),
|
path: PathBuf::from(path),
|
||||||
root_datasets: Vec::new(),
|
root_datasets: Vec::new(),
|
||||||
@@ -101,44 +108,51 @@ impl PyFile {
|
|||||||
Ok(false) // don't suppress exceptions
|
Ok(false) // don't suppress exceptions
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a child object (dataset or group) by path.
|
/// Get a child object (dataset or group) by path; `f['/']` is the root.
|
||||||
fn __getitem__(&self, py: Python<'_>, key: &str) -> PyResult<Py<PyAny>> {
|
fn __getitem__(&self, py: Python<'_>, key: &str) -> PyResult<Py<PyAny>> {
|
||||||
let file = self.read_file()?;
|
self.read_file()?.get_item(py, key)
|
||||||
// Try dataset first
|
|
||||||
match file.dataset(key) {
|
|
||||||
Ok(_) => {
|
|
||||||
let ds = PyDataset::new(Arc::clone(file), key.to_string())?;
|
|
||||||
Ok(ds.into_pyobject(py)?.into_any().unbind())
|
|
||||||
}
|
|
||||||
Err(clawhdf5_rs::Error::NotADataset(_)) => {
|
|
||||||
let grp = PyGroup::from_read(Arc::clone(file), key.to_string());
|
|
||||||
Ok(grp.into_pyobject(py)?.into_any().unbind())
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// Could be a group (no DataLayout message, no error)
|
|
||||||
match file.group(key) {
|
|
||||||
Ok(_) => {
|
|
||||||
let grp = PyGroup::from_read(Arc::clone(file), key.to_string());
|
|
||||||
Ok(grp.into_pyobject(py)?.into_any().unbind())
|
|
||||||
}
|
|
||||||
Err(e) => Err(PyErr::new::<pyo3::exceptions::PyKeyError, _>(format!(
|
|
||||||
"{key}: {e}"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `f.get(key, default=None)`.
|
||||||
|
#[pyo3(signature = (key, default=None))]
|
||||||
|
fn get(&self, py: Python<'_>, key: &str, default: Option<Py<PyAny>>) -> PyResult<Py<PyAny>> {
|
||||||
|
self.read_file()?.get(py, key, default)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List the names of all children in the root group.
|
/// List the names of all children in the root group.
|
||||||
fn keys(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
fn keys(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||||
let file = self.read_file()?;
|
let names = self.read_file()?.member_names()?;
|
||||||
let root = file.root();
|
Ok(PyList::new(py, names)?.into_any().unbind())
|
||||||
let mut names = root.datasets().map_err(to_py_err)?;
|
}
|
||||||
let groups = root.groups().map_err(to_py_err)?;
|
|
||||||
names.extend(groups);
|
fn values(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||||
names.sort();
|
let vals = self.read_file()?.values(py)?;
|
||||||
let list = pyo3::types::PyList::new(py, &names)?;
|
Ok(PyList::new(py, vals)?.into_any().unbind())
|
||||||
Ok(list.into_any().unbind())
|
}
|
||||||
|
|
||||||
|
fn items(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||||
|
let items = self.read_file()?.items(py)?;
|
||||||
|
Ok(PyList::new(py, items)?.into_any().unbind())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn __iter__(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||||
|
self.keys(py)?.call_method0(py, "__iter__")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn __len__(&self) -> PyResult<usize> {
|
||||||
|
Ok(self.read_file()?.member_names()?.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The root group's name, `/`.
|
||||||
|
#[getter]
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"/"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path the file was opened with.
|
||||||
|
#[getter]
|
||||||
|
fn filename(&self) -> &str {
|
||||||
|
&self.filename
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a dataset in the root group (write mode only).
|
/// Create a dataset in the root group (write mode only).
|
||||||
@@ -192,10 +206,7 @@ impl PyFile {
|
|||||||
#[getter]
|
#[getter]
|
||||||
fn attrs(&self) -> PyResult<PyAttrs> {
|
fn attrs(&self) -> PyResult<PyAttrs> {
|
||||||
match self.inner.as_ref() {
|
match self.inner.as_ref() {
|
||||||
Some(FileInner::Read(file)) => {
|
Some(FileInner::Read(root)) => root.attrs(),
|
||||||
let map = file.root().attrs().map_err(to_py_err)?;
|
|
||||||
Ok(PyAttrs::from_read(map))
|
|
||||||
}
|
|
||||||
Some(FileInner::Write(state)) => Ok(PyAttrs::from_write(Arc::clone(&state.root_attrs))),
|
Some(FileInner::Write(state)) => Ok(PyAttrs::from_write(Arc::clone(&state.root_attrs))),
|
||||||
None => Err(PyErr::new::<pyo3::exceptions::PyIOError, _>(
|
None => Err(PyErr::new::<pyo3::exceptions::PyIOError, _>(
|
||||||
"file is closed",
|
"file is closed",
|
||||||
@@ -205,8 +216,8 @@ impl PyFile {
|
|||||||
|
|
||||||
fn __repr__(&self) -> String {
|
fn __repr__(&self) -> String {
|
||||||
match &self.inner {
|
match &self.inner {
|
||||||
Some(FileInner::Read(f)) => {
|
Some(FileInner::Read(root)) => {
|
||||||
format!("<HDF5 File (read, {} bytes)>", f.as_bytes().len())
|
format!("<HDF5 File (read, {} bytes)>", root.file.as_bytes().len())
|
||||||
}
|
}
|
||||||
Some(FileInner::Write(s)) => {
|
Some(FileInner::Write(s)) => {
|
||||||
format!("<HDF5 File (write, \"{}\")>", s.path.display())
|
format!("<HDF5 File (write, \"{}\")>", s.path.display())
|
||||||
@@ -216,13 +227,13 @@ impl PyFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn __contains__(&self, key: &str) -> PyResult<bool> {
|
fn __contains__(&self, key: &str) -> PyResult<bool> {
|
||||||
let file = self.read_file()?;
|
Ok(self.read_file()?.contains(key))
|
||||||
Ok(file.dataset(key).is_ok() || file.group(key).is_ok())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PyFile {
|
impl PyFile {
|
||||||
fn read_file(&self) -> PyResult<&Arc<clawhdf5_rs::File>> {
|
/// The root group of a file opened for reading.
|
||||||
|
fn read_file(&self) -> PyResult<&ReadGroup> {
|
||||||
match &self.inner {
|
match &self.inner {
|
||||||
Some(FileInner::Read(f)) => Ok(f),
|
Some(FileInner::Read(f)) => Ok(f),
|
||||||
Some(FileInner::Write(_)) => Err(PyErr::new::<pyo3::exceptions::PyIOError, _>(
|
Some(FileInner::Write(_)) => Err(PyErr::new::<pyo3::exceptions::PyIOError, _>(
|
||||||
@@ -260,8 +271,14 @@ fn parse_compression(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn root_group(file: Arc<clawhdf5_rs::File>) -> ReadGroup {
|
||||||
|
let root = file.superblock().root_group_address;
|
||||||
|
ReadGroup::new(file, String::new(), root)
|
||||||
|
}
|
||||||
|
|
||||||
/// Build and write the HDF5 file from accumulated write state.
|
/// Build and write the HDF5 file from accumulated write state.
|
||||||
fn finalize_write(state: WriteState) -> PyResult<()> {
|
fn finalize_write(state: WriteState) -> PyResult<()> {
|
||||||
|
crate::no_panic(|| {
|
||||||
let mut builder = clawhdf5_rs::FileBuilder::new();
|
let mut builder = clawhdf5_rs::FileBuilder::new();
|
||||||
|
|
||||||
// Root attributes
|
// Root attributes
|
||||||
@@ -285,6 +302,7 @@ fn finalize_write(state: WriteState) -> PyResult<()> {
|
|||||||
|
|
||||||
builder.write(&state.path).map_err(to_py_err)?;
|
builder.write(&state.path).map_err(to_py_err)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+217
-101
@@ -1,13 +1,14 @@
|
|||||||
//! PyGroup — navigable HDF5 group with read and write support.
|
//! PyGroup — navigable HDF5 group with read and write support.
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
|
use pyo3::exceptions::{PyIOError, PyKeyError, PyValueError};
|
||||||
use pyo3::prelude::*;
|
use pyo3::prelude::*;
|
||||||
use pyo3::types::PyList;
|
use pyo3::types::PyList;
|
||||||
|
|
||||||
use crate::attrs::PyAttrs;
|
use crate::attrs::PyAttrs;
|
||||||
use crate::dataset::PyDataset;
|
use crate::{DatasetSpec, OwnedAttrValue, apply_dataset_spec, extract_numpy_data, node};
|
||||||
use crate::{DatasetSpec, OwnedAttrValue, apply_dataset_spec, extract_numpy_data, to_py_err};
|
|
||||||
|
|
||||||
/// Shared state for a group being written.
|
/// Shared state for a group being written.
|
||||||
pub(crate) struct WriteGroupState {
|
pub(crate) struct WriteGroupState {
|
||||||
@@ -18,32 +19,24 @@ pub(crate) struct WriteGroupState {
|
|||||||
|
|
||||||
/// An HDF5 group.
|
/// An HDF5 group.
|
||||||
///
|
///
|
||||||
/// In read mode, provides `__getitem__` navigation and child listing.
|
/// In read mode it behaves like an h5py group: `grp['name']`,
|
||||||
/// In write mode, supports `create_dataset` and `create_group` and
|
/// `grp['sub/path']` and `grp['/absolute/path']`, `keys()`, `values()`,
|
||||||
/// attribute setting.
|
/// `items()`, iteration, `len()`, `in`, `get()`, `name` and `attrs`.
|
||||||
///
|
/// In write mode, supports `create_dataset` and attribute setting.
|
||||||
/// ```python
|
|
||||||
/// grp = f['group_name']
|
|
||||||
/// grp.keys()
|
|
||||||
/// ds = grp['dataset']
|
|
||||||
/// ```
|
|
||||||
#[pyclass(name = "Group")]
|
#[pyclass(name = "Group")]
|
||||||
pub struct PyGroup {
|
pub struct PyGroup {
|
||||||
inner: GroupInner,
|
inner: GroupInner,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum GroupInner {
|
enum GroupInner {
|
||||||
Read {
|
Read(ReadGroup),
|
||||||
file: Arc<clawhdf5_rs::File>,
|
|
||||||
path: String,
|
|
||||||
},
|
|
||||||
Write(Arc<Mutex<WriteGroupState>>),
|
Write(Arc<Mutex<WriteGroupState>>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PyGroup {
|
impl PyGroup {
|
||||||
pub(crate) fn from_read(file: Arc<clawhdf5_rs::File>, path: String) -> Self {
|
pub(crate) fn from_read(file: Arc<clawhdf5_rs::File>, path: String, addr: u64) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: GroupInner::Read { file, path },
|
inner: GroupInner::Read(ReadGroup::new(file, path, addr)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,63 +45,172 @@ impl PyGroup {
|
|||||||
inner: GroupInner::Write(state),
|
inner: GroupInner::Write(state),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_group(&self, what: &str) -> PyResult<&ReadGroup> {
|
||||||
|
match &self.inner {
|
||||||
|
GroupInner::Read(g) => Ok(g),
|
||||||
|
GroupInner::Write(_) => Err(PyIOError::new_err(format!(
|
||||||
|
"cannot {what} a group opened for writing"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group in a file opened for reading (a file is its root group, as in
|
||||||
|
/// h5py). It keeps its own address and, once listed, its links, so looking
|
||||||
|
/// up a child neither resolves the path from the root nor scans the group's
|
||||||
|
/// links again: visiting every member of a large group is linear, not
|
||||||
|
/// quadratic.
|
||||||
|
pub(crate) struct ReadGroup {
|
||||||
|
pub file: Arc<clawhdf5_rs::File>,
|
||||||
|
pub path: String,
|
||||||
|
pub addr: u64,
|
||||||
|
/// Link name -> object address (soft links resolved), filled on first use.
|
||||||
|
links: OnceLock<HashMap<String, u64>>,
|
||||||
|
/// Names of the datasets and subgroups, sorted (h5py's order).
|
||||||
|
members: OnceLock<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReadGroup {
|
||||||
|
pub(crate) fn new(file: Arc<clawhdf5_rs::File>, path: String, addr: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
file,
|
||||||
|
path,
|
||||||
|
addr,
|
||||||
|
links: OnceLock::new(),
|
||||||
|
members: OnceLock::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn links(&self) -> PyResult<&HashMap<String, u64>> {
|
||||||
|
if let Some(links) = self.links.get() {
|
||||||
|
return Ok(links);
|
||||||
|
}
|
||||||
|
let entries = crate::no_panic(|| {
|
||||||
|
clawhdf5_format::group_v2::resolve_group_children(
|
||||||
|
self.file.as_bytes(),
|
||||||
|
self.file.superblock(),
|
||||||
|
self.addr,
|
||||||
|
)
|
||||||
|
.map_err(|e| PyValueError::new_err(format!("{}: {e}", node::name(&self.path))))
|
||||||
|
})?;
|
||||||
|
let map = entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| (e.name, e.object_header_address))
|
||||||
|
.collect();
|
||||||
|
Ok(self.links.get_or_init(|| map))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path and address of `key` (a name, a relative or an absolute path).
|
||||||
|
fn locate(&self, key: &str) -> PyResult<(String, u64)> {
|
||||||
|
let path = node::join(&self.path, key);
|
||||||
|
let rel = if self.path.is_empty() {
|
||||||
|
Some(path.as_str())
|
||||||
|
} else if path == self.path {
|
||||||
|
Some("")
|
||||||
|
} else {
|
||||||
|
path.strip_prefix(self.path.as_str())
|
||||||
|
.and_then(|r| r.strip_prefix('/'))
|
||||||
|
};
|
||||||
|
let addr = match rel {
|
||||||
|
// A direct child: the link table, when it has the name.
|
||||||
|
Some(name) if !name.is_empty() && !name.contains('/') => {
|
||||||
|
match self.links()?.get(name) {
|
||||||
|
Some(&a) => a,
|
||||||
|
None => node::resolve_from(&self.file, self.addr, name, &path)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(rel) => node::resolve_from(&self.file, self.addr, rel, &path)?,
|
||||||
|
None => node::address(&self.file, &path)?,
|
||||||
|
};
|
||||||
|
Ok((path, addr))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `group[key]`.
|
||||||
|
pub(crate) fn get_item(&self, py: Python<'_>, key: &str) -> PyResult<Py<PyAny>> {
|
||||||
|
let (path, addr) = self.locate(key)?;
|
||||||
|
node::open(py, &self.file, path, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `group.get(key, default)`.
|
||||||
|
pub(crate) fn get(
|
||||||
|
&self,
|
||||||
|
py: Python<'_>,
|
||||||
|
key: &str,
|
||||||
|
default: Option<Py<PyAny>>,
|
||||||
|
) -> PyResult<Py<PyAny>> {
|
||||||
|
match self.get_item(py, key) {
|
||||||
|
Err(e) if e.is_instance_of::<PyKeyError>(py) => {
|
||||||
|
Ok(default.unwrap_or_else(|| py.None()))
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Names of the group's datasets and subgroups, sorted (h5py's order).
|
||||||
|
pub(crate) fn member_names(&self) -> PyResult<&[String]> {
|
||||||
|
if let Some(m) = self.members.get() {
|
||||||
|
return Ok(m);
|
||||||
|
}
|
||||||
|
let mut names = Vec::new();
|
||||||
|
for (name, &addr) in self.links()? {
|
||||||
|
let hdr = node::header_at(&self.file, addr, &node::join(&self.path, name))?;
|
||||||
|
if matches!(
|
||||||
|
node::kind(&hdr),
|
||||||
|
Some(node::Kind::Dataset | node::Kind::Group)
|
||||||
|
) {
|
||||||
|
names.push(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
|
||||||
|
Ok(self.members.get_or_init(|| names))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn contains(&self, key: &str) -> bool {
|
||||||
|
self.locate(key)
|
||||||
|
.and_then(|(path, addr)| node::header_at(&self.file, addr, &path))
|
||||||
|
.ok()
|
||||||
|
.and_then(|h| node::kind(&h))
|
||||||
|
.is_some_and(|k| k != node::Kind::Datatype)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn values(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
|
||||||
|
self.member_names()?
|
||||||
|
.iter()
|
||||||
|
.map(|n| self.get_item(py, n))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn items(&self, py: Python<'_>) -> PyResult<Vec<(String, Py<PyAny>)>> {
|
||||||
|
self.member_names()?
|
||||||
|
.iter()
|
||||||
|
.map(|n| Ok((n.clone(), self.get_item(py, n)?)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn attrs(&self) -> PyResult<PyAttrs> {
|
||||||
|
PyAttrs::read(Arc::clone(&self.file), self.addr, &self.path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[pymethods]
|
#[pymethods]
|
||||||
impl PyGroup {
|
impl PyGroup {
|
||||||
/// Get a child object (dataset or subgroup) by name or path.
|
/// Get a child object (dataset or subgroup) by name or path.
|
||||||
fn __getitem__(&self, py: Python<'_>, key: &str) -> PyResult<Py<PyAny>> {
|
fn __getitem__(&self, py: Python<'_>, key: &str) -> PyResult<Py<PyAny>> {
|
||||||
match &self.inner {
|
self.read_group("read children from")?.get_item(py, key)
|
||||||
GroupInner::Read { file, path } => {
|
|
||||||
let full_path = if path.is_empty() {
|
|
||||||
key.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{path}/{key}")
|
|
||||||
};
|
|
||||||
// Try dataset first
|
|
||||||
match file.dataset(&full_path) {
|
|
||||||
Ok(_) => {
|
|
||||||
let ds = PyDataset::new(Arc::clone(file), full_path)?;
|
|
||||||
Ok(ds.into_pyobject(py)?.into_any().unbind())
|
|
||||||
}
|
|
||||||
Err(clawhdf5_rs::Error::NotADataset(_)) => {
|
|
||||||
let grp = PyGroup::from_read(Arc::clone(file), full_path);
|
|
||||||
Ok(grp.into_pyobject(py)?.into_any().unbind())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
// Could be a group without a DataLayout message
|
|
||||||
match file.group(&full_path) {
|
|
||||||
Ok(_) => {
|
|
||||||
let grp = PyGroup::from_read(Arc::clone(file), full_path);
|
|
||||||
Ok(grp.into_pyobject(py)?.into_any().unbind())
|
|
||||||
}
|
|
||||||
Err(_) => Err(PyErr::new::<pyo3::exceptions::PyKeyError, _>(format!(
|
|
||||||
"{key}: {e}"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
GroupInner::Write(_) => Err(PyErr::new::<pyo3::exceptions::PyIOError, _>(
|
|
||||||
"cannot read children from a group opened for writing",
|
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `group.get(key, default=None)`.
|
||||||
|
#[pyo3(signature = (key, default=None))]
|
||||||
|
fn get(&self, py: Python<'_>, key: &str, default: Option<Py<PyAny>>) -> PyResult<Py<PyAny>> {
|
||||||
|
self.read_group("read children from")?.get(py, key, default)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List the names of all children (datasets and subgroups).
|
/// List the names of all children (datasets and subgroups).
|
||||||
fn keys(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
fn keys(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||||
match &self.inner {
|
match &self.inner {
|
||||||
GroupInner::Read { file, path } => {
|
GroupInner::Read(g) => {
|
||||||
let group = if path.is_empty() {
|
let list = PyList::new(py, g.member_names()?)?;
|
||||||
file.root()
|
|
||||||
} else {
|
|
||||||
file.group(path).map_err(to_py_err)?
|
|
||||||
};
|
|
||||||
let mut names = group.datasets().map_err(to_py_err)?;
|
|
||||||
let groups = group.groups().map_err(to_py_err)?;
|
|
||||||
names.extend(groups);
|
|
||||||
names.sort();
|
|
||||||
let list = PyList::new(py, &names)?;
|
|
||||||
Ok(list.into_any().unbind())
|
Ok(list.into_any().unbind())
|
||||||
}
|
}
|
||||||
GroupInner::Write(state) => {
|
GroupInner::Write(state) => {
|
||||||
@@ -120,6 +222,36 @@ impl PyGroup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn values(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||||
|
let g = self.read_group("read children from")?;
|
||||||
|
Ok(PyList::new(py, g.values(py)?)?.into_any().unbind())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn items(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||||
|
let g = self.read_group("read children from")?;
|
||||||
|
Ok(PyList::new(py, g.items(py)?)?.into_any().unbind())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn __iter__(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||||
|
self.keys(py)?.call_method0(py, "__iter__")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn __len__(&self) -> PyResult<usize> {
|
||||||
|
match &self.inner {
|
||||||
|
GroupInner::Read(g) => Ok(g.member_names()?.len()),
|
||||||
|
GroupInner::Write(state) => Ok(state.lock().unwrap().datasets.len()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The group's full name, e.g. `/sensors`.
|
||||||
|
#[getter]
|
||||||
|
fn name(&self) -> String {
|
||||||
|
match &self.inner {
|
||||||
|
GroupInner::Read(g) => node::name(&g.path),
|
||||||
|
GroupInner::Write(state) => node::name(&state.lock().unwrap().name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a dataset inside this group (write mode only).
|
/// Create a dataset inside this group (write mode only).
|
||||||
///
|
///
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
@@ -161,7 +293,7 @@ impl PyGroup {
|
|||||||
state.lock().unwrap().datasets.push(spec);
|
state.lock().unwrap().datasets.push(spec);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
GroupInner::Read { .. } => Err(PyErr::new::<pyo3::exceptions::PyIOError, _>(
|
GroupInner::Read { .. } => Err(PyIOError::new_err(
|
||||||
"cannot create datasets on a read-only group",
|
"cannot create datasets on a read-only group",
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
@@ -171,15 +303,7 @@ impl PyGroup {
|
|||||||
#[getter]
|
#[getter]
|
||||||
fn attrs(&self) -> PyResult<PyAttrs> {
|
fn attrs(&self) -> PyResult<PyAttrs> {
|
||||||
match &self.inner {
|
match &self.inner {
|
||||||
GroupInner::Read { file, path } => {
|
GroupInner::Read(g) => g.attrs(),
|
||||||
let group = if path.is_empty() {
|
|
||||||
file.root()
|
|
||||||
} else {
|
|
||||||
file.group(path).map_err(to_py_err)?
|
|
||||||
};
|
|
||||||
let map = group.attrs().map_err(to_py_err)?;
|
|
||||||
Ok(PyAttrs::from_read(map))
|
|
||||||
}
|
|
||||||
GroupInner::Write(state) => {
|
GroupInner::Write(state) => {
|
||||||
let store = Arc::clone(&state.lock().unwrap().attrs);
|
let store = Arc::clone(&state.lock().unwrap().attrs);
|
||||||
Ok(PyAttrs::from_write(store))
|
Ok(PyAttrs::from_write(store))
|
||||||
@@ -189,12 +313,9 @@ impl PyGroup {
|
|||||||
|
|
||||||
fn __repr__(&self) -> String {
|
fn __repr__(&self) -> String {
|
||||||
match &self.inner {
|
match &self.inner {
|
||||||
GroupInner::Read { path, .. } => {
|
GroupInner::Read(g) => {
|
||||||
if path.is_empty() {
|
let n = g.member_names().map_or(0, |m| m.len());
|
||||||
"<HDF5 Group \"/\" (root)>".to_string()
|
format!("<HDF5 group \"{}\" ({n} members)>", node::name(&g.path))
|
||||||
} else {
|
|
||||||
format!("<HDF5 Group \"/{path}\">")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
GroupInner::Write(state) => {
|
GroupInner::Write(state) => {
|
||||||
let name = &state.lock().unwrap().name;
|
let name = &state.lock().unwrap().name;
|
||||||
@@ -205,14 +326,7 @@ impl PyGroup {
|
|||||||
|
|
||||||
fn __contains__(&self, key: &str) -> PyResult<bool> {
|
fn __contains__(&self, key: &str) -> PyResult<bool> {
|
||||||
match &self.inner {
|
match &self.inner {
|
||||||
GroupInner::Read { file, path } => {
|
GroupInner::Read(g) => Ok(g.contains(key)),
|
||||||
let full_path = if path.is_empty() {
|
|
||||||
key.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{path}/{key}")
|
|
||||||
};
|
|
||||||
Ok(file.dataset(&full_path).is_ok() || file.group(&full_path).is_ok())
|
|
||||||
}
|
|
||||||
GroupInner::Write(state) => {
|
GroupInner::Write(state) => {
|
||||||
let guard = state.lock().unwrap();
|
let guard = state.lock().unwrap();
|
||||||
Ok(guard.datasets.iter().any(|d| d.name == key))
|
Ok(guard.datasets.iter().any(|d| d.name == key))
|
||||||
@@ -244,26 +358,28 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_group_construction() {
|
fn member_names_are_sorted() {
|
||||||
let mut b = clawhdf5_rs::FileBuilder::new();
|
let mut b = clawhdf5_rs::FileBuilder::new();
|
||||||
let mut g = b.create_group("grp");
|
b.create_dataset("zeta").with_f64_data(&[1.0]);
|
||||||
|
b.create_dataset("alpha").with_f64_data(&[1.0]);
|
||||||
|
let mut g = b.create_group("mid");
|
||||||
g.create_dataset("x").with_f64_data(&[1.0]);
|
g.create_dataset("x").with_f64_data(&[1.0]);
|
||||||
let finished = g.finish();
|
let finished = g.finish();
|
||||||
b.add_group(finished);
|
b.add_group(finished);
|
||||||
let bytes = b.finish().unwrap();
|
let bytes = b.finish().unwrap();
|
||||||
let file = Arc::new(clawhdf5_rs::File::from_bytes(bytes).unwrap());
|
let file = Arc::new(clawhdf5_rs::File::from_bytes(bytes).unwrap());
|
||||||
let _grp = PyGroup::from_read(file, "grp".into());
|
let root = file.superblock().root_group_address;
|
||||||
}
|
let top = ReadGroup::new(Arc::clone(&file), String::new(), root);
|
||||||
|
assert_eq!(top.member_names().unwrap(), ["alpha", "mid", "zeta"]);
|
||||||
#[test]
|
let (path, addr) = top.locate("mid").unwrap();
|
||||||
fn write_group_state() {
|
assert_eq!(path, "mid");
|
||||||
let state = WriteGroupState {
|
let mid = ReadGroup::new(Arc::clone(&file), path, addr);
|
||||||
name: "test".into(),
|
assert_eq!(mid.member_names().unwrap(), ["x"]);
|
||||||
datasets: vec![],
|
assert!(top.contains("mid/x"));
|
||||||
attrs: Arc::new(Mutex::new(vec![])),
|
assert!(mid.contains("/alpha"));
|
||||||
};
|
assert!(mid.contains("x") && mid.contains("./x"));
|
||||||
let arc = Arc::new(Mutex::new(state));
|
assert!(!top.contains("nope"));
|
||||||
let _grp = PyGroup::from_write(arc);
|
assert!(!mid.contains("alpha"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -10,9 +10,12 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
mod attrs;
|
mod attrs;
|
||||||
|
mod convert;
|
||||||
mod dataset;
|
mod dataset;
|
||||||
mod file;
|
mod file;
|
||||||
mod group;
|
mod group;
|
||||||
|
mod node;
|
||||||
|
mod select;
|
||||||
|
|
||||||
use pyo3::prelude::*;
|
use pyo3::prelude::*;
|
||||||
|
|
||||||
@@ -21,6 +24,42 @@ pub(crate) use dataset::PyDataset;
|
|||||||
pub(crate) use file::PyFile;
|
pub(crate) use file::PyFile;
|
||||||
pub(crate) use group::PyGroup;
|
pub(crate) use group::PyGroup;
|
||||||
|
|
||||||
|
pyo3::create_exception!(
|
||||||
|
clawhdf5,
|
||||||
|
InternalError,
|
||||||
|
pyo3::exceptions::PyRuntimeError,
|
||||||
|
"A bug in clawhdf5 met while reading or writing a file (a Rust panic, \
|
||||||
|
caught). Derived from RuntimeError, so `except Exception` handles it."
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The text of a caught panic.
|
||||||
|
pub(crate) fn panic_text(payload: &(dyn std::any::Any + Send)) -> String {
|
||||||
|
payload
|
||||||
|
.downcast_ref::<&str>()
|
||||||
|
.map(|s| (*s).to_string())
|
||||||
|
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||||
|
.unwrap_or_else(|| "unknown panic".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `f`, turning a panic in the library into [`InternalError`] instead of
|
||||||
|
/// PyO3's `PanicException` (a `BaseException`, which `except Exception`
|
||||||
|
/// does not catch). Wraps every call into the library.
|
||||||
|
pub(crate) fn no_panic<T>(f: impl FnOnce() -> PyResult<T>) -> PyResult<T> {
|
||||||
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|p| {
|
||||||
|
Err(InternalError::new_err(format!(
|
||||||
|
"clawhdf5 internal error (please report it): {}",
|
||||||
|
panic_text(&*p)
|
||||||
|
)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A test hook: panics inside [`no_panic`], so the tests can check that a
|
||||||
|
/// library panic reaches Python as an ordinary exception.
|
||||||
|
#[pyfunction]
|
||||||
|
fn _panic_for_test() -> PyResult<()> {
|
||||||
|
no_panic(|| panic!("deliberate panic for the test suite"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a `clawhdf5_rs::Error` into a `PyErr`.
|
/// Convert a `clawhdf5_rs::Error` into a `PyErr`.
|
||||||
///
|
///
|
||||||
/// Maps different error variants to more specific Python exception types:
|
/// Maps different error variants to more specific Python exception types:
|
||||||
@@ -46,6 +85,54 @@ pub(crate) fn to_py_err(e: clawhdf5_rs::Error) -> PyErr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The value of a dataset or attribute with a null dataspace: a type but no
|
||||||
|
/// data. Mirrors `h5py.Empty`.
|
||||||
|
#[pyclass(name = "Empty", frozen)]
|
||||||
|
pub struct PyEmpty {
|
||||||
|
dtype: Py<PyAny>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PyEmpty {
|
||||||
|
pub(crate) fn new(dtype: Py<PyAny>) -> Self {
|
||||||
|
Self { dtype }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pymethods]
|
||||||
|
impl PyEmpty {
|
||||||
|
#[new]
|
||||||
|
fn py_new(py: Python<'_>, dtype: &Bound<'_, PyAny>) -> PyResult<Self> {
|
||||||
|
let dtype = py.import("numpy")?.getattr("dtype")?.call1((dtype,))?;
|
||||||
|
Ok(Self::new(dtype.unbind()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[getter]
|
||||||
|
fn dtype(&self, py: Python<'_>) -> Py<PyAny> {
|
||||||
|
self.dtype.clone_ref(py)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[getter]
|
||||||
|
fn shape(&self, py: Python<'_>) -> Py<PyAny> {
|
||||||
|
py.None()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[getter]
|
||||||
|
fn size(&self, py: Python<'_>) -> Py<PyAny> {
|
||||||
|
py.None()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn __eq__(&self, py: Python<'_>, other: &Bound<'_, PyAny>) -> PyResult<bool> {
|
||||||
|
match other.cast::<PyEmpty>() {
|
||||||
|
Ok(o) => self.dtype.bind(py).eq(o.get().dtype.bind(py)),
|
||||||
|
Err(_) => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn __repr__(&self, py: Python<'_>) -> PyResult<String> {
|
||||||
|
Ok(format!("Empty(dtype={})", self.dtype.bind(py).repr()?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The data payload for a dataset being written.
|
/// The data payload for a dataset being written.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) enum DatasetData {
|
pub(crate) enum DatasetData {
|
||||||
@@ -219,10 +306,14 @@ pub(crate) fn extract_numpy_data(
|
|||||||
/// The clawhdf5 Python module.
|
/// The clawhdf5 Python module.
|
||||||
#[pymodule]
|
#[pymodule]
|
||||||
fn clawhdf5(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
fn clawhdf5(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||||
|
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
|
||||||
m.add_class::<PyFile>()?;
|
m.add_class::<PyFile>()?;
|
||||||
m.add_class::<PyDataset>()?;
|
m.add_class::<PyDataset>()?;
|
||||||
m.add_class::<PyGroup>()?;
|
m.add_class::<PyGroup>()?;
|
||||||
m.add_class::<PyAttrs>()?;
|
m.add_class::<PyAttrs>()?;
|
||||||
|
m.add_class::<PyEmpty>()?;
|
||||||
|
m.add("InternalError", m.py().get_type::<InternalError>())?;
|
||||||
|
m.add_function(wrap_pyfunction!(_panic_for_test, m)?)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,9 +323,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn owned_attr_value_roundtrip() {
|
fn owned_attr_value_roundtrip() {
|
||||||
let val = OwnedAttrValue::F64(3.14);
|
let val = OwnedAttrValue::F64(2.5);
|
||||||
let attr: clawhdf5_rs::AttrValue = val.into();
|
let attr: clawhdf5_rs::AttrValue = val.into();
|
||||||
assert!(matches!(attr, clawhdf5_rs::AttrValue::F64(v) if (v - 3.14).abs() < 1e-10));
|
assert!(matches!(attr, clawhdf5_rs::AttrValue::F64(v) if (v - 2.5).abs() < 1e-10));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//! Resolving paths to objects in a file opened for reading.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use clawhdf5_format::attribute::AttributeMessage;
|
||||||
|
use clawhdf5_format::dataspace::{Dataspace, DataspaceType};
|
||||||
|
use clawhdf5_format::message_type::MessageType;
|
||||||
|
use clawhdf5_format::object_header::ObjectHeader;
|
||||||
|
use pyo3::exceptions::{PyKeyError, PyTypeError, PyValueError};
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
|
||||||
|
use crate::dataset::PyDataset;
|
||||||
|
use crate::group::PyGroup;
|
||||||
|
|
||||||
|
/// Join `key` onto the group path `base` the way h5py does: an absolute key
|
||||||
|
/// starts from the root, a relative one from `base`. Paths are kept without
|
||||||
|
/// a leading `/`; the root is `""`.
|
||||||
|
pub(crate) fn join(base: &str, key: &str) -> String {
|
||||||
|
let parts = if key.starts_with('/') {
|
||||||
|
key.split('/').collect::<Vec<_>>()
|
||||||
|
} else {
|
||||||
|
base.split('/').chain(key.split('/')).collect()
|
||||||
|
};
|
||||||
|
parts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| !p.is_empty() && *p != ".")
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The HDF5 name (`/a/b`) of a path.
|
||||||
|
pub(crate) fn name(path: &str) -> String {
|
||||||
|
format!("/{path}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The address of the object at `path`, resolved from the root group.
|
||||||
|
pub(crate) fn address(file: &clawhdf5_rs::File, path: &str) -> PyResult<u64> {
|
||||||
|
resolve_from(file, file.superblock().root_group_address, path, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The address of `rel` resolved from the group at `group` (`full` is the
|
||||||
|
/// resulting path, for the error message).
|
||||||
|
pub(crate) fn resolve_from(
|
||||||
|
file: &clawhdf5_rs::File,
|
||||||
|
group: u64,
|
||||||
|
rel: &str,
|
||||||
|
full: &str,
|
||||||
|
) -> PyResult<u64> {
|
||||||
|
if rel.is_empty() {
|
||||||
|
return Ok(group);
|
||||||
|
}
|
||||||
|
crate::no_panic(|| {
|
||||||
|
clawhdf5_format::group_v2::resolve_path_from(file.as_bytes(), file.superblock(), group, rel)
|
||||||
|
.map_err(|e| {
|
||||||
|
PyKeyError::new_err(format!(
|
||||||
|
"Unable to open object (object '{}' doesn't exist): {e}",
|
||||||
|
name(full)
|
||||||
|
))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The object header at `addr` (the object at `path`).
|
||||||
|
pub(crate) fn header_at(file: &clawhdf5_rs::File, addr: u64, path: &str) -> PyResult<ObjectHeader> {
|
||||||
|
crate::no_panic(|| {
|
||||||
|
let sb = file.superblock();
|
||||||
|
let at = usize::try_from(addr)
|
||||||
|
.map_err(|_| PyValueError::new_err(format!("{}: address out of range", name(path))))?;
|
||||||
|
ObjectHeader::parse(file.as_bytes(), at, sb.offset_size, sb.length_size)
|
||||||
|
.map_err(|e| PyValueError::new_err(format!("{}: {e}", name(path))))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an object header describes.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum Kind {
|
||||||
|
Dataset,
|
||||||
|
Group,
|
||||||
|
Datatype,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn kind(hdr: &ObjectHeader) -> Option<Kind> {
|
||||||
|
let has = |t: MessageType| hdr.messages.iter().any(|m| m.msg_type == t);
|
||||||
|
if has(MessageType::DataLayout) {
|
||||||
|
Some(Kind::Dataset)
|
||||||
|
} else if has(MessageType::LinkInfo)
|
||||||
|
|| has(MessageType::Link)
|
||||||
|
|| has(MessageType::SymbolTable)
|
||||||
|
|| has(MessageType::GroupInfo)
|
||||||
|
{
|
||||||
|
Some(Kind::Group)
|
||||||
|
} else if has(MessageType::Datatype) {
|
||||||
|
Some(Kind::Datatype)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the object at `addr` (whose path is `path`) as a `Dataset` or
|
||||||
|
/// `Group`. Both keep the address, so later reads resolve nothing.
|
||||||
|
pub(crate) fn open(
|
||||||
|
py: Python<'_>,
|
||||||
|
file: &Arc<clawhdf5_rs::File>,
|
||||||
|
path: String,
|
||||||
|
addr: u64,
|
||||||
|
) -> PyResult<Py<PyAny>> {
|
||||||
|
let hdr = header_at(file, addr, &path)?;
|
||||||
|
match kind(&hdr) {
|
||||||
|
Some(Kind::Dataset) => Ok(PyDataset::open(py, Arc::clone(file), path, addr, &hdr)?
|
||||||
|
.into_pyobject(py)?
|
||||||
|
.into_any()
|
||||||
|
.unbind()),
|
||||||
|
Some(Kind::Group) => Ok(PyGroup::from_read(Arc::clone(file), path, addr)
|
||||||
|
.into_pyobject(py)?
|
||||||
|
.into_any()
|
||||||
|
.unbind()),
|
||||||
|
Some(Kind::Datatype) => Err(PyTypeError::new_err(format!(
|
||||||
|
"{}: committed (named) datatypes are not supported by clawhdf5",
|
||||||
|
name(&path)
|
||||||
|
))),
|
||||||
|
None => Err(PyValueError::new_err(format!(
|
||||||
|
"{}: not a dataset, group or datatype",
|
||||||
|
name(&path)
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dataspace message of an object header.
|
||||||
|
pub(crate) fn dataspace(file: &clawhdf5_rs::File, hdr: &ObjectHeader) -> PyResult<Dataspace> {
|
||||||
|
crate::no_panic(|| {
|
||||||
|
let sb = file.superblock();
|
||||||
|
let msg = hdr
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.msg_type == MessageType::Dataspace)
|
||||||
|
.ok_or_else(|| PyValueError::new_err("object has no dataspace message"))?;
|
||||||
|
let data = clawhdf5_format::shared_message::message_data(
|
||||||
|
file.as_bytes(),
|
||||||
|
msg,
|
||||||
|
sb.offset_size,
|
||||||
|
sb.length_size,
|
||||||
|
)
|
||||||
|
.map_err(|e| PyValueError::new_err(e.to_string()))?;
|
||||||
|
Dataspace::parse(&data, sb.length_size).map_err(|e| PyValueError::new_err(e.to_string()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The chunk shape of a chunked dataset (one entry per dataset dimension),
|
||||||
|
/// or `None` for other layouts or a layout message that does not parse.
|
||||||
|
pub(crate) fn chunk_shape(
|
||||||
|
file: &clawhdf5_rs::File,
|
||||||
|
hdr: &ObjectHeader,
|
||||||
|
rank: usize,
|
||||||
|
) -> Option<Vec<u64>> {
|
||||||
|
let sb = file.superblock();
|
||||||
|
let msg = hdr
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.msg_type == MessageType::DataLayout)?;
|
||||||
|
match clawhdf5_format::data_layout::DataLayout::parse(&msg.data, sb.offset_size, sb.length_size)
|
||||||
|
.ok()?
|
||||||
|
{
|
||||||
|
clawhdf5_format::data_layout::DataLayout::Chunked {
|
||||||
|
chunk_dimensions, ..
|
||||||
|
} if chunk_dimensions.len() >= rank => Some(
|
||||||
|
chunk_dimensions[..rank]
|
||||||
|
.iter()
|
||||||
|
.map(|&d| u64::from(d))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_null(space: &Dataspace) -> bool {
|
||||||
|
space.space_type == DataspaceType::Null
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The attributes of the object at `addr` (whose path is `path`), sorted by
|
||||||
|
/// name (h5py's order). Attributes whose messages cannot be parsed are left
|
||||||
|
/// out, as the facade's `attrs()` does.
|
||||||
|
pub(crate) fn attributes(
|
||||||
|
file: &clawhdf5_rs::File,
|
||||||
|
addr: u64,
|
||||||
|
path: &str,
|
||||||
|
) -> PyResult<Vec<AttributeMessage>> {
|
||||||
|
let hdr = header_at(file, addr, path)?;
|
||||||
|
crate::no_panic(|| {
|
||||||
|
let sb = file.superblock();
|
||||||
|
let (mut attrs, _errors) = clawhdf5_format::attribute::extract_attributes_tolerant(
|
||||||
|
file.as_bytes(),
|
||||||
|
&hdr,
|
||||||
|
sb.offset_size,
|
||||||
|
sb.length_size,
|
||||||
|
)
|
||||||
|
.map_err(|e| PyValueError::new_err(format!("{}: {e}", name(path))))?;
|
||||||
|
attrs.sort_by(|a, b| a.name.as_bytes().cmp(b.name.as_bytes()));
|
||||||
|
Ok(attrs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_paths() {
|
||||||
|
assert_eq!(join("", "a"), "a");
|
||||||
|
assert_eq!(join("a", "b/c"), "a/b/c");
|
||||||
|
assert_eq!(join("a/b", "/x"), "x");
|
||||||
|
assert_eq!(join("a", "/"), "");
|
||||||
|
assert_eq!(join("", "/a//b/"), "a/b");
|
||||||
|
assert_eq!(join("a", "./b"), "a/b");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
//! h5py-style indexing (`ds[1, 2:10:3, ...]`) mapped onto hyperslab
|
||||||
|
//! selections, so the library reads the selection rather than the whole
|
||||||
|
//! dataset (it still decodes everything for large selections; see the
|
||||||
|
//! facade's `Dataset::read_selection`).
|
||||||
|
//!
|
||||||
|
//! The rules and error messages follow h5py's `selections.py`: integers
|
||||||
|
//! (negative from the end) drop their axis, slices must have a positive
|
||||||
|
//! step, one `Ellipsis` fills the unmentioned axes, a single increasing list
|
||||||
|
//! of integers may index one axis, and strings name compound fields.
|
||||||
|
//! Everything else (`None`/`np.newaxis`, boolean masks, several index lists)
|
||||||
|
//! is refused with the error h5py gives.
|
||||||
|
|
||||||
|
use clawhdf5_format::selection::Selection;
|
||||||
|
use pyo3::exceptions::{PyIndexError, PyTypeError, PyValueError};
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
use pyo3::types::{PyEllipsis, PySlice, PyString, PyTuple};
|
||||||
|
|
||||||
|
/// The selection along one axis.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub(crate) enum Axis {
|
||||||
|
/// A single index: the axis is dropped from the result.
|
||||||
|
Index(u64),
|
||||||
|
/// `start, start + step, ...`, `count` of them.
|
||||||
|
Slice { start: u64, step: u64, count: u64 },
|
||||||
|
/// Increasing, distinct indices.
|
||||||
|
List(Vec<u64>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Axis {
|
||||||
|
fn len(&self) -> u64 {
|
||||||
|
match self {
|
||||||
|
Axis::Index(_) => 1,
|
||||||
|
Axis::Slice { count, .. } => *count,
|
||||||
|
Axis::List(v) => v.len() as u64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parsed index expression.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub(crate) struct Plan {
|
||||||
|
/// One entry per dataset axis.
|
||||||
|
pub axes: Vec<Axis>,
|
||||||
|
/// Compound field names to keep (empty: all).
|
||||||
|
pub fields: Vec<String>,
|
||||||
|
/// For a scalar dataset: `ds[()]` gives a scalar, `ds[...]` a 0-d array.
|
||||||
|
/// For other datasets: every axis was an integer, so h5py gives a scalar.
|
||||||
|
pub scalar: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plan {
|
||||||
|
/// The shape of the result.
|
||||||
|
pub fn out_shape(&self) -> Vec<usize> {
|
||||||
|
self.axes
|
||||||
|
.iter()
|
||||||
|
.filter(|a| !matches!(a, Axis::Index(_)))
|
||||||
|
.map(|a| a.len() as usize)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shape of the result before the integer-indexed axes are dropped
|
||||||
|
/// (they have length 1 here): the shape of the joined reads.
|
||||||
|
pub fn read_shape(&self) -> Vec<usize> {
|
||||||
|
self.axes.iter().map(|a| a.len() as usize).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The axis indexed by a list, if any.
|
||||||
|
pub fn list_axis(&self) -> Option<usize> {
|
||||||
|
self.axes.iter().position(|a| matches!(a, Axis::List(_)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the selection is empty.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.axes.iter().any(|a| a.len() == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hyperslab reads that make up this selection, each with the shape
|
||||||
|
/// of its block (index axes kept at length 1). More than one only when an
|
||||||
|
/// axis is indexed by a list; those are joined along `list_axis`
|
||||||
|
/// afterwards (`join_along`), after keeping each read's `pick` rows.
|
||||||
|
///
|
||||||
|
/// A list is read in groups, each one hyperslab over a stretch of the
|
||||||
|
/// axis, not once per index: every read decodes the chunks it touches
|
||||||
|
/// (and lists the dataset's chunks), so a read per run of indices decoded
|
||||||
|
/// the same chunk again and again. For a chunked dataset (`chunk_len` is
|
||||||
|
/// the chunk's length along the list axis) a group ends only where a
|
||||||
|
/// whole chunk holds no selected index, so no chunk is decoded twice or
|
||||||
|
/// without need. Otherwise a group ends at a gap of more than
|
||||||
|
/// [`MAX_GAP_BYTES`] of unselected data.
|
||||||
|
pub fn reads(
|
||||||
|
&self,
|
||||||
|
dims: &[u64],
|
||||||
|
chunk_len: Option<u64>,
|
||||||
|
elem_size: usize,
|
||||||
|
) -> (Vec<Read>, Option<usize>) {
|
||||||
|
let list_axis = self.list_axis();
|
||||||
|
let groups: Vec<&[u64]> = match list_axis.map(|i| &self.axes[i]) {
|
||||||
|
Some(Axis::List(idx)) => {
|
||||||
|
let row_bytes = self.row_bytes(elem_size);
|
||||||
|
group_indices(idx, |last, next| match chunk_len {
|
||||||
|
Some(c) if c > 0 => next / c <= last / c + 1,
|
||||||
|
_ => (next - last - 1).saturating_mul(row_bytes) <= MAX_GAP_BYTES,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => vec![&[]],
|
||||||
|
};
|
||||||
|
let mut out = Vec::with_capacity(groups.len());
|
||||||
|
for group in groups {
|
||||||
|
let (first, span) = match (group.first(), group.last()) {
|
||||||
|
(Some(&f), Some(&l)) => (f, l - f + 1),
|
||||||
|
_ => (0, 0),
|
||||||
|
};
|
||||||
|
let pick = (span != group.len() as u64)
|
||||||
|
.then(|| group.iter().map(|&i| (i - first) as usize).collect());
|
||||||
|
let mut start = Vec::with_capacity(dims.len());
|
||||||
|
let mut stride = Vec::with_capacity(dims.len());
|
||||||
|
let mut count = Vec::with_capacity(dims.len());
|
||||||
|
for axis in &self.axes {
|
||||||
|
let (s, st, c) = match axis {
|
||||||
|
Axis::Index(i) => (*i, 1, 1),
|
||||||
|
Axis::Slice { start, step, count } => (*start, *step, *count),
|
||||||
|
Axis::List(_) => (first, 1, span),
|
||||||
|
};
|
||||||
|
start.push(s);
|
||||||
|
// A stride only matters between blocks; keep it >= 1.
|
||||||
|
stride.push(if c <= 1 { 1 } else { st });
|
||||||
|
count.push(c);
|
||||||
|
}
|
||||||
|
let shape: Vec<usize> = count.iter().map(|&c| c as usize).collect();
|
||||||
|
let whole = start.iter().all(|&s| s == 0)
|
||||||
|
&& stride.iter().all(|&s| s == 1)
|
||||||
|
&& count.as_slice() == dims;
|
||||||
|
let sel = if whole {
|
||||||
|
Selection::All
|
||||||
|
} else {
|
||||||
|
let block = vec![1; dims.len()];
|
||||||
|
Selection::Hyperslab {
|
||||||
|
start,
|
||||||
|
stride,
|
||||||
|
count,
|
||||||
|
block,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
out.push(Read { sel, shape, pick });
|
||||||
|
}
|
||||||
|
(out, list_axis)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes of one step along the list axis within a read's bounding box.
|
||||||
|
fn row_bytes(&self, elem_size: usize) -> u64 {
|
||||||
|
self.axes
|
||||||
|
.iter()
|
||||||
|
.map(|a| match a {
|
||||||
|
Axis::Slice { step, count, .. } if *count > 0 => (count - 1) * step + 1,
|
||||||
|
_ => 1,
|
||||||
|
})
|
||||||
|
.fold(elem_size as u64, u64::saturating_mul)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unselected data a read of a non-chunked dataset copies through rather
|
||||||
|
/// than start another read.
|
||||||
|
pub(crate) const MAX_GAP_BYTES: u64 = 64 * 1024;
|
||||||
|
|
||||||
|
/// One hyperslab read of a selection.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub(crate) struct Read {
|
||||||
|
pub sel: Selection,
|
||||||
|
/// The block's shape (index axes at length 1).
|
||||||
|
pub shape: Vec<usize>,
|
||||||
|
/// For a list: the positions along the list axis, within the block, to
|
||||||
|
/// keep (`None`: all of them).
|
||||||
|
pub pick: Option<Vec<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split increasing indices into groups; `joins(last, next)` says whether
|
||||||
|
/// `next` extends the group whose last index is `last`.
|
||||||
|
fn group_indices(idx: &[u64], joins: impl Fn(u64, u64) -> bool) -> Vec<&[u64]> {
|
||||||
|
let mut groups = Vec::new();
|
||||||
|
let mut from = 0;
|
||||||
|
for k in 1..idx.len() {
|
||||||
|
if !joins(idx[k - 1], idx[k]) {
|
||||||
|
groups.push(&idx[from..k]);
|
||||||
|
from = k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if from < idx.len() {
|
||||||
|
groups.push(&idx[from..]);
|
||||||
|
}
|
||||||
|
groups
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep the elements at positions `pick` along `axis` of a row-major block.
|
||||||
|
pub(crate) fn gather_along(
|
||||||
|
bytes: &[u8],
|
||||||
|
shape: &[usize],
|
||||||
|
axis: usize,
|
||||||
|
pick: &[usize],
|
||||||
|
elem_size: usize,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let outer: usize = shape[..axis].iter().product();
|
||||||
|
let inner: usize = shape[axis + 1..].iter().product::<usize>() * elem_size;
|
||||||
|
let len = shape[axis];
|
||||||
|
let mut out = Vec::with_capacity(outer * pick.len() * inner);
|
||||||
|
for o in 0..outer {
|
||||||
|
for &p in pick {
|
||||||
|
let at = (o * len + p) * inner;
|
||||||
|
out.extend_from_slice(&bytes[at..at + inner]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Join row-major blocks of `elem_size`-byte elements whose shapes differ
|
||||||
|
/// only along `axis` into one buffer, in order along that axis. Whole
|
||||||
|
/// elements are copied, so compound padding keeps the bytes that were read.
|
||||||
|
pub(crate) fn join_along(
|
||||||
|
blocks: &[(Vec<u8>, Vec<usize>)],
|
||||||
|
axis: usize,
|
||||||
|
elem_size: usize,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let Some((_, first)) = blocks.first() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let outer: usize = first[..axis].iter().product();
|
||||||
|
let inner: usize = first[axis + 1..].iter().product::<usize>() * elem_size;
|
||||||
|
let total: usize = blocks.iter().map(|(_, s)| s[axis]).sum();
|
||||||
|
let mut out = vec![0u8; outer * total * inner];
|
||||||
|
let mut at = 0;
|
||||||
|
for (bytes, shape) in blocks {
|
||||||
|
let len = shape[axis] * inner;
|
||||||
|
for o in 0..outer {
|
||||||
|
let dst = (o * total) * inner + at;
|
||||||
|
out[dst..dst + len].copy_from_slice(&bytes[o * len..(o + 1) * len]);
|
||||||
|
}
|
||||||
|
at += len;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `key` for a dataset of shape `dims`.
|
||||||
|
pub(crate) fn parse(key: &Bound<'_, PyAny>, dims: &[u64]) -> PyResult<Plan> {
|
||||||
|
let items: Vec<Bound<'_, PyAny>> = match key.cast::<PyTuple>() {
|
||||||
|
Ok(t) => t.iter().collect(),
|
||||||
|
Err(_) => vec![key.clone()],
|
||||||
|
};
|
||||||
|
let mut fields = Vec::new();
|
||||||
|
let mut args = Vec::new();
|
||||||
|
for item in items {
|
||||||
|
if let Ok(s) = item.cast::<PyString>() {
|
||||||
|
fields.push(s.to_str()?.to_owned());
|
||||||
|
} else {
|
||||||
|
args.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.iter().any(|a| a.is_none()) {
|
||||||
|
return Err(PyTypeError::new_err(
|
||||||
|
"Indexing with None (or np.newaxis) is not supported",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let rank = dims.len();
|
||||||
|
if rank == 0 {
|
||||||
|
return match args.as_slice() {
|
||||||
|
[] => Ok(Plan {
|
||||||
|
axes: vec![],
|
||||||
|
fields,
|
||||||
|
scalar: true,
|
||||||
|
}),
|
||||||
|
[a] if a.is_instance_of::<PyEllipsis>() => Ok(Plan {
|
||||||
|
axes: vec![],
|
||||||
|
fields,
|
||||||
|
scalar: false,
|
||||||
|
}),
|
||||||
|
_ => Err(PyValueError::new_err(
|
||||||
|
"Illegal slicing argument for scalar dataspace",
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand the ellipsis (at most one) to full slices.
|
||||||
|
let n_ellipsis = args
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.is_instance_of::<PyEllipsis>())
|
||||||
|
.count();
|
||||||
|
if n_ellipsis > 1 {
|
||||||
|
return Err(PyValueError::new_err("Only one ellipsis may be used."));
|
||||||
|
}
|
||||||
|
let explicit = args.len() - n_ellipsis;
|
||||||
|
if explicit > rank {
|
||||||
|
return Err(PyValueError::new_err(format!(
|
||||||
|
"{explicit} indexing arguments for {rank} dimensions"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let py = key.py();
|
||||||
|
let mut expanded: Vec<Option<Bound<'_, PyAny>>> = Vec::with_capacity(rank);
|
||||||
|
for a in args {
|
||||||
|
if a.is_instance_of::<PyEllipsis>() {
|
||||||
|
for _ in 0..(rank - explicit) {
|
||||||
|
expanded.push(None);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expanded.push(Some(a));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while expanded.len() < rank {
|
||||||
|
expanded.push(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut axes = Vec::with_capacity(rank);
|
||||||
|
for (arg, &n) in expanded.iter().zip(dims) {
|
||||||
|
axes.push(match arg {
|
||||||
|
None => Axis::Slice {
|
||||||
|
start: 0,
|
||||||
|
step: 1,
|
||||||
|
count: n,
|
||||||
|
},
|
||||||
|
Some(a) => parse_axis(py, a, n)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if axes.iter().filter(|a| matches!(a, Axis::List(_))).count() > 1 {
|
||||||
|
return Err(PyTypeError::new_err(
|
||||||
|
"Only one indexing vector or array is currently allowed for fancy indexing",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let scalar = axes.iter().all(|a| matches!(a, Axis::Index(_)));
|
||||||
|
Ok(Plan {
|
||||||
|
axes,
|
||||||
|
fields,
|
||||||
|
scalar,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_axis(py: Python<'_>, a: &Bound<'_, PyAny>, n: u64) -> PyResult<Axis> {
|
||||||
|
if a.is_none() {
|
||||||
|
return Err(PyTypeError::new_err(
|
||||||
|
"Indexing with None (or np.newaxis) is not supported",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Ok(s) = a.cast::<PySlice>() {
|
||||||
|
let n_isize = isize::try_from(n)
|
||||||
|
.map_err(|_| PyValueError::new_err("dimension too large to slice"))?;
|
||||||
|
let ind = s.indices(n_isize)?;
|
||||||
|
if ind.step < 1 {
|
||||||
|
return Err(PyValueError::new_err(format!(
|
||||||
|
"Step must be >= 1 (got {})",
|
||||||
|
ind.step
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// `slicelength` is the number of elements selected, >= 0.
|
||||||
|
let count = ind.slicelength as u64;
|
||||||
|
let start = if count == 0 { 0 } else { ind.start as u64 };
|
||||||
|
return Ok(Axis::Slice {
|
||||||
|
start,
|
||||||
|
step: ind.step as u64,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let np = py.import("numpy")?;
|
||||||
|
let is_bool =
|
||||||
|
a.is_instance_of::<pyo3::types::PyBool>() || a.is_instance(&np.getattr("bool_")?)?;
|
||||||
|
let is_array_like = a.is_instance(&np.getattr("ndarray")?)?
|
||||||
|
|| a.is_instance_of::<pyo3::types::PyList>()
|
||||||
|
|| a.is_instance_of::<PyTuple>();
|
||||||
|
// A 0-d integer array (`ds[np.array(1)]`) is an integer index, as in h5py.
|
||||||
|
if a.is_instance(&np.getattr("ndarray")?)? && a.getattr("ndim")?.extract::<usize>()? == 0 {
|
||||||
|
let kind: String = a.getattr("dtype")?.getattr("kind")?.extract()?;
|
||||||
|
if kind == "i" || kind == "u" {
|
||||||
|
let i: i128 = a.call_method0("item")?.extract()?;
|
||||||
|
return Ok(Axis::Index(normalize(i, n)?));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !is_bool && !is_array_like && a.hasattr("__index__")? {
|
||||||
|
let i: i128 = a.call_method0("__index__")?.extract()?;
|
||||||
|
return Ok(Axis::Index(normalize(i, n)?));
|
||||||
|
}
|
||||||
|
if is_array_like {
|
||||||
|
let arr = np.call_method1("asarray", (a,))?;
|
||||||
|
let kind: String = arr.getattr("dtype")?.getattr("kind")?.extract()?;
|
||||||
|
if kind == "b" {
|
||||||
|
return Err(PyTypeError::new_err(
|
||||||
|
"Boolean mask indexing is not supported by clawhdf5",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let ndim: usize = arr.getattr("ndim")?.extract()?;
|
||||||
|
let size: usize = arr.getattr("size")?.extract()?;
|
||||||
|
if size > 0 && kind != "i" && kind != "u" {
|
||||||
|
return Err(PyTypeError::new_err(
|
||||||
|
"Indexing arrays must have integer dtypes",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if ndim > 1 {
|
||||||
|
return Err(PyTypeError::new_err(
|
||||||
|
"Only 1-D integer lists or arrays can be used for fancy indexing",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let vals: Vec<i128> = arr.call_method0("tolist")?.extract()?;
|
||||||
|
let mut idx = Vec::with_capacity(vals.len());
|
||||||
|
for v in vals {
|
||||||
|
idx.push(normalize(v, n)?);
|
||||||
|
}
|
||||||
|
if idx.windows(2).any(|w| w[0] >= w[1]) {
|
||||||
|
return Err(PyTypeError::new_err(
|
||||||
|
"Indexing elements must be in increasing order",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok(Axis::List(idx));
|
||||||
|
}
|
||||||
|
Err(PyTypeError::new_err(format!(
|
||||||
|
"Illegal index type for clawhdf5 datasets: {}",
|
||||||
|
a.get_type().name()?
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize(i: i128, n: u64) -> PyResult<u64> {
|
||||||
|
let n_i = i128::from(n);
|
||||||
|
let j = if i < 0 { i + n_i } else { i };
|
||||||
|
if j < 0 || j >= n_i {
|
||||||
|
let hi = n_i - 1;
|
||||||
|
return Err(PyIndexError::new_err(format!(
|
||||||
|
"Index ({i}) out of range for (0-{hi})"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(j as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn indices_group_by_chunk() {
|
||||||
|
let chunked = |c: u64| move |last: u64, next: u64| next / c <= last / c + 1;
|
||||||
|
// Chunks of 10: 3, 5 and 15 are in neighbouring chunks; 42 skips two.
|
||||||
|
let idx = [3, 5, 15, 42, 43, 99];
|
||||||
|
assert_eq!(
|
||||||
|
group_indices(&idx, chunked(10)),
|
||||||
|
vec![&[3, 5, 15][..], &[42, 43], &[99]]
|
||||||
|
);
|
||||||
|
assert_eq!(group_indices(&[], chunked(10)), Vec::<&[u64]>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_list_reads_once_per_group() {
|
||||||
|
let plan = Plan {
|
||||||
|
axes: vec![
|
||||||
|
Axis::List(vec![0, 2, 3, 40]),
|
||||||
|
Axis::Slice {
|
||||||
|
start: 0,
|
||||||
|
step: 1,
|
||||||
|
count: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fields: vec![],
|
||||||
|
scalar: false,
|
||||||
|
};
|
||||||
|
// Chunks of 8 rows: rows 0-3 are one read, row 40 another.
|
||||||
|
let (reads, axis) = plan.reads(&[50, 5], Some(8), 4);
|
||||||
|
assert_eq!(axis, Some(0));
|
||||||
|
assert_eq!(reads.len(), 2);
|
||||||
|
assert_eq!(reads[0].shape, vec![4, 5]);
|
||||||
|
assert_eq!(reads[0].pick, Some(vec![0, 2, 3]));
|
||||||
|
assert_eq!(reads[1].shape, vec![1, 5]);
|
||||||
|
assert_eq!(reads[1].pick, None);
|
||||||
|
// Not chunked: a gap under MAX_GAP_BYTES is read through.
|
||||||
|
let (reads, _) = plan.reads(&[50, 5], None, 4);
|
||||||
|
assert_eq!(reads.len(), 1);
|
||||||
|
assert_eq!(reads[0].pick, Some(vec![0, 2, 3, 40]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gather_keeps_picked_rows() {
|
||||||
|
// A 2x3 block of 1-byte elements; keep columns 0 and 2.
|
||||||
|
let block = [1, 2, 3, 4, 5, 6];
|
||||||
|
assert_eq!(
|
||||||
|
gather_along(&block, &[2, 3], 1, &[0, 2], 1),
|
||||||
|
vec![1, 3, 4, 6]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocks_join_along_the_list_axis() {
|
||||||
|
// Two 2x1 and 2x2 blocks of 1-byte elements, joined along axis 1.
|
||||||
|
let a = (vec![1, 2], vec![2, 1]);
|
||||||
|
let b = (vec![3, 4, 5, 6], vec![2, 2]);
|
||||||
|
assert_eq!(join_along(&[a, b], 1, 1), vec![1, 3, 4, 2, 5, 6]);
|
||||||
|
// Along axis 0 it is concatenation; 2-byte elements stay whole.
|
||||||
|
let a = (vec![1, 2, 3, 4], vec![1, 2]);
|
||||||
|
let b = (vec![5, 6, 7, 8], vec![1, 2]);
|
||||||
|
assert_eq!(join_along(&[a, b], 0, 2), vec![1, 2, 3, 4, 5, 6, 7, 8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_selection_reads_everything() {
|
||||||
|
let plan = Plan {
|
||||||
|
axes: vec![
|
||||||
|
Axis::Slice {
|
||||||
|
start: 0,
|
||||||
|
step: 1,
|
||||||
|
count: 4,
|
||||||
|
},
|
||||||
|
Axis::Slice {
|
||||||
|
start: 0,
|
||||||
|
step: 1,
|
||||||
|
count: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fields: vec![],
|
||||||
|
scalar: false,
|
||||||
|
};
|
||||||
|
let (reads, list) = plan.reads(&[4, 3], None, 8);
|
||||||
|
assert_eq!(list, None);
|
||||||
|
assert_eq!(
|
||||||
|
reads,
|
||||||
|
vec![Read {
|
||||||
|
sel: Selection::All,
|
||||||
|
shape: vec![4, 3],
|
||||||
|
pick: None
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn index_and_step_map_to_a_hyperslab() {
|
||||||
|
let plan = Plan {
|
||||||
|
axes: vec![
|
||||||
|
Axis::Index(2),
|
||||||
|
Axis::Slice {
|
||||||
|
start: 1,
|
||||||
|
step: 3,
|
||||||
|
count: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fields: vec![],
|
||||||
|
scalar: false,
|
||||||
|
};
|
||||||
|
let (reads, _) = plan.reads(&[4, 8], None, 8);
|
||||||
|
assert_eq!(
|
||||||
|
reads,
|
||||||
|
vec![Read {
|
||||||
|
sel: Selection::Hyperslab {
|
||||||
|
start: vec![2, 1],
|
||||||
|
stride: vec![1, 3],
|
||||||
|
count: vec![1, 2],
|
||||||
|
block: vec![1, 1],
|
||||||
|
},
|
||||||
|
shape: vec![1, 2],
|
||||||
|
pick: None
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert_eq!(plan.out_shape(), vec![2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Shared fixtures for the clawhdf5 Python binding tests."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def h5py():
|
||||||
|
"""h5py, or a skip — unless CLAWHDF5_REQUIRE_INTEROP=1, which makes a
|
||||||
|
missing h5py a failure (as for the Rust interop suites)."""
|
||||||
|
try:
|
||||||
|
import h5py as mod
|
||||||
|
except ImportError:
|
||||||
|
if os.environ.get("CLAWHDF5_REQUIRE_INTEROP") == "1":
|
||||||
|
pytest.fail("h5py is required (CLAWHDF5_REQUIRE_INTEROP=1) but not importable")
|
||||||
|
pytest.skip("h5py not installed")
|
||||||
|
return mod
|
||||||
@@ -0,0 +1,653 @@
|
|||||||
|
"""Every read through clawhdf5 compared against h5py (libhdf5) on files h5py
|
||||||
|
writes: dtypes, shapes, values and the type of what comes back (array,
|
||||||
|
numpy scalar, bytes, str, Empty), for every datatype the bindings map and a
|
||||||
|
spread of index expressions; plus the errors h5py gives for the same keys."""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import clawhdf5
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The generated file
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
NUMERIC = [
|
||||||
|
"<i1", "<i2", "<i4", "<i8", "<u1", "<u2", "<u4", "<u8",
|
||||||
|
">i2", ">i4", ">i8", ">u2", ">u4", ">u8",
|
||||||
|
"<f2", "<f4", "<f8", ">f2", ">f4", ">f8",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _values(dtype, shape, seed):
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
dt = np.dtype(dtype)
|
||||||
|
n = int(np.prod(shape))
|
||||||
|
if dt.kind == "f":
|
||||||
|
return rng.standard_normal(n).astype(dt).reshape(shape)
|
||||||
|
info = np.iinfo(dt)
|
||||||
|
return rng.integers(info.min, info.max, size=n, dtype=dt.newbyteorder("=")).astype(dt).reshape(shape)
|
||||||
|
|
||||||
|
|
||||||
|
def _compound_dtype():
|
||||||
|
return np.dtype(
|
||||||
|
{
|
||||||
|
"names": ["id", "pos", "label", "flag", "vec"],
|
||||||
|
"formats": ["<i4", ">f8", "S6", "u1", ("<f4", (3,))],
|
||||||
|
"offsets": [0, 8, 16, 22, 24],
|
||||||
|
"itemsize": 40,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _nested_dtype():
|
||||||
|
inner = np.dtype([("a", "<i2"), ("b", "<f4")])
|
||||||
|
return np.dtype([("x", "<u8"), ("inner", inner), ("c", "<c8")])
|
||||||
|
|
||||||
|
|
||||||
|
def _write_fixture(h5py, path):
|
||||||
|
str_dt = h5py.string_dtype()
|
||||||
|
ascii_dt = h5py.string_dtype("ascii")
|
||||||
|
with h5py.File(path, "w") as f:
|
||||||
|
# Numeric types, both byte orders, 1-D contiguous and 2-D chunked+gzip.
|
||||||
|
for i, dt in enumerate(NUMERIC):
|
||||||
|
name = dt.replace("<", "le_").replace(">", "be_")
|
||||||
|
f.create_dataset(f"num/{name}_1d", data=_values(dt, (37,), i))
|
||||||
|
f.create_dataset(
|
||||||
|
f"num/{name}_2d_gzip",
|
||||||
|
data=_values(dt, (13, 11), 100 + i),
|
||||||
|
chunks=(4, 5),
|
||||||
|
compression="gzip",
|
||||||
|
)
|
||||||
|
f.create_dataset("num/f8_3d", data=_values("<f8", (6, 7, 5), 7), chunks=(2, 3, 5))
|
||||||
|
f.create_dataset(
|
||||||
|
"num/i4_3d_shuffle",
|
||||||
|
data=_values("<i4", (5, 9, 4), 8),
|
||||||
|
chunks=(3, 4, 2),
|
||||||
|
shuffle=True,
|
||||||
|
fletcher32=True,
|
||||||
|
compression="gzip",
|
||||||
|
)
|
||||||
|
f.create_dataset("num/scalar_f8", data=np.float64(3.25))
|
||||||
|
f.create_dataset("num/scalar_i2_be", data=np.array(-7, dtype=">i2"))
|
||||||
|
f.create_dataset("num/zero_size", shape=(0, 3), dtype="<f4")
|
||||||
|
f.create_dataset("num/empty", data=h5py.Empty("<f8"))
|
||||||
|
sparse = f.create_dataset("num/sparse_fill", shape=(40,), chunks=(8,), dtype="<i4", fillvalue=-3)
|
||||||
|
sparse[10:14] = [1, 2, 3, 4]
|
||||||
|
f.create_dataset("num/resizable", data=np.arange(12.0), maxshape=(None,), chunks=(5,))
|
||||||
|
# Compact layout (low level: h5py's create_dataset cannot ask for it).
|
||||||
|
dcpl = h5py.h5p.create(h5py.h5p.DATASET_CREATE)
|
||||||
|
dcpl.set_layout(h5py.h5d.COMPACT)
|
||||||
|
space = h5py.h5s.create_simple((9,))
|
||||||
|
dsid = h5py.h5d.create(f.id, b"num/compact_u2", h5py.h5t.py_create(np.dtype("<u2")), space, dcpl=dcpl)
|
||||||
|
dsid.write(h5py.h5s.ALL, h5py.h5s.ALL, np.arange(9, dtype="<u2") * 7)
|
||||||
|
|
||||||
|
# bool, enum, complex.
|
||||||
|
f.create_dataset("misc/bool", data=np.array([True, False, True, True, False]))
|
||||||
|
enum_dt = h5py.enum_dtype({"RED": 0, "GREEN": 1, "BLUE": 42}, basetype="i2")
|
||||||
|
f.create_dataset("misc/enum", data=np.array([0, 42, 1, 1, 0], dtype="<i2"), dtype=enum_dt)
|
||||||
|
uenum_dt = h5py.enum_dtype({"LO": 0, "HI": 2**40}, basetype="u8")
|
||||||
|
f.create_dataset("misc/enum_u8", data=np.array([0, 2**40], dtype="<u8"), dtype=uenum_dt)
|
||||||
|
f.create_dataset("misc/c8", data=(np.arange(6) + 1j * np.arange(6)).astype("<c8"))
|
||||||
|
f.create_dataset("misc/c16_2d", data=(np.arange(12) - 2j).reshape(3, 4).astype("<c16"))
|
||||||
|
f.create_dataset("misc/opaque", data=np.array([b"\x00\x01\x02", b"\xff\xfe\xfd"], dtype="V3"))
|
||||||
|
|
||||||
|
# Strings.
|
||||||
|
f.create_dataset("str/fixed", data=np.array([b"alpha", b"be", b"", b"gamma!"], dtype="S6"))
|
||||||
|
utf8_4 = h5py.string_dtype("utf-8", 4)
|
||||||
|
f.create_dataset(
|
||||||
|
"str/fixed_2d_utf8",
|
||||||
|
data=np.array([["é".encode(), b"b"], [b"c", b"dd"]], dtype=utf8_4),
|
||||||
|
)
|
||||||
|
f.create_dataset("str/vlen", data=["", "one", "twø", "a" * 300, "ünïcödé"], dtype=str_dt)
|
||||||
|
f.create_dataset("str/vlen_ascii", data=[b"x", b"yy", b"zzz"], dtype=ascii_dt)
|
||||||
|
f.create_dataset(
|
||||||
|
"str/vlen_2d_gzip",
|
||||||
|
data=np.array([[f"r{r}c{c}" * (r + c) for c in range(5)] for r in range(6)], dtype=object),
|
||||||
|
dtype=str_dt,
|
||||||
|
chunks=(2, 2),
|
||||||
|
compression="gzip",
|
||||||
|
)
|
||||||
|
f.create_dataset("str/vlen_scalar", data="just one", dtype=str_dt)
|
||||||
|
|
||||||
|
# Variable-length sequences.
|
||||||
|
vl_i = h5py.vlen_dtype(np.dtype("<i4"))
|
||||||
|
seqs = np.empty(4, dtype=object)
|
||||||
|
seqs[:] = [np.arange(3, dtype="<i4"), np.array([], dtype="<i4"), np.arange(10, dtype="<i4") * -1, np.array([7], dtype="<i4")]
|
||||||
|
f.create_dataset("vlen/i4", data=seqs, dtype=vl_i)
|
||||||
|
vl_f = h5py.vlen_dtype(np.dtype(">f8"))
|
||||||
|
fseqs = np.empty(3, dtype=object)
|
||||||
|
fseqs[:] = [np.linspace(0, 1, 5).astype(">f8"), np.array([2.5], dtype=">f8"), np.array([], dtype=">f8")]
|
||||||
|
f.create_dataset("vlen/f8_be", data=fseqs, dtype=vl_f)
|
||||||
|
|
||||||
|
# Compounds.
|
||||||
|
cdt = _compound_dtype()
|
||||||
|
rec = np.zeros(10, dtype=cdt)
|
||||||
|
rec["id"] = np.arange(10) * 3
|
||||||
|
rec["pos"] = np.linspace(-1, 1, 10)
|
||||||
|
rec["label"] = [f"n{i}".encode() for i in range(10)]
|
||||||
|
rec["flag"] = np.arange(10) % 2
|
||||||
|
rec["vec"] = np.arange(30, dtype="<f4").reshape(10, 3)
|
||||||
|
f.create_dataset("cmp/padded", data=rec)
|
||||||
|
f.create_dataset("cmp/padded_chunked", data=rec, chunks=(3,), compression="gzip")
|
||||||
|
ndt = _nested_dtype()
|
||||||
|
nrec = np.zeros((4, 3), dtype=ndt)
|
||||||
|
nrec["x"] = np.arange(12).reshape(4, 3)
|
||||||
|
nrec["inner"]["a"] = -np.arange(12).reshape(4, 3)
|
||||||
|
nrec["inner"]["b"] = np.arange(12).reshape(4, 3) / 4
|
||||||
|
nrec["c"] = np.arange(12).reshape(4, 3) * (1 + 1j)
|
||||||
|
f.create_dataset("cmp/nested_2d", data=nrec)
|
||||||
|
vdt = np.dtype([("n", "<i4"), ("s", h5py.string_dtype())])
|
||||||
|
vrec = np.array([(1, "a"), (2, "bb")], dtype=vdt)
|
||||||
|
f.create_dataset("cmp/with_vlen", data=vrec)
|
||||||
|
|
||||||
|
# A true HDF5 array datatype (h5py's high level would widen the shape).
|
||||||
|
tid = h5py.h5t.array_create(h5py.h5t.py_create(np.dtype("<i4")), (2, 3))
|
||||||
|
space = h5py.h5s.create_simple((4,))
|
||||||
|
dsid = h5py.h5d.create(f.id, b"cmp/array_type", tid, space)
|
||||||
|
dsid.write(h5py.h5s.ALL, h5py.h5s.ALL, np.arange(24, dtype="<i4").reshape(4, 2, 3), mtype=tid)
|
||||||
|
|
||||||
|
# Unsupported: references.
|
||||||
|
f.create_dataset("unsupported/refs", data=[f["num"].ref, f["misc"].ref], dtype=h5py.ref_dtype)
|
||||||
|
|
||||||
|
# Groups and attributes.
|
||||||
|
g = f.create_group("deep/er/est")
|
||||||
|
g.create_dataset("leaf", data=np.arange(5))
|
||||||
|
f.create_group("empty_group")
|
||||||
|
f.attrs["i4"] = np.int32(-5)
|
||||||
|
f.attrs["u8"] = np.uint64(2**63 + 1)
|
||||||
|
f.attrs["f2"] = np.float16(1.5)
|
||||||
|
f.attrs["f8_arr"] = np.array([1.0, 2.5, -3.0])
|
||||||
|
f.attrs["f8_one"] = np.array([4.0])
|
||||||
|
f.attrs["i2_2d_be"] = np.arange(6, dtype=">i2").reshape(2, 3)
|
||||||
|
f.attrs["bool"] = True
|
||||||
|
f.attrs["bool_arr"] = np.array([True, False])
|
||||||
|
f.attrs["vstr"] = "héllo"
|
||||||
|
f.attrs["vstr_arr"] = ["a", "bcd", ""]
|
||||||
|
f.attrs["fstr"] = np.bytes_(b"fixed")
|
||||||
|
f.attrs["fstr_arr"] = np.array([b"x", b"yz"], dtype="S2")
|
||||||
|
f.attrs["empty"] = h5py.Empty("<i4")
|
||||||
|
f.attrs["complex"] = np.complex128(1 - 2j)
|
||||||
|
f.attrs["compound"] = np.array((7, 2.5), dtype=[("a", "<i2"), ("b", "<f8")])
|
||||||
|
f.attrs["enum"] = np.array(42, dtype=enum_dt)
|
||||||
|
f["num/le_f8_1d"].attrs["units"] = "m/s"
|
||||||
|
f["num/le_f8_1d"].attrs["scale"] = np.float32(0.5)
|
||||||
|
g.attrs["depth"] = np.int8(3)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pair(h5py, tmp_path_factory):
|
||||||
|
path = str(tmp_path_factory.mktemp("h5") / "fixture.h5")
|
||||||
|
_write_fixture(h5py, path)
|
||||||
|
theirs = h5py.File(path, "r")
|
||||||
|
ours = clawhdf5.File(path, "r")
|
||||||
|
yield ours, theirs, path
|
||||||
|
theirs.close()
|
||||||
|
ours.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _all_datasets(h5py, f):
|
||||||
|
names = []
|
||||||
|
f.visititems(lambda n, o: names.append(n) if isinstance(o, h5py.Dataset) else None)
|
||||||
|
return sorted(names)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Comparison helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def assert_same(ours, theirs, what=""):
|
||||||
|
if type(theirs).__name__ == "Empty":
|
||||||
|
assert isinstance(ours, clawhdf5.Empty), what
|
||||||
|
assert ours.dtype == theirs.dtype, what
|
||||||
|
return
|
||||||
|
assert type(ours) is type(theirs), f"{what}: {type(ours)} vs {type(theirs)}"
|
||||||
|
if isinstance(theirs, np.ndarray):
|
||||||
|
assert ours.shape == theirs.shape, what
|
||||||
|
assert ours.dtype == theirs.dtype, f"{what}: {ours.dtype} vs {theirs.dtype}"
|
||||||
|
if theirs.dtype == object:
|
||||||
|
for a, b in zip(ours.ravel(), theirs.ravel()):
|
||||||
|
assert_same(a, b, what)
|
||||||
|
elif theirs.dtype.kind == "V":
|
||||||
|
# Structured and opaque: every byte, padding included (h5py's
|
||||||
|
# padding is zero; uninitialised memory there would leak).
|
||||||
|
if theirs.dtype.names is not None:
|
||||||
|
np.testing.assert_array_equal(ours, theirs, err_msg=what)
|
||||||
|
assert ours.tobytes() == theirs.tobytes(), f"{what}: bytes differ"
|
||||||
|
else:
|
||||||
|
np.testing.assert_array_equal(ours, theirs, err_msg=what)
|
||||||
|
elif isinstance(theirs, np.generic):
|
||||||
|
assert ours.dtype == theirs.dtype, what
|
||||||
|
if theirs.dtype.names is not None:
|
||||||
|
np.testing.assert_array_equal(np.asarray(ours), np.asarray(theirs), err_msg=what)
|
||||||
|
assert ours.tobytes() == theirs.tobytes(), f"{what}: bytes differ"
|
||||||
|
else:
|
||||||
|
assert ours == theirs or (ours != ours and theirs != theirs), what
|
||||||
|
else:
|
||||||
|
assert ours == theirs, what
|
||||||
|
|
||||||
|
|
||||||
|
def keys_for(shape):
|
||||||
|
if shape == ():
|
||||||
|
return [(), Ellipsis]
|
||||||
|
keys = [(), Ellipsis, 0, -1, slice(None), slice(None, None, 2), slice(1, None, 3), slice(0, 0), np.int64(0),
|
||||||
|
np.array(0), np.array(-1, dtype="i1")]
|
||||||
|
n0 = shape[0]
|
||||||
|
if n0 == 0:
|
||||||
|
return [(), Ellipsis, slice(None), slice(None, None, 2), slice(0, 0)]
|
||||||
|
keys += [slice(n0 // 2, None), slice(-3, None), [0, n0 - 1] if n0 > 1 else [0], (Ellipsis,)]
|
||||||
|
if n0 > 5:
|
||||||
|
keys += [[1, 2, 3, 5], [0, 4, 5]]
|
||||||
|
if len(shape) >= 2:
|
||||||
|
n1 = shape[1]
|
||||||
|
keys += [
|
||||||
|
(0, 0),
|
||||||
|
(-1, -1),
|
||||||
|
(slice(1, 3), slice(None, None, 2)),
|
||||||
|
(Ellipsis, 1),
|
||||||
|
(1, Ellipsis),
|
||||||
|
(slice(None), [0, n1 - 1] if n1 > 1 else [0]),
|
||||||
|
(slice(None, None, 2), 1),
|
||||||
|
(slice(0, 2), slice(3, 1)),
|
||||||
|
(np.array(1) if n0 > 1 else np.array(0), slice(None)),
|
||||||
|
(slice(None), np.array(n1 - 1, dtype="u2")),
|
||||||
|
]
|
||||||
|
if len(shape) >= 3:
|
||||||
|
keys += [(0, slice(None), -1), (slice(1, None, 2), 2, slice(None, None, 3)), (Ellipsis, 0, 0), (0, Ellipsis, 1)]
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
# h5py 3.16 (HDF5 2.0) returns the elements of a variable-length sequence of
|
||||||
|
# big-endian floats unswapped (0.25 comes back as 2.6e-319), so these are
|
||||||
|
# checked against the values written instead (test_vlen_big_endian).
|
||||||
|
H5PY_MISREADS = {"vlen/f8_be"}
|
||||||
|
|
||||||
|
ERROR_KEYS_1D = [
|
||||||
|
slice(None, None, -1), 10**6, -(10**6), None, (0, 0, 0, 0, 0), [3, 1], (Ellipsis, Ellipsis), 1.5, "nope",
|
||||||
|
np.array(1.0), np.array(True), np.array(10**6), [0, 0], [], (), Ellipsis, (0,), [-1], np.array([1, 2]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_dataset_matches_h5py(h5py, pair):
|
||||||
|
ours, theirs, _ = pair
|
||||||
|
checked = 0
|
||||||
|
for name in _all_datasets(h5py, theirs):
|
||||||
|
if name.startswith("unsupported/") or name in H5PY_MISREADS or name == "cmp/with_vlen":
|
||||||
|
continue
|
||||||
|
t = theirs[name]
|
||||||
|
o = ours[name]
|
||||||
|
assert o.shape == t.shape, name
|
||||||
|
assert o.dtype == t.dtype, f"{name}: {o.dtype} vs {t.dtype}"
|
||||||
|
assert dict(o.dtype.metadata or {}) == dict(t.dtype.metadata or {}), name
|
||||||
|
assert o.ndim == t.ndim and o.size == t.size, name
|
||||||
|
assert o.maxshape == t.maxshape, name
|
||||||
|
assert o.name == t.name, name
|
||||||
|
if t.shape is None:
|
||||||
|
assert_same(o[()], t[()], name)
|
||||||
|
continue
|
||||||
|
for key in keys_for(t.shape):
|
||||||
|
what = f"{name}[{key!r}]"
|
||||||
|
try:
|
||||||
|
expected = t[key]
|
||||||
|
except Exception as e: # noqa: BLE001 - h5py refuses: so must we
|
||||||
|
with pytest.raises(type(e)):
|
||||||
|
o[key]
|
||||||
|
continue
|
||||||
|
assert_same(o[key], expected, what)
|
||||||
|
checked += 1
|
||||||
|
assert checked > 500
|
||||||
|
|
||||||
|
|
||||||
|
def test_vlen_big_endian(pair):
|
||||||
|
ours, _, _ = pair
|
||||||
|
ds = ours["vlen/f8_be"]
|
||||||
|
assert ds.dtype.metadata["vlen"] == np.dtype(">f8")
|
||||||
|
got = ds[()]
|
||||||
|
expected = [np.linspace(0, 1, 5), np.array([2.5]), np.array([])]
|
||||||
|
assert got.shape == (3,)
|
||||||
|
for g, e in zip(got, expected):
|
||||||
|
assert g.dtype == np.dtype(">f8")
|
||||||
|
np.testing.assert_array_equal(g, e)
|
||||||
|
np.testing.assert_array_equal(ds[1], [2.5])
|
||||||
|
|
||||||
|
|
||||||
|
def test_errors_match_h5py(h5py, pair):
|
||||||
|
ours, theirs, _ = pair
|
||||||
|
for name in ["num/le_i4_1d", "num/be_f8_2d_gzip", "str/vlen", "num/scalar_f8"]:
|
||||||
|
for key in ERROR_KEYS_1D:
|
||||||
|
try:
|
||||||
|
expected = theirs[name][key]
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
with pytest.raises(type(e)):
|
||||||
|
ours[name][key]
|
||||||
|
else:
|
||||||
|
# h5py reads it, so must we (and the same values).
|
||||||
|
assert_same(ours[name][key], expected, f"{name}[{key!r}]")
|
||||||
|
|
||||||
|
|
||||||
|
def test_compound_fields_match_h5py(pair):
|
||||||
|
ours, theirs, _ = pair
|
||||||
|
for name in ["cmp/padded", "cmp/padded_chunked", "cmp/nested_2d"]:
|
||||||
|
t, o = theirs[name], ours[name]
|
||||||
|
for field in t.dtype.names:
|
||||||
|
assert_same(o[field], t[field], f"{name}[{field}]")
|
||||||
|
assert_same(o[field, 1:3], t[field, 1:3], f"{name}[{field}, 1:3]")
|
||||||
|
two = list(t.dtype.names[:2])
|
||||||
|
expected = t[tuple(two)]
|
||||||
|
got = o[tuple(two)]
|
||||||
|
assert got.dtype.names == expected.dtype.names
|
||||||
|
for field in two:
|
||||||
|
np.testing.assert_array_equal(got[field], expected[field])
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
ours["cmp/padded"]["no_such_field"]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
ours["num/le_i4_1d"]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_numpy_asarray_and_len(pair):
|
||||||
|
ours, theirs, _ = pair
|
||||||
|
assert_same(np.asarray(ours["num/f8_3d"]), np.asarray(theirs["num/f8_3d"]))
|
||||||
|
assert len(ours["num/f8_3d"]) == len(theirs["num/f8_3d"])
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
len(ours["num/scalar_f8"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_attributes_match_h5py(h5py, pair):
|
||||||
|
ours, theirs, _ = pair
|
||||||
|
for path in ["/", "num/le_f8_1d", "deep/er/est"]:
|
||||||
|
t = theirs[path].attrs
|
||||||
|
o = ours[path].attrs
|
||||||
|
assert list(o.keys()) == sorted(t.keys()), path
|
||||||
|
assert len(o) == len(t)
|
||||||
|
for k in t.keys():
|
||||||
|
assert k in o
|
||||||
|
assert_same(o[k], t[k], f"{path}.attrs[{k}]")
|
||||||
|
assert o.get("missing", 5) == 5
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
o["missing"]
|
||||||
|
assert [k for k, _ in o.items()] == list(o.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def test_groups_match_h5py(h5py, pair):
|
||||||
|
ours, theirs, _ = pair
|
||||||
|
assert list(ours.keys()) == list(theirs.keys())
|
||||||
|
assert len(ours) == len(theirs)
|
||||||
|
for path in ["num", "deep", "deep/er", "deep/er/est", "empty_group"]:
|
||||||
|
o, t = ours[path], theirs[path]
|
||||||
|
assert isinstance(o, clawhdf5.Group)
|
||||||
|
assert list(o.keys()) == list(t.keys()), path
|
||||||
|
assert list(o) == list(t), path
|
||||||
|
assert len(o) == len(t), path
|
||||||
|
assert o.name == t.name
|
||||||
|
g = ours["deep/er"]
|
||||||
|
assert isinstance(g["est"], clawhdf5.Group)
|
||||||
|
assert isinstance(g["est/leaf"], clawhdf5.Dataset)
|
||||||
|
assert g["/deep/er/est/leaf"].name == "/deep/er/est/leaf"
|
||||||
|
assert "est/leaf" in g and "/num" in g and "nope" not in g
|
||||||
|
assert ours["/"].name == "/"
|
||||||
|
assert "deep/er/est/leaf" in ours
|
||||||
|
assert ours.get("nope") is None
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
ours["deep/nope"]
|
||||||
|
names = [k for k, v in ours["deep/er/est"].items()]
|
||||||
|
assert names == ["leaf"]
|
||||||
|
assert isinstance(ours["deep/er/est"].values()[0], clawhdf5.Dataset)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsupported_types_are_errors_not_data(pair):
|
||||||
|
ours, _, _ = pair
|
||||||
|
ds = ours["unsupported/refs"] # opening works
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
ds.dtype
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
ds[()]
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
ours["cmp/with_vlen"][()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_boolean_masks_are_refused(pair):
|
||||||
|
ours, _, _ = pair
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
ours["num/le_i4_1d"][np.ones(37, dtype=bool)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reads_hand_numpy_the_rust_buffer(pair):
|
||||||
|
"""A fixed-size read is a view over the buffer the library filled, not a
|
||||||
|
copy of it."""
|
||||||
|
ours, _, _ = pair
|
||||||
|
arr = ours["num/le_f8_2d_gzip"][2:9, 1:4]
|
||||||
|
assert not arr.flags.owndata
|
||||||
|
assert arr.base is not None
|
||||||
|
assert arr.flags.aligned and arr.flags.c_contiguous
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_the_selected_chunks_are_read(h5py, tmp_path):
|
||||||
|
"""Damage one chunk: a selection that avoids it still reads, one that
|
||||||
|
touches it fails. Reading everything and slicing afterwards (what the
|
||||||
|
bindings did before) failed both. (The library reads the whole dataset
|
||||||
|
anyway when a selection's bounding box covers more than half of it, so
|
||||||
|
the selections here stay below that.)"""
|
||||||
|
path = str(tmp_path / "damaged.h5")
|
||||||
|
data = np.arange(1000, dtype="<f8")
|
||||||
|
with h5py.File(path, "w") as f:
|
||||||
|
f.create_dataset("d", data=data, chunks=(100,), compression="gzip")
|
||||||
|
with h5py.File(path, "r") as f:
|
||||||
|
info = f["d"].id.get_chunk_info(9) # the last chunk
|
||||||
|
with open(path, "r+b") as fh:
|
||||||
|
fh.seek(info.byte_offset)
|
||||||
|
fh.write(b"\xff" * info.size)
|
||||||
|
with clawhdf5.File(path, "r") as f:
|
||||||
|
ds = f["d"]
|
||||||
|
np.testing.assert_array_equal(ds[0:400], data[0:400])
|
||||||
|
np.testing.assert_array_equal(ds[805:900], data[805:900])
|
||||||
|
np.testing.assert_array_equal(ds[5:450:7], data[5:450:7])
|
||||||
|
np.testing.assert_array_equal(ds[[3, 450, 899]], data[[3, 450, 899]])
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
ds[950]
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
ds[:]
|
||||||
|
|
||||||
|
|
||||||
|
def test_threads_read_the_same_file(pair):
|
||||||
|
"""Reads from many threads at once return exactly what h5py returns."""
|
||||||
|
ours, theirs, _ = pair
|
||||||
|
names = ["num/le_f8_2d_gzip", "num/i4_3d_shuffle", "str/vlen_2d_gzip", "cmp/padded_chunked", "num/be_i8_1d"]
|
||||||
|
expected = {n: theirs[n][()] for n in names}
|
||||||
|
errors = []
|
||||||
|
barrier = threading.Barrier(8)
|
||||||
|
|
||||||
|
def work(i):
|
||||||
|
barrier.wait()
|
||||||
|
for r in range(40):
|
||||||
|
n = names[(i + r) % len(names)]
|
||||||
|
got = ours[n][()]
|
||||||
|
try:
|
||||||
|
assert_same(got, expected[n], n)
|
||||||
|
except AssertionError as e:
|
||||||
|
errors.append(e)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(8) as pool:
|
||||||
|
list(pool.map(work, range(8)))
|
||||||
|
assert not errors, errors[:3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reads_release_the_gil(h5py, tmp_path):
|
||||||
|
"""While one thread is inside a long read, another Python thread keeps
|
||||||
|
running. With the GIL held for the read, the other thread would stall
|
||||||
|
for the whole read; the test measures its longest stall."""
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
path = str(tmp_path / "gil.h5")
|
||||||
|
data = np.arange(2048 * 4096, dtype="<f4").reshape(2048, 4096)
|
||||||
|
with h5py.File(path, "w") as f:
|
||||||
|
f.create_dataset("d", data=data, chunks=(64, 4096), compression="gzip", compression_opts=1)
|
||||||
|
ds = clawhdf5.File(path, "r")["d"]
|
||||||
|
key = (slice(0, 1000), slice(None)) # under half: the uncached selection path
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
ds[key]
|
||||||
|
one_read = time.perf_counter() - t0
|
||||||
|
assert one_read > 0.03, f"a read took only {one_read:.3f} s; too short to measure"
|
||||||
|
|
||||||
|
old = sys.getswitchinterval()
|
||||||
|
sys.setswitchinterval(0.001)
|
||||||
|
stop = threading.Event()
|
||||||
|
gaps = []
|
||||||
|
|
||||||
|
def spin():
|
||||||
|
last = time.perf_counter()
|
||||||
|
worst = 0.0
|
||||||
|
while not stop.is_set():
|
||||||
|
now = time.perf_counter()
|
||||||
|
worst = max(worst, now - last)
|
||||||
|
last = now
|
||||||
|
gaps.append(worst)
|
||||||
|
|
||||||
|
try:
|
||||||
|
t = threading.Thread(target=spin)
|
||||||
|
t.start()
|
||||||
|
time.sleep(0.01)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
for _ in range(2):
|
||||||
|
ds[key]
|
||||||
|
reading = time.perf_counter() - t0
|
||||||
|
stop.set()
|
||||||
|
t.join()
|
||||||
|
finally:
|
||||||
|
sys.setswitchinterval(old)
|
||||||
|
np.testing.assert_array_equal(ds[key], data[:1000])
|
||||||
|
# Held, the spinner would stall for about one read.
|
||||||
|
assert gaps[0] < one_read / 3, f"spinner stalled {gaps[0]:.3f} s during reads of {one_read:.3f} s ({reading:.3f} s)"
|
||||||
|
|
||||||
|
|
||||||
|
def _v4_index_fixture(h5py, path):
|
||||||
|
"""One 2-D dataset per v4 chunk index (HDF5 1.10+ layout, libver='latest')."""
|
||||||
|
data = (np.arange(37 * 23, dtype="<i4") * 7 - 1000).reshape(37, 23)
|
||||||
|
early = h5py.h5p.create(h5py.h5p.DATASET_CREATE)
|
||||||
|
early.set_alloc_time(h5py.h5d.ALLOC_TIME_EARLY)
|
||||||
|
with h5py.File(path, "w", libver="latest") as f:
|
||||||
|
f.create_dataset("implicit", data=data, chunks=(5, 4), dcpl=early)
|
||||||
|
f.create_dataset("fixed_array", data=data, chunks=(5, 4), compression="gzip")
|
||||||
|
f.create_dataset("extensible_array", data=data, chunks=(5, 4), maxshape=(None, 23), compression="gzip")
|
||||||
|
f.create_dataset("btree2", data=data, chunks=(5, 4), maxshape=(None, None), compression="gzip")
|
||||||
|
f.create_dataset("single_chunk", data=data, chunks=(37, 23), compression="gzip")
|
||||||
|
|
||||||
|
|
||||||
|
V4_KEYS = [
|
||||||
|
slice(0, 3), slice(0, 30), (slice(7, 16), slice(3, 9)), (36, 22), (slice(None, None, 3), slice(1, None, 4)),
|
||||||
|
(slice(1, None, 2), Ellipsis), (Ellipsis, slice(2, 22)), [0, 5, 6, 36], (slice(None), [0, 3, 22]), -1, (),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_v4_chunk_index_matches_h5py(h5py, tmp_path):
|
||||||
|
"""Partial reads of each v4 chunk index. The implicit index (early
|
||||||
|
allocation, no filters) used to panic in the library for any selection
|
||||||
|
covering more than half the dataset, e.g. ds[0:30]."""
|
||||||
|
path = str(tmp_path / "v4.h5")
|
||||||
|
_v4_index_fixture(h5py, path)
|
||||||
|
with h5py.File(path, "r") as theirs, clawhdf5.File(path, "r") as ours:
|
||||||
|
for name in theirs:
|
||||||
|
for key in V4_KEYS:
|
||||||
|
assert_same(ours[name][key], theirs[name][key], f"{name}[{key!r}]")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_library_panic_is_an_ordinary_exception():
|
||||||
|
"""PyO3 turns a Rust panic into PanicException, a BaseException that
|
||||||
|
`except Exception` does not catch. Every call into the library is
|
||||||
|
guarded, so a panic surfaces as clawhdf5.InternalError instead."""
|
||||||
|
assert issubclass(clawhdf5.InternalError, RuntimeError)
|
||||||
|
with pytest.raises(clawhdf5.InternalError, match="deliberate panic"):
|
||||||
|
clawhdf5._panic_for_test()
|
||||||
|
try:
|
||||||
|
clawhdf5._panic_for_test()
|
||||||
|
except Exception: # noqa: BLE001 - the point of the test
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_compound_padding_bytes_match_h5py(pair):
|
||||||
|
"""Every byte of a padded compound, padding included, is h5py's, for
|
||||||
|
index lists with many runs as well as slices. Joining the runs with
|
||||||
|
np.concatenate left the padding uninitialised: process memory ended up
|
||||||
|
in tobytes()."""
|
||||||
|
ours, theirs, _ = pair
|
||||||
|
keys = [[0, 3, 6], [1, 2, 5, 9], [0, 2, 4, 6, 8], slice(None), slice(1, 9, 3), 4, [9]]
|
||||||
|
for name in ["cmp/padded", "cmp/padded_chunked"]:
|
||||||
|
for _ in range(20): # garbage varies between runs; zeros do not
|
||||||
|
for key in keys:
|
||||||
|
assert ours[name][key].tobytes() == theirs[name][key].tobytes(), f"{name}[{key!r}]"
|
||||||
|
for key in [(slice(None), [0, 2]), ([0, 2, 3], slice(None)), ([1, 3], 1)]:
|
||||||
|
assert ours["cmp/nested_2d"][key].tobytes() == theirs["cmp/nested_2d"][key].tobytes(), key
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_long_index_list_decodes_each_chunk_once(h5py, tmp_path):
|
||||||
|
"""An index list is read one group of chunks at a time, not one
|
||||||
|
hyperslab per run of indices: 5000 runs over 20 gzip chunks used to
|
||||||
|
decode the chunks 5000 times (8 s, against h5py's 0.014 s)."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
path = str(tmp_path / "long_list.h5")
|
||||||
|
data = np.arange(200000, dtype="<f8")
|
||||||
|
grid = np.arange(400 * 3000, dtype="<i4").reshape(400, 3000)
|
||||||
|
with h5py.File(path, "w") as f:
|
||||||
|
f.create_dataset("d", data=data, chunks=(10000,), compression="gzip")
|
||||||
|
f.create_dataset("grid", data=grid, chunks=(50, 100), compression="gzip")
|
||||||
|
f.create_dataset("flat", data=data) # contiguous
|
||||||
|
rng = np.random.default_rng(3)
|
||||||
|
cases = [
|
||||||
|
("d", list(range(0, 200000, 40))),
|
||||||
|
("d", sorted(rng.choice(200000, 3000, replace=False).tolist())),
|
||||||
|
("flat", list(range(0, 200000, 40))),
|
||||||
|
("flat", [0, 7, 199999]),
|
||||||
|
("grid", (slice(None), list(range(0, 3000, 3)))),
|
||||||
|
("grid", (sorted(rng.choice(400, 150, replace=False).tolist()), slice(5, 2900, 7))),
|
||||||
|
("grid", (7, [0, 1, 2, 2000, 2999])),
|
||||||
|
]
|
||||||
|
with h5py.File(path, "r") as theirs, clawhdf5.File(path, "r") as ours:
|
||||||
|
for name, key in cases:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
got = ours[name][key]
|
||||||
|
took = time.perf_counter() - t0
|
||||||
|
assert_same(got, theirs[name][key], f"{name}[{len(key)}-key]")
|
||||||
|
assert took < 2.0, f"{name}: {took:.2f} s"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("libver", ["earliest", "latest"])
|
||||||
|
def test_big_groups_are_not_quadratic(h5py, tmp_path, libver):
|
||||||
|
"""A dataset or group remembers where its object is, and a group its
|
||||||
|
links, so reads and walks over a large group do not resolve every path
|
||||||
|
from the root again (it was O(n) per access: O(n^2) to visit a group)."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
path = str(tmp_path / f"big_{libver}.h5")
|
||||||
|
n = 4000
|
||||||
|
with h5py.File(path, "w", libver=libver) as f:
|
||||||
|
g = f.create_group("g")
|
||||||
|
for i in range(n):
|
||||||
|
g.create_dataset(f"d{i:05d}", data=np.int32(i))
|
||||||
|
g.create_group("sub").create_dataset("leaf", data=np.arange(3))
|
||||||
|
with h5py.File(path, "r") as theirs, clawhdf5.File(path, "r") as ours:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
g = ours["g"]
|
||||||
|
assert list(g.keys()) == list(theirs["g"].keys())
|
||||||
|
total = sum(int(v[()]) for k, v in g.items() if k.startswith("d"))
|
||||||
|
assert total == n * (n - 1) // 2
|
||||||
|
seen = 0
|
||||||
|
for k in g:
|
||||||
|
if k.startswith("d"):
|
||||||
|
seen += int(g[k][()]) == int(k[1:])
|
||||||
|
assert seen == n
|
||||||
|
ds = g["d00007"]
|
||||||
|
assert all(ds[()] == 7 for _ in range(2000))
|
||||||
|
assert list(g["sub"]["leaf"][:]) == [0, 1, 2]
|
||||||
|
assert ours["/g/sub/leaf"][1] == 1 and g["/g/d00003"][()] == 3
|
||||||
|
took = time.perf_counter() - t0
|
||||||
|
assert took < 5.0, f"{took:.2f} s"
|
||||||
+45
-45
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for rustyhdf5 Python bindings."""
|
"""Tests for clawhdf5 Python bindings."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -6,7 +6,7 @@ import tempfile
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import rustyhdf5
|
import clawhdf5
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -18,7 +18,7 @@ def tmp_h5(tmp_path):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_read_file(tmp_h5):
|
def sample_read_file(tmp_h5):
|
||||||
"""Create a sample HDF5 file for reading tests."""
|
"""Create a sample HDF5 file for reading tests."""
|
||||||
with rustyhdf5.File(tmp_h5, "w") as f:
|
with clawhdf5.File(tmp_h5, "w") as f:
|
||||||
f.create_dataset("temperatures", data=np.array([22.5, 23.1, 21.8]))
|
f.create_dataset("temperatures", data=np.array([22.5, 23.1, 21.8]))
|
||||||
f.create_dataset("counts", data=np.array([10, 20, 30], dtype=np.int32))
|
f.create_dataset("counts", data=np.array([10, 20, 30], dtype=np.int32))
|
||||||
f.attrs["version"] = 1
|
f.attrs["version"] = 1
|
||||||
@@ -29,7 +29,7 @@ def sample_read_file(tmp_h5):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def grouped_read_file(tmp_h5):
|
def grouped_read_file(tmp_h5):
|
||||||
"""Create an HDF5 file with groups for reading tests."""
|
"""Create an HDF5 file with groups for reading tests."""
|
||||||
with rustyhdf5.File(tmp_h5, "w") as f:
|
with clawhdf5.File(tmp_h5, "w") as f:
|
||||||
f.create_dataset("root_data", data=np.array([0.0, 1.0]))
|
f.create_dataset("root_data", data=np.array([0.0, 1.0]))
|
||||||
grp = f.create_group("sensors")
|
grp = f.create_group("sensors")
|
||||||
grp.create_dataset("temperature", data=np.array([22.5, 23.1, 21.8]))
|
grp.create_dataset("temperature", data=np.array([22.5, 23.1, 21.8]))
|
||||||
@@ -46,7 +46,7 @@ def grouped_read_file(tmp_h5):
|
|||||||
|
|
||||||
|
|
||||||
def test_open_and_read_f64(sample_read_file):
|
def test_open_and_read_f64(sample_read_file):
|
||||||
f = rustyhdf5.File(sample_read_file, "r")
|
f = clawhdf5.File(sample_read_file, "r")
|
||||||
ds = f["temperatures"]
|
ds = f["temperatures"]
|
||||||
data = ds[:]
|
data = ds[:]
|
||||||
np.testing.assert_array_almost_equal(data, [22.5, 23.1, 21.8])
|
np.testing.assert_array_almost_equal(data, [22.5, 23.1, 21.8])
|
||||||
@@ -54,7 +54,7 @@ def test_open_and_read_f64(sample_read_file):
|
|||||||
|
|
||||||
|
|
||||||
def test_open_and_read_i32(sample_read_file):
|
def test_open_and_read_i32(sample_read_file):
|
||||||
f = rustyhdf5.File(sample_read_file, "r")
|
f = clawhdf5.File(sample_read_file, "r")
|
||||||
ds = f["counts"]
|
ds = f["counts"]
|
||||||
data = ds[:]
|
data = ds[:]
|
||||||
np.testing.assert_array_equal(data, [10, 20, 30])
|
np.testing.assert_array_equal(data, [10, 20, 30])
|
||||||
@@ -68,13 +68,13 @@ def test_open_and_read_i32(sample_read_file):
|
|||||||
|
|
||||||
|
|
||||||
def test_dataset_shape(sample_read_file):
|
def test_dataset_shape(sample_read_file):
|
||||||
with rustyhdf5.File(sample_read_file, "r") as f:
|
with clawhdf5.File(sample_read_file, "r") as f:
|
||||||
ds = f["temperatures"]
|
ds = f["temperatures"]
|
||||||
assert ds.shape == (3,)
|
assert ds.shape == (3,)
|
||||||
|
|
||||||
|
|
||||||
def test_dataset_dtype(sample_read_file):
|
def test_dataset_dtype(sample_read_file):
|
||||||
with rustyhdf5.File(sample_read_file, "r") as f:
|
with clawhdf5.File(sample_read_file, "r") as f:
|
||||||
assert f["temperatures"].dtype == "float64"
|
assert f["temperatures"].dtype == "float64"
|
||||||
assert f["counts"].dtype == "int32"
|
assert f["counts"].dtype == "int32"
|
||||||
|
|
||||||
@@ -85,24 +85,24 @@ def test_dataset_dtype(sample_read_file):
|
|||||||
|
|
||||||
|
|
||||||
def test_read_root_attrs(sample_read_file):
|
def test_read_root_attrs(sample_read_file):
|
||||||
with rustyhdf5.File(sample_read_file, "r") as f:
|
with clawhdf5.File(sample_read_file, "r") as f:
|
||||||
assert f.attrs["version"] == 1
|
assert f.attrs["version"] == 1
|
||||||
assert f.attrs["description"] == "test file"
|
assert f.attrs["description"] == b"test file" # fixed-length string: numpy.bytes_, as in h5py
|
||||||
|
|
||||||
|
|
||||||
def test_attrs_len(sample_read_file):
|
def test_attrs_len(sample_read_file):
|
||||||
with rustyhdf5.File(sample_read_file, "r") as f:
|
with clawhdf5.File(sample_read_file, "r") as f:
|
||||||
assert len(f.attrs) >= 2
|
assert len(f.attrs) >= 2
|
||||||
|
|
||||||
|
|
||||||
def test_attrs_contains(sample_read_file):
|
def test_attrs_contains(sample_read_file):
|
||||||
with rustyhdf5.File(sample_read_file, "r") as f:
|
with clawhdf5.File(sample_read_file, "r") as f:
|
||||||
assert "version" in f.attrs
|
assert "version" in f.attrs
|
||||||
assert "nonexistent" not in f.attrs
|
assert "nonexistent" not in f.attrs
|
||||||
|
|
||||||
|
|
||||||
def test_attrs_keys(sample_read_file):
|
def test_attrs_keys(sample_read_file):
|
||||||
with rustyhdf5.File(sample_read_file, "r") as f:
|
with clawhdf5.File(sample_read_file, "r") as f:
|
||||||
keys = f.attrs.keys()
|
keys = f.attrs.keys()
|
||||||
assert "version" in keys
|
assert "version" in keys
|
||||||
assert "description" in keys
|
assert "description" in keys
|
||||||
@@ -114,7 +114,7 @@ def test_attrs_keys(sample_read_file):
|
|||||||
|
|
||||||
|
|
||||||
def test_read_group_keys(grouped_read_file):
|
def test_read_group_keys(grouped_read_file):
|
||||||
with rustyhdf5.File(grouped_read_file, "r") as f:
|
with clawhdf5.File(grouped_read_file, "r") as f:
|
||||||
keys = f.keys()
|
keys = f.keys()
|
||||||
assert "sensors" in keys
|
assert "sensors" in keys
|
||||||
assert "metadata" in keys
|
assert "metadata" in keys
|
||||||
@@ -122,7 +122,7 @@ def test_read_group_keys(grouped_read_file):
|
|||||||
|
|
||||||
|
|
||||||
def test_read_group_dataset(grouped_read_file):
|
def test_read_group_dataset(grouped_read_file):
|
||||||
with rustyhdf5.File(grouped_read_file, "r") as f:
|
with clawhdf5.File(grouped_read_file, "r") as f:
|
||||||
grp = f["sensors"]
|
grp = f["sensors"]
|
||||||
ds = grp["temperature"]
|
ds = grp["temperature"]
|
||||||
data = ds[:]
|
data = ds[:]
|
||||||
@@ -130,14 +130,14 @@ def test_read_group_dataset(grouped_read_file):
|
|||||||
|
|
||||||
|
|
||||||
def test_read_group_attrs(grouped_read_file):
|
def test_read_group_attrs(grouped_read_file):
|
||||||
with rustyhdf5.File(grouped_read_file, "r") as f:
|
with clawhdf5.File(grouped_read_file, "r") as f:
|
||||||
grp = f["sensors"]
|
grp = f["sensors"]
|
||||||
assert grp.attrs["location"] == "lab"
|
assert grp.attrs["location"] == b"lab"
|
||||||
|
|
||||||
|
|
||||||
def test_nested_path_access(grouped_read_file):
|
def test_nested_path_access(grouped_read_file):
|
||||||
"""Test f['group/dataset'] path navigation."""
|
"""Test f['group/dataset'] path navigation."""
|
||||||
with rustyhdf5.File(grouped_read_file, "r") as f:
|
with clawhdf5.File(grouped_read_file, "r") as f:
|
||||||
ds = f["sensors/temperature"]
|
ds = f["sensors/temperature"]
|
||||||
data = ds[:]
|
data = ds[:]
|
||||||
np.testing.assert_array_almost_equal(data, [22.5, 23.1, 21.8])
|
np.testing.assert_array_almost_equal(data, [22.5, 23.1, 21.8])
|
||||||
@@ -149,7 +149,7 @@ def test_nested_path_access(grouped_read_file):
|
|||||||
|
|
||||||
|
|
||||||
def test_context_manager(sample_read_file):
|
def test_context_manager(sample_read_file):
|
||||||
with rustyhdf5.File(sample_read_file, "r") as f:
|
with clawhdf5.File(sample_read_file, "r") as f:
|
||||||
data = f["temperatures"][:]
|
data = f["temperatures"][:]
|
||||||
np.testing.assert_array_almost_equal(data, [22.5, 23.1, 21.8])
|
np.testing.assert_array_almost_equal(data, [22.5, 23.1, 21.8])
|
||||||
# File should be closed after with block
|
# File should be closed after with block
|
||||||
@@ -162,30 +162,30 @@ def test_context_manager(sample_read_file):
|
|||||||
|
|
||||||
|
|
||||||
def test_write_simple(tmp_h5):
|
def test_write_simple(tmp_h5):
|
||||||
with rustyhdf5.File(tmp_h5, "w") as f:
|
with clawhdf5.File(tmp_h5, "w") as f:
|
||||||
f.create_dataset("data", data=np.array([1.0, 2.0, 3.0]))
|
f.create_dataset("data", data=np.array([1.0, 2.0, 3.0]))
|
||||||
# Verify by reading back
|
# Verify by reading back
|
||||||
with rustyhdf5.File(tmp_h5, "r") as f:
|
with clawhdf5.File(tmp_h5, "r") as f:
|
||||||
data = f["data"][:]
|
data = f["data"][:]
|
||||||
np.testing.assert_array_almost_equal(data, [1.0, 2.0, 3.0])
|
np.testing.assert_array_almost_equal(data, [1.0, 2.0, 3.0])
|
||||||
|
|
||||||
|
|
||||||
def test_write_with_attrs(tmp_h5):
|
def test_write_with_attrs(tmp_h5):
|
||||||
with rustyhdf5.File(tmp_h5, "w") as f:
|
with clawhdf5.File(tmp_h5, "w") as f:
|
||||||
f.create_dataset("values", data=np.array([10, 20], dtype=np.int32))
|
f.create_dataset("values", data=np.array([10, 20], dtype=np.int32))
|
||||||
f.attrs["author"] = "test"
|
f.attrs["author"] = "test"
|
||||||
f.attrs["count"] = 42
|
f.attrs["count"] = 42
|
||||||
with rustyhdf5.File(tmp_h5, "r") as f:
|
with clawhdf5.File(tmp_h5, "r") as f:
|
||||||
assert f.attrs["author"] == "test"
|
assert f.attrs["author"] == b"test"
|
||||||
assert f.attrs["count"] == 42
|
assert f.attrs["count"] == 42
|
||||||
|
|
||||||
|
|
||||||
def test_write_with_group(tmp_h5):
|
def test_write_with_group(tmp_h5):
|
||||||
with rustyhdf5.File(tmp_h5, "w") as f:
|
with clawhdf5.File(tmp_h5, "w") as f:
|
||||||
grp = f.create_group("experiment")
|
grp = f.create_group("experiment")
|
||||||
grp.create_dataset("results", data=np.array([3.14, 2.72]))
|
grp.create_dataset("results", data=np.array([3.14, 2.72]))
|
||||||
grp.attrs["version"] = 1
|
grp.attrs["version"] = 1
|
||||||
with rustyhdf5.File(tmp_h5, "r") as f:
|
with clawhdf5.File(tmp_h5, "r") as f:
|
||||||
ds = f["experiment/results"]
|
ds = f["experiment/results"]
|
||||||
np.testing.assert_array_almost_equal(ds[:], [3.14, 2.72])
|
np.testing.assert_array_almost_equal(ds[:], [3.14, 2.72])
|
||||||
grp = f["experiment"]
|
grp = f["experiment"]
|
||||||
@@ -199,9 +199,9 @@ def test_write_with_group(tmp_h5):
|
|||||||
|
|
||||||
def test_roundtrip_float64(tmp_h5):
|
def test_roundtrip_float64(tmp_h5):
|
||||||
original = np.array([1.1, 2.2, 3.3], dtype=np.float64)
|
original = np.array([1.1, 2.2, 3.3], dtype=np.float64)
|
||||||
with rustyhdf5.File(tmp_h5, "w") as f:
|
with clawhdf5.File(tmp_h5, "w") as f:
|
||||||
f.create_dataset("data", data=original)
|
f.create_dataset("data", data=original)
|
||||||
with rustyhdf5.File(tmp_h5, "r") as f:
|
with clawhdf5.File(tmp_h5, "r") as f:
|
||||||
result = f["data"][:]
|
result = f["data"][:]
|
||||||
np.testing.assert_array_almost_equal(result, original)
|
np.testing.assert_array_almost_equal(result, original)
|
||||||
assert result.dtype == np.float64
|
assert result.dtype == np.float64
|
||||||
@@ -209,9 +209,9 @@ def test_roundtrip_float64(tmp_h5):
|
|||||||
|
|
||||||
def test_roundtrip_float32(tmp_h5):
|
def test_roundtrip_float32(tmp_h5):
|
||||||
original = np.array([1.5, 2.5, 3.5], dtype=np.float32)
|
original = np.array([1.5, 2.5, 3.5], dtype=np.float32)
|
||||||
with rustyhdf5.File(tmp_h5, "w") as f:
|
with clawhdf5.File(tmp_h5, "w") as f:
|
||||||
f.create_dataset("data", data=original)
|
f.create_dataset("data", data=original)
|
||||||
with rustyhdf5.File(tmp_h5, "r") as f:
|
with clawhdf5.File(tmp_h5, "r") as f:
|
||||||
result = f["data"][:]
|
result = f["data"][:]
|
||||||
np.testing.assert_array_almost_equal(result, original)
|
np.testing.assert_array_almost_equal(result, original)
|
||||||
assert result.dtype == np.float32
|
assert result.dtype == np.float32
|
||||||
@@ -219,9 +219,9 @@ def test_roundtrip_float32(tmp_h5):
|
|||||||
|
|
||||||
def test_roundtrip_int32(tmp_h5):
|
def test_roundtrip_int32(tmp_h5):
|
||||||
original = np.array([-10, 0, 10, 100], dtype=np.int32)
|
original = np.array([-10, 0, 10, 100], dtype=np.int32)
|
||||||
with rustyhdf5.File(tmp_h5, "w") as f:
|
with clawhdf5.File(tmp_h5, "w") as f:
|
||||||
f.create_dataset("data", data=original)
|
f.create_dataset("data", data=original)
|
||||||
with rustyhdf5.File(tmp_h5, "r") as f:
|
with clawhdf5.File(tmp_h5, "r") as f:
|
||||||
result = f["data"][:]
|
result = f["data"][:]
|
||||||
np.testing.assert_array_equal(result, original)
|
np.testing.assert_array_equal(result, original)
|
||||||
assert result.dtype == np.int32
|
assert result.dtype == np.int32
|
||||||
@@ -229,9 +229,9 @@ def test_roundtrip_int32(tmp_h5):
|
|||||||
|
|
||||||
def test_roundtrip_int64(tmp_h5):
|
def test_roundtrip_int64(tmp_h5):
|
||||||
original = np.array([-1, 0, 1, 2**40], dtype=np.int64)
|
original = np.array([-1, 0, 1, 2**40], dtype=np.int64)
|
||||||
with rustyhdf5.File(tmp_h5, "w") as f:
|
with clawhdf5.File(tmp_h5, "w") as f:
|
||||||
f.create_dataset("data", data=original)
|
f.create_dataset("data", data=original)
|
||||||
with rustyhdf5.File(tmp_h5, "r") as f:
|
with clawhdf5.File(tmp_h5, "r") as f:
|
||||||
result = f["data"][:]
|
result = f["data"][:]
|
||||||
np.testing.assert_array_equal(result, original)
|
np.testing.assert_array_equal(result, original)
|
||||||
assert result.dtype == np.int64
|
assert result.dtype == np.int64
|
||||||
@@ -239,9 +239,9 @@ def test_roundtrip_int64(tmp_h5):
|
|||||||
|
|
||||||
def test_roundtrip_uint8(tmp_h5):
|
def test_roundtrip_uint8(tmp_h5):
|
||||||
original = np.array([0, 127, 255], dtype=np.uint8)
|
original = np.array([0, 127, 255], dtype=np.uint8)
|
||||||
with rustyhdf5.File(tmp_h5, "w") as f:
|
with clawhdf5.File(tmp_h5, "w") as f:
|
||||||
f.create_dataset("data", data=original)
|
f.create_dataset("data", data=original)
|
||||||
with rustyhdf5.File(tmp_h5, "r") as f:
|
with clawhdf5.File(tmp_h5, "r") as f:
|
||||||
result = f["data"][:]
|
result = f["data"][:]
|
||||||
np.testing.assert_array_equal(result, original)
|
np.testing.assert_array_equal(result, original)
|
||||||
assert result.dtype == np.uint8
|
assert result.dtype == np.uint8
|
||||||
@@ -254,7 +254,7 @@ def test_roundtrip_uint8(tmp_h5):
|
|||||||
|
|
||||||
def test_chunked_gzip(tmp_h5):
|
def test_chunked_gzip(tmp_h5):
|
||||||
original = np.arange(100, dtype=np.float64)
|
original = np.arange(100, dtype=np.float64)
|
||||||
with rustyhdf5.File(tmp_h5, "w") as f:
|
with clawhdf5.File(tmp_h5, "w") as f:
|
||||||
f.create_dataset(
|
f.create_dataset(
|
||||||
"compressed",
|
"compressed",
|
||||||
data=original,
|
data=original,
|
||||||
@@ -262,7 +262,7 @@ def test_chunked_gzip(tmp_h5):
|
|||||||
compression="gzip",
|
compression="gzip",
|
||||||
compression_opts=6,
|
compression_opts=6,
|
||||||
)
|
)
|
||||||
with rustyhdf5.File(tmp_h5, "r") as f:
|
with clawhdf5.File(tmp_h5, "r") as f:
|
||||||
result = f["compressed"][:]
|
result = f["compressed"][:]
|
||||||
np.testing.assert_array_equal(result, original)
|
np.testing.assert_array_equal(result, original)
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@ def test_h5py_can_read_our_file(tmp_h5):
|
|||||||
"""Verify that h5py can read files we create."""
|
"""Verify that h5py can read files we create."""
|
||||||
import h5py
|
import h5py
|
||||||
|
|
||||||
with rustyhdf5.File(tmp_h5, "w") as f:
|
with clawhdf5.File(tmp_h5, "w") as f:
|
||||||
f.create_dataset("values", data=np.array([1.0, 2.0, 3.0]))
|
f.create_dataset("values", data=np.array([1.0, 2.0, 3.0]))
|
||||||
f.attrs["meta"] = "hello"
|
f.attrs["meta"] = "hello"
|
||||||
with h5py.File(tmp_h5, "r") as f:
|
with h5py.File(tmp_h5, "r") as f:
|
||||||
@@ -292,7 +292,7 @@ def test_we_can_read_h5py_file(tmp_h5):
|
|||||||
with h5py.File(tmp_h5, "w") as f:
|
with h5py.File(tmp_h5, "w") as f:
|
||||||
f.create_dataset("data", data=np.array([10.0, 20.0, 30.0]))
|
f.create_dataset("data", data=np.array([10.0, 20.0, 30.0]))
|
||||||
f.attrs["version"] = 2
|
f.attrs["version"] = 2
|
||||||
with rustyhdf5.File(tmp_h5, "r") as f:
|
with clawhdf5.File(tmp_h5, "r") as f:
|
||||||
data = f["data"][:]
|
data = f["data"][:]
|
||||||
np.testing.assert_array_equal(data, [10.0, 20.0, 30.0])
|
np.testing.assert_array_equal(data, [10.0, 20.0, 30.0])
|
||||||
assert f.attrs["version"] == 2
|
assert f.attrs["version"] == 2
|
||||||
@@ -305,9 +305,9 @@ def test_we_can_read_h5py_file(tmp_h5):
|
|||||||
|
|
||||||
def test_2d_array_roundtrip(tmp_h5):
|
def test_2d_array_roundtrip(tmp_h5):
|
||||||
original = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float64)
|
original = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float64)
|
||||||
with rustyhdf5.File(tmp_h5, "w") as f:
|
with clawhdf5.File(tmp_h5, "w") as f:
|
||||||
f.create_dataset("matrix", data=original)
|
f.create_dataset("matrix", data=original)
|
||||||
with rustyhdf5.File(tmp_h5, "r") as f:
|
with clawhdf5.File(tmp_h5, "r") as f:
|
||||||
ds = f["matrix"]
|
ds = f["matrix"]
|
||||||
assert ds.shape == (2, 3)
|
assert ds.shape == (2, 3)
|
||||||
result = ds[:]
|
result = ds[:]
|
||||||
@@ -321,15 +321,15 @@ def test_2d_array_roundtrip(tmp_h5):
|
|||||||
|
|
||||||
def test_open_nonexistent_file():
|
def test_open_nonexistent_file():
|
||||||
with pytest.raises(OSError):
|
with pytest.raises(OSError):
|
||||||
rustyhdf5.File("/nonexistent/path.h5", "r")
|
clawhdf5.File("/nonexistent/path.h5", "r")
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_mode(tmp_h5):
|
def test_invalid_mode(tmp_h5):
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
rustyhdf5.File(tmp_h5, "x")
|
clawhdf5.File(tmp_h5, "x")
|
||||||
|
|
||||||
|
|
||||||
def test_key_error_on_missing_dataset(sample_read_file):
|
def test_key_error_on_missing_dataset(sample_read_file):
|
||||||
with rustyhdf5.File(sample_read_file, "r") as f:
|
with clawhdf5.File(sample_read_file, "r") as f:
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
f["nonexistent"]
|
f["nonexistent"]
|
||||||
@@ -76,8 +76,13 @@ print the same bytes as h5dump 1.14.6 and as Debian's h5dump 1.14.5 (the
|
|||||||
was run in that image on 2026-09-26) — `dump_matches_h5dump` in
|
was run in that image on 2026-09-26) — `dump_matches_h5dump` in
|
||||||
`tests/h5rs_interop.rs` checks this, and `dump_shows_nul_padding_in_nested_strings`
|
`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
|
that null-padded strings show their NULs (`"a\000b"`) at any depth, as
|
||||||
h5dump's do. Not covered by those tests: references, opaque, bitfield,
|
h5dump's do. `dump_prints_vl_data_like_h5dump` covers variable-length
|
||||||
variable-length sequences and virtual datasets. Known differences from
|
strings (one with an embedded NUL, which prints up to the NUL; empty; null,
|
||||||
|
which prints `NULL`), variable-length sequences, a VL compound member and
|
||||||
|
a VL attribute, with 8- and 4-byte offsets. Not covered by those tests:
|
||||||
|
references, opaque, bitfield, non-ASCII UTF-8 (h5dump prints each byte
|
||||||
|
above 0x7f as a sign-extended octal escape, h5rs the character) and
|
||||||
|
virtual datasets. Known differences from
|
||||||
h5dump:
|
h5dump:
|
||||||
|
|
||||||
- Floats print at their own precision (a `float32` 0.1 prints as `0.1`),
|
- Floats print at their own precision (a `float32` 0.1 prints as `0.1`),
|
||||||
@@ -233,9 +238,11 @@ extension) and checks:
|
|||||||
(which catches corrupt compressed data and Fletcher-32 mismatches), and
|
(which catches corrupt compressed data and Fletcher-32 mismatches), and
|
||||||
follows every variable-length element (strings and sequences, also inside
|
follows every variable-length element (strings and sequences, also inside
|
||||||
compounds and arrays) of every dataset and attribute into its global heap
|
compounds and arrays) of every dataset and attribute into its global heap
|
||||||
collection: a collection that does not parse, a missing heap object, or a
|
collection: a collection that does not parse or overlaps another, a missing
|
||||||
sequence longer than its heap object is a problem at the collection's
|
heap object, or a heap object whose size is not exactly the element's
|
||||||
address. Data the
|
length times its base size (libhdf5 refuses such an element) is a problem
|
||||||
|
at the collection's address. Variable-length elements are resolved by the
|
||||||
|
library's `VlResolver`, as `clawhdf5::File` resolves them. Data the
|
||||||
tool cannot decode (a filter it does not implement, such as szip, or a
|
tool cannot decode (a filter it does not implement, such as szip, or a
|
||||||
dataset over `--max-bytes`) is a `note:`, not a problem. Every problem is
|
dataset over `--max-bytes`) is a `note:`, not a problem. Every problem is
|
||||||
printed with the address of the structure involved; the exit status is 0
|
printed with the address of the structure involved; the exit status is 0
|
||||||
@@ -252,9 +259,10 @@ none at all without `--data`), and objects reachable only by external links. It
|
|||||||
clawhdf5's parsers, so it accepts what they accept: some header damage that
|
clawhdf5's parsers, so it accepts what they accept: some header damage that
|
||||||
libhdf5 refuses goes unreported. Of the 150 CVE and fuzzer files of the
|
libhdf5 refuses goes unreported. Of the 150 CVE and fuzzer files of the
|
||||||
HDF Group's `cve_hdf5` corpus (`cvefiles/` and `fuzzerfiles/`),
|
HDF Group's `cve_hdf5` corpus (`cvefiles/` and `fuzzerfiles/`),
|
||||||
`check --data` passes 16, and h5dump 1.14.6 rejects 9 of those (tank,
|
`check --data` passes 15, and h5dump 1.14.6 rejects 8 of those (tank,
|
||||||
2026-09-26, `h5rs check --data F` and `h5dump F` per file; before the
|
2026-09-26, `h5rs check --data F` and `h5dump F` per file; before the
|
||||||
library's header checks it passed 28, of which h5dump rejects 21).
|
library's header checks it passed 28, of which h5dump rejects 21, and 16
|
||||||
|
and 9 before a VL type's stored element size was checked).
|
||||||
|
|
||||||
## Robustness
|
## Robustness
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ use clawhdf5_format::btree_v2::{BTreeV2Header, collect_btree_v2_records};
|
|||||||
use clawhdf5_format::data_layout::DataLayout;
|
use clawhdf5_format::data_layout::DataLayout;
|
||||||
use clawhdf5_format::dataspace::{Dataspace, DataspaceType};
|
use clawhdf5_format::dataspace::{Dataspace, DataspaceType};
|
||||||
use clawhdf5_format::datatype::Datatype;
|
use clawhdf5_format::datatype::Datatype;
|
||||||
|
use clawhdf5_format::error::FormatError;
|
||||||
use clawhdf5_format::group_info::GroupInfoMessage;
|
use clawhdf5_format::group_info::GroupInfoMessage;
|
||||||
use clawhdf5_format::link_info::LinkInfoMessage;
|
use clawhdf5_format::link_info::LinkInfoMessage;
|
||||||
use clawhdf5_format::message_type::MessageType;
|
use clawhdf5_format::message_type::MessageType;
|
||||||
use clawhdf5_format::object_header::ObjectHeader;
|
use clawhdf5_format::object_header::ObjectHeader;
|
||||||
use clawhdf5_format::symbol_table::SymbolTableMessage;
|
use clawhdf5_format::symbol_table::SymbolTableMessage;
|
||||||
|
use clawhdf5_format::vl_data::{VlResolver, check_element_size, parse_vl_references};
|
||||||
|
|
||||||
use crate::cli::{Args, Out};
|
use crate::cli::{Args, Out};
|
||||||
use crate::h5::{Error, ErrorKind, H5, Kind};
|
use crate::h5::{Error, ErrorKind, H5, Kind};
|
||||||
@@ -49,6 +51,15 @@ found, 3 internal error.";
|
|||||||
|
|
||||||
const MAX_CHUNKS_CHECKED: usize = 10_000_000;
|
const MAX_CHUNKS_CHECKED: usize = 10_000_000;
|
||||||
|
|
||||||
|
/// A variable-length element's problem, worded as `check` reports heap
|
||||||
|
/// problems ("global heap ...").
|
||||||
|
fn heap_problem(e: FormatError) -> String {
|
||||||
|
match e {
|
||||||
|
FormatError::VlDataError(m) if m.starts_with("global heap") => m,
|
||||||
|
e => format!("global heap: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether values of `dt` hold variable-length data (in the global heap).
|
/// Whether values of `dt` hold variable-length data (in the global heap).
|
||||||
fn has_vl(dt: &Datatype, depth: u32) -> bool {
|
fn has_vl(dt: &Datatype, depth: u32) -> bool {
|
||||||
if depth > 32 {
|
if depth > 32 {
|
||||||
@@ -104,6 +115,8 @@ struct Checker<'a> {
|
|||||||
btrees_seen: HashSet<u64>,
|
btrees_seen: HashSet<u64>,
|
||||||
/// Global heap collections already read (with --data).
|
/// Global heap collections already read (with --data).
|
||||||
gcols_seen: HashSet<u64>,
|
gcols_seen: HashSet<u64>,
|
||||||
|
/// Resolves variable-length elements (with --data), for the whole file.
|
||||||
|
vl: VlResolver<'a>,
|
||||||
panicked: bool,
|
panicked: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +170,7 @@ pub fn run(args: &mut Args, out: &mut Out) -> std::io::Result<i32> {
|
|||||||
heaps_seen: HashSet::new(),
|
heaps_seen: HashSet::new(),
|
||||||
btrees_seen: HashSet::new(),
|
btrees_seen: HashSet::new(),
|
||||||
gcols_seen: HashSet::new(),
|
gcols_seen: HashSet::new(),
|
||||||
|
vl: VlResolver::new(h5.data(), h5.os(), h5.ls()),
|
||||||
panicked: false,
|
panicked: false,
|
||||||
};
|
};
|
||||||
c.superblock();
|
c.superblock();
|
||||||
@@ -707,62 +721,48 @@ impl Checker<'_> {
|
|||||||
}
|
}
|
||||||
match dt {
|
match dt {
|
||||||
Datatype::VariableLength {
|
Datatype::VariableLength {
|
||||||
|
size,
|
||||||
is_string,
|
is_string,
|
||||||
base_type,
|
base_type,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let os = usize::from(self.h5.os());
|
// Resolved by the library's VlResolver, as every other
|
||||||
let (Some(lenb), Some(addrb), Some(idxb)) =
|
// reader resolves them (and as libhdf5 does): a heap object
|
||||||
(b.get(..4), b.get(4..4 + os), b.get(4 + os..8 + os))
|
// whose size is not the element's length × base size, a
|
||||||
else {
|
// collection that overlaps another, or a missing object is
|
||||||
|
// a problem at the collection's address.
|
||||||
|
let Ok(vl) = parse_vl_references(b, 1, self.h5.os()) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let le = |x: &[u8]| {
|
let gcol = vl[0].collection_address;
|
||||||
x.iter()
|
if gcol == 0 || bad.contains_key(&gcol) {
|
||||||
.enumerate()
|
|
||||||
.fold(0u64, |a, (i, &v)| a | (u64::from(v) << (8 * i)))
|
|
||||||
};
|
|
||||||
let (len, gcol, idx) = (le(lenb), le(addrb), le(idxb));
|
|
||||||
let undef = if os >= 8 {
|
|
||||||
u64::MAX
|
|
||||||
} else {
|
|
||||||
(1u64 << (8 * os)) - 1
|
|
||||||
};
|
|
||||||
if len == 0 || gcol == 0 || gcol == undef || bad.contains_key(&gcol) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let obj = match self.h5.heap_object(gcol, idx as u32) {
|
if let Err(e) = check_element_size(*size, self.h5.os()) {
|
||||||
Ok(o) => o,
|
bad.insert(gcol, e.to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let bs = if *is_string {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
base_type.type_size() as usize
|
||||||
|
};
|
||||||
|
if bs == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let obj = match self.vl.element(b, bs) {
|
||||||
|
Ok(o) => o.unwrap_or(&[]),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
bad.insert(e.addr.unwrap_or(gcol), e.msg);
|
bad.insert(gcol, heap_problem(e));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if self.gcols_seen.insert(gcol) {
|
if self.gcols_seen.insert(gcol) {
|
||||||
self.counts.global_heaps += 1;
|
self.counts.global_heaps += 1;
|
||||||
}
|
}
|
||||||
let bs = if *is_string {
|
if !*is_string && has_vl(base_type, depth + 1) {
|
||||||
1
|
for eb in obj.chunks_exact(bs) {
|
||||||
} else {
|
self.vl_element(base_type, eb, depth + 1, bad);
|
||||||
u64::from(base_type.type_size())
|
|
||||||
};
|
|
||||||
if len
|
|
||||||
.checked_mul(bs)
|
|
||||||
.is_none_or(|need| need > obj.len() as u64)
|
|
||||||
{
|
|
||||||
bad.insert(
|
|
||||||
gcol,
|
|
||||||
format!(
|
|
||||||
"global heap object {idx} holds {} bytes; the element needs {len} x {bs}",
|
|
||||||
obj.len()
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if !*is_string && bs > 0 && has_vl(base_type, depth + 1) {
|
|
||||||
let bs = bs as usize;
|
|
||||||
for k in 0..len as usize {
|
|
||||||
self.vl_element(base_type, &obj[k * bs..(k + 1) * bs], depth + 1, bad);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -699,6 +699,9 @@ impl Diff {
|
|||||||
}
|
}
|
||||||
match (x, y) {
|
match (x, y) {
|
||||||
(Value::Str(p), Value::Str(q)) => p == q,
|
(Value::Str(p), Value::Str(q)) => p == q,
|
||||||
|
// h5diff compares a null VL string equal to an empty one.
|
||||||
|
(Value::NullStr, Value::NullStr) => true,
|
||||||
|
(Value::NullStr, Value::Str(s)) | (Value::Str(s), Value::NullStr) => s.is_empty(),
|
||||||
(Value::Bytes(p), Value::Bytes(q)) | (Value::OtherRef(p), Value::OtherRef(q)) => p == q,
|
(Value::Bytes(p), Value::Bytes(q)) | (Value::OtherRef(p), Value::OtherRef(q)) => p == q,
|
||||||
(Value::Compound(p), Value::Compound(q)) => {
|
(Value::Compound(p), Value::Compound(q)) => {
|
||||||
p.len() == q.len()
|
p.len() == q.len()
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use clawhdf5::File;
|
use clawhdf5::File;
|
||||||
use clawhdf5_format::attribute::{AttributeMessage, extract_attributes_tolerant};
|
use clawhdf5_format::attribute::{AttributeMessage, extract_attributes_tolerant};
|
||||||
@@ -20,7 +19,6 @@ use clawhdf5_format::datatype::Datatype;
|
|||||||
use clawhdf5_format::error::FormatError;
|
use clawhdf5_format::error::FormatError;
|
||||||
use clawhdf5_format::filter_pipeline::FilterPipeline;
|
use clawhdf5_format::filter_pipeline::FilterPipeline;
|
||||||
use clawhdf5_format::fractal_heap::FractalHeapHeader;
|
use clawhdf5_format::fractal_heap::FractalHeapHeader;
|
||||||
use clawhdf5_format::global_heap::GlobalHeapCollection;
|
|
||||||
use clawhdf5_format::group_v1;
|
use clawhdf5_format::group_v1;
|
||||||
use clawhdf5_format::link_info::LinkInfoMessage;
|
use clawhdf5_format::link_info::LinkInfoMessage;
|
||||||
use clawhdf5_format::link_message::{LinkMessage, LinkTarget};
|
use clawhdf5_format::link_message::{LinkMessage, LinkTarget};
|
||||||
@@ -191,7 +189,6 @@ pub struct H5 {
|
|||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
pub file: File,
|
pub file: File,
|
||||||
pub max_bytes: u64,
|
pub max_bytes: u64,
|
||||||
heaps: RefCell<HashMap<u64, std::result::Result<Rc<GlobalHeapCollection>, String>>>,
|
|
||||||
/// Fractal heaps whose blocks were verified: `None` = sound.
|
/// Fractal heaps whose blocks were verified: `None` = sound.
|
||||||
verified_heaps: RefCell<HashMap<u64, Option<Error>>>,
|
verified_heaps: RefCell<HashMap<u64, Option<Error>>>,
|
||||||
}
|
}
|
||||||
@@ -211,7 +208,6 @@ impl H5 {
|
|||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
file,
|
file,
|
||||||
max_bytes: DEFAULT_MAX_BYTES,
|
max_bytes: DEFAULT_MAX_BYTES,
|
||||||
heaps: RefCell::new(HashMap::new()),
|
|
||||||
verified_heaps: RefCell::new(HashMap::new()),
|
verified_heaps: RefCell::new(HashMap::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -433,29 +429,6 @@ impl H5 {
|
|||||||
r.map_or(Ok(()), Err)
|
r.map_or(Ok(()), Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The global heap object `idx` of the collection at `addr` (cached per
|
|
||||||
/// collection).
|
|
||||||
pub fn heap_object(&self, addr: u64, idx: u32) -> Result<Vec<u8>> {
|
|
||||||
let coll = {
|
|
||||||
let mut cache = self.heaps.borrow_mut();
|
|
||||||
cache
|
|
||||||
.entry(addr)
|
|
||||||
.or_insert_with(|| match usize::try_from(addr) {
|
|
||||||
Ok(a) => GlobalHeapCollection::parse(self.data(), a, self.ls())
|
|
||||||
.map(Rc::new)
|
|
||||||
.map_err(|e| e.to_string()),
|
|
||||||
Err(_) => Err("address out of range".into()),
|
|
||||||
})
|
|
||||||
.clone()
|
|
||||||
.map_err(|e| Error::at(addr, format!("global heap: {e}")))?
|
|
||||||
};
|
|
||||||
let idx16 = u16::try_from(idx)
|
|
||||||
.map_err(|_| Error::at(addr, format!("global heap object index {idx} out of range")))?;
|
|
||||||
coll.get_object(idx16)
|
|
||||||
.map(|o| o.data.clone())
|
|
||||||
.ok_or_else(|| Error::at(addr, format!("global heap has no object {idx}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The dataspace of the dataset at `path` with a virtual dataset's
|
/// The dataspace of the dataset at `path` with a virtual dataset's
|
||||||
/// extent resolved from its sources (as libhdf5 reports it) instead of
|
/// extent resolved from its sources (as libhdf5 reports it) instead of
|
||||||
/// the stored one.
|
/// the stored one.
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
//! Decoding never panics: a short buffer, an unknown byte order or a
|
//! Decoding never panics: a short buffer, an unknown byte order or a
|
||||||
//! dangling heap reference becomes [`Value::Error`].
|
//! dangling heap reference becomes [`Value::Error`].
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
use clawhdf5_format::datatype::{Datatype, DatatypeByteOrder, ReferenceType, StringPadding};
|
use clawhdf5_format::datatype::{Datatype, DatatypeByteOrder, ReferenceType, StringPadding};
|
||||||
|
use clawhdf5_format::vl_data::{VlResolver, check_element_size};
|
||||||
use serde_json::Value as J;
|
use serde_json::Value as J;
|
||||||
|
|
||||||
use crate::dtype;
|
use crate::dtype;
|
||||||
@@ -16,6 +19,9 @@ pub enum Value {
|
|||||||
/// its own precision.
|
/// its own precision.
|
||||||
Float(f64, u8),
|
Float(f64, u8),
|
||||||
Str(String),
|
Str(String),
|
||||||
|
/// A null variable-length string (heap address 0): h5dump prints it as
|
||||||
|
/// `NULL`, h5py reads it as empty.
|
||||||
|
NullStr,
|
||||||
/// Opaque, bitfield, time and oversized integers.
|
/// Opaque, bitfield, time and oversized integers.
|
||||||
Bytes(Vec<u8>),
|
Bytes(Vec<u8>),
|
||||||
/// An enum member (name, when the value matches one) and its value.
|
/// An enum member (name, when the value matches one) and its value.
|
||||||
@@ -129,10 +135,10 @@ fn decode_float(dt: &Datatype, b: &[u8]) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn trim_string(b: &[u8], pad: Option<&StringPadding>) -> String {
|
fn trim_string(b: &[u8], pad: &StringPadding) -> String {
|
||||||
let cut = b.iter().position(|&c| c == 0).unwrap_or(b.len());
|
let cut = b.iter().position(|&c| c == 0).unwrap_or(b.len());
|
||||||
let mut s = &b[..cut];
|
let mut s = &b[..cut];
|
||||||
if matches!(pad, Some(StringPadding::SpacePad)) {
|
if matches!(pad, StringPadding::SpacePad) {
|
||||||
while let [rest @ .., b' '] = s {
|
while let [rest @ .., b' '] = s {
|
||||||
s = rest;
|
s = rest;
|
||||||
}
|
}
|
||||||
@@ -140,22 +146,20 @@ fn trim_string(b: &[u8], pad: Option<&StringPadding>) -> String {
|
|||||||
String::from_utf8_lossy(s).into_owned()
|
String::from_utf8_lossy(s).into_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Little-endian unsigned integer of `b` (up to 8 bytes).
|
|
||||||
fn le(b: &[u8]) -> u64 {
|
|
||||||
b.iter()
|
|
||||||
.take(8)
|
|
||||||
.enumerate()
|
|
||||||
.fold(0u64, |a, (i, &x)| a | (u64::from(x) << (8 * i)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decodes elements of one file.
|
/// Decodes elements of one file.
|
||||||
pub struct Decoder<'a> {
|
pub struct Decoder<'a> {
|
||||||
pub h5: &'a H5,
|
pub h5: &'a H5,
|
||||||
|
/// Variable-length elements are resolved as the library resolves them
|
||||||
|
/// (so as libhdf5 does), not by a decoder of our own.
|
||||||
|
vl: RefCell<VlResolver<'a>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Decoder<'a> {
|
impl<'a> Decoder<'a> {
|
||||||
pub fn new(h5: &'a H5) -> Self {
|
pub fn new(h5: &'a H5) -> Self {
|
||||||
Self { h5 }
|
Self {
|
||||||
|
h5,
|
||||||
|
vl: RefCell::new(VlResolver::new(h5.data(), h5.os(), h5.ls())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode element `i` of `raw`, an array of `dt` elements.
|
/// Decode element `i` of `raw`, an array of `dt` elements.
|
||||||
@@ -187,7 +191,7 @@ impl<'a> Decoder<'a> {
|
|||||||
Datatype::Time { .. } | Datatype::BitField { .. } | Datatype::Opaque { .. } => {
|
Datatype::Time { .. } | Datatype::BitField { .. } | Datatype::Opaque { .. } => {
|
||||||
Value::Bytes(b.to_vec())
|
Value::Bytes(b.to_vec())
|
||||||
}
|
}
|
||||||
Datatype::String { padding, .. } => Value::Str(trim_string(b, Some(padding))),
|
Datatype::String { padding, .. } => Value::Str(trim_string(b, padding)),
|
||||||
Datatype::Compound { members, .. } => {
|
Datatype::Compound { members, .. } => {
|
||||||
let mut out = Vec::with_capacity(members.len());
|
let mut out = Vec::with_capacity(members.len());
|
||||||
for m in members {
|
for m in members {
|
||||||
@@ -246,61 +250,43 @@ impl<'a> Decoder<'a> {
|
|||||||
Value::Array(out)
|
Value::Array(out)
|
||||||
}
|
}
|
||||||
Datatype::VariableLength {
|
Datatype::VariableLength {
|
||||||
|
size,
|
||||||
is_string,
|
is_string,
|
||||||
padding,
|
|
||||||
base_type,
|
base_type,
|
||||||
..
|
..
|
||||||
} => self.decode_vlen(*is_string, padding.as_ref(), base_type, b, depth),
|
} => match check_element_size(*size, self.h5.os()) {
|
||||||
|
Ok(()) => self.decode_vlen(*is_string, base_type, b, depth),
|
||||||
|
Err(e) => Value::Error(e.to_string()),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode_vlen(
|
/// A variable-length element, resolved by the library's
|
||||||
&self,
|
/// [`VlResolver`]: a string ends at its first NUL, a heap object whose
|
||||||
is_string: bool,
|
/// size is not the element's length × base size is an error, and a
|
||||||
padding: Option<&StringPadding>,
|
/// heap address of 0 is null — all as libhdf5 (and so h5dump and h5py)
|
||||||
base: &Datatype,
|
/// has it.
|
||||||
b: &[u8],
|
fn decode_vlen(&self, is_string: bool, base: &Datatype, b: &[u8], depth: u32) -> Value {
|
||||||
depth: u32,
|
|
||||||
) -> Value {
|
|
||||||
let os = usize::from(self.h5.os());
|
|
||||||
let (Some(lenb), Some(addrb), Some(idxb)) =
|
|
||||||
(b.get(..4), b.get(4..4 + os), b.get(4 + os..8 + os))
|
|
||||||
else {
|
|
||||||
return Value::Error("short VL element".into());
|
|
||||||
};
|
|
||||||
let len = le(lenb) as usize;
|
|
||||||
let addr = le(addrb);
|
|
||||||
let idx = le(idxb) as u32;
|
|
||||||
let undef = if os >= 8 {
|
|
||||||
u64::MAX
|
|
||||||
} else {
|
|
||||||
(1u64 << (8 * os)) - 1
|
|
||||||
};
|
|
||||||
let obj = if len == 0 || addr == 0 || addr == undef {
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
match self.h5.heap_object(addr, idx) {
|
|
||||||
Ok(o) => o,
|
|
||||||
Err(e) => return Value::Error(e.to_string()),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if is_string {
|
if is_string {
|
||||||
let l = len.min(obj.len());
|
return match self.vl.borrow_mut().string_element(b) {
|
||||||
return Value::Str(trim_string(&obj[..l], padding));
|
Ok(Some(s)) => Value::Str(String::from_utf8_lossy(s).into_owned()),
|
||||||
|
Ok(None) => Value::NullStr,
|
||||||
|
Err(e) => Value::Error(e.to_string()),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
let bs = base.type_size() as usize;
|
let bs = base.type_size() as usize;
|
||||||
if bs == 0 {
|
if bs == 0 {
|
||||||
return Value::Error("VL base type of size 0".into());
|
return Value::Error("VL base type of size 0".into());
|
||||||
}
|
}
|
||||||
match len.checked_mul(bs) {
|
let obj = match self.vl.borrow_mut().element(b, bs) {
|
||||||
Some(need) if need <= obj.len() => {}
|
Ok(o) => o.unwrap_or(&[]),
|
||||||
_ => return Value::Error("VL sequence longer than its heap object".into()),
|
Err(e) => return Value::Error(e.to_string()),
|
||||||
}
|
};
|
||||||
let mut out = Vec::with_capacity(len);
|
Value::Seq(
|
||||||
for k in 0..len {
|
obj.chunks_exact(bs)
|
||||||
out.push(self.decode(base, &obj[k * bs..], depth + 1));
|
.map(|e| self.decode(base, e, depth + 1))
|
||||||
}
|
.collect(),
|
||||||
Value::Seq(out)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +337,7 @@ pub fn text(v: &Value, h5paths: &dyn Fn(u64) -> Option<String>) -> String {
|
|||||||
Value::Int(i) => i.to_string(),
|
Value::Int(i) => i.to_string(),
|
||||||
Value::Float(f, w) => fmt_float(*f, *w),
|
Value::Float(f, w) => fmt_float(*f, *w),
|
||||||
Value::Str(s) => format!("\"{}\"", escape(s)),
|
Value::Str(s) => format!("\"{}\"", escape(s)),
|
||||||
|
Value::NullStr => "NULL".into(),
|
||||||
Value::Bytes(b) => hex(b),
|
Value::Bytes(b) => hex(b),
|
||||||
Value::Enum(Some(n), _) => n.clone(),
|
Value::Enum(Some(n), _) => n.clone(),
|
||||||
Value::Enum(None, i) => i.to_string(),
|
Value::Enum(None, i) => i.to_string(),
|
||||||
@@ -411,6 +398,7 @@ pub fn to_json(v: &Value, h5paths: &dyn Fn(u64) -> Option<String>) -> J {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Value::Str(s) => J::from(s.as_str()),
|
Value::Str(s) => J::from(s.as_str()),
|
||||||
|
Value::NullStr => J::from(""),
|
||||||
Value::Bytes(b) | Value::OtherRef(b) => J::from(hex(b)),
|
Value::Bytes(b) | Value::OtherRef(b) => J::from(hex(b)),
|
||||||
Value::Enum(_, i) => to_json(&Value::Int(*i), h5paths),
|
Value::Enum(_, i) => to_json(&Value::Int(*i), h5paths),
|
||||||
Value::Compound(ms) => J::Array(ms.iter().map(|(_, v)| to_json(v, h5paths)).collect()),
|
Value::Compound(ms) => J::Array(ms.iter().map(|(_, v)| to_json(v, h5paths)).collect()),
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Write the variable-length data files the h5rs VL tests run on.
|
||||||
|
|
||||||
|
usage: gen_vl_files.py OUTDIR
|
||||||
|
|
||||||
|
For 8-byte (`vl8`) and 4-byte (`vl4`) offsets, writes OUTDIR/vl8.h5 and
|
||||||
|
OUTDIR/vl4.h5, which libhdf5 reads in full, and OUTDIR/bad8.h5 and
|
||||||
|
OUTDIR/bad4.h5, whose `bad` and `badseq` elements 0 have a length that
|
||||||
|
disagrees with their global heap object (libhdf5: "Expected global heap
|
||||||
|
object size does not match"), and whose `undef` element 1 has length 0 and
|
||||||
|
the undefined heap address (libhdf5: "addr undefined"). h5py cannot write a VL string with a NUL in
|
||||||
|
it or a null element in a contiguous dataset, so those are patched in.
|
||||||
|
|
||||||
|
Prints one JSON object: for each file, each dataset's values as h5py reads
|
||||||
|
them one element at a time (strings as text, sequences as lists, a compound
|
||||||
|
as a list of its fields), with null for an element h5py cannot read; the
|
||||||
|
root attribute `va`; and the addresses of the `bad` elements' collections.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import h5py
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
out = sys.argv[1]
|
||||||
|
S = h5py.string_dtype("utf-8")
|
||||||
|
I4 = h5py.vlen_dtype(np.dtype("<i4"))
|
||||||
|
|
||||||
|
|
||||||
|
def create(path, sizes):
|
||||||
|
if sizes is None:
|
||||||
|
return h5py.File(path, "w")
|
||||||
|
fcpl = h5py.h5p.create(h5py.h5p.FILE_CREATE)
|
||||||
|
fcpl.set_sizes(*sizes)
|
||||||
|
return h5py.File(h5py.h5f.create(path.encode(), h5py.h5f.ACC_TRUNC, fcpl=fcpl))
|
||||||
|
|
||||||
|
|
||||||
|
def element(length, addr, index, os_):
|
||||||
|
return struct.pack("<I", length) + addr.to_bytes(os_, "little") + struct.pack("<I", index)
|
||||||
|
|
||||||
|
|
||||||
|
def good(path, sizes):
|
||||||
|
os_ = 8 if sizes is None else sizes[0]
|
||||||
|
with create(path, sizes) as f:
|
||||||
|
f.create_dataset(
|
||||||
|
"d", data=np.array(["aXb", "", "ok", "zz", "hello"], dtype=object), dtype=S
|
||||||
|
)
|
||||||
|
u = f.create_dataset("u", shape=(4,), dtype=S, chunks=(1,))
|
||||||
|
u[1] = "w"
|
||||||
|
s = f.create_dataset("seq", shape=(3,), dtype=I4)
|
||||||
|
s[0] = [1, 2, 3]
|
||||||
|
s[1] = []
|
||||||
|
s[2] = [-5]
|
||||||
|
s = f.create_dataset("sequ", shape=(3,), dtype=I4, chunks=(1,))
|
||||||
|
s[0] = [7, 8]
|
||||||
|
ct = np.dtype([("id", "<i4"), ("name", S)])
|
||||||
|
arr = np.zeros(3, dtype=ct)
|
||||||
|
arr["id"] = [1, 2, 3]
|
||||||
|
arr["name"] = ["one", "", "three"]
|
||||||
|
f.create_dataset("cmp", data=arr)
|
||||||
|
f.attrs.create("va", np.array(["p", "", "q"], dtype=object), dtype=S)
|
||||||
|
off = f["d"].id.get_offset()
|
||||||
|
b = bytearray(open(path, "rb").read())
|
||||||
|
i = b.index(b"aXb")
|
||||||
|
b[i + 1] = 0 # "a\0b"
|
||||||
|
es = 8 + os_
|
||||||
|
b[off + 2 * es : off + 3 * es] = element(2, 0, 1, os_) # "ok" -> null
|
||||||
|
open(path, "wb").write(bytes(b))
|
||||||
|
|
||||||
|
|
||||||
|
def bad(path, sizes):
|
||||||
|
os_ = 8 if sizes is None else sizes[0]
|
||||||
|
with create(path, sizes) as f:
|
||||||
|
f.create_dataset("bad", data=np.array(["cdefgh", "ok"], dtype=object), dtype=S)
|
||||||
|
s = f.create_dataset("badseq", shape=(2,), dtype=I4)
|
||||||
|
s[0] = [1, 2, 3]
|
||||||
|
s[1] = [4]
|
||||||
|
f.create_dataset("undef", data=np.array(["x", "", "yz"], dtype=object), dtype=S)
|
||||||
|
off, soff = f["bad"].id.get_offset(), f["badseq"].id.get_offset()
|
||||||
|
uoff = f["undef"].id.get_offset()
|
||||||
|
b = bytearray(open(path, "rb").read())
|
||||||
|
gcol = int.from_bytes(b[off + 4 : off + 4 + os_], "little")
|
||||||
|
struct.pack_into("<I", b, off, 3) # "cdefgh": length 6 -> 3
|
||||||
|
struct.pack_into("<I", b, soff, 2) # [1, 2, 3]: length 3 -> 2
|
||||||
|
# "": length 0 at the undefined address (all 0xff), which libhdf5 fails
|
||||||
|
# to read ("addr undefined"); it writes a null element as address 0.
|
||||||
|
es = 8 + os_
|
||||||
|
b[uoff + es : uoff + 2 * es] = element(0, (1 << (8 * os_)) - 1, 1, os_)
|
||||||
|
open(path, "wb").write(bytes(b))
|
||||||
|
return gcol
|
||||||
|
|
||||||
|
|
||||||
|
def value(v):
|
||||||
|
if isinstance(v, bytes):
|
||||||
|
return v.decode()
|
||||||
|
if isinstance(v, str):
|
||||||
|
return v
|
||||||
|
if isinstance(v, np.void):
|
||||||
|
return [value(x) for x in v]
|
||||||
|
if isinstance(v, np.ndarray):
|
||||||
|
return [value(x) for x in v]
|
||||||
|
return v.item() if hasattr(v, "item") else v
|
||||||
|
|
||||||
|
|
||||||
|
def read(ds):
|
||||||
|
got = []
|
||||||
|
for i in range(ds.shape[0]):
|
||||||
|
try:
|
||||||
|
got.append(value(ds[i]))
|
||||||
|
except OSError:
|
||||||
|
got.append(None)
|
||||||
|
return got
|
||||||
|
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for tag, sizes in (("8", None), ("4", (4, 4))):
|
||||||
|
g, x = os.path.join(out, f"vl{tag}.h5"), os.path.join(out, f"bad{tag}.h5")
|
||||||
|
good(g, sizes)
|
||||||
|
gcol = bad(x, sizes)
|
||||||
|
with h5py.File(g, "r") as f:
|
||||||
|
result[f"vl{tag}"] = {n: read(f[n]) for n in ("d", "u", "seq", "sequ", "cmp")}
|
||||||
|
result[f"vl{tag}"]["va"] = [value(s) for s in f.attrs["va"]]
|
||||||
|
with h5py.File(x, "r") as f:
|
||||||
|
result[f"bad{tag}"] = {n: read(f[n]) for n in ("bad", "badseq", "undef")}
|
||||||
|
result[f"bad{tag}"]["gcol"] = gcol
|
||||||
|
json.dump(result, sys.stdout)
|
||||||
@@ -773,3 +773,254 @@ fn every_subcommand_rejects_a_non_hdf5_file_cleanly() {
|
|||||||
assert_eq!(code(&h5rs(&["ls"])), 2);
|
assert_eq!(code(&h5rs(&["ls"])), 2);
|
||||||
assert_eq!(code(&h5rs(&["--help"])), 0);
|
assert_eq!(code(&h5rs(&["--help"])), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// variable-length data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Runs `tests/gen_vl_files.py`: VL strings (with an embedded NUL, empty
|
||||||
|
/// and null elements), VL sequences, a VL compound member and a VL
|
||||||
|
/// attribute, with 8- and 4-byte offsets, plus files whose heap objects
|
||||||
|
/// disagree with their elements' lengths.
|
||||||
|
fn generate_vl() -> Option<Files> {
|
||||||
|
if missing(python_available(), "python3 with h5py") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/gen_vl_files.py");
|
||||||
|
let out = Command::new(python())
|
||||||
|
.arg(&script)
|
||||||
|
.arg(dir.path())
|
||||||
|
.output()
|
||||||
|
.expect("run gen_vl_files.py");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"gen_vl_files.py failed:\n{}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
let values = serde_json::from_slice(&out.stdout).expect("gen_vl_files.py output");
|
||||||
|
Some(Files { dir, values })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `dump` resolves VL elements through the library's `VlResolver`, as
|
||||||
|
/// libhdf5 does: "a\0b" prints as "a", a null string as NULL (it printed
|
||||||
|
/// ""), and with 4-byte offsets too; the output is h5dump's byte for byte.
|
||||||
|
#[test]
|
||||||
|
fn dump_prints_vl_data_like_h5dump() {
|
||||||
|
let Some(f) = generate_vl() else { return };
|
||||||
|
for name in ["vl8.h5", "vl4.h5"] {
|
||||||
|
let p = f.p(name);
|
||||||
|
let ours = stdout(&h5rs(&["dump", &p]));
|
||||||
|
assert!(
|
||||||
|
ours.contains(r#"(0): "a", "", NULL, "zz", "hello""#),
|
||||||
|
"{name}:\n{ours}"
|
||||||
|
);
|
||||||
|
assert!(ours.contains(r#"(0): NULL, "w", NULL, NULL"#), "{name}");
|
||||||
|
assert!(ours.contains("(0): (1, 2, 3), (), (-5)"), "{name}");
|
||||||
|
if missing(tool_available("h5dump"), "h5dump") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let reference = run("h5dump", &[&p]);
|
||||||
|
assert!(reference.status.success(), "{name}: {reference:?}");
|
||||||
|
assert_eq!(ours, stdout(&reference).replacen(&p, name, 1), "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `dump --json` gives the values h5py reads, element by element; and an
|
||||||
|
/// element whose heap object is not its length × base size is an error, as
|
||||||
|
/// in h5py, not a truncated value (it printed "cde" and (1, 2)); so is a
|
||||||
|
/// length-0 element at the undefined heap address (it printed "").
|
||||||
|
#[test]
|
||||||
|
fn dump_json_vl_values_match_h5py() {
|
||||||
|
let Some(f) = generate_vl() else { return };
|
||||||
|
for tag in ["8", "4"] {
|
||||||
|
let (good, bad) = (format!("vl{tag}"), format!("bad{tag}"));
|
||||||
|
let o = h5rs(&["dump", "--json", &f.p(&format!("{good}.h5"))]);
|
||||||
|
assert!(o.status.success(), "{good}: {o:?}");
|
||||||
|
let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap();
|
||||||
|
let want = &f.values[&good];
|
||||||
|
for d in doc["datasets"].as_object().unwrap().values() {
|
||||||
|
let path = d["alias"][0].as_str().unwrap();
|
||||||
|
assert_eq!(d["value"], want[&path[1..]], "{good}: {path}");
|
||||||
|
}
|
||||||
|
let attrs = &doc["groups"][doc["root"].as_str().unwrap()]["attributes"];
|
||||||
|
assert_eq!(attrs[0]["name"], "va");
|
||||||
|
assert_eq!(attrs[0]["value"], want["va"], "{good}: va");
|
||||||
|
|
||||||
|
let o = h5rs(&["dump", "--json", &f.p(&format!("{bad}.h5"))]);
|
||||||
|
let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap();
|
||||||
|
let want = &f.values[&bad];
|
||||||
|
for d in doc["datasets"].as_object().unwrap().values() {
|
||||||
|
let path = d["alias"][0].as_str().unwrap();
|
||||||
|
let got = d["value"].as_array().unwrap();
|
||||||
|
let want = want[&path[1..]].as_array().unwrap();
|
||||||
|
assert_eq!(got.len(), want.len(), "{bad}: {path}");
|
||||||
|
for (g, w) in got.iter().zip(want) {
|
||||||
|
if w.is_null() {
|
||||||
|
// h5py cannot read it: neither can we.
|
||||||
|
let e = g["error"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_else(|| panic!("{bad}: {path}: {g}"));
|
||||||
|
let why = if path == "/undef" {
|
||||||
|
"undefined"
|
||||||
|
} else {
|
||||||
|
"holds"
|
||||||
|
};
|
||||||
|
assert!(e.contains(why), "{bad}: {path}: {e}");
|
||||||
|
} else {
|
||||||
|
assert_eq!(g, w, "{bad}: {path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `check --data` holds VL elements to libhdf5's rule: a heap object whose
|
||||||
|
/// size is not exactly the element's length × base size is a problem (it
|
||||||
|
/// only caught objects shorter than the element), and so is an element at
|
||||||
|
/// the undefined heap address.
|
||||||
|
#[test]
|
||||||
|
fn check_data_flags_mis_sized_vl_heap_objects() {
|
||||||
|
let Some(f) = generate_vl() else { return };
|
||||||
|
for tag in ["8", "4"] {
|
||||||
|
let o = h5rs(&["check", "--data", &f.p(&format!("vl{tag}.h5"))]);
|
||||||
|
let s = stdout(&o);
|
||||||
|
assert_eq!(code(&o), 0, "vl{tag}: {s}");
|
||||||
|
assert!(
|
||||||
|
s.contains("global heap collections read: 1"),
|
||||||
|
"vl{tag}: {s}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let o = h5rs(&["check", "--data", &f.p(&format!("bad{tag}.h5"))]);
|
||||||
|
let s = stdout(&o);
|
||||||
|
assert_eq!(code(&o), 1, "bad{tag}: {s}");
|
||||||
|
let at = f.values[format!("bad{tag}")]["gcol"].as_u64().unwrap();
|
||||||
|
for (path, what) in [("/bad", "6 bytes"), ("/badseq", "12 bytes")] {
|
||||||
|
let want = format!("problem: {at:#x} {path}: variable-length data: global heap object");
|
||||||
|
assert!(s.contains(&want), "bad{tag}: no {want:?} in\n{s}");
|
||||||
|
assert!(s.contains(what), "bad{tag}: {s}");
|
||||||
|
}
|
||||||
|
// A length-0 element at the undefined heap address: libhdf5 fails
|
||||||
|
// to read it; check skipped it.
|
||||||
|
let undef: u64 = if tag == "8" { u64::MAX } else { 0xffff_ffff };
|
||||||
|
let want = format!("problem: {undef:#x} /undef: variable-length data: global heap:");
|
||||||
|
assert!(s.contains(&want), "bad{tag}: no {want:?} in\n{s}");
|
||||||
|
assert!(s.contains("undefined global heap address"), "bad{tag}: {s}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// files clawhdf5 writes: nested groups and links
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Nested groups (4 levels, by builders and by path names), soft, hard and
|
||||||
|
/// external links, creation-order tracking, and dense link and attribute
|
||||||
|
/// storage, as `FileBuilder` writes them.
|
||||||
|
fn write_nested_links(dir: &Path) -> Vec<String> {
|
||||||
|
use clawhdf5::{AttrValue, FileBuilder};
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
b.set_attr("title", AttrValue::String("links".into()));
|
||||||
|
b.create_dataset("x/y").with_f64_data(&[1.0, 2.0]);
|
||||||
|
b.create_dataset("a/b/c/d/leaf")
|
||||||
|
.with_i32_data(&[4, 5])
|
||||||
|
.set_attr("depth", AttrValue::I64(5));
|
||||||
|
b.add_soft_link("soft", "/x/y");
|
||||||
|
b.add_soft_link("dangling", "/nowhere");
|
||||||
|
b.add_hard_link("alias", "/x/y");
|
||||||
|
b.add_external_link("ext", "other.h5", "/data");
|
||||||
|
let mut g = b.create_group("a/b");
|
||||||
|
g.set_attr("merged", AttrValue::I64(1));
|
||||||
|
for i in 0..10 {
|
||||||
|
g.set_attr(&format!("attr{i}"), AttrValue::F64(i as f64));
|
||||||
|
}
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let mut g = b.create_group("ordered");
|
||||||
|
g.track_order(true);
|
||||||
|
for i in (0..40).rev() {
|
||||||
|
g.create_dataset(&format!("n{i:02}")).with_i32_data(&[i]);
|
||||||
|
}
|
||||||
|
g.add_hard_link("back", "/a/b/c");
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let mut g = b.create_group("compact_ordered");
|
||||||
|
g.track_order(true);
|
||||||
|
g.create_dataset("z").with_i32_data(&[1]);
|
||||||
|
g.create_dataset("a").with_i32_data(&[2]);
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let nested = dir.join("nested.h5");
|
||||||
|
b.write(&nested).unwrap();
|
||||||
|
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
let mut g = b.create_group("many");
|
||||||
|
for i in 0..10_000 {
|
||||||
|
g.create_dataset(&format!("d{i:05}")).with_i32_data(&[i]);
|
||||||
|
}
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let many = dir.join("many.h5");
|
||||||
|
b.write(&many).unwrap();
|
||||||
|
[nested, many]
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_and_dump_files_with_nested_groups_and_links() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let files = write_nested_links(dir.path());
|
||||||
|
// `--data` reads every dataset by path, and a lookup in a dense group
|
||||||
|
// scans all of its links: 10 000 datasets take minutes in a debug
|
||||||
|
// build, so the big file is checked structurally only (and not dumped).
|
||||||
|
for (p, data) in [(&files[0], true), (&files[1], false)] {
|
||||||
|
let args: &[&str] = if data {
|
||||||
|
&["check", "--data", p]
|
||||||
|
} else {
|
||||||
|
&["check", p]
|
||||||
|
};
|
||||||
|
let o = h5rs(args);
|
||||||
|
assert_eq!(code(&o), 0, "{p}:\n{}", stdout(&o));
|
||||||
|
assert!(stdout(&o).contains("no problems found"), "{}", stdout(&o));
|
||||||
|
}
|
||||||
|
if missing(tool_available("h5dump"), "h5dump") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for p in &files[..1] {
|
||||||
|
let name = Path::new(p).file_name().unwrap().to_string_lossy();
|
||||||
|
let ours = h5rs(&["dump", p]);
|
||||||
|
assert!(ours.status.success(), "{p}: {ours:?}");
|
||||||
|
let reference = run("h5dump", &[p]);
|
||||||
|
assert!(reference.status.success(), "h5dump {p}: {reference:?}");
|
||||||
|
let r = stdout(&reference).replacen(p.as_str(), &name, 1);
|
||||||
|
assert_eq!(stdout(&ours), r, "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_files_with_big_dense_storage() {
|
||||||
|
// Dense links and attributes past the 512 KiB the root indirect block's
|
||||||
|
// direct blocks hold: the heap then needs child indirect blocks, which
|
||||||
|
// the writer used to write as direct blocks ("fractal heap indirect
|
||||||
|
// block: bad signature").
|
||||||
|
use clawhdf5::{AttrValue, FileBuilder};
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
let x = b.create_dataset("x");
|
||||||
|
x.with_i32_data(&[7]);
|
||||||
|
for i in 0..150usize {
|
||||||
|
let len = if i % 3 == 0 { 7_000 } else { 1 + i };
|
||||||
|
x.set_attr(
|
||||||
|
&format!("a{i:03}"),
|
||||||
|
AttrValue::F64Array(vec![i as f64; len]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut g = b.create_group("g");
|
||||||
|
for i in 0..40_000 {
|
||||||
|
g.add_hard_link(&format!("link_{i:06}_{}", "x".repeat(88)), "/x");
|
||||||
|
}
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let p = dir.path().join("big.h5").to_string_lossy().into_owned();
|
||||||
|
b.write(&p).unwrap();
|
||||||
|
// Structure only: `--data` looks every link up by a linear scan.
|
||||||
|
let o = h5rs(&["check", &p]);
|
||||||
|
assert_eq!(code(&o), 0, "{p}:\n{}", stdout(&o));
|
||||||
|
assert!(stdout(&o).contains("no problems found"), "{}", stdout(&o));
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
use clawhdf5::{AttrValue, File, Selection};
|
use clawhdf5::{AttrValue, File, Selection};
|
||||||
use clawhdf5_format::data_read;
|
use clawhdf5_format::data_read;
|
||||||
use clawhdf5_format::datatype::{Datatype, DatatypeByteOrder};
|
use clawhdf5_format::datatype::{Datatype, DatatypeByteOrder};
|
||||||
|
use clawhdf5_format::vl_data::{VlResolver, check_element_size};
|
||||||
|
|
||||||
/// Errors are reported to JavaScript as messages.
|
/// Errors are reported to JavaScript as messages.
|
||||||
pub type Result<T> = std::result::Result<T, String>;
|
pub type Result<T> = std::result::Result<T, String>;
|
||||||
@@ -221,6 +222,12 @@ impl Reader {
|
|||||||
None => (Selection::All, shape.clone()),
|
None => (Selection::All, shape.clone()),
|
||||||
Some(h) => hyperslab_selection(h, &shape)?,
|
Some(h) => hyperslab_selection(h, &shape)?,
|
||||||
};
|
};
|
||||||
|
// A VL type whose stored element size is not the one the file's
|
||||||
|
// offset size implies is refused before its data is read, as
|
||||||
|
// `File::read_string` refuses it.
|
||||||
|
if let Datatype::VariableLength { size, .. } = array_base(&dt) {
|
||||||
|
check_element_size(*size, self.file.superblock().offset_size).map_err(err)?;
|
||||||
|
}
|
||||||
let raw = ds.read_selection(&selection).map_err(err)?;
|
let raw = ds.read_selection(&selection).map_err(err)?;
|
||||||
let data = self.decode(&raw, &dt)?;
|
let data = self.decode(&raw, &dt)?;
|
||||||
out_shape.extend(element_shape(&dt));
|
out_shape.extend(element_shape(&dt));
|
||||||
@@ -270,22 +277,13 @@ impl Reader {
|
|||||||
Datatype::VariableLength {
|
Datatype::VariableLength {
|
||||||
is_string: true, ..
|
is_string: true, ..
|
||||||
} if !is_array => {
|
} if !is_array => {
|
||||||
let size = dt.type_size() as usize;
|
// The library's resolver, as File::read_string uses: a
|
||||||
if size == 0 || !raw.len().is_multiple_of(size) {
|
// string ends at its first NUL and a heap object of the
|
||||||
return Err(format!(
|
// wrong size is an error, as in libhdf5 and h5py.
|
||||||
"{} bytes is not a whole number of {size}-byte string references",
|
|
||||||
raw.len()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let sb = self.file.superblock();
|
let sb = self.file.superblock();
|
||||||
Data::Strings(
|
Data::Strings(
|
||||||
clawhdf5_format::vl_data::read_vl_strings(
|
VlResolver::new(self.file.as_bytes(), sb.offset_size, sb.length_size)
|
||||||
self.file.as_bytes(),
|
.strings(raw)
|
||||||
raw,
|
|
||||||
(raw.len() / size) as u64,
|
|
||||||
sb.offset_size,
|
|
||||||
sb.length_size,
|
|
||||||
)
|
|
||||||
.map_err(err)?,
|
.map_err(err)?,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
//! The wasm reader resolves VL strings with the library's `VlResolver`, so
|
||||||
|
//! it returns what `File::read_string` and h5py return: a string ends at
|
||||||
|
//! its first NUL, a null element is empty, a heap object of the wrong size
|
||||||
|
//! is an error, an element at the undefined heap address is an error, and a
|
||||||
|
//! VL datatype whose stored element size disagrees with the file's offset
|
||||||
|
//! size is refused. Checked with 8- and 4-byte offsets.
|
||||||
|
//!
|
||||||
|
//! Skipped when python3 with h5py is missing, unless
|
||||||
|
//! `CLAWHDF5_REQUIRE_INTEROP=1`. `CLAWHDF5_PYTHON` names the interpreter.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use clawhdf5::File;
|
||||||
|
use clawhdf5_wasm::core::{Data, Reader};
|
||||||
|
|
||||||
|
fn python() -> String {
|
||||||
|
std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn h5py_available() -> bool {
|
||||||
|
let ok = Command::new(python())
|
||||||
|
.args(["-c", "import h5py, numpy"])
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !ok {
|
||||||
|
assert!(
|
||||||
|
std::env::var("CLAWHDF5_REQUIRE_INTEROP").as_deref() != Ok("1"),
|
||||||
|
"CLAWHDF5_REQUIRE_INTEROP=1 but python with h5py is not available"
|
||||||
|
);
|
||||||
|
eprintln!("SKIP: python with h5py not available");
|
||||||
|
}
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For each offset size: `vl{8,4}.h5` with dataset `d` = "a\0b", "", null,
|
||||||
|
/// "zz" (patched: h5py writes neither a NUL nor a null element);
|
||||||
|
/// `bad{8,4}.h5` whose element 0 claims 3 bytes of a 6-byte heap object;
|
||||||
|
/// `size{8,4}.h5` whose VL datatype message stores a 24-byte element; and
|
||||||
|
/// `undef{8,4}.h5` whose element 1 has length 0 and the undefined heap
|
||||||
|
/// address.
|
||||||
|
/// Prints h5py's reading of each element as hex, or "error".
|
||||||
|
const SCRIPT: &str = r#"
|
||||||
|
import struct, sys, h5py, numpy as np
|
||||||
|
out = sys.argv[1]
|
||||||
|
S = h5py.string_dtype('utf-8')
|
||||||
|
def create(path, os_):
|
||||||
|
if os_ == 8:
|
||||||
|
return h5py.File(path, 'w', libver='earliest')
|
||||||
|
# The earliest format, so the patched object header has no checksum.
|
||||||
|
fcpl = h5py.h5p.create(h5py.h5p.FILE_CREATE); fcpl.set_sizes(4, 4)
|
||||||
|
fapl = h5py.h5p.create(h5py.h5p.FILE_ACCESS)
|
||||||
|
fapl.set_libver_bounds(h5py.h5f.LIBVER_EARLIEST, h5py.h5f.LIBVER_V18)
|
||||||
|
return h5py.File(h5py.h5f.create(path.encode(), h5py.h5f.ACC_TRUNC, fcpl=fcpl, fapl=fapl))
|
||||||
|
def elem(length, addr, index, os_):
|
||||||
|
return struct.pack('<I', length) + addr.to_bytes(os_, 'little') + struct.pack('<I', index)
|
||||||
|
def make(path, os_, values):
|
||||||
|
with create(path, os_) as f:
|
||||||
|
f.create_dataset('d', data=np.array(values, dtype=object), dtype=S)
|
||||||
|
return f['d'].id.get_offset()
|
||||||
|
for os_ in (8, 4):
|
||||||
|
es = 8 + os_
|
||||||
|
p = '%s/vl%d.h5' % (out, os_)
|
||||||
|
off = make(p, os_, ['aXb', '', 'ok', 'zz'])
|
||||||
|
b = bytearray(open(p, 'rb').read())
|
||||||
|
b[b.index(b'aXb') + 1] = 0
|
||||||
|
b[off + 2 * es:off + 3 * es] = elem(2, 0, 1, os_)
|
||||||
|
open(p, 'wb').write(bytes(b))
|
||||||
|
p = '%s/bad%d.h5' % (out, os_)
|
||||||
|
off = make(p, os_, ['cdefgh', 'ok'])
|
||||||
|
b = bytearray(open(p, 'rb').read())
|
||||||
|
struct.pack_into('<I', b, off, 3)
|
||||||
|
open(p, 'wb').write(bytes(b))
|
||||||
|
p = '%s/size%d.h5' % (out, os_)
|
||||||
|
make(p, os_, ['x', 'yy'])
|
||||||
|
b = bytearray(open(p, 'rb').read())
|
||||||
|
# datatype message: version 1, class 9 (VL); string, null-terminated, UTF-8
|
||||||
|
pat = bytes([0x19, 0x01, 0x01, 0x00]) + struct.pack('<I', es)
|
||||||
|
assert b.count(pat) == 1, b.count(pat)
|
||||||
|
i = b.index(pat)
|
||||||
|
struct.pack_into('<I', b, i + 4, 24)
|
||||||
|
open(p, 'wb').write(bytes(b))
|
||||||
|
p = '%s/undef%d.h5' % (out, os_)
|
||||||
|
off = make(p, os_, ['x', '', 'yz'])
|
||||||
|
b = bytearray(open(p, 'rb').read())
|
||||||
|
b[off + es:off + 2 * es] = elem(0, (1 << (8 * os_)) - 1, 1, os_)
|
||||||
|
open(p, 'wb').write(bytes(b))
|
||||||
|
for name in ('vl', 'bad', 'size', 'undef'):
|
||||||
|
with h5py.File('%s/%s%d.h5' % (out, name, os_), 'r') as f:
|
||||||
|
got = []
|
||||||
|
for i in range(f['d'].shape[0]):
|
||||||
|
try:
|
||||||
|
got.append(f['d'][i].hex())
|
||||||
|
except OSError:
|
||||||
|
got.append('error')
|
||||||
|
print('%s%d\t%s' % (name, os_, ','.join(got)))
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vl_strings_read_like_file_and_h5py() {
|
||||||
|
if !h5py_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let out = Command::new(python())
|
||||||
|
.args(["-c", SCRIPT])
|
||||||
|
.arg(dir.path())
|
||||||
|
.output()
|
||||||
|
.expect("run python");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
let h5py: std::collections::HashMap<String, String> = String::from_utf8(out.stdout)
|
||||||
|
.unwrap()
|
||||||
|
.lines()
|
||||||
|
.map(|l| {
|
||||||
|
let (k, v) = l.split_once('\t').unwrap();
|
||||||
|
(k.to_string(), v.to_string())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let read = |name: &str| {
|
||||||
|
let bytes = std::fs::read(dir.path().join(format!("{name}.h5"))).unwrap();
|
||||||
|
let wasm = Reader::open(bytes).unwrap().read("/d", None);
|
||||||
|
let file = File::open(dir.path().join(format!("{name}.h5")))
|
||||||
|
.unwrap()
|
||||||
|
.dataset("d")
|
||||||
|
.unwrap()
|
||||||
|
.read_string();
|
||||||
|
(wasm, file)
|
||||||
|
};
|
||||||
|
for os in [8, 4] {
|
||||||
|
// h5py: "a\0b" is "a"; the null element (address 0) is empty.
|
||||||
|
assert_eq!(h5py[&format!("vl{os}")], "61,,,7a7a");
|
||||||
|
let (wasm, file) = read(&format!("vl{os}"));
|
||||||
|
let Data::Strings(wasm) = wasm.unwrap().data else {
|
||||||
|
panic!("vl{os}: not strings")
|
||||||
|
};
|
||||||
|
assert_eq!(wasm, ["a", "", "", "zz"], "vl{os}");
|
||||||
|
assert_eq!(wasm, file.unwrap(), "vl{os}");
|
||||||
|
|
||||||
|
// h5py refuses the mis-sized element; so do both readers.
|
||||||
|
assert_eq!(h5py[&format!("bad{os}")], "error,6f6b");
|
||||||
|
let (wasm, file) = read(&format!("bad{os}"));
|
||||||
|
assert!(wasm.unwrap_err().contains("holds 6 bytes"), "bad{os}");
|
||||||
|
assert!(file.is_err(), "bad{os}");
|
||||||
|
|
||||||
|
// libhdf5 ignores the stored element size and reads the values;
|
||||||
|
// File refuses the datatype rather than guess its layout, and the
|
||||||
|
// wasm reader now does the same (it read with the stored size).
|
||||||
|
assert_eq!(h5py[&format!("size{os}")], "78,7979");
|
||||||
|
let (wasm, file) = read(&format!("size{os}"));
|
||||||
|
let e = wasm.unwrap_err();
|
||||||
|
assert!(e.contains("stores 24-byte elements"), "size{os}: {e}");
|
||||||
|
assert!(file.is_err(), "size{os}");
|
||||||
|
|
||||||
|
// Length 0 at the undefined heap address: libhdf5 fails the read
|
||||||
|
// ("addr undefined"); both readers returned "".
|
||||||
|
assert_eq!(h5py[&format!("undef{os}")], "78,error,797a");
|
||||||
|
let (wasm, file) = read(&format!("undef{os}"));
|
||||||
|
let e = wasm.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
e.contains("undefined global heap address"),
|
||||||
|
"undef{os}: {e}"
|
||||||
|
);
|
||||||
|
assert!(file.is_err(), "undef{os}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -422,11 +422,47 @@ impl<'f, R: HDF5Read> LazyDataset<'f, R> {
|
|||||||
Ok(data_read::read_as_u64(&raw, &dt)?)
|
Ok(data_read::read_as_u64(&raw, &dt)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read all data as `String` values.
|
/// Read all data as `String` values: fixed- or variable-length strings
|
||||||
|
/// (see [`Dataset::read_string`](crate::Dataset::read_string)).
|
||||||
pub fn read_string(&self) -> Result<Vec<String>, Error> {
|
pub fn read_string(&self) -> Result<Vec<String>, Error> {
|
||||||
let raw = self.read_raw()?;
|
let raw = self.read_raw()?;
|
||||||
let dt = self.datatype()?;
|
let dt = self.datatype()?;
|
||||||
Ok(data_read::read_as_strings(&raw, &dt)?)
|
crate::vlen::decode_strings(
|
||||||
|
self.file.hdf5_bytes(),
|
||||||
|
&dt,
|
||||||
|
&raw,
|
||||||
|
self.file.offset_size(),
|
||||||
|
self.file.length_size(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a variable-length string dataset as the exact bytes of each
|
||||||
|
/// string (see
|
||||||
|
/// [`Dataset::read_string_bytes`](crate::Dataset::read_string_bytes)).
|
||||||
|
pub fn read_string_bytes(&self) -> Result<Vec<Vec<u8>>, Error> {
|
||||||
|
let raw = self.read_raw()?;
|
||||||
|
let dt = self.datatype()?;
|
||||||
|
crate::vlen::decode_string_bytes(
|
||||||
|
self.file.hdf5_bytes(),
|
||||||
|
&dt,
|
||||||
|
&raw,
|
||||||
|
self.file.offset_size(),
|
||||||
|
self.file.length_size(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a variable-length sequence dataset as one `Vec<T>` per element
|
||||||
|
/// (see [`Dataset::read_vlen`](crate::Dataset::read_vlen)).
|
||||||
|
pub fn read_vlen<T: crate::vlen::VlenValue>(&self) -> Result<Vec<Vec<T>>, Error> {
|
||||||
|
let raw = self.read_raw()?;
|
||||||
|
let dt = self.datatype()?;
|
||||||
|
crate::vlen::decode_vlen(
|
||||||
|
self.file.hdf5_bytes(),
|
||||||
|
&dt,
|
||||||
|
&raw,
|
||||||
|
self.file.offset_size(),
|
||||||
|
self.file.length_size(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read all attributes of this dataset.
|
/// Read all attributes of this dataset.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub mod lazy;
|
|||||||
pub mod mmap_file;
|
pub mod mmap_file;
|
||||||
pub mod reader;
|
pub mod reader;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
pub mod vlen;
|
||||||
pub mod writer;
|
pub mod writer;
|
||||||
|
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
@@ -38,6 +39,7 @@ pub use lazy::{LazyDataset, LazyFile, LazyGroup};
|
|||||||
pub use mmap_file::{MmapDataset, MmapFile, MmapGroup};
|
pub use mmap_file::{MmapDataset, MmapFile, MmapGroup};
|
||||||
pub use reader::{Dataset, File, Group};
|
pub use reader::{Dataset, File, Group};
|
||||||
pub use types::{AttrValue, DType};
|
pub use types::{AttrValue, DType};
|
||||||
|
pub use vlen::VlenValue;
|
||||||
pub use writer::FileBuilder;
|
pub use writer::FileBuilder;
|
||||||
#[cfg(feature = "parallel")]
|
#[cfg(feature = "parallel")]
|
||||||
pub use writer::{DatasetSpec, create_datasets_parallel};
|
pub use writer::{DatasetSpec, create_datasets_parallel};
|
||||||
|
|||||||
@@ -336,11 +336,47 @@ impl<'f> MmapDataset<'f> {
|
|||||||
Ok(data_read::read_as_u64(&raw, &dt)?)
|
Ok(data_read::read_as_u64(&raw, &dt)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read all data as `String` values.
|
/// Read all data as `String` values: fixed- or variable-length strings
|
||||||
|
/// (see [`Dataset::read_string`](crate::Dataset::read_string)).
|
||||||
pub fn read_string(&self) -> Result<Vec<String>, Error> {
|
pub fn read_string(&self) -> Result<Vec<String>, Error> {
|
||||||
let raw = self.read_raw()?;
|
let raw = self.read_raw()?;
|
||||||
let dt = self.datatype()?;
|
let dt = self.datatype()?;
|
||||||
Ok(data_read::read_as_strings(&raw, &dt)?)
|
crate::vlen::decode_strings(
|
||||||
|
self.file.hdf5_bytes(),
|
||||||
|
&dt,
|
||||||
|
&raw,
|
||||||
|
self.file.offset_size(),
|
||||||
|
self.file.length_size(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a variable-length string dataset as the exact bytes of each
|
||||||
|
/// string (see
|
||||||
|
/// [`Dataset::read_string_bytes`](crate::Dataset::read_string_bytes)).
|
||||||
|
pub fn read_string_bytes(&self) -> Result<Vec<Vec<u8>>, Error> {
|
||||||
|
let raw = self.read_raw()?;
|
||||||
|
let dt = self.datatype()?;
|
||||||
|
crate::vlen::decode_string_bytes(
|
||||||
|
self.file.hdf5_bytes(),
|
||||||
|
&dt,
|
||||||
|
&raw,
|
||||||
|
self.file.offset_size(),
|
||||||
|
self.file.length_size(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a variable-length sequence dataset as one `Vec<T>` per element
|
||||||
|
/// (see [`Dataset::read_vlen`](crate::Dataset::read_vlen)).
|
||||||
|
pub fn read_vlen<T: crate::vlen::VlenValue>(&self) -> Result<Vec<Vec<T>>, Error> {
|
||||||
|
let raw = self.read_raw()?;
|
||||||
|
let dt = self.datatype()?;
|
||||||
|
crate::vlen::decode_vlen(
|
||||||
|
self.file.hdf5_bytes(),
|
||||||
|
&dt,
|
||||||
|
&raw,
|
||||||
|
self.file.offset_size(),
|
||||||
|
self.file.length_size(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// For contiguous datasets, return a zero-copy slice into the mmap.
|
/// For contiguous datasets, return a zero-copy slice into the mmap.
|
||||||
|
|||||||
+155
-15
@@ -176,6 +176,22 @@ impl File {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A `Dataset` handle for the object header at `address` (an address
|
||||||
|
/// from a group listing, or one kept from an earlier lookup), without
|
||||||
|
/// resolving a path. Resolving a path walks every group on it, which in
|
||||||
|
/// a large group costs a scan of its links; keep the address instead to
|
||||||
|
/// open the same dataset repeatedly.
|
||||||
|
pub fn dataset_at(&self, address: u64) -> Result<Dataset<'_>, Error> {
|
||||||
|
let hdr = self.parse_header(address)?;
|
||||||
|
if !has_message(&hdr, MessageType::DataLayout) {
|
||||||
|
return Err(Error::NotADataset(format!("object at address {address}")));
|
||||||
|
}
|
||||||
|
Ok(Dataset {
|
||||||
|
file: self,
|
||||||
|
header: hdr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve a path and return a `Group` handle.
|
/// Resolve a path and return a `Group` handle.
|
||||||
///
|
///
|
||||||
/// The path uses `/` separators (e.g., `"sensors"`).
|
/// The path uses `/` separators (e.g., `"sensors"`).
|
||||||
@@ -262,6 +278,56 @@ impl File {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decode the strings in `raw`, a buffer of elements of `datatype` read
|
||||||
|
/// from this file — for instance a variable-length string field of a
|
||||||
|
/// compound ([`clawhdf5_format::data_read::read_compound_fields`]) or an
|
||||||
|
/// [`AttrValue::Raw`] attribute. Variable-length strings are resolved in
|
||||||
|
/// this file's global heap; see [`Dataset::read_string`] for the values.
|
||||||
|
pub fn decode_strings(&self, datatype: &Datatype, raw: &[u8]) -> Result<Vec<String>, Error> {
|
||||||
|
crate::vlen::decode_strings(
|
||||||
|
self.as_bytes(),
|
||||||
|
datatype,
|
||||||
|
raw,
|
||||||
|
self.offset_size(),
|
||||||
|
self.length_size(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`decode_strings`](Self::decode_strings) for variable-length
|
||||||
|
/// strings, returning each string's exact bytes (see
|
||||||
|
/// [`Dataset::read_string_bytes`]).
|
||||||
|
pub fn decode_string_bytes(
|
||||||
|
&self,
|
||||||
|
datatype: &Datatype,
|
||||||
|
raw: &[u8],
|
||||||
|
) -> Result<Vec<Vec<u8>>, Error> {
|
||||||
|
crate::vlen::decode_string_bytes(
|
||||||
|
self.as_bytes(),
|
||||||
|
datatype,
|
||||||
|
raw,
|
||||||
|
self.offset_size(),
|
||||||
|
self.length_size(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the variable-length sequences in `raw`, a buffer of elements
|
||||||
|
/// of the sequence type `datatype` read from this file (a compound
|
||||||
|
/// field, an [`AttrValue::Raw`] attribute, ...). See
|
||||||
|
/// [`Dataset::read_vlen`].
|
||||||
|
pub fn decode_vlen<T: crate::vlen::VlenValue>(
|
||||||
|
&self,
|
||||||
|
datatype: &Datatype,
|
||||||
|
raw: &[u8],
|
||||||
|
) -> Result<Vec<Vec<T>>, Error> {
|
||||||
|
crate::vlen::decode_vlen(
|
||||||
|
self.as_bytes(),
|
||||||
|
datatype,
|
||||||
|
raw,
|
||||||
|
self.offset_size(),
|
||||||
|
self.length_size(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_header(&self, address: u64) -> Result<ObjectHeader, FormatError> {
|
fn parse_header(&self, address: u64) -> Result<ObjectHeader, FormatError> {
|
||||||
ObjectHeader::parse(
|
ObjectHeader::parse(
|
||||||
self.data.as_bytes(),
|
self.data.as_bytes(),
|
||||||
@@ -498,19 +564,74 @@ impl<'f> Dataset<'f> {
|
|||||||
Ok(data_read::read_as_u64(&raw, &dt)?)
|
Ok(data_read::read_as_u64(&raw, &dt)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read all data as `String` values.
|
/// Read all data as `String` values, in row-major order.
|
||||||
|
///
|
||||||
|
/// Works for fixed-length and variable-length string datasets (h5py's
|
||||||
|
/// default `str` dtype). A variable-length string ends at its first NUL
|
||||||
|
/// and a null element (e.g. never written) is `""`, as h5py returns
|
||||||
|
/// them; bytes that are not valid UTF-8 are replaced with U+FFFD — use
|
||||||
|
/// [`read_string_bytes`](Self::read_string_bytes) for the exact bytes.
|
||||||
pub fn read_string(&self) -> Result<Vec<String>, Error> {
|
pub fn read_string(&self) -> Result<Vec<String>, Error> {
|
||||||
let raw = self.read_raw()?;
|
let raw = self.read_raw()?;
|
||||||
let dt = self.datatype()?;
|
let dt = self.datatype()?;
|
||||||
Ok(data_read::read_as_strings(&raw, &dt)?)
|
self.file.decode_strings(&dt, &raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a variable-length string dataset as the exact bytes of each
|
||||||
|
/// string (what h5py's `Dataset[()]` returns), in row-major order.
|
||||||
|
pub fn read_string_bytes(&self) -> Result<Vec<Vec<u8>>, Error> {
|
||||||
|
let raw = self.read_raw()?;
|
||||||
|
let dt = self.datatype()?;
|
||||||
|
self.file.decode_string_bytes(&dt, &raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the selected elements of a fixed- or variable-length string
|
||||||
|
/// dataset (see [`read_string`](Self::read_string)).
|
||||||
|
pub fn read_string_selection(
|
||||||
|
&self,
|
||||||
|
selection: &clawhdf5_format::selection::Selection,
|
||||||
|
) -> Result<Vec<String>, Error> {
|
||||||
|
let raw = self.read_selection(selection)?;
|
||||||
|
let dt = self.datatype()?;
|
||||||
|
self.file.decode_strings(&dt, &raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a variable-length sequence dataset (h5py
|
||||||
|
/// `vlen_dtype(np.int32)`, ...) as one `Vec<T>` per element, in
|
||||||
|
/// row-major order. The base type must be an integer or float type; it
|
||||||
|
/// is converted to `T` as [`read_f64`](Self::read_f64) and the other
|
||||||
|
/// typed readers convert. A null element is an empty sequence.
|
||||||
|
pub fn read_vlen<T: crate::vlen::VlenValue>(&self) -> Result<Vec<Vec<T>>, Error> {
|
||||||
|
let raw = self.read_raw()?;
|
||||||
|
let dt = self.datatype()?;
|
||||||
|
self.file.decode_vlen(&dt, &raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the selected elements of a variable-length sequence dataset
|
||||||
|
/// (see [`read_vlen`](Self::read_vlen)).
|
||||||
|
pub fn read_vlen_selection<T: crate::vlen::VlenValue>(
|
||||||
|
&self,
|
||||||
|
selection: &clawhdf5_format::selection::Selection,
|
||||||
|
) -> Result<Vec<Vec<T>>, Error> {
|
||||||
|
let raw = self.read_selection(selection)?;
|
||||||
|
let dt = self.datatype()?;
|
||||||
|
self.file.decode_vlen(&dt, &raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Selection-based read methods -----
|
// ----- Selection-based read methods -----
|
||||||
|
|
||||||
/// Read selected elements as raw bytes.
|
/// Read selected elements as raw bytes.
|
||||||
///
|
///
|
||||||
/// Only the elements matching the [`clawhdf5_format::selection::Selection`] are returned. For chunked
|
/// Only the elements matching the [`clawhdf5_format::selection::Selection`] are returned.
|
||||||
/// datasets, only intersecting chunks are decompressed.
|
///
|
||||||
|
/// What is read to get them: when the selection's bounding box covers at
|
||||||
|
/// most half the dataset, only that box — the chunks overlapping it, or
|
||||||
|
/// the rows of a contiguous dataset. The whole dataset is decoded instead
|
||||||
|
/// when the box covers more than half (a strided selection spanning the
|
||||||
|
/// dataset does), for compact and virtual layouts, for a dataset with no
|
||||||
|
/// storage, and for a chunked dataset with a non-default fill value.
|
||||||
|
/// [`Selection::All`](clawhdf5_format::selection::Selection::All) goes
|
||||||
|
/// through the file's chunk cache; other selections do not.
|
||||||
pub fn read_selection(
|
pub fn read_selection(
|
||||||
&self,
|
&self,
|
||||||
selection: &clawhdf5_format::selection::Selection,
|
selection: &clawhdf5_format::selection::Selection,
|
||||||
@@ -569,9 +690,7 @@ impl<'f> Dataset<'f> {
|
|||||||
&self,
|
&self,
|
||||||
selection: &clawhdf5_format::selection::Selection,
|
selection: &clawhdf5_format::selection::Selection,
|
||||||
) -> Result<Vec<f64>, Error> {
|
) -> Result<Vec<f64>, Error> {
|
||||||
let raw = self.read_selection(selection)?;
|
self.read_typed_selection(selection, data_read::read_as_f64, || self.read_f64())
|
||||||
let dt = self.datatype()?;
|
|
||||||
Ok(data_read::read_as_f64(&raw, &dt)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read selected elements as `f32` values.
|
/// Read selected elements as `f32` values.
|
||||||
@@ -579,9 +698,7 @@ impl<'f> Dataset<'f> {
|
|||||||
&self,
|
&self,
|
||||||
selection: &clawhdf5_format::selection::Selection,
|
selection: &clawhdf5_format::selection::Selection,
|
||||||
) -> Result<Vec<f32>, Error> {
|
) -> Result<Vec<f32>, Error> {
|
||||||
let raw = self.read_selection(selection)?;
|
self.read_typed_selection(selection, data_read::read_as_f32, || self.read_f32())
|
||||||
let dt = self.datatype()?;
|
|
||||||
Ok(data_read::read_as_f32(&raw, &dt)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read selected elements as `i32` values.
|
/// Read selected elements as `i32` values.
|
||||||
@@ -589,9 +706,7 @@ impl<'f> Dataset<'f> {
|
|||||||
&self,
|
&self,
|
||||||
selection: &clawhdf5_format::selection::Selection,
|
selection: &clawhdf5_format::selection::Selection,
|
||||||
) -> Result<Vec<i32>, Error> {
|
) -> Result<Vec<i32>, Error> {
|
||||||
let raw = self.read_selection(selection)?;
|
self.read_typed_selection(selection, data_read::read_as_i32, || self.read_i32())
|
||||||
let dt = self.datatype()?;
|
|
||||||
Ok(data_read::read_as_i32(&raw, &dt)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read selected elements as `i64` values.
|
/// Read selected elements as `i64` values.
|
||||||
@@ -599,9 +714,34 @@ impl<'f> Dataset<'f> {
|
|||||||
&self,
|
&self,
|
||||||
selection: &clawhdf5_format::selection::Selection,
|
selection: &clawhdf5_format::selection::Selection,
|
||||||
) -> Result<Vec<i64>, Error> {
|
) -> Result<Vec<i64>, Error> {
|
||||||
let raw = self.read_selection(selection)?;
|
self.read_typed_selection(selection, data_read::read_as_i64, || self.read_i64())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The typed selection readers. `All` is a full read. A contiguous dataset
|
||||||
|
/// that stores `T` natively is copied from the file straight into the
|
||||||
|
/// `Vec<T>`, one copy per contiguous run of selected elements; anything
|
||||||
|
/// else reads the selection's bytes and converts them with `convert`.
|
||||||
|
fn read_typed_selection<T: data_read::NativeElement>(
|
||||||
|
&self,
|
||||||
|
selection: &clawhdf5_format::selection::Selection,
|
||||||
|
convert: fn(&[u8], &Datatype) -> Result<Vec<T>, FormatError>,
|
||||||
|
full: impl FnOnce() -> Result<Vec<T>, Error>,
|
||||||
|
) -> Result<Vec<T>, Error> {
|
||||||
|
if matches!(selection, clawhdf5_format::selection::Selection::All) {
|
||||||
|
return full();
|
||||||
|
}
|
||||||
let dt = self.datatype()?;
|
let dt = self.datatype()?;
|
||||||
Ok(data_read::read_as_i64(&raw, &dt)?)
|
if T::is_native(&dt)
|
||||||
|
&& let Ok(Some(raw)) = self.read_raw_ref()
|
||||||
|
{
|
||||||
|
let dims = self.dataspace()?.dimensions;
|
||||||
|
if let Some(values) = data_read::read_selection_native::<T>(raw, &dims, &dt, selection)?
|
||||||
|
{
|
||||||
|
return Ok(values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let raw = self.read_selection(selection)?;
|
||||||
|
Ok(convert(&raw, &dt)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zero-copy read of contiguous raw data.
|
/// Zero-copy read of contiguous raw data.
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//! Variable-length data: VL strings and VL sequences of numbers.
|
||||||
|
//!
|
||||||
|
//! A variable-length element stores a reference into the file's global heap;
|
||||||
|
//! these helpers resolve the references in a buffer of raw elements (from a
|
||||||
|
//! dataset read, a selection, a compound field or an [`AttrValue::Raw`]
|
||||||
|
//! attribute) against the file they came from.
|
||||||
|
//!
|
||||||
|
//! Values match libhdf5 (and h5py): a string ends at its first NUL, a null
|
||||||
|
//! element is an empty string or sequence, and a heap object whose size
|
||||||
|
//! disagrees with its element is an error rather than a truncated value.
|
||||||
|
//!
|
||||||
|
//! [`AttrValue::Raw`]: crate::AttrValue::Raw
|
||||||
|
|
||||||
|
use clawhdf5_format::data_read;
|
||||||
|
use clawhdf5_format::datatype::Datatype;
|
||||||
|
use clawhdf5_format::error::FormatError;
|
||||||
|
use clawhdf5_format::vl_data::{VlResolver, check_element_size};
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
mod sealed {
|
||||||
|
pub trait Sealed {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A number type that [`Dataset::read_vlen`](crate::Dataset::read_vlen) can
|
||||||
|
/// return: the sequence's base type is converted to it as libhdf5 converts
|
||||||
|
/// numbers (the same rules as `read_f64`, `read_i64`, ...).
|
||||||
|
pub trait VlenValue: sealed::Sealed + Sized {
|
||||||
|
#[doc(hidden)]
|
||||||
|
fn decode(raw: &[u8], base: &Datatype) -> Result<Vec<Self>, FormatError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! vlen_value {
|
||||||
|
($t:ty, $f:path) => {
|
||||||
|
impl sealed::Sealed for $t {}
|
||||||
|
impl VlenValue for $t {
|
||||||
|
fn decode(raw: &[u8], base: &Datatype) -> Result<Vec<Self>, FormatError> {
|
||||||
|
$f(raw, base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
vlen_value!(f64, data_read::read_as_f64);
|
||||||
|
vlen_value!(f32, data_read::read_as_f32);
|
||||||
|
vlen_value!(i64, data_read::read_as_i64);
|
||||||
|
vlen_value!(i32, data_read::read_as_i32);
|
||||||
|
vlen_value!(u64, data_read::read_as_u64);
|
||||||
|
|
||||||
|
fn class_name(dt: &Datatype) -> &'static str {
|
||||||
|
match dt {
|
||||||
|
Datatype::FixedPoint { .. } => "integer",
|
||||||
|
Datatype::FloatingPoint { .. } => "float",
|
||||||
|
Datatype::Time { .. } => "time",
|
||||||
|
Datatype::String { .. } => "fixed-length string",
|
||||||
|
Datatype::BitField { .. } => "bitfield",
|
||||||
|
Datatype::Opaque { .. } => "opaque",
|
||||||
|
Datatype::Compound { .. } => "compound",
|
||||||
|
Datatype::Reference { .. } => "reference",
|
||||||
|
Datatype::Enumeration { .. } => "enum",
|
||||||
|
Datatype::VariableLength {
|
||||||
|
is_string: true, ..
|
||||||
|
} => "variable-length string",
|
||||||
|
Datatype::VariableLength { .. } => "variable-length sequence",
|
||||||
|
Datatype::Array { .. } => "array",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The strings in `raw`, elements of `dt`: fixed-length strings decoded as
|
||||||
|
/// `read_string` always has, variable-length strings resolved in the heap.
|
||||||
|
pub(crate) fn decode_strings(
|
||||||
|
file_data: &[u8],
|
||||||
|
dt: &Datatype,
|
||||||
|
raw: &[u8],
|
||||||
|
offset_size: u8,
|
||||||
|
length_size: u8,
|
||||||
|
) -> Result<Vec<String>, Error> {
|
||||||
|
match dt {
|
||||||
|
Datatype::VariableLength {
|
||||||
|
size,
|
||||||
|
is_string: true,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
check_element_size(*size, offset_size)?;
|
||||||
|
Ok(VlResolver::new(file_data, offset_size, length_size).strings(raw)?)
|
||||||
|
}
|
||||||
|
_ => Ok(data_read::read_as_strings(raw, dt)?),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exact bytes of the variable-length strings in `raw`.
|
||||||
|
pub(crate) fn decode_string_bytes(
|
||||||
|
file_data: &[u8],
|
||||||
|
dt: &Datatype,
|
||||||
|
raw: &[u8],
|
||||||
|
offset_size: u8,
|
||||||
|
length_size: u8,
|
||||||
|
) -> Result<Vec<Vec<u8>>, Error> {
|
||||||
|
match dt {
|
||||||
|
Datatype::VariableLength {
|
||||||
|
size,
|
||||||
|
is_string: true,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
check_element_size(*size, offset_size)?;
|
||||||
|
Ok(VlResolver::new(file_data, offset_size, length_size).string_bytes(raw)?)
|
||||||
|
}
|
||||||
|
other => Err(Error::Format(FormatError::TypeMismatch {
|
||||||
|
expected: "variable-length string",
|
||||||
|
actual: class_name(other),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sequences in `raw`, elements of the variable-length sequence type
|
||||||
|
/// `dt`, converted to `T`.
|
||||||
|
pub(crate) fn decode_vlen<T: VlenValue>(
|
||||||
|
file_data: &[u8],
|
||||||
|
dt: &Datatype,
|
||||||
|
raw: &[u8],
|
||||||
|
offset_size: u8,
|
||||||
|
length_size: u8,
|
||||||
|
) -> Result<Vec<Vec<T>>, Error> {
|
||||||
|
let Datatype::VariableLength {
|
||||||
|
size,
|
||||||
|
is_string: false,
|
||||||
|
base_type,
|
||||||
|
..
|
||||||
|
} = dt
|
||||||
|
else {
|
||||||
|
return Err(Error::Format(FormatError::TypeMismatch {
|
||||||
|
expected: "variable-length sequence",
|
||||||
|
actual: class_name(dt),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
check_element_size(*size, offset_size)?;
|
||||||
|
let base_size = base_type.type_size() as usize;
|
||||||
|
VlResolver::new(file_data, offset_size, length_size)
|
||||||
|
.sequences(raw, base_size)?
|
||||||
|
.iter()
|
||||||
|
.map(|bytes| Ok(T::decode(bytes, base_type)?))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -42,14 +42,17 @@ impl FileBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a dataset at the root level. Returns a mutable reference to
|
/// Create a dataset. Returns a mutable reference to a `DatasetBuilder`
|
||||||
/// a `DatasetBuilder` for configuring data, shape, and attributes.
|
/// for configuring data, shape, and attributes. `name` may be a path
|
||||||
|
/// (`"a/b/x"`): missing intermediate groups are created, as in h5py.
|
||||||
pub fn create_dataset(&mut self, name: &str) -> &mut FormatDatasetBuilder {
|
pub fn create_dataset(&mut self, name: &str) -> &mut FormatDatasetBuilder {
|
||||||
self.writer.create_dataset(name)
|
self.writer.create_dataset(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a group builder. Call `.finish()` on the returned builder
|
/// Create a group builder. Call `.finish()` on the returned builder
|
||||||
/// to complete it, then pass to `add_group()`.
|
/// to complete it, then pass to `add_group()`. `name` may be a path;
|
||||||
|
/// groups nest to any depth (see `GroupBuilder::add_group`), and a group
|
||||||
|
/// added at a path that already holds a group is merged into it.
|
||||||
pub fn create_group(&mut self, name: &str) -> FormatGroupBuilder {
|
pub fn create_group(&mut self, name: &str) -> FormatGroupBuilder {
|
||||||
self.writer.create_group(name)
|
self.writer.create_group(name)
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,40 @@ impl FileBuilder {
|
|||||||
self.writer.add_group(group);
|
self.writer.add_group(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a soft link `name` to the path `target`, like h5py's
|
||||||
|
/// `f[name] = h5py.SoftLink(target)`. The target need not exist.
|
||||||
|
pub fn add_soft_link(&mut self, name: &str, target: &str) -> &mut Self {
|
||||||
|
self.writer.add_soft_link(name, target);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add another hard link `name` to the object at `target`, like h5py's
|
||||||
|
/// `f[name] = f[target]`. The target must be written in this file.
|
||||||
|
pub fn add_hard_link(&mut self, name: &str, target: &str) -> &mut Self {
|
||||||
|
self.writer.add_hard_link(name, target);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an external link `name` to `target_path` in `target_file`.
|
||||||
|
pub fn add_external_link(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
target_file: &str,
|
||||||
|
target_path: &str,
|
||||||
|
) -> &mut Self {
|
||||||
|
self.writer
|
||||||
|
.add_external_link(name, target_file, target_path);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track link creation order in every group that does not set its own
|
||||||
|
/// (`GroupBuilder::track_order`), as h5py's `track_order=True`: libhdf5
|
||||||
|
/// then lists members in the order they were added.
|
||||||
|
pub fn track_order(&mut self, track: bool) -> &mut Self {
|
||||||
|
self.writer.track_order(track);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Set an attribute on the root group.
|
/// Set an attribute on the root group.
|
||||||
pub fn set_attr(&mut self, name: &str, value: AttrValue) {
|
pub fn set_attr(&mut self, name: &str, value: AttrValue) {
|
||||||
self.writer.set_root_attr(name, value);
|
self.writer.set_root_attr(name, value);
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
//! Reads of contiguous datasets — full reads and hyperslab/point selections,
|
||||||
|
//! through every typed reader — checked against h5py/libhdf5 for every
|
||||||
|
//! integer and float width, both byte orders, ranks 1 to 4, and datasets
|
||||||
|
//! larger than the huge-page threshold (4 MiB) the read buffers use.
|
||||||
|
//!
|
||||||
|
//! h5py writes the file and, for each selection, reads it with libhdf5's own
|
||||||
|
//! hyperslab/point selection (`select_hyperslab` with stride and block,
|
||||||
|
//! `select_elements`) and saves the raw bytes it gets back; the byte-level
|
||||||
|
//! [`Dataset::read_selection`] must return exactly those bytes, and the typed
|
||||||
|
//! readers the same values. Skipped when python3 with h5py is unavailable,
|
||||||
|
//! unless `CLAWHDF5_REQUIRE_INTEROP=1`.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use clawhdf5::File;
|
||||||
|
use clawhdf5_format::selection::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 python_available() -> bool {
|
||||||
|
Command::new(python())
|
||||||
|
.args(["-c", "import h5py, numpy"])
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! skip_if_no_python {
|
||||||
|
() => {
|
||||||
|
if !python_available() {
|
||||||
|
assert!(
|
||||||
|
!interop_required(),
|
||||||
|
"CLAWHDF5_REQUIRE_INTEROP=1 but python3 with h5py is not available"
|
||||||
|
);
|
||||||
|
eprintln!("SKIP: python3 with h5py not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_python(script: &str) {
|
||||||
|
let output = Command::new(python())
|
||||||
|
.args(["-c", script])
|
||||||
|
.output()
|
||||||
|
.expect("failed to run python");
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"python failed:\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// numpy type codes, with the modulus of the value pattern: every value is an
|
||||||
|
/// integer exactly representable in the type and in every typed reader's
|
||||||
|
/// output (f16 is exact below 2048, f32 below 2^24).
|
||||||
|
const DTYPES: [(&str, i64, bool); 11] = [
|
||||||
|
("i1", 201, true),
|
||||||
|
("u1", 251, false),
|
||||||
|
("i2", 2039, true),
|
||||||
|
("u2", 2039, false),
|
||||||
|
("i4", 1_000_003, true),
|
||||||
|
("u4", 1_000_003, false),
|
||||||
|
("i8", 1_000_003, true),
|
||||||
|
("u8", 1_000_003, false),
|
||||||
|
("f2", 2039, true),
|
||||||
|
("f4", 1_000_003, true),
|
||||||
|
("f8", 1_000_003, true),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Value of element `i` of a dataset of type `code` (the same formula as the
|
||||||
|
/// Python side): a permutation-revealing pattern, centred on 0 when signed.
|
||||||
|
fn value(code: &str, i: u64) -> i64 {
|
||||||
|
let (_, m, signed) = DTYPES.iter().find(|d| d.0 == code).unwrap();
|
||||||
|
let v = ((i as i128 * 7919) % *m as i128) as i64;
|
||||||
|
if *signed { v - m / 2 } else { v }
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHAPES: [&[u64]; 4] = [&[1000], &[37, 53], &[7, 11, 13], &[3, 5, 7, 9]];
|
||||||
|
|
||||||
|
/// Datasets past the 4 MiB huge-page threshold, as (type, shape).
|
||||||
|
const BIG: [(&str, [u64; 2]); 4] = [
|
||||||
|
("f4", [1100, 1024]),
|
||||||
|
("i4", [1100, 1024]),
|
||||||
|
("f8", [600, 1024]),
|
||||||
|
("i8", [600, 1024]),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn datasets() -> Vec<(String, String, Vec<u64>)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (code, _, _) in DTYPES {
|
||||||
|
for (tag, _) in [("le", '<'), ("be", '>')] {
|
||||||
|
for shape in SHAPES {
|
||||||
|
out.push((
|
||||||
|
format!("{code}{tag}_r{}", shape.len()),
|
||||||
|
code.to_string(),
|
||||||
|
shape.to_vec(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (code, shape) in BIG {
|
||||||
|
for tag in ["le", "be"] {
|
||||||
|
out.push((format!("{code}{tag}_big"), code.to_string(), shape.to_vec()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_file(path: &Path) {
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
import h5py, numpy as np
|
||||||
|
M = {{'i1': 201, 'u1': 251, 'i2': 2039, 'u2': 2039, 'i4': 1000003, 'u4': 1000003,
|
||||||
|
'i8': 1000003, 'u8': 1000003, 'f2': 2039, 'f4': 1000003, 'f8': 1000003}}
|
||||||
|
def values(code, n):
|
||||||
|
v = (np.arange(n, dtype=np.int64) * 7919) % M[code]
|
||||||
|
if code[0] != 'u':
|
||||||
|
v -= M[code] // 2
|
||||||
|
return v
|
||||||
|
shapes = [(1000,), (37, 53), (7, 11, 13), (3, 5, 7, 9)]
|
||||||
|
big = [('f4', (1100, 1024)), ('i4', (1100, 1024)), ('f8', (600, 1024)), ('i8', (600, 1024))]
|
||||||
|
with h5py.File("{path}", "w") as f:
|
||||||
|
for code in M:
|
||||||
|
for tag, e in (('le', '<'), ('be', '>')):
|
||||||
|
for shape in shapes:
|
||||||
|
n = int(np.prod(shape))
|
||||||
|
f.create_dataset(f"{{code}}{{tag}}_r{{len(shape)}}",
|
||||||
|
data=values(code, n).astype(e + code).reshape(shape))
|
||||||
|
for code, shape in big:
|
||||||
|
for tag, e in (('le', '<'), ('be', '>')):
|
||||||
|
n = int(np.prod(shape))
|
||||||
|
f.create_dataset(f"{{code}}{{tag}}_big",
|
||||||
|
data=values(code, n).astype(e + code).reshape(shape))
|
||||||
|
"#,
|
||||||
|
path = path.display()
|
||||||
|
);
|
||||||
|
run_python(&script);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_reads_match_h5py_for_every_type_order_and_size() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("contig.h5");
|
||||||
|
write_file(&path);
|
||||||
|
let file = File::open(&path).unwrap();
|
||||||
|
for (name, code, shape) in datasets() {
|
||||||
|
let ds = file.dataset(&name).unwrap();
|
||||||
|
assert!(ds.read_raw_ref().unwrap().is_some(), "{name} is contiguous");
|
||||||
|
let n: u64 = shape.iter().product();
|
||||||
|
let want: Vec<i64> = (0..n).map(|i| value(&code, i)).collect();
|
||||||
|
assert_eq!(
|
||||||
|
ds.read_f64().unwrap(),
|
||||||
|
want.iter().map(|&v| v as f64).collect::<Vec<_>>(),
|
||||||
|
"{name} read_f64"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ds.read_f32().unwrap(),
|
||||||
|
want.iter().map(|&v| v as f32).collect::<Vec<_>>(),
|
||||||
|
"{name} read_f32"
|
||||||
|
);
|
||||||
|
assert_eq!(ds.read_i64().unwrap(), want, "{name} read_i64");
|
||||||
|
assert_eq!(
|
||||||
|
ds.read_i32().unwrap(),
|
||||||
|
want.iter().map(|&v| v as i32).collect::<Vec<_>>(),
|
||||||
|
"{name} read_i32"
|
||||||
|
);
|
||||||
|
// libhdf5 saturates negative values to 0 when reading as unsigned.
|
||||||
|
assert_eq!(
|
||||||
|
ds.read_u64().unwrap(),
|
||||||
|
want.iter().map(|&v| v.max(0) as u64).collect::<Vec<_>>(),
|
||||||
|
"{name} read_u64"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.max(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hyperslab from per-dimension `(start, stride, count, block)`.
|
||||||
|
fn slab(dims: &[(u64, u64, u64, u64)]) -> Selection {
|
||||||
|
Selection::Hyperslab {
|
||||||
|
start: dims.iter().map(|d| d.0).collect(),
|
||||||
|
stride: dims.iter().map(|d| d.1).collect(),
|
||||||
|
count: dims.iter().map(|d| d.2).collect(),
|
||||||
|
block: dims.iter().map(|d| d.3).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selections of every shape the read paths distinguish, all valid for `dims`.
|
||||||
|
fn selections(rng: &mut Rng, dims: &[u64]) -> Vec<Selection> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
// Unit-stride box.
|
||||||
|
out.push(slab(
|
||||||
|
&dims
|
||||||
|
.iter()
|
||||||
|
.map(|&n| {
|
||||||
|
let c = 1 + rng.below(n);
|
||||||
|
(rng.below(n - c + 1), 1, c, 1)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
));
|
||||||
|
// Strided (block 1), blocked (stride > block), and adjacent blocks
|
||||||
|
// (stride == block, which reads like a box): (block, stride - block).
|
||||||
|
for (block, gap) in [(1, 1), (2, 1), (2, 0)] {
|
||||||
|
out.push(slab(
|
||||||
|
&dims
|
||||||
|
.iter()
|
||||||
|
.map(|&n| {
|
||||||
|
let b = (block + rng.below(2)).min(n);
|
||||||
|
let st = b + gap + rng.below(2) * gap;
|
||||||
|
let s = rng.below(n - b + 1);
|
||||||
|
let c = 1 + rng.below((n - s - b) / st + 1);
|
||||||
|
(s, st, c, b)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Whole inner rows (one run across rows), and the whole dataset.
|
||||||
|
let r0 = rng.below(dims[0]);
|
||||||
|
let mut rows = vec![(r0, 1, 1 + rng.below(dims[0] - r0), 1)];
|
||||||
|
rows.extend(dims[1..].iter().map(|&n| (0, 1, n, 1)));
|
||||||
|
out.push(slab(&rows));
|
||||||
|
out.push(slab(
|
||||||
|
&dims.iter().map(|&n| (0, 1, n, 1)).collect::<Vec<_>>(),
|
||||||
|
));
|
||||||
|
// One element.
|
||||||
|
out.push(slab(
|
||||||
|
&dims
|
||||||
|
.iter()
|
||||||
|
.map(|&n| (rng.below(n), 1, 1, 1))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
));
|
||||||
|
// Distinct points in no particular order.
|
||||||
|
let mut points: Vec<Vec<u64>> = Vec::new();
|
||||||
|
for _ in 0..1 + rng.below(15) {
|
||||||
|
let p: Vec<u64> = dims.iter().map(|&n| rng.below(n)).collect();
|
||||||
|
if !points.contains(&p) {
|
||||||
|
points.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(Selection::Points(points));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join(v: &[u64]) -> String {
|
||||||
|
v.iter().map(u64::to_string).collect::<Vec<_>>().join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One element of type `code`, given as its raw file-order bytes, as an
|
||||||
|
/// integer (every value in these files is one).
|
||||||
|
fn decode(code: &str, big_endian: bool, bytes: &[u8]) -> i64 {
|
||||||
|
let mut b = bytes.to_vec();
|
||||||
|
if big_endian {
|
||||||
|
b.reverse();
|
||||||
|
}
|
||||||
|
let mut w = [0u8; 8];
|
||||||
|
w[..b.len()].copy_from_slice(&b);
|
||||||
|
let u = u64::from_le_bytes(w);
|
||||||
|
match code {
|
||||||
|
"i1" => u as u8 as i8 as i64,
|
||||||
|
"i2" => u as u16 as i16 as i64,
|
||||||
|
"i4" => u as u32 as i32 as i64,
|
||||||
|
"i8" => u as i64,
|
||||||
|
"u1" | "u2" | "u4" | "u8" => u as i64,
|
||||||
|
"f2" => clawhdf5_format::float16::f16_bits_to_f32(u as u16) as i64,
|
||||||
|
"f4" => f32::from_bits(u as u32) as i64,
|
||||||
|
"f8" => f64::from_bits(u) as i64,
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_reads_match_h5py_for_every_type_order_and_rank() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("contig.h5");
|
||||||
|
write_file(&path);
|
||||||
|
|
||||||
|
let mut rng = Rng(2026);
|
||||||
|
let mut cases: Vec<(String, String, Selection)> = Vec::new();
|
||||||
|
for (name, code, shape) in datasets() {
|
||||||
|
for sel in selections(&mut rng, &shape) {
|
||||||
|
cases.push((name.clone(), code.clone(), sel));
|
||||||
|
}
|
||||||
|
// Empty: a zero count, and Selection::None.
|
||||||
|
let mut empty = shape.iter().map(|&n| (0, 1, n, 1)).collect::<Vec<_>>();
|
||||||
|
empty[shape.len() - 1].2 = 0;
|
||||||
|
cases.push((name.clone(), code.clone(), slab(&empty)));
|
||||||
|
cases.push((name, code, Selection::None));
|
||||||
|
}
|
||||||
|
let mut spec = String::new();
|
||||||
|
for (k, (name, _, sel)) in cases.iter().enumerate() {
|
||||||
|
let line = match sel {
|
||||||
|
Selection::Hyperslab { count, .. } if count.contains(&0) => "N".to_string(),
|
||||||
|
Selection::Hyperslab {
|
||||||
|
start,
|
||||||
|
stride,
|
||||||
|
count,
|
||||||
|
block,
|
||||||
|
} => format!(
|
||||||
|
"H {};{};{};{}",
|
||||||
|
join(start),
|
||||||
|
join(stride),
|
||||||
|
join(count),
|
||||||
|
join(block)
|
||||||
|
),
|
||||||
|
Selection::Points(points) => format!(
|
||||||
|
"P {}",
|
||||||
|
points.iter().map(|p| join(p)).collect::<Vec<_>>().join(";")
|
||||||
|
),
|
||||||
|
Selection::None => "N".to_string(),
|
||||||
|
Selection::All => unreachable!(),
|
||||||
|
};
|
||||||
|
spec.push_str(&format!("{k} {name} {line}\n"));
|
||||||
|
}
|
||||||
|
let spec_path = dir.path().join("cases.txt");
|
||||||
|
std::fs::write(&spec_path, spec).unwrap();
|
||||||
|
run_python(&format!(
|
||||||
|
r#"
|
||||||
|
import h5py, numpy as np
|
||||||
|
with h5py.File("{path}", "r") as f:
|
||||||
|
for line in open("{spec}"):
|
||||||
|
k, name, kind, *rest = line.split()
|
||||||
|
d = f[name]
|
||||||
|
space = d.id.get_space()
|
||||||
|
if kind == 'H':
|
||||||
|
start, stride, count, block = (tuple(int(x) for x in part.split(','))
|
||||||
|
for part in rest[0].split(';'))
|
||||||
|
space.select_hyperslab(start, count, stride, block)
|
||||||
|
elif kind == 'P':
|
||||||
|
pts = np.array([[int(x) for x in p.split(',')] for p in rest[0].split(';')],
|
||||||
|
dtype=np.uint64)
|
||||||
|
space.select_elements(pts)
|
||||||
|
else:
|
||||||
|
space.select_none()
|
||||||
|
n = space.get_select_npoints()
|
||||||
|
out = np.empty(n, dtype=d.dtype)
|
||||||
|
if n:
|
||||||
|
d.id.read(h5py.h5s.create_simple((n,)), space, out)
|
||||||
|
open("{dir}/sel_" + k + ".bin", "wb").write(out.tobytes())
|
||||||
|
"#,
|
||||||
|
path = path.display(),
|
||||||
|
spec = spec_path.display(),
|
||||||
|
dir = dir.path().display(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let file = File::open(&path).unwrap();
|
||||||
|
for (k, (name, code, sel)) in cases.iter().enumerate() {
|
||||||
|
let ds = file.dataset(name).unwrap();
|
||||||
|
let want_bytes = std::fs::read(dir.path().join(format!("sel_{k}.bin"))).unwrap();
|
||||||
|
let got_bytes = ds.read_selection(sel).unwrap();
|
||||||
|
assert!(
|
||||||
|
got_bytes == want_bytes,
|
||||||
|
"{name} {sel:?}: raw bytes differ from libhdf5's ({} vs {} bytes)",
|
||||||
|
got_bytes.len(),
|
||||||
|
want_bytes.len()
|
||||||
|
);
|
||||||
|
let size = ds.raw_datatype().unwrap().type_size() as usize;
|
||||||
|
let want: Vec<i64> = want_bytes
|
||||||
|
.chunks_exact(size)
|
||||||
|
.map(|e| decode(code, name.contains("be_"), e))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
ds.read_f64_selection(sel).unwrap(),
|
||||||
|
want.iter().map(|&v| v as f64).collect::<Vec<_>>(),
|
||||||
|
"{name} {sel:?} as f64"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ds.read_f32_selection(sel).unwrap(),
|
||||||
|
want.iter().map(|&v| v as f32).collect::<Vec<_>>(),
|
||||||
|
"{name} {sel:?} as f32"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ds.read_i64_selection(sel).unwrap(),
|
||||||
|
want,
|
||||||
|
"{name} {sel:?} as i64"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ds.read_i32_selection(sel).unwrap(),
|
||||||
|
want.iter().map(|&v| v as i32).collect::<Vec<_>>(),
|
||||||
|
"{name} {sel:?} as i32"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -986,3 +986,35 @@ fn u64_data_roundtrip() {
|
|||||||
values
|
values
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Opening a dataset by address
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dataset_at_opens_the_same_dataset_as_its_path() {
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
let mut g = b.create_group("grp");
|
||||||
|
g.create_dataset("vals").with_f64_data(&[1.0, 2.5, -3.0]);
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let file = File::from_bytes(b.finish().unwrap()).unwrap();
|
||||||
|
|
||||||
|
let addr =
|
||||||
|
clawhdf5_format::group_v2::resolve_path_any(file.as_bytes(), file.superblock(), "grp/vals")
|
||||||
|
.unwrap();
|
||||||
|
let by_addr = file.dataset_at(addr).unwrap();
|
||||||
|
assert_eq!(by_addr.read_f64().unwrap(), vec![1.0, 2.5, -3.0]);
|
||||||
|
assert_eq!(
|
||||||
|
by_addr.shape().unwrap(),
|
||||||
|
file.dataset("grp/vals").unwrap().shape().unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
// The group's own header is not a dataset.
|
||||||
|
let group_addr =
|
||||||
|
clawhdf5_format::group_v2::resolve_path_any(file.as_bytes(), file.superblock(), "grp")
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
file.dataset_at(group_addr),
|
||||||
|
Err(clawhdf5::Error::NotADataset(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//! With a one-thread rayon pool, full reads of chunked datasets must decode
|
||||||
|
//! on the calling thread.
|
||||||
|
//!
|
||||||
|
//! Handing a read's chunks to a one-worker pool made every reading thread
|
||||||
|
//! queue behind that single worker: N threads reading through one `File`
|
||||||
|
//! decoded on one core, and full reads stopped scaling at about 2x in the
|
||||||
|
//! `concurrent_read` benchmark with `--decode-threads 1` (see
|
||||||
|
//! `docs/known-issues.md`). The test makes that queueing observable: it keeps
|
||||||
|
//! the pool's only worker busy and requires reads to finish anyway.
|
||||||
|
//!
|
||||||
|
//! One test in its own binary: it configures the process-wide rayon pool.
|
||||||
|
|
||||||
|
#![cfg(feature = "parallel")]
|
||||||
|
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use clawhdf5::{File, FileBuilder};
|
||||||
|
|
||||||
|
const N: usize = 4096; // 64 chunks of 64 elements
|
||||||
|
|
||||||
|
fn values() -> Vec<f64> {
|
||||||
|
(0..N).map(|i| i as f64 * 0.5).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build() -> File {
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
b.create_dataset("data")
|
||||||
|
.with_f64_data(&values())
|
||||||
|
.with_shape(&[N as u64])
|
||||||
|
.with_chunks(&[64])
|
||||||
|
.with_deflate(1)
|
||||||
|
.with_provenance("test-suite", "2026-09-26T00:00:00Z", None);
|
||||||
|
File::from_bytes(b.finish().unwrap()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `f` on a fresh thread; `None` if it has not finished within `limit`.
|
||||||
|
fn finishes_within<T: Send + 'static>(
|
||||||
|
limit: Duration,
|
||||||
|
f: impl FnOnce() -> T + Send + 'static,
|
||||||
|
) -> Option<T> {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = tx.send(f());
|
||||||
|
});
|
||||||
|
rx.recv_timeout(limit).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_reads_do_not_wait_for_a_busy_one_thread_pool() {
|
||||||
|
rayon::ThreadPoolBuilder::new()
|
||||||
|
.num_threads(1)
|
||||||
|
.build_global()
|
||||||
|
.expect("this test binary configures the global pool first");
|
||||||
|
|
||||||
|
// Built first: the writer compresses on the pool too.
|
||||||
|
let file = std::sync::Arc::new(build());
|
||||||
|
let file2 = std::sync::Arc::clone(&file);
|
||||||
|
|
||||||
|
// Occupy the pool's only worker until the reads are done.
|
||||||
|
let (started_tx, started_rx) = mpsc::channel();
|
||||||
|
let (release_tx, release_rx) = mpsc::channel::<()>();
|
||||||
|
rayon::spawn(move || {
|
||||||
|
started_tx.send(()).unwrap();
|
||||||
|
let _ = release_rx.recv();
|
||||||
|
});
|
||||||
|
started_rx.recv().unwrap();
|
||||||
|
|
||||||
|
let limit = Duration::from_secs(20);
|
||||||
|
// Cached full read (the path `read_*` uses), then the uncached reader
|
||||||
|
// behind `verify_provenance`.
|
||||||
|
let read = finishes_within(limit, move || {
|
||||||
|
file.dataset("data").unwrap().read_f64().unwrap()
|
||||||
|
});
|
||||||
|
let verified = finishes_within(limit, move || {
|
||||||
|
file2.dataset("data").unwrap().verify_provenance().unwrap()
|
||||||
|
});
|
||||||
|
// Free the worker before asserting, so a failure does not hang the
|
||||||
|
// blocked reader threads forever.
|
||||||
|
release_tx.send(()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
read.expect("a full read waited for the busy one-thread rayon pool"),
|
||||||
|
values()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
verified.expect("verify_provenance waited for the busy one-thread rayon pool"),
|
||||||
|
clawhdf5::provenance::VerifyResult::Ok
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
//! Partial hyperslab reads of every v4 (`libver='latest'`) chunk index type,
|
||||||
|
//! compared element for element with h5py.
|
||||||
|
//!
|
||||||
|
//! `Dataset::read_selection` takes two routes: `partial_read` materialises
|
||||||
|
//! the selection's bounding box when it covers at most half the dataset, and
|
||||||
|
//! `data_read::read_raw_data_selection` handles the rest. The second route
|
||||||
|
//! once passed the layout's full chunk dimensions (which carry the element
|
||||||
|
//! size as an extra, last dimension) to the implicit-index chunk generator,
|
||||||
|
//! which then indexed past the dataset's rank and panicked. So every case
|
||||||
|
//! below reads both a small window and one covering most of the dataset.
|
||||||
|
//!
|
||||||
|
//! Each case asserts which chunk index the file actually uses (parsed from
|
||||||
|
//! the layout message), so a change in how h5py lays the file out can't turn
|
||||||
|
//! this into a test of the wrong index.
|
||||||
|
//!
|
||||||
|
//! Skipped when python3 with h5py is unavailable, unless
|
||||||
|
//! `CLAWHDF5_REQUIRE_INTEROP=1`.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use clawhdf5::File;
|
||||||
|
use clawhdf5_format::data_layout::DataLayout;
|
||||||
|
use clawhdf5_format::message_type::MessageType;
|
||||||
|
use clawhdf5_format::object_header::ObjectHeader;
|
||||||
|
use clawhdf5_format::selection::Selection;
|
||||||
|
use clawhdf5_format::superblock::Superblock;
|
||||||
|
|
||||||
|
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 python_available() -> bool {
|
||||||
|
Command::new(python())
|
||||||
|
.args(["-c", "import h5py"])
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_python(script: &str) -> String {
|
||||||
|
let output = Command::new(python())
|
||||||
|
.args(["-c", script])
|
||||||
|
.output()
|
||||||
|
.expect("failed to run python");
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"Python script failed:\nSTDOUT: {}\nSTDERR: {}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The v4 chunk index type recorded in `name`'s layout message
|
||||||
|
/// (1 single chunk, 2 implicit, 3 fixed array, 4 extensible array, 5 B-tree v2).
|
||||||
|
fn chunk_index_type(path: &std::path::Path, name: &str) -> u8 {
|
||||||
|
let data = std::fs::read(path).unwrap();
|
||||||
|
let sb = Superblock::parse(&data, 0).unwrap();
|
||||||
|
let addr = clawhdf5_format::group_v2::resolve_path_any(&data, &sb, name).unwrap();
|
||||||
|
let hdr = ObjectHeader::parse(&data, addr as usize, sb.offset_size, sb.length_size).unwrap();
|
||||||
|
let msg = hdr
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.msg_type == MessageType::DataLayout)
|
||||||
|
.expect("layout message");
|
||||||
|
match DataLayout::parse(&msg.data, sb.offset_size, sb.length_size).unwrap() {
|
||||||
|
DataLayout::Chunked {
|
||||||
|
version: 4,
|
||||||
|
chunk_index_type: Some(t),
|
||||||
|
..
|
||||||
|
} => t,
|
||||||
|
other => panic!("{name}: expected a v4 chunked layout, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Case {
|
||||||
|
name: &'static str,
|
||||||
|
/// Python keyword arguments to `create_dataset` besides `data`.
|
||||||
|
kwargs: &'static str,
|
||||||
|
index_type: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHAPE: [u64; 2] = [37, 23];
|
||||||
|
|
||||||
|
const CASES: &[Case] = &[
|
||||||
|
Case {
|
||||||
|
name: "implicit",
|
||||||
|
// Early allocation, no filters, fixed maximum: the implicit index.
|
||||||
|
kwargs: "chunks=(5, 4), dcpl=early()",
|
||||||
|
index_type: 2,
|
||||||
|
},
|
||||||
|
Case {
|
||||||
|
name: "fixed_array",
|
||||||
|
kwargs: "chunks=(5, 4), compression='gzip'",
|
||||||
|
index_type: 3,
|
||||||
|
},
|
||||||
|
Case {
|
||||||
|
name: "extensible_array",
|
||||||
|
kwargs: "chunks=(5, 4), maxshape=(None, 23), compression='gzip'",
|
||||||
|
index_type: 4,
|
||||||
|
},
|
||||||
|
Case {
|
||||||
|
name: "btree2",
|
||||||
|
kwargs: "chunks=(5, 4), maxshape=(None, None), compression='gzip'",
|
||||||
|
index_type: 5,
|
||||||
|
},
|
||||||
|
Case {
|
||||||
|
name: "single_chunk",
|
||||||
|
kwargs: "chunks=(37, 23), compression='gzip'",
|
||||||
|
index_type: 1,
|
||||||
|
},
|
||||||
|
Case {
|
||||||
|
name: "single_chunk_unfiltered",
|
||||||
|
kwargs: "chunks=(37, 23)",
|
||||||
|
index_type: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// `(start, stride, count, block)` per dimension; the first few stay below
|
||||||
|
/// half the dataset (bounding-box path), the rest exceed it (full path).
|
||||||
|
/// `(start, stride, count, block)` of a 2-D hyperslab.
|
||||||
|
type Hyperslab2 = ([u64; 2], [u64; 2], [u64; 2], [u64; 2]);
|
||||||
|
|
||||||
|
fn selections() -> Vec<Hyperslab2> {
|
||||||
|
vec![
|
||||||
|
([0, 0], [1, 1], [3, 23], [1, 1]), // ds[0:3]
|
||||||
|
([7, 3], [1, 1], [9, 6], [1, 1]), // interior window across chunks
|
||||||
|
([36, 22], [1, 1], [1, 1], [1, 1]), // last element (edge chunk)
|
||||||
|
([2, 1], [3, 4], [4, 3], [1, 1]), // strided, small
|
||||||
|
([0, 0], [1, 1], [30, 23], [1, 1]), // most rows
|
||||||
|
([1, 0], [2, 1], [18, 23], [1, 1]), // every other row, spanning all
|
||||||
|
([0, 2], [1, 1], [37, 20], [1, 1]), // columns 2..22 of every row
|
||||||
|
([3, 1], [5, 3], [7, 7], [2, 2]), // strided blocks over everything
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn py_slice(start: u64, stride: u64, count: u64, block: u64) -> String {
|
||||||
|
// Each case is expressible as a numpy index when block == 1; with a block
|
||||||
|
// the selected indices are listed explicitly.
|
||||||
|
let idx: Vec<String> = (0..count)
|
||||||
|
.flat_map(|c| (0..block).map(move |b| start + c * stride + b))
|
||||||
|
.map(|i| i.to_string())
|
||||||
|
.collect();
|
||||||
|
format!("[{}]", idx.join(","))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_hyperslabs_of_every_v4_chunk_index_match_h5py() {
|
||||||
|
if !python_available() {
|
||||||
|
assert!(
|
||||||
|
!interop_required(),
|
||||||
|
"CLAWHDF5_REQUIRE_INTEROP=1 but python3 with h5py is not available"
|
||||||
|
);
|
||||||
|
eprintln!("SKIP: python3 with h5py not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("v4_index_selection.h5");
|
||||||
|
let path_str = path.display().to_string();
|
||||||
|
|
||||||
|
// h5py writes the file, then reads every selection back and prints the
|
||||||
|
// values, one line per (case, selection).
|
||||||
|
let mut script = format!(
|
||||||
|
"import h5py, numpy as np\n\
|
||||||
|
def early():\n\
|
||||||
|
\x20 p = h5py.h5p.create(h5py.h5p.DATASET_CREATE)\n\
|
||||||
|
\x20 p.set_alloc_time(h5py.h5d.ALLOC_TIME_EARLY)\n\
|
||||||
|
\x20 return p\n\
|
||||||
|
data = (np.arange({n}, dtype='<i4') * 7 - 1000).reshape({r}, {c})\n\
|
||||||
|
with h5py.File(r'{path_str}', 'w', libver='latest') as f:\n",
|
||||||
|
n = SHAPE[0] * SHAPE[1],
|
||||||
|
r = SHAPE[0],
|
||||||
|
c = SHAPE[1],
|
||||||
|
);
|
||||||
|
for case in CASES {
|
||||||
|
script += &format!(
|
||||||
|
" f.create_dataset('{}', data=data, {})\n",
|
||||||
|
case.name, case.kwargs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
script += &format!("with h5py.File(r'{path_str}', 'r') as f:\n");
|
||||||
|
for case in CASES {
|
||||||
|
for (start, stride, count, block) in selections() {
|
||||||
|
let rows = py_slice(start[0], stride[0], count[0], block[0]);
|
||||||
|
let cols = py_slice(start[1], stride[1], count[1], block[1]);
|
||||||
|
script += &format!(
|
||||||
|
" print(' '.join(map(str, f['{}'][{rows}][:, {cols}].ravel())))\n",
|
||||||
|
case.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let out = run_python(&script);
|
||||||
|
let mut expected = out.lines();
|
||||||
|
|
||||||
|
let file = File::open(&path).unwrap();
|
||||||
|
for case in CASES {
|
||||||
|
assert_eq!(
|
||||||
|
chunk_index_type(&path, case.name),
|
||||||
|
case.index_type,
|
||||||
|
"{}: h5py did not produce the intended chunk index",
|
||||||
|
case.name
|
||||||
|
);
|
||||||
|
let ds = file.dataset(case.name).unwrap();
|
||||||
|
assert_eq!(ds.shape().unwrap(), SHAPE);
|
||||||
|
for (start, stride, count, block) in selections() {
|
||||||
|
let sel = Selection::Hyperslab {
|
||||||
|
start: start.to_vec(),
|
||||||
|
stride: stride.to_vec(),
|
||||||
|
count: count.to_vec(),
|
||||||
|
block: block.to_vec(),
|
||||||
|
};
|
||||||
|
let want: Vec<i32> = expected
|
||||||
|
.next()
|
||||||
|
.expect("h5py printed too few lines")
|
||||||
|
.split_whitespace()
|
||||||
|
.map(|v| v.parse().unwrap())
|
||||||
|
.collect();
|
||||||
|
let raw = ds
|
||||||
|
.read_selection(&sel)
|
||||||
|
.unwrap_or_else(|e| panic!("{}: read_selection {sel:?} failed: {e}", case.name));
|
||||||
|
let got: Vec<i32> = raw
|
||||||
|
.as_chunks::<4>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|b| i32::from_le_bytes(*b))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(got, want, "{}: selection {sel:?}", case.name);
|
||||||
|
assert_eq!(
|
||||||
|
ds.read_i32_selection(&sel).unwrap(),
|
||||||
|
want,
|
||||||
|
"{}: read_i32_selection {sel:?}",
|
||||||
|
case.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(expected.next().is_none(), "h5py printed extra lines");
|
||||||
|
}
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
//! Variable-length data (VL strings and VL sequences) read through the
|
||||||
|
//! facade, checked against h5py/libhdf5.
|
||||||
|
//!
|
||||||
|
//! h5py writes each file — once with the default 8-byte offsets and once
|
||||||
|
//! with 4-byte offsets and lengths (`sizeof_addr = 4`) — and prints what
|
||||||
|
//! libhdf5 reads back; `File`, `MmapFile` and `LazyFile` must return the same
|
||||||
|
//! values. Skipped when python3 with h5py is unavailable, unless
|
||||||
|
//! `CLAWHDF5_REQUIRE_INTEROP=1`.
|
||||||
|
|
||||||
|
// `Selection::slice(&[0..1])` is one range per dimension, not a Vec of a range.
|
||||||
|
#![allow(clippy::single_range_in_vec_init)]
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use clawhdf5::{AttrValue, File, LazyFile, MmapFile, 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 python_available() -> bool {
|
||||||
|
Command::new(python())
|
||||||
|
.args(["-c", "import h5py, numpy"])
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! skip_if_no_python {
|
||||||
|
() => {
|
||||||
|
if !python_available() {
|
||||||
|
assert!(
|
||||||
|
!interop_required(),
|
||||||
|
"CLAWHDF5_REQUIRE_INTEROP=1 but python3 with h5py is not available"
|
||||||
|
);
|
||||||
|
eprintln!("SKIP: python3 with h5py not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `script` and return its stdout as `key -> value`, one
|
||||||
|
/// `key<TAB>value` line per key.
|
||||||
|
fn run_python(script: &str) -> HashMap<String, String> {
|
||||||
|
let output = Command::new(python())
|
||||||
|
.args(["-c", script])
|
||||||
|
.output()
|
||||||
|
.expect("failed to run python");
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"python failed:\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
String::from_utf8_lossy(&output.stdout)
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let (k, v) = line.split_once('\t')?;
|
||||||
|
Some((k.to_string(), v.to_string()))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `hex,hex,...` -> the strings' bytes.
|
||||||
|
fn parse_strings(v: &str) -> Vec<Vec<u8>> {
|
||||||
|
v.split(',')
|
||||||
|
.map(|h| {
|
||||||
|
(0..h.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `1 2 3|| -5` -> sequences.
|
||||||
|
fn parse_seqs(v: &str) -> Vec<Vec<f64>> {
|
||||||
|
v.split('|')
|
||||||
|
.map(|s| s.split_whitespace().map(|x| x.parse().unwrap()).collect())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn utf8(bytes: &[Vec<u8>]) -> Vec<String> {
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.map(|b| String::from_utf8(b.clone()).unwrap())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `vl8.h5` (8-byte offsets) and `vl4.h5` (4-byte offsets and
|
||||||
|
/// lengths) into `dir` and prints h5py's reading of both.
|
||||||
|
const SCRIPT: &str = r#"
|
||||||
|
import sys, h5py, numpy as np
|
||||||
|
d = sys.argv[1]
|
||||||
|
S = h5py.string_dtype('utf-8'); A = h5py.string_dtype('ascii')
|
||||||
|
def make(path, sizes):
|
||||||
|
if sizes:
|
||||||
|
fcpl = h5py.h5p.create(h5py.h5p.FILE_CREATE); fcpl.set_sizes(*sizes)
|
||||||
|
f = h5py.File(h5py.h5f.create(path.encode(), h5py.h5f.ACC_TRUNC, fcpl=fcpl))
|
||||||
|
else:
|
||||||
|
f = h5py.File(path, 'w')
|
||||||
|
f.create_dataset('scalar_utf8', data='héllo', dtype=S)
|
||||||
|
f.create_dataset('scalar_ascii', data=b'hello', dtype=A)
|
||||||
|
f.create_dataset('d1', data=np.array(['a', '', 'ccc', 'δδ'], dtype=object), dtype=S)
|
||||||
|
f.create_dataset('d2', data=np.array([['x', 'yy', 'zzz'], ['', 'w', 'vv']], dtype=object), dtype=S)
|
||||||
|
f.create_dataset('chunked', data=np.array(['s%d' % i * (i % 5) for i in range(100)], dtype=object),
|
||||||
|
dtype=S, chunks=(7,), compression='gzip')
|
||||||
|
f.create_dataset('chunked2d', data=np.array([['r%dc%d' % (r, c) for c in range(9)] for r in range(11)], dtype=object),
|
||||||
|
dtype=S, chunks=(4, 4), compression='gzip', shuffle=True)
|
||||||
|
f.create_dataset('unwritten', shape=(5,), dtype=S, chunks=(2,))
|
||||||
|
p = f.create_dataset('partial', shape=(6,), dtype=S, chunks=(2,)); p[0] = 'first'; p[5] = 'last'
|
||||||
|
f.create_dataset('contig_empty', shape=(3,), dtype=S)
|
||||||
|
dcpl = h5py.h5p.create(h5py.h5p.DATASET_CREATE); dcpl.set_layout(h5py.h5d.COMPACT)
|
||||||
|
f.create_dataset('compact', data=np.array(['c1', '', 'c3'], dtype=object), dtype=S, dcpl=dcpl)
|
||||||
|
assert f['compact'].id.get_create_plist().get_layout() == h5py.h5d.COMPACT
|
||||||
|
f.attrs['vlattr'] = 'attr-value'
|
||||||
|
f.attrs.create('vlattr_arr', np.array(['p', 'qq', ''], dtype=object), dtype=S)
|
||||||
|
ct = np.dtype([('id', '<i4'), ('name', S), ('v', '<f8')])
|
||||||
|
arr = np.zeros(3, dtype=ct); arr['id'] = [1, 2, 3]; arr['name'] = ['one', '', 'three']; arr['v'] = [.5, 1.5, 2.5]
|
||||||
|
f.create_dataset('compound', data=arr)
|
||||||
|
f.attrs.create('compound_attr', arr)
|
||||||
|
v = f.create_dataset('vlen_i4', shape=(3,), dtype=h5py.vlen_dtype(np.dtype('<i4')))
|
||||||
|
v[0] = [1, 2, 3]; v[1] = []; v[2] = [-5]
|
||||||
|
v = f.create_dataset('vlen_f8', shape=(2, 2), dtype=h5py.vlen_dtype(np.dtype('<f8')), chunks=(1, 2), compression='gzip')
|
||||||
|
v[0, 0] = [1.5]; v[0, 1] = [2.5, 3.5]; v[1, 1] = [9.0]
|
||||||
|
v = f.create_dataset('vlen_u2_be', shape=(2,), dtype=h5py.vlen_dtype(np.dtype('>u2')))
|
||||||
|
v[0] = [1, 65535]; v[1] = [300]
|
||||||
|
f.attrs.create('vlen_attr', np.array([np.array([1, 2], dtype='<i8'), np.array([3], dtype='<i8')], dtype=object),
|
||||||
|
dtype=h5py.vlen_dtype(np.dtype('<i8')))
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
def hexes(a):
|
||||||
|
return ','.join(bytes(x).hex() for x in np.asarray(a, dtype=object).ravel())
|
||||||
|
def seqs(a):
|
||||||
|
return '|'.join(' '.join(repr(float(x)) for x in s) for s in np.asarray(a, dtype=object).ravel())
|
||||||
|
|
||||||
|
for tag, sizes in (('8', None), ('4', (4, 4))):
|
||||||
|
path = '%s/vl%s.h5' % (d, tag)
|
||||||
|
make(path, sizes)
|
||||||
|
with h5py.File(path, 'r') as f:
|
||||||
|
for name in ('compact', 'scalar_utf8', 'scalar_ascii', 'd1', 'd2', 'chunked', 'chunked2d', 'unwritten',
|
||||||
|
'partial', 'contig_empty'):
|
||||||
|
v = f[name][()]
|
||||||
|
print('%s:%s\t%s' % (tag, name, hexes([v] if np.ndim(v) == 0 else v)))
|
||||||
|
print('%s:d2[1,1:3]\t%s' % (tag, hexes(f['d2'][1, 1:3])))
|
||||||
|
print('%s:chunked[5:60:3]\t%s' % (tag, hexes(f['chunked'][5:60:3])))
|
||||||
|
print('%s:chunked2d[2:9:2,3:8]\t%s' % (tag, hexes(f['chunked2d'][2:9:2, 3:8])))
|
||||||
|
print('%s:compound.name\t%s' % (tag, hexes(f['compound']['name'])))
|
||||||
|
print('%s:compound_attr.name\t%s' % (tag, hexes(f.attrs['compound_attr']['name'])))
|
||||||
|
print('%s:vlattr\t%s' % (tag, hexes([f.attrs['vlattr'].encode()])))
|
||||||
|
print('%s:vlattr_arr\t%s' % (tag, hexes([s.encode() for s in f.attrs['vlattr_arr']])))
|
||||||
|
for name in ('vlen_i4', 'vlen_f8'):
|
||||||
|
print('%s:%s\t%s' % (tag, name, seqs(f[name][()])))
|
||||||
|
print('%s:vlen_f8[1,:]\t%s' % (tag, seqs(f['vlen_f8'][1, :])))
|
||||||
|
print('%s:vlen_attr\t%s' % (tag, seqs(f.attrs['vlen_attr'])))
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn make_files(dir: &Path) -> HashMap<String, String> {
|
||||||
|
let script = format!(
|
||||||
|
"import sys; sys.argv = ['x', {:?}]\n{SCRIPT}",
|
||||||
|
dir.display().to_string()
|
||||||
|
);
|
||||||
|
run_python(&script)
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRING_DATASETS: [&str; 10] = [
|
||||||
|
"compact",
|
||||||
|
"scalar_utf8",
|
||||||
|
"scalar_ascii",
|
||||||
|
"d1",
|
||||||
|
"d2",
|
||||||
|
"chunked",
|
||||||
|
"chunked2d",
|
||||||
|
"unwritten",
|
||||||
|
"partial",
|
||||||
|
"contig_empty",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vl_string_datasets_read_like_h5py() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let expected = make_files(dir.path());
|
||||||
|
for tag in ["8", "4"] {
|
||||||
|
let path = dir.path().join(format!("vl{tag}.h5"));
|
||||||
|
let file = File::open(&path).unwrap();
|
||||||
|
let mmap = MmapFile::open(&path).unwrap();
|
||||||
|
let lazy = LazyFile::open_mmap(&path).unwrap();
|
||||||
|
for name in STRING_DATASETS {
|
||||||
|
let want = parse_strings(&expected[&format!("{tag}:{name}")]);
|
||||||
|
let ctx = format!("vl{tag}.h5 {name}");
|
||||||
|
let ds = file.dataset(name).unwrap();
|
||||||
|
assert_eq!(ds.read_string_bytes().unwrap(), want, "{ctx}");
|
||||||
|
assert_eq!(ds.read_string().unwrap(), utf8(&want), "{ctx}");
|
||||||
|
let m = mmap.dataset(name).unwrap();
|
||||||
|
assert_eq!(m.read_string_bytes().unwrap(), want, "{ctx} (mmap)");
|
||||||
|
assert_eq!(m.read_string().unwrap(), utf8(&want), "{ctx} (mmap)");
|
||||||
|
let l = lazy.dataset(name).unwrap();
|
||||||
|
assert_eq!(l.read_string_bytes().unwrap(), want, "{ctx} (lazy)");
|
||||||
|
assert_eq!(l.read_string().unwrap(), utf8(&want), "{ctx} (lazy)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vl_string_selections_read_like_h5py() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let expected = make_files(dir.path());
|
||||||
|
let hyperslab = |start: &[u64], stride: &[u64], count: &[u64]| Selection::Hyperslab {
|
||||||
|
start: start.to_vec(),
|
||||||
|
stride: stride.to_vec(),
|
||||||
|
count: count.to_vec(),
|
||||||
|
block: vec![1; start.len()],
|
||||||
|
};
|
||||||
|
let cases = [
|
||||||
|
("d2", "d2[1,1:3]", Selection::slice(&[1..2, 1..3])),
|
||||||
|
("chunked", "chunked[5:60:3]", hyperslab(&[5], &[3], &[19])),
|
||||||
|
(
|
||||||
|
"chunked2d",
|
||||||
|
"chunked2d[2:9:2,3:8]",
|
||||||
|
hyperslab(&[2, 3], &[2, 1], &[4, 5]),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for tag in ["8", "4"] {
|
||||||
|
let file = File::open(dir.path().join(format!("vl{tag}.h5"))).unwrap();
|
||||||
|
for (name, key, sel) in &cases {
|
||||||
|
let want = utf8(&parse_strings(&expected[&format!("{tag}:{key}")]));
|
||||||
|
let got = file
|
||||||
|
.dataset(name)
|
||||||
|
.unwrap()
|
||||||
|
.read_string_selection(sel)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(got, want, "vl{tag}.h5 {key}");
|
||||||
|
}
|
||||||
|
// A selection of VL integers is not strings.
|
||||||
|
assert!(
|
||||||
|
file.dataset("vlen_i4")
|
||||||
|
.unwrap()
|
||||||
|
.read_string_selection(&Selection::slice(&[0..1]))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vl_values_in_compounds_and_attributes_read_like_h5py() {
|
||||||
|
// With 4-byte offsets these failed with GlobalHeapObjectNotFound or came
|
||||||
|
// back as `AttrValue::Raw`: the VL type claimed 16-byte elements and the
|
||||||
|
// global heap was read without the padding libhdf5 puts after its
|
||||||
|
// headers.
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let expected = make_files(dir.path());
|
||||||
|
for tag in ["8", "4"] {
|
||||||
|
let file = File::open(dir.path().join(format!("vl{tag}.h5"))).unwrap();
|
||||||
|
let want = |key: &str| utf8(&parse_strings(&expected[&format!("{tag}:{key}")]));
|
||||||
|
|
||||||
|
let attrs = file.root().attrs().unwrap();
|
||||||
|
match &attrs["vlattr"] {
|
||||||
|
AttrValue::String(s) => assert_eq!(*s, want("vlattr")[0], "vl{tag}.h5"),
|
||||||
|
other => panic!("vl{tag}.h5 vlattr: {other:?}"),
|
||||||
|
}
|
||||||
|
match &attrs["vlattr_arr"] {
|
||||||
|
AttrValue::StringArray(s) => assert_eq!(*s, want("vlattr_arr"), "vl{tag}.h5"),
|
||||||
|
other => panic!("vl{tag}.h5 vlattr_arr: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compound with a VL string member: dataset and attribute.
|
||||||
|
let ds = file.dataset("compound").unwrap();
|
||||||
|
let dt = ds.raw_datatype().unwrap();
|
||||||
|
let raw = ds.read_selection(&Selection::All).unwrap();
|
||||||
|
let fields = clawhdf5_format::data_read::read_compound_fields(&raw, &dt).unwrap();
|
||||||
|
let name = fields.iter().find(|f| f.name == "name").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
file.decode_strings(&name.datatype, &name.raw_data).unwrap(),
|
||||||
|
want("compound.name"),
|
||||||
|
"vl{tag}.h5 compound"
|
||||||
|
);
|
||||||
|
let id = fields.iter().find(|f| f.name == "id").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
clawhdf5_format::data_read::read_as_i64(&id.raw_data, &id.datatype).unwrap(),
|
||||||
|
vec![1, 2, 3]
|
||||||
|
);
|
||||||
|
let v = fields.iter().find(|f| f.name == "v").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
clawhdf5_format::data_read::read_as_f64(&v.raw_data, &v.datatype).unwrap(),
|
||||||
|
vec![0.5, 1.5, 2.5]
|
||||||
|
);
|
||||||
|
|
||||||
|
let AttrValue::Raw { datatype, data, .. } = &attrs["compound_attr"] else {
|
||||||
|
panic!("compound attribute is Raw");
|
||||||
|
};
|
||||||
|
let fields = clawhdf5_format::data_read::read_compound_fields(data, datatype).unwrap();
|
||||||
|
let name = fields.iter().find(|f| f.name == "name").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
file.decode_strings(&name.datatype, &name.raw_data).unwrap(),
|
||||||
|
want("compound_attr.name"),
|
||||||
|
"vl{tag}.h5 compound attribute"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A VL sequence attribute.
|
||||||
|
let AttrValue::Raw { datatype, data, .. } = &attrs["vlen_attr"] else {
|
||||||
|
panic!("vlen attribute is Raw");
|
||||||
|
};
|
||||||
|
let got: Vec<Vec<i64>> = file.decode_vlen(datatype, data).unwrap();
|
||||||
|
let want_seqs = parse_seqs(&expected[&format!("{tag}:vlen_attr")]);
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
want_seqs
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.iter().map(|&x| x as i64).collect::<Vec<_>>())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
"vl{tag}.h5 vlen_attr"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vl_sequence_datasets_read_like_h5py() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let expected = make_files(dir.path());
|
||||||
|
for tag in ["8", "4"] {
|
||||||
|
let path = dir.path().join(format!("vl{tag}.h5"));
|
||||||
|
let file = File::open(&path).unwrap();
|
||||||
|
let seqs = |key: &str| parse_seqs(&expected[&format!("{tag}:{key}")]);
|
||||||
|
|
||||||
|
let i4 = file.dataset("vlen_i4").unwrap();
|
||||||
|
let want: Vec<Vec<i32>> = seqs("vlen_i4")
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.iter().map(|&x| x as i32).collect())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(i4.read_vlen::<i32>().unwrap(), want, "vl{tag}.h5 vlen_i4");
|
||||||
|
let as_f64: Vec<Vec<f64>> = i4.read_vlen().unwrap();
|
||||||
|
assert_eq!(as_f64, seqs("vlen_i4"));
|
||||||
|
|
||||||
|
let f8 = file.dataset("vlen_f8").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
f8.read_vlen::<f64>().unwrap(),
|
||||||
|
seqs("vlen_f8"),
|
||||||
|
"vl{tag}.h5"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
f8.read_vlen_selection::<f64>(&Selection::slice(&[1..2, 0..2]))
|
||||||
|
.unwrap(),
|
||||||
|
seqs("vlen_f8[1,:]"),
|
||||||
|
"vl{tag}.h5 vlen_f8[1,:]"
|
||||||
|
);
|
||||||
|
|
||||||
|
// h5py returns big-endian VL elements byte-swapped (an h5py bug, see
|
||||||
|
// CONFORMANCE.md); the values written are [1, 65535] and [300].
|
||||||
|
assert_eq!(
|
||||||
|
file.dataset("vlen_u2_be")
|
||||||
|
.unwrap()
|
||||||
|
.read_vlen::<u64>()
|
||||||
|
.unwrap(),
|
||||||
|
vec![vec![1, 65535], vec![300]]
|
||||||
|
);
|
||||||
|
|
||||||
|
let mmap = MmapFile::open(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
mmap.dataset("vlen_f8").unwrap().read_vlen::<f64>().unwrap(),
|
||||||
|
seqs("vlen_f8")
|
||||||
|
);
|
||||||
|
let lazy = LazyFile::open_mmap(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
lazy.dataset("vlen_f8").unwrap().read_vlen::<f64>().unwrap(),
|
||||||
|
seqs("vlen_f8")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wrong kind of data is an error, not a value.
|
||||||
|
assert!(i4.read_string().is_err());
|
||||||
|
assert!(i4.read_string_bytes().is_err());
|
||||||
|
assert!(file.dataset("d1").unwrap().read_vlen::<f64>().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vl_strings_end_at_nul_and_mis_sized_elements_fail_like_h5py() {
|
||||||
|
// h5py cannot write a VL string with a NUL in it, so the file is patched:
|
||||||
|
// one string gets an embedded NUL, and two elements get a length that
|
||||||
|
// disagrees with their heap object. libhdf5 returns the string up to the
|
||||||
|
// NUL and refuses the others ("Expected global heap object size does
|
||||||
|
// not match"); we used to return the NUL and a truncated string.
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("patched.h5");
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
import struct, h5py, numpy as np
|
||||||
|
path = {path:?}
|
||||||
|
with h5py.File(path, 'w') as f:
|
||||||
|
f.create_dataset('d', data=np.array(['aXb', 'cdefgh', 'ij', 'ok'], dtype=object),
|
||||||
|
dtype=h5py.string_dtype())
|
||||||
|
s = f.create_dataset('seq', shape=(2,), dtype=h5py.vlen_dtype(np.dtype('<i4')))
|
||||||
|
s[0] = np.array([1, 2, 3], dtype='<i4'); s[1] = np.array([4], dtype='<i4')
|
||||||
|
off = f['d'].id.get_offset(); soff = f['seq'].id.get_offset()
|
||||||
|
b = bytearray(open(path, 'rb').read())
|
||||||
|
i = b.index(b'aXb'); b[i + 1] = 0
|
||||||
|
struct.pack_into('<I', b, off + 16, 3) # 'cdefgh': length 6 -> 3
|
||||||
|
struct.pack_into('<I', b, off + 32, 9) # 'ij': length 2 -> 9
|
||||||
|
struct.pack_into('<I', b, soff, 2) # [1, 2, 3]: length 3 -> 2
|
||||||
|
open(path, 'wb').write(bytes(b))
|
||||||
|
with h5py.File(path, 'r') as f:
|
||||||
|
for i in range(4):
|
||||||
|
try:
|
||||||
|
print('d%d\t%s' % (i, f['d'][i].hex()))
|
||||||
|
except OSError as e:
|
||||||
|
print('d%d\terror' % i)
|
||||||
|
for i in range(2):
|
||||||
|
try:
|
||||||
|
print('seq%d\t%s' % (i, ' '.join(str(x) for x in f['seq'][i])))
|
||||||
|
except OSError as e:
|
||||||
|
print('seq%d\terror' % i)
|
||||||
|
"#,
|
||||||
|
path = path.display().to_string()
|
||||||
|
);
|
||||||
|
let expected = run_python(&script);
|
||||||
|
assert_eq!(expected["d0"], "61", "h5py cuts 'a\\0b' at the NUL");
|
||||||
|
assert_eq!(expected["d1"], "error");
|
||||||
|
assert_eq!(expected["d2"], "error");
|
||||||
|
assert_eq!(expected["d3"], "6f6b");
|
||||||
|
assert_eq!(expected["seq0"], "error");
|
||||||
|
assert_eq!(expected["seq1"], "4");
|
||||||
|
|
||||||
|
let file = File::open(&path).unwrap();
|
||||||
|
let d = file.dataset("d").unwrap();
|
||||||
|
let one = |i: u64| d.read_string_selection(&Selection::slice(&[i..i + 1]));
|
||||||
|
assert_eq!(one(0).unwrap(), vec!["a"]);
|
||||||
|
assert!(one(1).is_err());
|
||||||
|
assert!(one(2).is_err());
|
||||||
|
assert_eq!(one(3).unwrap(), vec!["ok"]);
|
||||||
|
assert!(d.read_string().is_err());
|
||||||
|
let seq = file.dataset("seq").unwrap();
|
||||||
|
let one = |i: u64| seq.read_vlen_selection::<i32>(&Selection::slice(&[i..i + 1]));
|
||||||
|
assert!(one(0).is_err());
|
||||||
|
assert_eq!(one(1).unwrap(), vec![vec![4]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_vl_element_at_the_undefined_heap_address_fails_like_h5py() {
|
||||||
|
// libhdf5 writes a null element with heap address 0 (h5py reads it as
|
||||||
|
// b''), and an empty string as a real zero-size heap object; neither
|
||||||
|
// uses the undefined address. An element of length 0 at the undefined
|
||||||
|
// address fails in libhdf5 ("addr undefined"); we returned "".
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("undef.h5");
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
import struct, h5py, numpy as np
|
||||||
|
path = {path:?}
|
||||||
|
with h5py.File(path, 'w') as f:
|
||||||
|
f.create_dataset('d', data=np.array(['x', '', 'yz', ''], dtype=object), dtype=h5py.string_dtype())
|
||||||
|
off = f['d'].id.get_offset()
|
||||||
|
b = bytearray(open(path, 'rb').read())
|
||||||
|
# h5py's '' (element 3): length 0 at a real heap address, not 0 or all 0xff.
|
||||||
|
length, addr, _ = struct.unpack_from('<IQI', b, off + 48)
|
||||||
|
print('empty\t%d %d' % (length, addr not in (0, 2**64 - 1)))
|
||||||
|
struct.pack_into('<IQI', b, off + 16, 0, 2**64 - 1, 1)
|
||||||
|
open(path, 'wb').write(bytes(b))
|
||||||
|
with h5py.File(path, 'r') as f:
|
||||||
|
for i in range(4):
|
||||||
|
try:
|
||||||
|
print('d%d\t%s' % (i, f['d'][i].hex()))
|
||||||
|
except OSError as e:
|
||||||
|
print('d%d\terror %s' % (i, 'addr undefined' in str(e)))
|
||||||
|
"#,
|
||||||
|
path = path.display().to_string()
|
||||||
|
);
|
||||||
|
let expected = run_python(&script);
|
||||||
|
assert_eq!(expected["empty"], "0 1", "h5py writes '' at a real address");
|
||||||
|
assert_eq!(expected["d0"], "78");
|
||||||
|
assert_eq!(expected["d1"], "error True");
|
||||||
|
assert_eq!(expected["d2"], "797a");
|
||||||
|
assert_eq!(expected["d3"], "");
|
||||||
|
|
||||||
|
let file = File::open(&path).unwrap();
|
||||||
|
let d = file.dataset("d").unwrap();
|
||||||
|
let one = |i: u64| d.read_string_selection(&Selection::slice(&[i..i + 1]));
|
||||||
|
assert_eq!(one(0).unwrap(), vec!["x"]);
|
||||||
|
let e = one(1).unwrap_err().to_string();
|
||||||
|
assert!(e.contains("undefined global heap address"), "{e}");
|
||||||
|
assert_eq!(one(2).unwrap(), vec!["yz"]);
|
||||||
|
assert_eq!(one(3).unwrap(), vec![""]);
|
||||||
|
assert!(d.read_string().is_err());
|
||||||
|
assert!(d.read_string_bytes().is_err());
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
//! Variable-length values in files with 4-byte offsets and lengths
|
||||||
|
//! (`sizeof_addr = 4`), checked against h5py/libhdf5 through the
|
||||||
|
//! `clawhdf5_format` decoders.
|
||||||
|
//!
|
||||||
|
//! These failed with `GlobalHeapObjectNotFound` or came back as
|
||||||
|
//! `AttrValue::Raw`: the VL datatype claimed 16-byte elements whatever the
|
||||||
|
//! file's offset size, and the global heap was read without the padding
|
||||||
|
//! libhdf5 puts after its collection and object headers. Skipped when
|
||||||
|
//! python3 with h5py is unavailable, unless `CLAWHDF5_REQUIRE_INTEROP=1`.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use clawhdf5::{AttrValue, File, Selection};
|
||||||
|
use clawhdf5_format::data_read::{read_as_i64, read_compound_fields};
|
||||||
|
use clawhdf5_format::vl_data::{read_vl_bytes, read_vl_strings};
|
||||||
|
|
||||||
|
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 python_available() -> bool {
|
||||||
|
Command::new(python())
|
||||||
|
.args(["-c", "import h5py, numpy"])
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vl_values_in_a_file_with_4_byte_offsets_read_like_h5py() {
|
||||||
|
if !python_available() {
|
||||||
|
assert!(
|
||||||
|
!interop_required(),
|
||||||
|
"CLAWHDF5_REQUIRE_INTEROP=1 but python3 with h5py is not available"
|
||||||
|
);
|
||||||
|
eprintln!("SKIP: python3 with h5py not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("offset4.h5");
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
import h5py, numpy as np
|
||||||
|
S = h5py.string_dtype()
|
||||||
|
fcpl = h5py.h5p.create(h5py.h5p.FILE_CREATE); fcpl.set_sizes(4, 4)
|
||||||
|
with h5py.File(h5py.h5f.create({path:?}.encode(), h5py.h5f.ACC_TRUNC, fcpl=fcpl)) as f:
|
||||||
|
f.attrs['vlattr'] = 'attr-value'
|
||||||
|
f.attrs.create('vlattr_arr', np.array(['p', 'qq', ''], dtype=object), dtype=S)
|
||||||
|
ct = np.dtype([('id', '<i4'), ('name', S), ('v', '<f8')])
|
||||||
|
arr = np.zeros(3, dtype=ct); arr['id'] = [1, 2, 3]; arr['name'] = ['one', '', 'three']
|
||||||
|
f.create_dataset('compound', data=arr)
|
||||||
|
f.attrs.create('vlen_attr', np.array([np.array([1, 2], dtype='<i8'), np.array([3], dtype='<i8')],
|
||||||
|
dtype=object), dtype=h5py.vlen_dtype(np.dtype('<i8')))
|
||||||
|
with h5py.File({path:?}, 'r') as f:
|
||||||
|
assert f.id.get_create_plist().get_sizes() == (4, 4)
|
||||||
|
print(f.attrs['vlattr'])
|
||||||
|
print(','.join(f.attrs['vlattr_arr']))
|
||||||
|
print(','.join(s.decode() for s in f['compound']['name']))
|
||||||
|
print(';'.join(' '.join(str(x) for x in s) for s in f.attrs['vlen_attr']))
|
||||||
|
"#,
|
||||||
|
path = path.display().to_string()
|
||||||
|
);
|
||||||
|
let output = Command::new(python())
|
||||||
|
.args(["-c", &script])
|
||||||
|
.output()
|
||||||
|
.expect("failed to run python");
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"python failed:\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||||
|
let lines: Vec<&str> = stdout.lines().collect();
|
||||||
|
let (vlattr, vlattr_arr, names, seqs) = (lines[0], lines[1], lines[2], lines[3]);
|
||||||
|
|
||||||
|
let file = File::open(&path).unwrap();
|
||||||
|
let sb = file.superblock();
|
||||||
|
assert_eq!((sb.offset_size, sb.length_size), (4, 4));
|
||||||
|
|
||||||
|
let attrs = file.root().attrs().unwrap();
|
||||||
|
match &attrs["vlattr"] {
|
||||||
|
AttrValue::String(s) => assert_eq!(s, vlattr),
|
||||||
|
other => panic!("vlattr: {other:?}"),
|
||||||
|
}
|
||||||
|
match &attrs["vlattr_arr"] {
|
||||||
|
AttrValue::StringArray(s) => assert_eq!(s.join(","), vlattr_arr),
|
||||||
|
other => panic!("vlattr_arr: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// The compound's VL string member.
|
||||||
|
let ds = file.dataset("compound").unwrap();
|
||||||
|
let dt = ds.raw_datatype().unwrap();
|
||||||
|
assert_eq!(dt.type_size(), 24, "4 + 12-byte VL element + 8");
|
||||||
|
let raw = ds.read_selection(&Selection::All).unwrap();
|
||||||
|
let fields = read_compound_fields(&raw, &dt).unwrap();
|
||||||
|
let name = fields.iter().find(|f| f.name == "name").unwrap();
|
||||||
|
assert_eq!(name.datatype.type_size(), 12);
|
||||||
|
let got = read_vl_strings(file.as_bytes(), &name.raw_data, 3, 4, 4).unwrap();
|
||||||
|
assert_eq!(got.join(","), names);
|
||||||
|
let id = fields.iter().find(|f| f.name == "id").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
read_as_i64(&id.raw_data, &id.datatype).unwrap(),
|
||||||
|
vec![1, 2, 3]
|
||||||
|
);
|
||||||
|
|
||||||
|
// A VL sequence attribute.
|
||||||
|
let AttrValue::Raw { datatype, data, .. } = &attrs["vlen_attr"] else {
|
||||||
|
panic!("vlen_attr is Raw");
|
||||||
|
};
|
||||||
|
let clawhdf5_format::datatype::Datatype::VariableLength { base_type, .. } = datatype else {
|
||||||
|
panic!("vlen_attr is VL");
|
||||||
|
};
|
||||||
|
let got: Vec<String> = read_vl_bytes(file.as_bytes(), data, 2, 4, 4)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|b| {
|
||||||
|
let v = read_as_i64(b, base_type).unwrap();
|
||||||
|
v.iter().map(i64::to_string).collect::<Vec<_>>().join(" ")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(got.join(";"), seqs);
|
||||||
|
}
|
||||||
@@ -0,0 +1,940 @@
|
|||||||
|
//! Groups and links written by `FileBuilder`, read back by h5py (libhdf5)
|
||||||
|
//! and h5dump, and by clawhdf5 itself.
|
||||||
|
//!
|
||||||
|
//! Skipped when python3 with h5py is unavailable, unless
|
||||||
|
//! `CLAWHDF5_REQUIRE_INTEROP=1`.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use clawhdf5::{AttrValue, File, FileBuilder, Group};
|
||||||
|
|
||||||
|
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 python_available() -> bool {
|
||||||
|
Command::new(python())
|
||||||
|
.args(["-c", "import h5py"])
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn h5dump_available() -> bool {
|
||||||
|
Command::new("h5dump")
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! skip_if_no_python {
|
||||||
|
() => {
|
||||||
|
if !python_available() {
|
||||||
|
assert!(
|
||||||
|
!interop_required(),
|
||||||
|
"CLAWHDF5_REQUIRE_INTEROP=1 but python3 with h5py is not available"
|
||||||
|
);
|
||||||
|
eprintln!("SKIP: python3 with h5py not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_python(script: &str) -> String {
|
||||||
|
let output = Command::new(python())
|
||||||
|
.args(["-c", script])
|
||||||
|
.output()
|
||||||
|
.expect("failed to run python");
|
||||||
|
if !output.status.success() {
|
||||||
|
panic!(
|
||||||
|
"Python script failed:\nSTDOUT: {}\nSTDERR: {}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `body` under h5py with `path` bound to the file's path.
|
||||||
|
fn h5py(path: &str, body: &str) -> String {
|
||||||
|
run_python(&format!(
|
||||||
|
"import h5py, numpy as np, json\npath = r'{path}'\n{body}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(dir: &tempfile::TempDir, name: &str, b: FileBuilder) -> String {
|
||||||
|
let path = dir.path().join(name).display().to_string();
|
||||||
|
b.write(&path).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// h5dump must read the whole file without an error.
|
||||||
|
fn h5dump_ok(path: &str) -> String {
|
||||||
|
if !h5dump_available() {
|
||||||
|
assert!(!interop_required(), "h5dump is not available");
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let o = Command::new("h5dump").arg(path).output().unwrap();
|
||||||
|
let out = String::from_utf8_lossy(&o.stdout).to_string();
|
||||||
|
assert!(
|
||||||
|
o.status.success(),
|
||||||
|
"h5dump failed:\n{out}{}",
|
||||||
|
String::from_utf8_lossy(&o.stderr)
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- libhdf5 can modify the groups we write ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn h5py_can_add_links_to_groups_we_wrote() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
// Measured before the fix: h5py in "r+" mode could not add a link to any
|
||||||
|
// group we wrote ("Unable to create link (message type not found)"):
|
||||||
|
// libhdf5 reads a group's Group Info message before inserting a link, and
|
||||||
|
// the writer wrote none.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
b.create_dataset("x").with_f64_data(&[1.0, 2.0]);
|
||||||
|
let mut g = b.create_group("small");
|
||||||
|
g.create_dataset("a").with_i32_data(&[1]);
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let mut g = b.create_group("big"); // dense link storage
|
||||||
|
for i in 0..20 {
|
||||||
|
g.create_dataset(&format!("d{i:02}")).with_i32_data(&[i]);
|
||||||
|
}
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let path = write(&dir, "modify.h5", b);
|
||||||
|
|
||||||
|
let out = h5py(
|
||||||
|
&path,
|
||||||
|
"with h5py.File(path, 'r+') as f:\n\
|
||||||
|
\x20 f['alias'] = f['x']\n\
|
||||||
|
\x20 f['small']['new'] = np.arange(3)\n\
|
||||||
|
\x20 f['big']['new'] = np.arange(4)\n\
|
||||||
|
\x20 f.create_group('added/deeper')\n\
|
||||||
|
with h5py.File(path, 'r') as f:\n\
|
||||||
|
\x20 print(json.dumps([sorted(f), sorted(f['small']), len(f['big']),\n\
|
||||||
|
\x20 f['alias'][()].tolist(), f['big/new'][()].tolist(), f['big/d07'][()].tolist()]))",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
r#"[["added", "alias", "big", "small", "x"], ["a", "new"], 21, [1.0, 2.0], [0, 1, 2, 3], [7]]"#
|
||||||
|
);
|
||||||
|
h5dump_ok(&path);
|
||||||
|
let f = File::open(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
f.dataset("big/new").unwrap().read_i64().unwrap(),
|
||||||
|
[0, 1, 2, 3]
|
||||||
|
);
|
||||||
|
assert_eq!(f.dataset("alias").unwrap().read_f64().unwrap(), [1.0, 2.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the whole tree, as h5py and as clawhdf5 read it ----
|
||||||
|
|
||||||
|
fn fmt_num(x: f64) -> String {
|
||||||
|
format!("{x:.6}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_attr(v: &AttrValue) -> String {
|
||||||
|
let join = |v: Vec<String>| v.join(",");
|
||||||
|
match v {
|
||||||
|
AttrValue::F64(x) => fmt_num(*x),
|
||||||
|
AttrValue::I64(x) => fmt_num(*x as f64),
|
||||||
|
AttrValue::U64(x) => fmt_num(*x as f64),
|
||||||
|
AttrValue::F64Array(a) => join(a.iter().map(|x| fmt_num(*x)).collect()),
|
||||||
|
AttrValue::I64Array(a) => join(a.iter().map(|x| fmt_num(*x as f64)).collect()),
|
||||||
|
AttrValue::U64Array(a) => join(a.iter().map(|x| fmt_num(*x as f64)).collect()),
|
||||||
|
AttrValue::String(s) => s.clone(),
|
||||||
|
AttrValue::StringArray(a) => a.join(","),
|
||||||
|
AttrValue::Raw { .. } => "raw".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_attrs(attrs: std::collections::HashMap<String, AttrValue>) -> String {
|
||||||
|
let mut v: Vec<_> = attrs.into_iter().collect();
|
||||||
|
v.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
v.iter()
|
||||||
|
.map(|(k, a)| format!("{k}={}", fmt_attr(a)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(";")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn child_path(path: &str, name: &str) -> String {
|
||||||
|
if path == "/" {
|
||||||
|
format!("/{name}")
|
||||||
|
} else {
|
||||||
|
format!("{path}/{name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every group and dataset reachable from `g` (following hard and soft
|
||||||
|
/// links; the tree must be acyclic), one line each: path, kind, attributes
|
||||||
|
/// and (datasets) values.
|
||||||
|
fn walk(g: &Group<'_>, path: &str, out: &mut Vec<String>) {
|
||||||
|
out.push(format!("{path}|group|{}", fmt_attrs(g.attrs().unwrap())));
|
||||||
|
let mut names: Vec<(String, bool)> = g
|
||||||
|
.datasets()
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|n| (n, false))
|
||||||
|
.chain(g.groups().unwrap().into_iter().map(|n| (n, true)))
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
for (name, is_group) in names {
|
||||||
|
let p = child_path(path, &name);
|
||||||
|
if is_group {
|
||||||
|
walk(&g.group(&name).unwrap(), &p, out);
|
||||||
|
} else {
|
||||||
|
let ds = g.dataset(&name).unwrap();
|
||||||
|
let values: Vec<String> = ds.read_f64().unwrap().into_iter().map(fmt_num).collect();
|
||||||
|
out.push(format!(
|
||||||
|
"{p}|dataset|{}|{}",
|
||||||
|
fmt_attrs(ds.attrs().unwrap()),
|
||||||
|
values.join(",")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clawhdf5_tree(path: &str) -> String {
|
||||||
|
let f = File::open(path).unwrap();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
walk(&f.root(), "/", &mut out);
|
||||||
|
out.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same listing as [`walk`], from h5py. External and dangling soft
|
||||||
|
/// links are skipped, as clawhdf5's group listings skip them.
|
||||||
|
const H5PY_WALK: &str = r#"
|
||||||
|
def fmt(v):
|
||||||
|
if isinstance(v, bytes): return v.decode()
|
||||||
|
if isinstance(v, str): return v
|
||||||
|
a = np.asarray(v)
|
||||||
|
if a.dtype.kind in 'SUO':
|
||||||
|
return ','.join(x.decode() if isinstance(x, bytes) else str(x) for x in a.ravel())
|
||||||
|
if a.ndim == 0: return '%.6f' % float(a)
|
||||||
|
return ','.join('%.6f' % float(x) for x in a.ravel())
|
||||||
|
def attrs(o): return ';'.join(f'{k}={fmt(o.attrs[k])}' for k in sorted(o.attrs))
|
||||||
|
out = []
|
||||||
|
def walk(g, path):
|
||||||
|
out.append(f'{path}|group|{attrs(g)}')
|
||||||
|
for k in sorted(g.keys()):
|
||||||
|
if isinstance(g.get(k, getlink=True), h5py.ExternalLink): continue
|
||||||
|
o = g.get(k)
|
||||||
|
if o is None: continue
|
||||||
|
p = '/' + k if path == '/' else path + '/' + k
|
||||||
|
if isinstance(o, h5py.Group): walk(o, p)
|
||||||
|
else:
|
||||||
|
vals = ','.join('%.6f' % float(x) for x in np.asarray(o[()]).ravel())
|
||||||
|
out.append(f'{p}|dataset|{attrs(o)}|{vals}')
|
||||||
|
with h5py.File(path, 'r') as f:
|
||||||
|
walk(f, '/')
|
||||||
|
print('\n'.join(out))
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn h5py_tree(path: &str) -> String {
|
||||||
|
h5py(path, H5PY_WALK)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A four-level tree with attributes on every object: nested builders,
|
||||||
|
/// path names (with intermediate groups made on the way) and a group added
|
||||||
|
/// twice (merged), with dense attribute storage at one level and dense link
|
||||||
|
/// storage at another.
|
||||||
|
fn nested_builder() -> FileBuilder {
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
b.set_attr("title", AttrValue::String("nested".into()));
|
||||||
|
let mut l1 = b.create_group("l1");
|
||||||
|
l1.set_attr("depth", AttrValue::I64(1));
|
||||||
|
l1.create_dataset("d1")
|
||||||
|
.with_f64_data(&[1.0, 1.5])
|
||||||
|
.set_attr("unit", AttrValue::String("m".into()));
|
||||||
|
let mut l2 = l1.create_group("l2");
|
||||||
|
l2.set_attr("depth", AttrValue::I64(2));
|
||||||
|
l2.create_dataset("d2").with_i32_data(&[2, 3, 4]);
|
||||||
|
let mut l3 = l2.create_group("l3");
|
||||||
|
for i in 0..10 {
|
||||||
|
l3.set_attr(&format!("a{i}"), AttrValue::F64(i as f64 / 4.0)); // dense
|
||||||
|
}
|
||||||
|
for i in 0..12 {
|
||||||
|
l3.create_dataset(&format!("x{i:02}")) // dense links
|
||||||
|
.with_i64_data(&[i, -i])
|
||||||
|
.set_attr("i", AttrValue::I64(i));
|
||||||
|
}
|
||||||
|
let mut l4 = l3.create_group("l4");
|
||||||
|
l4.set_attr("depth", AttrValue::I64(4));
|
||||||
|
l4.create_dataset("leaf")
|
||||||
|
.with_f64_data(&[4.0, 4.25, 4.5])
|
||||||
|
.set_attr(
|
||||||
|
"tags",
|
||||||
|
AttrValue::StringArray(vec!["a".into(), "bc".into()]),
|
||||||
|
);
|
||||||
|
l3.add_group(l4.finish());
|
||||||
|
l2.add_group(l3.finish());
|
||||||
|
l1.add_group(l2.finish());
|
||||||
|
b.add_group(l1.finish());
|
||||||
|
// Path names: /p, /p/q and /p/q/r are made on the way to the dataset.
|
||||||
|
b.create_dataset("p/q/r/s")
|
||||||
|
.with_f64_data(&[7.0])
|
||||||
|
.set_attr("deep", AttrValue::I64(4));
|
||||||
|
// A group at an existing path is merged into it.
|
||||||
|
let mut pq = b.create_group("p/q");
|
||||||
|
pq.set_attr("merged", AttrValue::I64(1));
|
||||||
|
pq.create_dataset("t").with_i32_data(&[8]);
|
||||||
|
b.add_group(pq.finish());
|
||||||
|
let mut l1b = b.create_group("l1/l2/l3/l4/l5");
|
||||||
|
l1b.set_attr("depth", AttrValue::I64(5));
|
||||||
|
b.add_group(l1b.finish());
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
const NESTED_TREE: &str = "\
|
||||||
|
/|group|title=nested
|
||||||
|
/l1|group|depth=1.000000
|
||||||
|
/l1/d1|dataset|unit=m|1.000000,1.500000
|
||||||
|
/l1/l2|group|depth=2.000000
|
||||||
|
/l1/l2/d2|dataset||2.000000,3.000000,4.000000";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_groups_read_the_same_in_h5py_and_clawhdf5() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = write(&dir, "nested.h5", nested_builder());
|
||||||
|
let ours = clawhdf5_tree(&path);
|
||||||
|
let theirs = h5py_tree(&path);
|
||||||
|
assert_eq!(ours, theirs);
|
||||||
|
assert!(ours.starts_with(NESTED_TREE), "{ours}");
|
||||||
|
for line in [
|
||||||
|
"/l1/l2/l3|group|a0=0.000000;a1=0.250000;a2=0.500000;a3=0.750000;a4=1.000000;\
|
||||||
|
a5=1.250000;a6=1.500000;a7=1.750000;a8=2.000000;a9=2.250000",
|
||||||
|
"/l1/l2/l3/l4|group|depth=4.000000",
|
||||||
|
"/l1/l2/l3/l4/l5|group|depth=5.000000",
|
||||||
|
"/l1/l2/l3/l4/leaf|dataset|tags=a,bc|4.000000,4.250000,4.500000",
|
||||||
|
"/l1/l2/l3/x11|dataset|i=11.000000|11.000000,-11.000000",
|
||||||
|
"/p|group|",
|
||||||
|
"/p/q|group|merged=1.000000",
|
||||||
|
"/p/q/r/s|dataset|deep=4.000000|7.000000",
|
||||||
|
"/p/q/t|dataset||8.000000",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
ours.lines().any(|l| l == line),
|
||||||
|
"missing {line:?} in\n{ours}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(ours.lines().count(), 26, "{ours}");
|
||||||
|
let dump = h5dump_ok(&path);
|
||||||
|
if !dump.is_empty() {
|
||||||
|
assert!(dump.contains("GROUP \"l5\""), "{dump}");
|
||||||
|
assert!(dump.contains("DATASET \"leaf\""), "{dump}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn soft_hard_and_external_links() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut other = FileBuilder::new();
|
||||||
|
other.create_dataset("data").with_i32_data(&[42, 43]);
|
||||||
|
write(&dir, "other.h5", other);
|
||||||
|
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
b.create_dataset("x/y").with_f64_data(&[1.0, 2.0, 3.0]);
|
||||||
|
b.create_dataset("x/z").with_i32_data(&[9]);
|
||||||
|
b.add_soft_link("soft_abs", "/x/y");
|
||||||
|
b.add_soft_link("dangling", "/nowhere");
|
||||||
|
b.add_hard_link("alias", "/x/y");
|
||||||
|
b.add_hard_link("x_again", "x");
|
||||||
|
b.add_external_link("ext", "other.h5", "/data");
|
||||||
|
let mut g = b.create_group("a/b/c");
|
||||||
|
g.add_soft_link("rel", "sib"); // relative to /a/b/c
|
||||||
|
g.create_dataset("sib").with_i32_data(&[5]);
|
||||||
|
g.add_hard_link("deep_alias", "/x_again/z"); // through a hard link
|
||||||
|
g.add_soft_link("to_group", "/x");
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let path = write(&dir, "links.h5", b);
|
||||||
|
|
||||||
|
let out = h5py(
|
||||||
|
&path,
|
||||||
|
"import os\nos.chdir(os.path.dirname(path))\n\
|
||||||
|
with h5py.File(path, 'r') as f:\n\
|
||||||
|
\x20 def kind(g, k):\n\
|
||||||
|
\x20 l = g.get(k, getlink=True)\n\
|
||||||
|
\x20 if isinstance(l, h5py.SoftLink): return 'soft:' + l.path\n\
|
||||||
|
\x20 if isinstance(l, h5py.ExternalLink): return 'ext:' + l.filename + ':' + l.path\n\
|
||||||
|
\x20 return 'hard'\n\
|
||||||
|
\x20 print(json.dumps({\n\
|
||||||
|
\x20 'root': {k: kind(f, k) for k in f},\n\
|
||||||
|
\x20 'abc': {k: kind(f['a/b/c'], k) for k in f['a/b/c']},\n\
|
||||||
|
\x20 'same': [f['alias'].id == f['x/y'].id, f['x_again'].id == f['x'].id,\n\
|
||||||
|
\x20 f['a/b/c/deep_alias'].id == f['x/z'].id],\n\
|
||||||
|
\x20 'rc': [h5py.h5o.get_info(f['x/y'].id).rc, h5py.h5o.get_info(f['x'].id).rc,\n\
|
||||||
|
\x20 h5py.h5o.get_info(f['x/z'].id).rc, h5py.h5o.get_info(f['a'].id).rc],\n\
|
||||||
|
\x20 'vals': [f['soft_abs'][()].tolist(), f['ext'][()].tolist(),\n\
|
||||||
|
\x20 f['a/b/c/rel'][()].tolist(), sorted(f['a/b/c/to_group'])],\n\
|
||||||
|
\x20 'dangling': f.get('dangling') is None,\n\
|
||||||
|
\x20 }, sort_keys=True))",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
r#"{"abc": {"deep_alias": "hard", "rel": "soft:sib", "sib": "hard", "to_group": "soft:/x"}, "dangling": true, "rc": [2, 2, 2, 1], "root": {"a": "hard", "alias": "hard", "dangling": "soft:/nowhere", "ext": "ext:other.h5:/data", "soft_abs": "soft:/x/y", "x": "hard", "x_again": "hard"}, "same": [true, true, true], "vals": [[1.0, 2.0, 3.0], [42, 43], [5], ["y", "z"]]}"#
|
||||||
|
);
|
||||||
|
assert_eq!(clawhdf5_tree(&path), h5py_tree(&path));
|
||||||
|
h5dump_ok(&path);
|
||||||
|
|
||||||
|
let f = File::open(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
f.dataset("alias").unwrap().read_f64().unwrap(),
|
||||||
|
[1.0, 2.0, 3.0]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
f.dataset("soft_abs").unwrap().read_f64().unwrap(),
|
||||||
|
[1.0, 2.0, 3.0]
|
||||||
|
);
|
||||||
|
assert_eq!(f.dataset("a/b/c/rel").unwrap().read_i32().unwrap(), [5]);
|
||||||
|
assert_eq!(
|
||||||
|
f.dataset("a/b/c/deep_alias").unwrap().read_i32().unwrap(),
|
||||||
|
[9]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
f.dataset("x_again/y").unwrap().read_f64().unwrap(),
|
||||||
|
[1.0, 2.0, 3.0]
|
||||||
|
);
|
||||||
|
drop(f);
|
||||||
|
|
||||||
|
// The reference counts let libhdf5 delete one of two hard links and
|
||||||
|
// keep the object; with a count of 1 it would free an object still
|
||||||
|
// linked from elsewhere.
|
||||||
|
let out = h5py(
|
||||||
|
&path,
|
||||||
|
"with h5py.File(path, 'r+') as f:\n\
|
||||||
|
\x20 del f['alias']\n\
|
||||||
|
\x20 del f['x_again']\n\
|
||||||
|
\x20 f.create_dataset('filler', data=np.arange(1000))\n\
|
||||||
|
with h5py.File(path, 'r') as f:\n\
|
||||||
|
\x20 print(json.dumps([f['x/y'][()].tolist(), sorted(f['x']), h5py.h5o.get_info(f['x/y'].id).rc]))",
|
||||||
|
);
|
||||||
|
assert_eq!(out, r#"[[1.0, 2.0, 3.0], ["y", "z"], 1]"#);
|
||||||
|
h5dump_ok(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hard_link_can_make_a_cycle() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
let mut g = b.create_group("g");
|
||||||
|
g.create_dataset("v").with_i32_data(&[1]);
|
||||||
|
g.add_hard_link("up", "/");
|
||||||
|
g.add_hard_link("me", ".");
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let path = write(&dir, "cycle.h5", b);
|
||||||
|
let out = h5py(
|
||||||
|
&path,
|
||||||
|
"with h5py.File(path, 'r') as f:\n\
|
||||||
|
\x20 print(json.dumps([sorted(f['g/up/g']), f['g/up/g/me/me/v'][()].tolist(),\n\
|
||||||
|
\x20 h5py.h5o.get_info(f.id).rc, h5py.h5o.get_info(f['g'].id).rc]))",
|
||||||
|
);
|
||||||
|
assert_eq!(out, r#"[["me", "up", "v"], [1], 2, 2]"#);
|
||||||
|
h5dump_ok(&path);
|
||||||
|
let f = File::open(&path).unwrap();
|
||||||
|
assert_eq!(f.dataset("g/up/g/me/v").unwrap().read_i32().unwrap(), [1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_links_are_errors() {
|
||||||
|
for setup in [
|
||||||
|
|b: &mut FileBuilder| {
|
||||||
|
b.add_hard_link("h", "/missing");
|
||||||
|
},
|
||||||
|
|b: &mut FileBuilder| {
|
||||||
|
b.create_dataset("x").with_i32_data(&[1]);
|
||||||
|
b.add_soft_link("s", "/x");
|
||||||
|
b.add_hard_link("h", "/s"); // through a soft link
|
||||||
|
},
|
||||||
|
|b: &mut FileBuilder| {
|
||||||
|
b.add_hard_link("h1", "/h2");
|
||||||
|
b.add_hard_link("h2", "/h1");
|
||||||
|
},
|
||||||
|
|b: &mut FileBuilder| {
|
||||||
|
b.create_dataset("x").with_i32_data(&[1]);
|
||||||
|
b.add_hard_link("h", "/x/y"); // a dataset is not a group
|
||||||
|
},
|
||||||
|
|b: &mut FileBuilder| {
|
||||||
|
b.add_soft_link("s", "");
|
||||||
|
},
|
||||||
|
|b: &mut FileBuilder| {
|
||||||
|
b.add_external_link("e", "", "/x");
|
||||||
|
},
|
||||||
|
|b: &mut FileBuilder| {
|
||||||
|
b.create_dataset("x").with_i32_data(&[1]);
|
||||||
|
b.add_soft_link("x", "/y"); // name taken
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
setup(&mut b);
|
||||||
|
assert!(b.finish().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ten_thousand_links_in_one_group() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
let mut g = b.create_group("many");
|
||||||
|
for i in 0..10_000 {
|
||||||
|
g.create_dataset(&format!("d{i:05}")).with_i32_data(&[i]);
|
||||||
|
}
|
||||||
|
g.set_attr("n", AttrValue::I64(10_000));
|
||||||
|
b.add_group(g.finish());
|
||||||
|
// The same in creation order, added in reverse name order, with soft
|
||||||
|
// links among them.
|
||||||
|
let mut g = b.create_group("ordered");
|
||||||
|
g.track_order(true);
|
||||||
|
for i in (0..10_000).rev() {
|
||||||
|
if i % 1000 == 0 {
|
||||||
|
g.add_soft_link(&format!("s{i:05}"), &format!("/many/d{i:05}"));
|
||||||
|
}
|
||||||
|
g.create_dataset(&format!("d{i:05}")).with_i32_data(&[i]);
|
||||||
|
}
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let path = write(&dir, "many.h5", b);
|
||||||
|
|
||||||
|
let out = h5py(
|
||||||
|
&path,
|
||||||
|
"with h5py.File(path, 'r') as f:\n\
|
||||||
|
\x20 m, o = f['many'], f['ordered']\n\
|
||||||
|
\x20 names = list(m)\n\
|
||||||
|
\x20 onames = list(o)\n\
|
||||||
|
\x20 print(json.dumps([len(names), names == sorted(names), names[:2], int(m.attrs['n']),\n\
|
||||||
|
\x20 [int(m['d%05d' % i][0]) for i in (0, 1, 4096, 9999)],\n\
|
||||||
|
\x20 len(onames), onames[:3], onames[-2:], int(o['s05000'][0]),\n\
|
||||||
|
\x20 o.id.get_create_plist().get_link_creation_order()]))",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
r#"[10000, true, ["d00000", "d00001"], 10000, [0, 1, 4096, 9999], 10010, ["d09999", "d09998", "d09997"], ["s00000", "d00000"], 5000, 3]"#
|
||||||
|
);
|
||||||
|
h5dump_ok(&path);
|
||||||
|
let f = File::open(&path).unwrap();
|
||||||
|
let g = f.group("many").unwrap();
|
||||||
|
assert_eq!(g.datasets().unwrap().len(), 10_000);
|
||||||
|
assert_eq!(g.dataset("d09999").unwrap().read_i32().unwrap(), [9999]);
|
||||||
|
assert_eq!(
|
||||||
|
f.dataset("ordered/s05000").unwrap().read_i32().unwrap(),
|
||||||
|
[5000]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn more_links_than_one_index_leaf_holds_is_an_error() {
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
for i in 0..70_000 {
|
||||||
|
b.add_soft_link(&format!("s{i}"), "/x");
|
||||||
|
}
|
||||||
|
let err = b.finish().unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err.contains("70000 links in one group: at most 65535"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
// Dense attributes have the same one-leaf index. Their count used to
|
||||||
|
// be written modulo 65 536.
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
let x = b.create_dataset("x");
|
||||||
|
x.with_i32_data(&[1]);
|
||||||
|
for i in 0..70_000 {
|
||||||
|
x.set_attr(&format!("a{i}"), AttrValue::I64(i));
|
||||||
|
}
|
||||||
|
let err = b.finish().unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err.contains("70000 attributes on one object: at most 65535"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn track_order_lists_members_in_creation_order() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let names = ["zeta", "alpha", "mid", "beta"];
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
b.track_order(true); // the root and every group without its own setting
|
||||||
|
for n in names {
|
||||||
|
b.create_dataset(n).with_i32_data(&[1]);
|
||||||
|
}
|
||||||
|
let mut g = b.create_group("by_name");
|
||||||
|
g.track_order(false);
|
||||||
|
for n in names {
|
||||||
|
g.create_dataset(n).with_i32_data(&[2]);
|
||||||
|
}
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let mut g = b.create_group("dense");
|
||||||
|
for i in (0..20).rev() {
|
||||||
|
g.create_dataset(&format!("n{i:02}")).with_i32_data(&[i]);
|
||||||
|
}
|
||||||
|
g.add_soft_link("soft", "/zeta");
|
||||||
|
b.add_group(g.finish());
|
||||||
|
b.create_dataset("made/on/the/way").with_i32_data(&[3]);
|
||||||
|
let path = write(&dir, "order.h5", b);
|
||||||
|
|
||||||
|
let out = h5py(
|
||||||
|
&path,
|
||||||
|
"with h5py.File(path, 'r') as f:\n\
|
||||||
|
\x20 print(json.dumps([list(f), list(f['by_name']), list(f['dense'])[:3],\n\
|
||||||
|
\x20 list(f['dense'])[-2:], list(f['made/on'])]))",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
r#"[["zeta", "alpha", "mid", "beta", "by_name", "dense", "made"], ["alpha", "beta", "mid", "zeta"], ["n19", "n18", "n17"], ["n00", "soft"], ["the"]]"#
|
||||||
|
);
|
||||||
|
h5dump_ok(&path);
|
||||||
|
assert_eq!(clawhdf5_tree(&path), h5py_tree(&path));
|
||||||
|
|
||||||
|
// libhdf5 keeps the order when it adds to (and converts) these groups.
|
||||||
|
let out = h5py(
|
||||||
|
&path,
|
||||||
|
"with h5py.File(path, 'r+') as f:\n\
|
||||||
|
\x20 f['aaa'] = np.arange(2)\n\
|
||||||
|
\x20 f['dense']['aaa'] = np.arange(2)\n\
|
||||||
|
\x20 del f['dense/n10']\n\
|
||||||
|
with h5py.File(path, 'r') as f:\n\
|
||||||
|
\x20 print(json.dumps([list(f)[-1], list(f['dense'])[-2:], len(f['dense'])]))",
|
||||||
|
);
|
||||||
|
assert_eq!(out, r#"["aaa", ["soft", "aaa"], 21]"#);
|
||||||
|
h5dump_ok(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_ascii_names_are_utf8() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
b.create_dataset("größe/wert").with_i32_data(&[1]);
|
||||||
|
let path = write(&dir, "utf8.h5", b);
|
||||||
|
let out = h5py(
|
||||||
|
&path,
|
||||||
|
"with h5py.File(path, 'r') as f:\n\
|
||||||
|
\x20 l = f.id.links.get_info('größe'.encode())\n\
|
||||||
|
\x20 print(json.dumps([list(f), list(f['größe']), l.cset], ensure_ascii=False))",
|
||||||
|
);
|
||||||
|
assert_eq!(out, r#"[["größe"], ["wert"], 1]"#);
|
||||||
|
let f = File::open(&path).unwrap();
|
||||||
|
assert_eq!(f.dataset("größe/wert").unwrap().read_i32().unwrap(), [1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_group_attribute_set_again_takes_the_new_value() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
// Setting a group attribute twice wrote two attribute messages with one
|
||||||
|
// name. Now the later value replaces the earlier, as `attrs[name] = v`
|
||||||
|
// does in h5py — also across a group merged from two builders.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
b.set_attr("v", AttrValue::I64(1));
|
||||||
|
b.set_attr("v", AttrValue::I64(2));
|
||||||
|
let mut g = b.create_group("g");
|
||||||
|
g.set_attr("w", AttrValue::I64(1));
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let mut g = b.create_group("g");
|
||||||
|
g.set_attr("w", AttrValue::String("two".into()));
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let path = write(&dir, "attrs.h5", b);
|
||||||
|
let out = h5py(
|
||||||
|
&path,
|
||||||
|
"with h5py.File(path, 'r') as f:\n\
|
||||||
|
\x20 print(json.dumps([list(f.attrs), int(f.attrs['v']), list(f['g'].attrs),\n\
|
||||||
|
\x20 f['g'].attrs['w'].decode()]))",
|
||||||
|
);
|
||||||
|
assert_eq!(out, r#"[["v"], 2, ["w"], "two"]"#);
|
||||||
|
let f = File::open(&path).unwrap();
|
||||||
|
assert!(matches!(f.root().attrs().unwrap()["v"], AttrValue::I64(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- big dense storage: child indirect blocks in the fractal heap ----
|
||||||
|
|
||||||
|
/// A name `len` bytes long, unique per `i`.
|
||||||
|
fn long_name(i: usize, len: usize) -> String {
|
||||||
|
let n = format!("link_{i:06}_");
|
||||||
|
format!("{n}{}", "x".repeat(len - n.len()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dense_links_past_the_direct_blocks_of_the_root() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
// A dense group's links live in a fractal heap whose root indirect
|
||||||
|
// block holds direct blocks up to 64 KiB: 512 KiB of link messages.
|
||||||
|
// Rows past that are child indirect blocks. The writer used to write
|
||||||
|
// them as direct blocks, which libhdf5 cannot read ("incorrect metadata
|
||||||
|
// checksum"), from about 17 000 links with 20-byte names.
|
||||||
|
// `g` crosses the first boundary (0.6 MB of links); `deep` has 65 535
|
||||||
|
// links of about 110 bytes (7 MB), so its heap reaches the child indirect
|
||||||
|
// blocks that hold indirect blocks themselves.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
b.create_dataset("x").with_i32_data(&[7]);
|
||||||
|
let mut g = b.create_group("g");
|
||||||
|
for i in 0..20_000 {
|
||||||
|
g.create_dataset(&format!("dataset_number_{i:06}"))
|
||||||
|
.with_i32_data(&[i]);
|
||||||
|
}
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let mut g = b.create_group("deep");
|
||||||
|
g.track_order(true);
|
||||||
|
for i in 0..usize::from(u16::MAX) {
|
||||||
|
g.add_hard_link(&long_name(i, 100), "/x");
|
||||||
|
}
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let path = write(&dir, "big_links.h5", b);
|
||||||
|
|
||||||
|
let out = h5py(
|
||||||
|
&path,
|
||||||
|
"with h5py.File(path, 'r') as f:\n\
|
||||||
|
\x20 g, d = f['g'], f['deep']\n\
|
||||||
|
\x20 names = list(g)\n\
|
||||||
|
\x20 dn = list(d)\n\
|
||||||
|
\x20 print(json.dumps([len(names), names[-1], int(g[names[-1]][0]),\n\
|
||||||
|
\x20 sum(int(g[n][0]) for n in names), len(dn), dn[0][:12], dn[-1][:12],\n\
|
||||||
|
\x20 int(d[dn[-1]][0]), h5py.h5o.get_info(f['x'].id).rc]))",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
r#"[20000, "dataset_number_019999", 19999, 199990000, 65535, "link_000000_", "link_065534_", 7, 65536]"#
|
||||||
|
);
|
||||||
|
h5dump_ok(&path);
|
||||||
|
let f = File::open(&path).unwrap();
|
||||||
|
let g = f.group("g").unwrap();
|
||||||
|
assert_eq!(g.datasets().unwrap().len(), 20_000);
|
||||||
|
assert_eq!(
|
||||||
|
g.dataset("dataset_number_019999")
|
||||||
|
.unwrap()
|
||||||
|
.read_i32()
|
||||||
|
.unwrap(),
|
||||||
|
[19999]
|
||||||
|
);
|
||||||
|
let d = f.group("deep").unwrap();
|
||||||
|
assert_eq!(d.datasets().unwrap().len(), usize::from(u16::MAX));
|
||||||
|
assert_eq!(
|
||||||
|
d.dataset(&long_name(65_534, 100))
|
||||||
|
.unwrap()
|
||||||
|
.read_i32()
|
||||||
|
.unwrap(),
|
||||||
|
[7]
|
||||||
|
);
|
||||||
|
|
||||||
|
// libhdf5 can add to and delete from the heap. It could not when the
|
||||||
|
// header's block allocation offset was 0: its next block overwrote the
|
||||||
|
// first ("bad version number for message"). Adding to `deep` also
|
||||||
|
// needs its index's leaf node to have room for at most 65 535 records:
|
||||||
|
// a bigger node made libhdf5 overflow the leaf's 2-byte record count
|
||||||
|
// (a crash, or "unknown link class" when listing).
|
||||||
|
let out = h5py(
|
||||||
|
&path,
|
||||||
|
"with h5py.File(path, 'r+') as f:\n\
|
||||||
|
\x20 f['g']['zz_new'] = np.arange(3)\n\
|
||||||
|
\x20 f['deep']['zz_new'] = np.arange(4)\n\
|
||||||
|
\x20 del f['g/dataset_number_000005']\n\
|
||||||
|
with h5py.File(path, 'r') as f:\n\
|
||||||
|
\x20 print(json.dumps([len(f['g']), int(f['g/zz_new'][2]), len(f['deep']),\n\
|
||||||
|
\x20 list(f['deep'])[-1], int(f['g/dataset_number_019998'][0])]))",
|
||||||
|
);
|
||||||
|
assert_eq!(out, r#"[20000, 2, 65536, "zz_new", 19998]"#);
|
||||||
|
h5dump_ok(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dense_attributes_past_the_direct_blocks_of_the_root() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
// Dense attributes share the heap writer. 150 attributes of up to 56 KB
|
||||||
|
// (8 MB) need child indirect blocks, and a big attribute after small
|
||||||
|
// ones must skip the small blocks rather than overrun one.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
let ds = b.create_dataset("x");
|
||||||
|
ds.with_i32_data(&[1]);
|
||||||
|
for i in 0..150usize {
|
||||||
|
let len = if i % 3 == 0 { 7_000 } else { 1 + i };
|
||||||
|
let v: Vec<f64> = (0..len).map(|k| (i * 100_000 + k) as f64).collect();
|
||||||
|
ds.set_attr(&format!("a{i:03}"), AttrValue::F64Array(v));
|
||||||
|
}
|
||||||
|
let path = write(&dir, "big_attrs.h5", b);
|
||||||
|
|
||||||
|
let out = h5py(
|
||||||
|
&path,
|
||||||
|
"with h5py.File(path, 'r') as f:\n\
|
||||||
|
\x20 a = f['x'].attrs\n\
|
||||||
|
\x20 ok = all(np.array_equal(a['a%03d' % i],\n\
|
||||||
|
\x20 np.arange(7000 if i % 3 == 0 else 1 + i) + i * 100000) for i in range(150))\n\
|
||||||
|
\x20 print(json.dumps([len(a), ok]))",
|
||||||
|
);
|
||||||
|
assert_eq!(out, "[150, true]");
|
||||||
|
h5dump_ok(&path);
|
||||||
|
let f = File::open(&path).unwrap();
|
||||||
|
let attrs = f.dataset("x").unwrap().attrs().unwrap();
|
||||||
|
assert_eq!(attrs.len(), 150);
|
||||||
|
for i in [0usize, 1, 147, 149] {
|
||||||
|
let len = if i % 3 == 0 { 7_000 } else { 1 + i };
|
||||||
|
let want: Vec<f64> = (0..len).map(|k| (i * 100_000 + k) as f64).collect();
|
||||||
|
match &attrs[&format!("a{i:03}")] {
|
||||||
|
AttrValue::F64Array(v) => assert_eq!(*v, want, "a{i:03}"),
|
||||||
|
other => panic!("a{i:03}: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_link_too_big_for_dense_storage_is_an_error() {
|
||||||
|
// A link message must fit one fractal heap direct block (64 KiB less
|
||||||
|
// its header); the writer has no huge-object path. It used to be
|
||||||
|
// written anyway, cut off, and libhdf5 could not list the group.
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
for i in 0..10 {
|
||||||
|
b.create_dataset(&format!("d{i}")).with_i32_data(&[i]);
|
||||||
|
}
|
||||||
|
b.add_soft_link("s", &"/y".repeat(40_000));
|
||||||
|
let err = b.finish().unwrap_err().to_string();
|
||||||
|
assert!(err.contains("fractal heap object holds at most"), "{err}");
|
||||||
|
// The same for a dense attribute.
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
let x = b.create_dataset("x");
|
||||||
|
x.with_i32_data(&[1]);
|
||||||
|
for i in 0..9 {
|
||||||
|
x.set_attr(&format!("a{i}"), AttrValue::I64(i));
|
||||||
|
}
|
||||||
|
x.set_attr("big", AttrValue::F64Array(vec![0.5; 9_000]));
|
||||||
|
let err = b.finish().unwrap_err().to_string();
|
||||||
|
assert!(err.contains("fractal heap object holds at most"), "{err}");
|
||||||
|
|
||||||
|
// Just under the limit is fine, and libhdf5 reads it back.
|
||||||
|
skip_if_no_python!();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
for i in 0..10 {
|
||||||
|
b.create_dataset(&format!("d{i}")).with_i32_data(&[i]);
|
||||||
|
}
|
||||||
|
let target = format!("/{}", "y".repeat(65_000));
|
||||||
|
b.add_soft_link("s", &target);
|
||||||
|
let path = write(&dir, "long_soft.h5", b);
|
||||||
|
let out = h5py(
|
||||||
|
&path,
|
||||||
|
"with h5py.File(path, 'r') as f:\n\
|
||||||
|
\x20 print(json.dumps([len(f), len(f.get('s', getlink=True).path)]))",
|
||||||
|
);
|
||||||
|
assert_eq!(out, "[11, 65001]");
|
||||||
|
h5dump_ok(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chained_hard_links_resolve_in_linear_time() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
// Each link's target goes through the previous link twice. Resolving
|
||||||
|
// them without remembering resolved links doubled the work per link:
|
||||||
|
// 26 links took 46 s in a debug build, so 60 would never finish.
|
||||||
|
fn chain(reverse: bool) -> FileBuilder {
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
let mut g = b.create_group("g");
|
||||||
|
g.create_dataset("v").with_i32_data(&[5]);
|
||||||
|
b.add_group(g.finish());
|
||||||
|
let mut order: Vec<usize> = (0..60).collect();
|
||||||
|
if reverse {
|
||||||
|
order.reverse();
|
||||||
|
}
|
||||||
|
for i in order {
|
||||||
|
if i == 0 {
|
||||||
|
b.add_hard_link("g/s0", "/g");
|
||||||
|
} else {
|
||||||
|
b.add_hard_link(&format!("g/s{i}"), &format!("/g/s{}/s{}", i - 1, i - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b
|
||||||
|
}
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let bytes = [false, true].map(|r| chain(r).finish().unwrap());
|
||||||
|
tx.send(bytes).unwrap();
|
||||||
|
});
|
||||||
|
let [forward, reverse] = rx
|
||||||
|
.recv_timeout(std::time::Duration::from_secs(60))
|
||||||
|
.expect("resolving 60 chained hard links took over a minute");
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
for (name, bytes) in [("forward.h5", forward), ("reverse.h5", reverse)] {
|
||||||
|
let path = dir.path().join(name).display().to_string();
|
||||||
|
std::fs::write(&path, bytes).unwrap();
|
||||||
|
let out = h5py(
|
||||||
|
&path,
|
||||||
|
"with h5py.File(path, 'r') as f:\n\
|
||||||
|
\x20 print(json.dumps([h5py.h5o.get_info(f['g'].id).rc, len(f['g']),\n\
|
||||||
|
\x20 int(f['g/s59/s30/s0/v'][0]), f['g/s59'] == f['g']]))",
|
||||||
|
);
|
||||||
|
assert_eq!(out, "[61, 61, 5, true]", "{name}");
|
||||||
|
let f = File::open(&path).unwrap();
|
||||||
|
assert_eq!(f.dataset("g/s59/s0/v").unwrap().read_i32().unwrap(), [5]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dataset_attribute_set_again_takes_the_new_value() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
// Setting a dataset attribute twice wrote two attribute messages with
|
||||||
|
// one name, and h5py read back the first value. Also with dense
|
||||||
|
// attribute storage (more than 8).
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
b.create_dataset("x")
|
||||||
|
.with_f64_data(&[1.0])
|
||||||
|
.set_attr("a", AttrValue::I64(1))
|
||||||
|
.set_attr("a", AttrValue::I64(2));
|
||||||
|
let d = b.create_dataset("dense");
|
||||||
|
d.with_i32_data(&[1]);
|
||||||
|
for i in 0..12 {
|
||||||
|
d.set_attr(&format!("k{i:02}"), AttrValue::I64(i));
|
||||||
|
}
|
||||||
|
d.set_attr("k03", AttrValue::String("three".into()));
|
||||||
|
let path = write(&dir, "ds_attrs.h5", b);
|
||||||
|
let out = h5py(
|
||||||
|
&path,
|
||||||
|
"with h5py.File(path, 'r') as f:\n\
|
||||||
|
\x20 a, d = f['x'].attrs, f['dense'].attrs\n\
|
||||||
|
\x20 print(json.dumps([list(a), int(a['a']), len(d), d['k03'].decode(), int(d['k04'])]))",
|
||||||
|
);
|
||||||
|
assert_eq!(out, r#"[["a"], 2, 12, "three", 4]"#);
|
||||||
|
let f = File::open(&path).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
f.dataset("x").unwrap().attrs().unwrap()["a"],
|
||||||
|
AttrValue::I64(2)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "provenance")]
|
||||||
|
#[test]
|
||||||
|
fn provenance_attributes_replace_ones_set_by_hand() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
// A hand-set attribute with a provenance attribute's name was written
|
||||||
|
// next to the computed one, and h5py read the hand-set value.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut b = FileBuilder::new();
|
||||||
|
b.create_dataset("p")
|
||||||
|
.with_i32_data(&[1, 2])
|
||||||
|
.with_provenance("me", "2026-09-26T00:00:00Z", None)
|
||||||
|
.set_attr("_provenance_sha256", AttrValue::String("forged".into()));
|
||||||
|
let path = write(&dir, "prov.h5", b);
|
||||||
|
let out = h5py(
|
||||||
|
&path,
|
||||||
|
"with h5py.File(path, 'r') as f:\n\
|
||||||
|
\x20 a = f['p'].attrs\n\
|
||||||
|
\x20 h = a['_provenance_sha256']\n\
|
||||||
|
\x20 h = h.decode() if isinstance(h, bytes) else h\n\
|
||||||
|
\x20 print(json.dumps([list(a).count('_provenance_sha256'), h != 'forged']))",
|
||||||
|
);
|
||||||
|
assert_eq!(out, "[1, true]");
|
||||||
|
let f = File::open(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
f.dataset("p").unwrap().verify_provenance().unwrap(),
|
||||||
|
clawhdf5_format::provenance::VerifyResult::Ok
|
||||||
|
);
|
||||||
|
}
|
||||||
+9
-6
@@ -395,19 +395,22 @@ clawhdf5 --path agent.h5 snapshot backup_2026-03-19.h5
|
|||||||
Read HDF5 files from Python without libhdf5:
|
Read HDF5 files from Python without libhdf5:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install clawhdf5 # coming soon — build from source for now
|
# Not on PyPI yet: build from source into a virtualenv
|
||||||
cd crates/clawhdf5-py && maturin develop
|
pip install maturin numpy
|
||||||
|
cd crates/clawhdf5-py && maturin develop --release
|
||||||
```
|
```
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import clawhdf5
|
import clawhdf5
|
||||||
|
|
||||||
# Read
|
# Read (h5py-style)
|
||||||
f = clawhdf5.open("data.h5")
|
with clawhdf5.File("data.h5", "r") as f:
|
||||||
temps = f.read_f64("temperatures")
|
temps = f["temperatures"][:]
|
||||||
print(temps) # [22.5, 23.1, 21.8]
|
print(temps) # [22.5 23.1 21.8]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
See `crates/clawhdf5-py/README.md` for the supported types and indexing.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Common Patterns
|
## Common Patterns
|
||||||
|
|||||||
+117
-15
@@ -7,16 +7,60 @@ deleting it.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Selection reads that decode more than the selection
|
||||||
|
|
||||||
|
**Status:** open (documented 2026-09-26). `Dataset::read_selection` (and so
|
||||||
|
the Python `ds[...]`) materialises only the selection's bounding box when
|
||||||
|
that box covers at most half the dataset (`partial_read`). It decodes the
|
||||||
|
whole dataset and extracts the selection instead when:
|
||||||
|
- the bounding box covers more than half the dataset — which a strided
|
||||||
|
selection across a chunked dataset (`ds[::100]`) always does, although
|
||||||
|
it may touch few chunks;
|
||||||
|
- the dataset is compact or virtual, or has no storage;
|
||||||
|
- it is chunked with a non-default fill value (the box path does not fill
|
||||||
|
unallocated chunks, so the fill-aware full read is used).
|
||||||
|
Values are correct in every case; this is cost only. Selections other than
|
||||||
|
`Selection::All` also bypass the file's chunk cache. The bounding-box
|
||||||
|
heuristic's other cost is measured under "Concurrent and contiguous read
|
||||||
|
performance" below.
|
||||||
|
|
||||||
## Concurrent and contiguous read performance (measured 2026-09-26)
|
## Concurrent and contiguous read performance (measured 2026-09-26)
|
||||||
|
|
||||||
**Status:** open. Measured on tank with `concurrent_read` against h5py
|
**Status:** open for chunked full reads (one cause fixed 2026-09-26); the
|
||||||
3.16 / HDF5 2.0 (`BENCHMARKS.md`, "Concurrent reads"):
|
contiguous item is fixed (2026-09-26). Measured on
|
||||||
- Full reads of chunked datasets from several threads through one `File`
|
tank with `concurrent_read` against h5py 3.16 / HDF5 2.0 (`BENCHMARKS.md`,
|
||||||
stop scaling at about 4 threads (880 MB/s on deflate data vs 4424 MB/s
|
"Concurrent reads"):
|
||||||
for 16 h5py processes). Hyperslab reads, which skip the chunk cache,
|
- **Partly fixed 2026-09-26.** Full reads of chunked datasets from several threads
|
||||||
scale to 1244 MB/s, so the `File`'s shared chunk cache is the suspect.
|
through one `File` stop scaling at about 4 threads (880 MB/s on deflate
|
||||||
|
data vs 4424 MB/s for 16 h5py processes). Hyperslab reads, which skip the
|
||||||
|
chunk cache, scale to 1244 MB/s, so the `File`'s shared chunk cache is the
|
||||||
|
suspect. *Cause:* not the cache. Those numbers were taken with
|
||||||
|
`--decode-threads 1`, a one-thread rayon pool, and every full read handed
|
||||||
|
its chunks to that pool, so all reader threads queued behind its single
|
||||||
|
worker (per-thread CPU time: one thread did all the decoding, the 16
|
||||||
|
readers almost none). Hyperslab reads touch one chunk each and never used
|
||||||
|
the pool. Reads now decode on the calling thread when the pool has one
|
||||||
|
thread (`tests/single_thread_decode_pool.rs`); re-measured at
|
||||||
|
`408f69e`, 8 threads went from 887 to 2943 MB/s (h5py processes: 3042).
|
||||||
|
**Still open:** at 16 threads full chunked reads reach 2142-2341 MB/s,
|
||||||
|
0.69x-0.76x 16 h5py processes (3083 MB/s in the same run), with the
|
||||||
|
default pool as with a one-thread one; with a small pool (2-4 threads)
|
||||||
|
readers outside it still wait on its workers. Datasets larger than the
|
||||||
|
cache's budget were already read without inserting into it, and skipping
|
||||||
|
its lookups entirely gained only a few percent at 16 threads. Remaining
|
||||||
|
per-read overhead, not yet addressed: each full `read_f32` of a chunked
|
||||||
|
dataset faults in about three times its size in fresh pages (the output,
|
||||||
|
the `f32` copy of it, and a new buffer per decoded chunk).
|
||||||
- Contiguous datasets read 4x slower than h5py on one thread (2.5 vs
|
- Contiguous datasets read 4x slower than h5py on one thread (2.5 vs
|
||||||
9.8 GB/s full, 0.12x for 256 x 256 hyperslabs).
|
9.8 GB/s full, 0.12x for 256 x 256 hyperslabs).
|
||||||
|
**Fixed 2026-09-26** (re-measured on tank at `408f69e`: 13665 MB/s
|
||||||
|
full and 31991 MB/s for 256 x 256 hyperslabs on one thread, 1.44x and
|
||||||
|
6.3x h5py; `BENCHMARKS.md`): full
|
||||||
|
reads were dominated by 4 KiB page faults on the fresh output buffer,
|
||||||
|
which is now backed by transparent huge pages as numpy's is; hyperslab
|
||||||
|
reads copied the selection three times, element by element, and now copy
|
||||||
|
each contiguous run once, straight from the file into the output (see
|
||||||
|
`CHANGELOG.md`). The chunked-read scaling item above is still open.
|
||||||
Values are correct; this is speed only.
|
Values are correct; this is speed only.
|
||||||
|
|
||||||
## Silent wrong data found by the 2026-09-25 HDF5 audit
|
## Silent wrong data found by the 2026-09-25 HDF5 audit
|
||||||
@@ -140,13 +184,24 @@ fill-value item that did is fixed).
|
|||||||
is left out of `attrs()` (reported by `attrs_with_errors()`) instead of
|
is left out of `attrs()` (reported by `attrs_with_errors()`) instead of
|
||||||
failing the others.
|
failing the others.
|
||||||
- **Other readers:**
|
- **Other readers:**
|
||||||
- VL-string datasets are not readable through `File`.
|
- VL-string datasets are not readable through `File`. **Fixed
|
||||||
|
2026-09-26:** `read_string` reads them (also `read_string_bytes`,
|
||||||
|
`read_string_selection`, and on `MmapFile`/`LazyFile`), with h5py's
|
||||||
|
values: strings end at a NUL, null elements (heap address 0) are `""`,
|
||||||
|
and an element at the undefined heap address is an error as in libhdf5
|
||||||
|
(it read as `""` until 2026-09-26); VL sequences of
|
||||||
|
numbers read with `read_vlen::<T>()`, and VL values inside compounds or
|
||||||
|
`AttrValue::Raw` attributes decode with `File::decode_strings` /
|
||||||
|
`File::decode_vlen` (`crates/clawhdf5/tests/vl_data_interop.rs`).
|
||||||
- Variable-length values inside a compound (and VL-string attributes) in
|
- Variable-length values inside a compound (and VL-string attributes) in
|
||||||
a file with 4-byte offsets (`sizeof_addr = 4`) fail with
|
a file with 4-byte offsets (`sizeof_addr = 4`) fail with
|
||||||
`GlobalHeapObjectNotFound` or come back as `Raw`: these paths assume
|
`GlobalHeapObjectNotFound` or come back as `Raw`: these paths assume
|
||||||
the 16-byte element of an 8-byte-offset file. The datatype itself reads
|
the 16-byte element of an 8-byte-offset file. The datatype itself reads
|
||||||
(it was refused as "member overlaps with previous member" until
|
(it was refused as "member overlaps with previous member" until
|
||||||
2026-09-26).
|
2026-09-26). **Fixed 2026-09-26:** a VL type's element size is the one
|
||||||
|
its datatype message stores (12 with 4-byte offsets), and the global
|
||||||
|
heap is read with libhdf5's header padding
|
||||||
|
(`crates/clawhdf5/tests/vl_offset4_interop.rs`).
|
||||||
- Metadata cache images are not supported.
|
- Metadata cache images are not supported.
|
||||||
- x87 long double and binary128 are refused.
|
- x87 long double and binary128 are refused.
|
||||||
- N-Bit on 64-bit scale-offset data and some N-Bit parameter layouts fail.
|
- N-Bit on 64-bit scale-offset data and some N-Bit parameter layouts fail.
|
||||||
@@ -198,13 +253,32 @@ fill-value item that did is fixed).
|
|||||||
- (`cve-2024-32616` `/group1/dset3` and `cve-2025-2309`'s `Comp_OBJREF`
|
- (`cve-2024-32616` `/group1/dset3` and `cve-2025-2309`'s `Comp_OBJREF`
|
||||||
attribute are h5py/numpy type-mapping failures, not libhdf5 refusals.)
|
attribute are h5py/numpy type-mapping failures, not libhdf5 refusals.)
|
||||||
- `h5rs check` validates with the library's parsers, so it inherits what
|
- `h5rs check` validates with the library's parsers, so it inherits what
|
||||||
they accept: of the 150 CVE and fuzzer files, `check --data` passes 16,
|
they accept: of the 150 CVE and fuzzer files, `check --data` passes 15,
|
||||||
and h5dump 1.14.6 rejects 9 of those (tank, 2026-09-26; 28 and 21
|
and h5dump 1.14.6 rejects 8 of those (tank, 2026-09-26; 28 and 21
|
||||||
before these checks).
|
before these checks, 16 and 9 before a VL type's stored element size
|
||||||
|
was checked, which flags `cve-2024-32608`).
|
||||||
- **Writer:**
|
- **Writer:**
|
||||||
- Nested groups beyond one level: path-like names are now refused, not
|
- ~~Nested groups beyond one level: path-like names are now refused, not
|
||||||
created.
|
created.~~ **Fixed 2026-09-26:** groups nest to any depth (path names
|
||||||
- Dense attribute storage for attributes over 64 KiB.
|
create intermediate groups, as h5py does), with soft, extra hard and
|
||||||
|
external links at any depth and optional creation-order tracking;
|
||||||
|
h5py, h5dump and `h5rs check --data` read them
|
||||||
|
(`crates/clawhdf5/tests/writer_groups_interop.rs`,
|
||||||
|
`crates/clawhdf5-tools/tests/h5rs_interop.rs`). Still missing: a group
|
||||||
|
with more than 65 535 links, or an object with more than 65 535 dense
|
||||||
|
attributes, is an error (the index is one B-tree leaf), and attribute
|
||||||
|
creation order is not tracked.
|
||||||
|
- ~~Dense link or attribute storage past 512 KiB of messages was written
|
||||||
|
unreadable (child indirect blocks of the fractal heap written as direct
|
||||||
|
blocks).~~ **Fixed 2026-09-26** (it affected 2.7.0 too): tested with
|
||||||
|
20 000 and 65 535 links and with 8 MB of dense attributes, read by
|
||||||
|
h5py, h5dump, `h5rs check` and clawhdf5, and h5py can add links to
|
||||||
|
such groups.
|
||||||
|
- ~~libhdf5 could not add a link to a group we wrote (no Group Info
|
||||||
|
message).~~ **Fixed 2026-09-26.**
|
||||||
|
- Huge fractal heap objects: in dense storage (more than 8 attributes on
|
||||||
|
an object, or more than 8 links in a group) one attribute or link
|
||||||
|
message over 65 515 bytes is an error.
|
||||||
- Output that HDF5 1.8 can read.
|
- Output that HDF5 1.8 can read.
|
||||||
- A B-tree v2 chunk index larger than one leaf, so datasets with several
|
- A B-tree v2 chunk index larger than one leaf, so datasets with several
|
||||||
unlimited dimensions are limited to 65 535 chunks.
|
unlimited dimensions are limited to 65 535 chunks.
|
||||||
@@ -400,6 +474,32 @@ has produced more records than the file could physically hold.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Crafted global heaps exhaust the variable-length reader's memory
|
||||||
|
|
||||||
|
**Status:** fixed on `feat/p2-vl-strings` (2026-09-26). Not a regression of
|
||||||
|
that branch: every earlier release is affected through `read_vl_strings`.
|
||||||
|
|
||||||
|
Reading variable-length values kept an owned copy of every object of every
|
||||||
|
global heap collection visited, for the whole read. A file whose collections
|
||||||
|
nest inside one another's object data (32 bytes apart, each element pointing
|
||||||
|
at a different one) made retained memory O(elements × file size): a 744 KB
|
||||||
|
file reached 1.58 GB. Letting every collection's object chain jump to one
|
||||||
|
shared run of tiny objects made the parse time O(elements × objects) too.
|
||||||
|
libhdf5 refuses such files.
|
||||||
|
|
||||||
|
Now `VlResolver` caches where each object lies instead of a copy, drops its
|
||||||
|
cache past a 32 MiB budget, and refuses a collection that overlaps one it
|
||||||
|
has already read (libhdf5 gives each collection its own block, so only a
|
||||||
|
crafted file has them). `GlobalHeapCollection::parse` (and the new
|
||||||
|
`parse_index`) also refuse a collection that runs past the end of the file,
|
||||||
|
or an object that runs past the end of its collection. Guarded by
|
||||||
|
`crates/clawhdf5-format/tests/vl_heap_bounds.rs`, which measures peak heap
|
||||||
|
use with a counting allocator. Still open: a file may point many elements
|
||||||
|
at one large heap object, and a VL-*sequence* read then returns that
|
||||||
|
object once per element, as h5py would.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Extensible Array chunk indexes read back wrong data past the inline elements
|
## Extensible Array chunk indexes read back wrong data past the inline elements
|
||||||
|
|
||||||
**Status:** fixed on `main` (2026-09-20), after v2.6.0. **Every release up to
|
**Status:** fixed on `main` (2026-09-20), after v2.6.0. **Every release up to
|
||||||
@@ -502,7 +602,9 @@ which is what libhdf5 itself writes.
|
|||||||
followed (no file system).
|
followed (no file system).
|
||||||
- Variable-length string datasets are read by decoding `read_selection`'s
|
- Variable-length string datasets are read by decoding `read_selection`'s
|
||||||
bytes with `clawhdf5_format::vl_data` in the wasm crate; `File` itself still
|
bytes with `clawhdf5_format::vl_data` in the wasm crate; `File` itself still
|
||||||
cannot (see the audit gaps above).
|
cannot (see the audit gaps above). (`File` can since 2026-09-26. Since
|
||||||
|
2026-09-26 the wasm crate resolves them with the same `VlResolver` as
|
||||||
|
`File` and `h5rs`, so all three return h5py's values.)
|
||||||
|
|
||||||
## The Node.js package (`packages/clawhdf5-node`) does not work
|
## The Node.js package (`packages/clawhdf5-node`) does not work
|
||||||
|
|
||||||
|
|||||||
+35
-2
@@ -7,7 +7,9 @@
|
|||||||
#
|
#
|
||||||
# Environment:
|
# Environment:
|
||||||
# CLAWHDF5_REQUIRE_INTEROP=1 Fail (instead of skip) when python3 with
|
# CLAWHDF5_REQUIRE_INTEROP=1 Fail (instead of skip) when python3 with
|
||||||
# h5py/netCDF4/xarray is missing. CI sets this.
|
# h5py/netCDF4/xarray is missing, or without
|
||||||
|
# maturin/pytest for the Python package step.
|
||||||
|
# CI sets this.
|
||||||
# Unset locally, the interop steps are skipped
|
# Unset locally, the interop steps are skipped
|
||||||
# if python3+h5py is not importable.
|
# if python3+h5py is not importable.
|
||||||
# CLAWHDF5_FUZZ_SECONDS=N Run each cargo-fuzz target for N seconds
|
# CLAWHDF5_FUZZ_SECONDS=N Run each cargo-fuzz target for N seconds
|
||||||
@@ -55,7 +57,8 @@ run_step "cargo fmt --check" cargo fmt --check
|
|||||||
|
|
||||||
# 2. Clippy over every target (lib, bins, tests, benches, examples). Without
|
# 2. Clippy over every target (lib, bins, tests, benches, examples). Without
|
||||||
# --all-targets, test and bench code is never linted. clawhdf5-py is
|
# --all-targets, test and bench code is never linted. clawhdf5-py is
|
||||||
# excluded because it needs PyO3/Python headers.
|
# excluded here: PyO3 needs a Python interpreter to build, so it is
|
||||||
|
# linted in the Python package step (5b) instead.
|
||||||
run_step "cargo clippy --all-targets" cargo clippy \
|
run_step "cargo clippy --all-targets" cargo clippy \
|
||||||
--workspace \
|
--workspace \
|
||||||
--exclude clawhdf5-py \
|
--exclude clawhdf5-py \
|
||||||
@@ -216,6 +219,36 @@ else
|
|||||||
STEPS+=("SKIP: h5py interop (format, ignored tests)")
|
STEPS+=("SKIP: h5py interop (format, ignored tests)")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 5b. The Python package (crates/clawhdf5-py): lint it, build the wheel with
|
||||||
|
# maturin and run its pytest suite, which compares every read with h5py.
|
||||||
|
# The wheel is unpacked under target/ and put on PYTHONPATH, so the
|
||||||
|
# interpreter's environment is left as it was. Needs maturin and pytest
|
||||||
|
# in $PYTHON (CI installs both); skipped without them, and a failure
|
||||||
|
# instead when CLAWHDF5_REQUIRE_INTEROP=1.
|
||||||
|
python_package() {
|
||||||
|
local root="$SCRIPT_DIR/.." out
|
||||||
|
out="${CARGO_TARGET_DIR:-$root/target}/py-package"
|
||||||
|
rm -rf "$out" && mkdir -p "$out/wheel" "$out/site" || return 1
|
||||||
|
cargo clippy -p clawhdf5-py --all-targets -- -D warnings || return 1
|
||||||
|
"$PYTHON" -m maturin build \
|
||||||
|
-m "$root/crates/clawhdf5-py/Cargo.toml" \
|
||||||
|
-i "$PYTHON" \
|
||||||
|
--out "$out/wheel" || return 1
|
||||||
|
"$PYTHON" -m pip install --quiet --no-deps --target "$out/site" "$out"/wheel/*.whl || return 1
|
||||||
|
PYTHONPATH="$out/site" "$PYTHON" -m pytest -q -p no:cacheprovider \
|
||||||
|
"$root/crates/clawhdf5-py/tests"
|
||||||
|
}
|
||||||
|
if "$PYTHON" -m maturin --version >/dev/null 2>&1 && "$PYTHON" -c "import pytest" >/dev/null 2>&1; then
|
||||||
|
run_step "Python package (maturin build + pytest vs h5py)" python_package
|
||||||
|
elif [ "${CLAWHDF5_REQUIRE_INTEROP:-0}" = "1" ]; then
|
||||||
|
run_step "Python package (maturin build + pytest vs h5py)" \
|
||||||
|
bash -c "echo \"maturin and pytest are required in $PYTHON (CLAWHDF5_REQUIRE_INTEROP=1)\"; exit 1"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "==> [Python package] SKIPPED: needs maturin and pytest in $PYTHON"
|
||||||
|
STEPS+=("SKIP: Python package (maturin build + pytest)")
|
||||||
|
fi
|
||||||
|
|
||||||
# 6. Benches must keep compiling (they are not run).
|
# 6. Benches must keep compiling (they are not run).
|
||||||
run_step "cargo bench --no-run" cargo bench \
|
run_step "cargo bench --no-run" cargo bench \
|
||||||
--workspace \
|
--workspace \
|
||||||
|
|||||||
Reference in New Issue
Block a user