py: remote files (clawhdf5.File(url), File.open_url) through File::storage()
The Python bindings could not open a remote file: they parsed through File::as_bytes() in eight places (path lookups, object headers, dataspaces, attributes, group listings, the global heap of variable-length data), which a storage-backed file does not have. - Every object of a File now shares one handle (src/handle.rs) that runs all file access, metadata included, with the GIL released and parses through File::storage() and the clawhdf5_format *_in functions. Local files take the same path (their storage is the mmap). - clawhdf5.File(url) opens any scheme://... through clawhdf5_remote::storage_for_url (read-only; another mode is a ValueError). File.open_url(url, **options) takes the cache and HTTP options (block_size, cache_size, headers, retries, timeout, allow_full_download, max_full_download, require_validator, max_redirects, max_parallel); File.remote_stats gives the block cache's counters. - Default build: plain HTTP only, no C. https (rustls/ring) and s3/gcs/azure (aws-lc-rs) are opt-in features of clawhdf5-py, and ci-test.sh's no-C check now covers the crate. - A failed storage read (network error, file changed on the server) is an OSError, never KeyError/ValueError and never data; `key in group` raises it instead of answering False. Tests: the read-vs-h5py suite runs locally and over HTTP (1 MiB and 1 KiB blocks) against a range-capable http.server in the test process (conftest.RangeServer); test_remote.py covers request counts, cache hits, a server without Range support, a changed file, a server that hangs up, 16 threads, and a spinning thread that keeps running while a read waits on 0.2 s requests. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
@@ -1,16 +1,24 @@
|
||||
//! Resolving paths to objects in a file opened for reading.
|
||||
//!
|
||||
//! Everything here parses through `File::storage()` (the `clawhdf5_format`
|
||||
//! `*_in` functions), never `File::as_bytes()`, so it works the same on a
|
||||
//! memory-mapped local file and on a remote one; and it runs inside
|
||||
//! `Handle::with`, without the GIL.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use clawhdf5_format::attribute::AttributeMessage;
|
||||
use clawhdf5_format::dataspace::{Dataspace, DataspaceType};
|
||||
use clawhdf5_format::error::FormatError;
|
||||
use clawhdf5_format::message_type::MessageType;
|
||||
use clawhdf5_format::object_header::ObjectHeader;
|
||||
use pyo3::exceptions::{PyKeyError, PyTypeError, PyValueError};
|
||||
use clawhdf5_rs::File;
|
||||
use pyo3::exceptions::{PyKeyError, PyOSError, PyTypeError, PyValueError};
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use crate::dataset::PyDataset;
|
||||
use crate::dataset::{DatasetMeta, PyDataset};
|
||||
use crate::group::PyGroup;
|
||||
use crate::handle::Handle;
|
||||
|
||||
/// 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
|
||||
@@ -33,42 +41,46 @@ pub(crate) fn name(path: &str) -> String {
|
||||
format!("/{path}")
|
||||
}
|
||||
|
||||
/// A format error met at `path`: a failed read of the storage (a network
|
||||
/// error on a remote file) is an `OSError`, anything else `other(message)`.
|
||||
pub(crate) fn format_err(path: &str, e: FormatError, other: fn(String) -> PyErr) -> PyErr {
|
||||
let msg = format!("{}: {e}", name(path));
|
||||
match e {
|
||||
FormatError::Storage(_) => PyOSError::new_err(msg),
|
||||
_ => other(msg),
|
||||
}
|
||||
}
|
||||
|
||||
fn value_err(msg: String) -> PyErr {
|
||||
PyValueError::new_err(msg)
|
||||
}
|
||||
|
||||
/// The address of the object at `path`, resolved from the root group.
|
||||
pub(crate) fn address(file: &clawhdf5_rs::File, path: &str) -> PyResult<u64> {
|
||||
pub(crate) fn address(file: &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> {
|
||||
pub(crate) fn resolve_from(file: &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)
|
||||
))
|
||||
})
|
||||
})
|
||||
clawhdf5_format::group_v2::resolve_path_from_in(file.storage(), file.superblock(), group, rel)
|
||||
.map_err(|e| match e {
|
||||
FormatError::Storage(_) => format_err(full, e, value_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))))
|
||||
})
|
||||
pub(crate) fn header_at(file: &File, addr: u64, path: &str) -> PyResult<ObjectHeader> {
|
||||
let sb = file.superblock();
|
||||
ObjectHeader::parse_in(file.storage(), addr, sb.offset_size, sb.length_size)
|
||||
.map_err(|e| format_err(path, e, value_err))
|
||||
}
|
||||
|
||||
/// What an object header describes.
|
||||
@@ -96,29 +108,50 @@ pub(crate) fn kind(hdr: &ObjectHeader) -> Option<Kind> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of the object at `addr`, from its header.
|
||||
pub(crate) fn kind_at(file: &File, addr: u64, path: &str) -> PyResult<Option<Kind>> {
|
||||
Ok(kind(&header_at(file, addr, path)?))
|
||||
}
|
||||
|
||||
/// What opening an object found, read without the GIL.
|
||||
enum Found {
|
||||
Dataset(DatasetMeta),
|
||||
Group,
|
||||
Datatype,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
handle: &Arc<Handle>,
|
||||
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)?
|
||||
let found = handle.with(py, |f| {
|
||||
let hdr = header_at(f, addr, &path)?;
|
||||
Ok(match kind(&hdr) {
|
||||
Some(Kind::Dataset) => Found::Dataset(DatasetMeta::load(f, addr, &hdr, &path)?),
|
||||
Some(Kind::Group) => Found::Group,
|
||||
Some(Kind::Datatype) => Found::Datatype,
|
||||
None => Found::Other,
|
||||
})
|
||||
})?;
|
||||
match found {
|
||||
Found::Dataset(meta) => Ok(PyDataset::new(py, Arc::clone(handle), path, addr, meta)
|
||||
.into_pyobject(py)?
|
||||
.into_any()
|
||||
.unbind()),
|
||||
Some(Kind::Group) => Ok(PyGroup::from_read(Arc::clone(file), path, addr)
|
||||
Found::Group => Ok(PyGroup::from_read(Arc::clone(handle), path, addr)
|
||||
.into_pyobject(py)?
|
||||
.into_any()
|
||||
.unbind()),
|
||||
Some(Kind::Datatype) => Err(PyTypeError::new_err(format!(
|
||||
Found::Datatype => Err(PyTypeError::new_err(format!(
|
||||
"{}: committed (named) datatypes are not supported by clawhdf5",
|
||||
name(&path)
|
||||
))),
|
||||
None => Err(PyValueError::new_err(format!(
|
||||
Found::Other => Err(PyValueError::new_err(format!(
|
||||
"{}: not a dataset, group or datatype",
|
||||
name(&path)
|
||||
))),
|
||||
@@ -126,32 +159,26 @@ pub(crate) fn open(
|
||||
}
|
||||
|
||||
/// 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()))
|
||||
})
|
||||
pub(crate) fn dataspace(file: &File, hdr: &ObjectHeader, path: &str) -> PyResult<Dataspace> {
|
||||
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_in(
|
||||
file.storage(),
|
||||
msg,
|
||||
sb.offset_size,
|
||||
sb.length_size,
|
||||
)
|
||||
.map_err(|e| format_err(path, e, value_err))?;
|
||||
Dataspace::parse(&data, sb.length_size).map_err(|e| format_err(path, e, value_err))
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
pub(crate) fn chunk_shape(file: &File, hdr: &ObjectHeader, rank: usize) -> Option<Vec<u64>> {
|
||||
let sb = file.superblock();
|
||||
let msg = hdr
|
||||
.messages
|
||||
@@ -179,24 +206,18 @@ pub(crate) fn is_null(space: &Dataspace) -> bool {
|
||||
/// 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>> {
|
||||
pub(crate) fn attributes(file: &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)
|
||||
})
|
||||
let sb = file.superblock();
|
||||
let (mut attrs, _errors) = clawhdf5_format::attribute::extract_attributes_tolerant_in(
|
||||
file.storage(),
|
||||
&hdr,
|
||||
sb.offset_size,
|
||||
sb.length_size,
|
||||
)
|
||||
.map_err(|e| format_err(path, e, value_err))?;
|
||||
attrs.sort_by(|a, b| a.name.as_bytes().cmp(b.name.as_bytes()));
|
||||
Ok(attrs)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user