Source code for h5col.lists

"""List columns: the H5Col offsets encoding for variable-length row values.

A list column is an HDF5 group (a direct child of the table group) with
``CLASS="LIST_COLUMN"`` and ``KIND="OFFSETS"``. It stores a variable-length
list per row using the Apache Arrow offsets layout: all elements are flattened
back-to-back into a ``VALUES`` member, and a monotonic ``OFFSETS`` dataset
records each entry's slice. ``VALUES`` is one of three things — a rank-1 *leaf*
dataset, a nested ``LIST_COLUMN`` group (lists of lists), or a ``STRING_VALUES``
group (variable-length UTF-8 via a second ``OFFSETS``/``CHARS`` level). An
optional ``MASK`` at any level distinguishes a null entry from an empty one.

The create/append/read/validate functions here are driven by the *file*
structure (not the Python spec), so a table can be reopened and appended without
its original :class:`~h5col.specs.ListColumnSpec`. Writing follows the H5Col
leaf-first order (deepest elements first, each enclosing ``OFFSETS`` last) so
that committed rows stay fully described at every moment.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

import h5py
import numpy as np

from . import missing
from ._hdf5 import (
    create_column_dataset,
    extend_to,
    read_str_attr,
    write_ascii_token_attr,
    write_utf8_attr,
)
from .booleans import bool_dtype, decode_bool, encode_bool, is_bool_dtype
from .exceptions import ConformanceError, SchemaError
from .missing import recommended_fill, validate_fill_outside_range
from .reserved import (
    ATTR_CLASS,
    ATTR_DESCRIPTION,
    ATTR_KIND,
    ATTR_UNITS,
    ATTR_UNITS_VOCABULARY,
    ATTR_VALID_MAX,
    ATTR_VALID_MIN,
    CLASS_LIST_COLUMN,
    CLASS_STRING_VALUES,
    KIND_OFFSETS,
    MEMBER_CHARS,
    MEMBER_MASK,
    MEMBER_OFFSETS,
    MEMBER_VALUES,
    validate_column_name,
)
from .specs import LeafValuesSpec, ListColumnSpec, NestedListSpec, StringValuesSpec
from .strings import FixedString

_U8 = np.dtype("u8")


# --------------------------------------------------------------------------- #
# No variable-length datatypes below a list column (H5Col rule 11)
# --------------------------------------------------------------------------- #
[docs] def reject_vlen(dtype: Any) -> None: """Raise if *dtype* — or any datatype nested inside it — is variable-length. H5Col rule 11 forbids *any* HDF5 variable-length datatype anywhere below a list column, including one hidden inside a compound field or an array subtype. h5py's ``check_string_dtype`` / ``check_vlen_dtype`` only inspect the top level, so this descends into compound fields and array bases first. """ dt = np.dtype(dtype) if dt.subdtype is not None: reject_vlen(dt.subdtype[0]) return if dt.fields is not None: for name in dt.names: reject_vlen(dt.fields[name][0]) return info = h5py.check_string_dtype(dt) if info is not None and info.length is None: raise SchemaError( "variable-length strings are prohibited below a list column; " "use a STRING_VALUES member or a fixed-length string leaf" ) if h5py.check_vlen_dtype(dt) is not None: raise SchemaError( "variable-length sequences are prohibited below a list column; " "use a nested list column instead" )
# --------------------------------------------------------------------------- # # Spec validation (no file I/O) # --------------------------------------------------------------------------- #
[docs] def validate_list_column_spec(spec: ListColumnSpec) -> None: """Validate a list column spec without touching the file.""" validate_column_name(spec.name) _validate_values_spec(spec.values)
def _validate_values_spec(vs: Any) -> None: if isinstance(vs, LeafValuesSpec): dtype = vs.resolved_dtype() reject_vlen(dtype) if vs.is_boolean: if vs.fill_value is not None: raise SchemaError("boolean list leaf must not declare a fill value") if vs.valid_min is not None or vs.valid_max is not None: raise SchemaError("boolean list leaf must not declare valid_min/max") else: fill = ( vs.fill_value if vs.fill_value is not None else recommended_fill(dtype) ) validate_fill_outside_range(fill, vs.valid_min, vs.valid_max) elif isinstance(vs, StringValuesSpec): return elif isinstance(vs, NestedListSpec): _validate_values_spec(vs.values) else: # pragma: no cover - guarded by Pydantic typing raise SchemaError(f"unsupported list values spec {type(vs).__name__}") # --------------------------------------------------------------------------- # # Creation # --------------------------------------------------------------------------- # def _create_offsets( group: Any, chunks: int | None, filters: Any, *, default_chunk_bytes: int | None ) -> Any: ds = create_column_dataset( group, MEMBER_OFFSETS, _U8, chunks=chunks, fill_value=None, filters=filters, initial_len=1, default_chunk_bytes=default_chunk_bytes, ) ds[0] = 0 # OFFSETS[0] MUST be 0 return ds def _create_mask(group: Any, *, default_chunk_bytes: int | None) -> Any: return create_column_dataset( group, MEMBER_MASK, bool_dtype(), fill_value=None, default_chunk_bytes=default_chunk_bytes, ) def _create_leaf( parent: Any, leaf: LeafValuesSpec, *, default_chunk_bytes: int | None ) -> Any: dtype = leaf.resolved_dtype() reject_vlen(dtype) if leaf.is_boolean: fill: Any = None else: fill = ( leaf.fill_value if leaf.fill_value is not None else recommended_fill(dtype) ) validate_fill_outside_range(fill, leaf.valid_min, leaf.valid_max) ds = create_column_dataset( parent, MEMBER_VALUES, dtype, chunks=leaf.chunks, fill_value=fill, filters=leaf.filters, default_chunk_bytes=default_chunk_bytes, ) if leaf.valid_min is not None: ds.attrs.create(ATTR_VALID_MIN, np.asarray(leaf.valid_min, dtype=dtype)) if leaf.valid_max is not None: ds.attrs.create(ATTR_VALID_MAX, np.asarray(leaf.valid_max, dtype=dtype)) if leaf.units is not None: write_utf8_attr(ds, ATTR_UNITS, leaf.units) if leaf.units_vocabulary is not None: write_utf8_attr(ds, ATTR_UNITS_VOCABULARY, leaf.units_vocabulary) if leaf.description is not None: write_utf8_attr(ds, ATTR_DESCRIPTION, leaf.description) return ds def _create_string_values( parent: Any, sv: StringValuesSpec, *, default_chunk_bytes: int | None ) -> Any: g = parent.create_group(MEMBER_VALUES) write_ascii_token_attr(g, ATTR_CLASS, CLASS_STRING_VALUES) _create_offsets(g, sv.chunks, None, default_chunk_bytes=default_chunk_bytes) create_column_dataset( g, MEMBER_CHARS, np.dtype("u1"), chunks=sv.chunks, fill_value=None, filters=sv.filters, default_chunk_bytes=default_chunk_bytes, ) if sv.nullable: _create_mask(g, default_chunk_bytes=default_chunk_bytes) return g def _create_values( parent: Any, values_spec: Any, *, default_chunk_bytes: int | None ) -> None: if isinstance(values_spec, LeafValuesSpec): _create_leaf(parent, values_spec, default_chunk_bytes=default_chunk_bytes) elif isinstance(values_spec, StringValuesSpec): _create_string_values( parent, values_spec, default_chunk_bytes=default_chunk_bytes ) elif isinstance(values_spec, NestedListSpec): g = parent.create_group(MEMBER_VALUES) write_ascii_token_attr(g, ATTR_CLASS, CLASS_LIST_COLUMN) write_ascii_token_attr(g, ATTR_KIND, KIND_OFFSETS) _create_list_level( g, values_spec.values, values_spec.nullable, values_spec.chunks, values_spec.filters, default_chunk_bytes=default_chunk_bytes, ) else: # pragma: no cover - guarded by Pydantic typing raise SchemaError(f"unsupported list values spec {type(values_spec).__name__}") def _create_list_level( group: Any, values_spec: Any, nullable: bool, chunks: int | None, filters: Any, *, default_chunk_bytes: int | None, ) -> None: _create_offsets(group, chunks, filters, default_chunk_bytes=default_chunk_bytes) if nullable: _create_mask(group, default_chunk_bytes=default_chunk_bytes) _create_values(group, values_spec, default_chunk_bytes=default_chunk_bytes)
[docs] def create_list_column( table_group: Any, spec: ListColumnSpec, *, default_chunk_bytes: int | None = None ) -> Any: """Create an empty list column group under *table_group* from *spec*.""" name = validate_column_name(spec.name) g = table_group.create_group(name) write_ascii_token_attr(g, ATTR_CLASS, CLASS_LIST_COLUMN) write_ascii_token_attr(g, ATTR_KIND, KIND_OFFSETS) if spec.units is not None: write_utf8_attr(g, ATTR_UNITS, spec.units) if spec.units_vocabulary is not None: write_utf8_attr(g, ATTR_UNITS_VOCABULARY, spec.units_vocabulary) if spec.description is not None: write_utf8_attr(g, ATTR_DESCRIPTION, spec.description) _create_list_level( g, spec.values, spec.nullable, spec.chunks, spec.filters, default_chunk_bytes=default_chunk_bytes, ) return g
# --------------------------------------------------------------------------- # # Encoding (pure: row values -> a write plan) # --------------------------------------------------------------------------- # @dataclass class _LeafData: array: np.ndarray @dataclass class _StringData: byte_counts: list[int] mask: list[bool] | None buffer: np.ndarray # uint8 @dataclass class _LevelData: counts: list[int] mask: list[bool] | None child: _LeafData | _StringData | _LevelData def _encode_level(level_group: Any, entries: list[Any]) -> _LevelData: nullable = MEMBER_MASK in level_group counts: list[int] = [] mask: list[bool] | None = [] if nullable else None child_entries: list[Any] = [] for e in entries: if e is None: if mask is None: raise SchemaError( f"null entry in list {level_group.name!r} which has no MASK " "(not nullable)" ) counts.append(0) mask.append(False) else: elems = list(e) counts.append(len(elems)) child_entries.extend(elems) if mask is not None: mask.append(True) child = _encode_values(level_group[MEMBER_VALUES], child_entries) return _LevelData(counts, mask, child) def _encode_values(obj: Any, entries: list[Any]) -> Any: if isinstance(obj, h5py.Dataset): return _encode_leaf(obj, entries) cls = read_str_attr(obj, ATTR_CLASS) if cls == CLASS_STRING_VALUES: return _encode_string(obj, entries) if cls == CLASS_LIST_COLUMN: return _encode_level(obj, entries) raise ConformanceError(f"{obj.name!r}: VALUES group has unexpected CLASS {cls!r}") def _encode_leaf(ds: Any, entries: list[Any]) -> _LeafData: dtype = ds.dtype if is_bool_dtype(dtype): if any(v is None for v in entries): raise SchemaError("a boolean list element cannot be missing (None)") arr = encode_bool(entries).astype(np.bool_) if entries else np.empty(0, bool) return _LeafData(arr) has_fill = ds.id.get_create_plist().fill_value_defined() == 2 if FixedString.is_fixed_string(dtype): fs = FixedString.from_dtype(dtype) fill_str = ds.fillvalue if has_fill else b"" vals = [fill_str if v is None else v for v in entries] return _LeafData(fs.encode(vals) if vals else np.empty(0, dtype=dtype)) if any(v is None for v in entries) and not has_fill: raise SchemaError( "a missing (None) leaf element requires the VALUES dataset to declare " "a fill value" ) arr = np.empty(len(entries), dtype=dtype) fillv = np.asarray(ds.fillvalue, dtype=dtype) if has_fill else None for i, v in enumerate(entries): arr[i] = fillv if v is None else v return _LeafData(arr) def _encode_string(sv_group: Any, entries: list[Any]) -> _StringData: nullable = MEMBER_MASK in sv_group byte_counts: list[int] = [] mask: list[bool] | None = [] if nullable else None buf = bytearray() for v in entries: if v is None: if mask is None: raise SchemaError( f"null string element in {sv_group.name!r} which has no MASK" ) byte_counts.append(0) mask.append(False) else: b = v.encode("utf-8") if isinstance(v, str) else bytes(v) buf.extend(b) byte_counts.append(len(b)) if mask is not None: mask.append(True) return _StringData(byte_counts, mask, np.frombuffer(bytes(buf), dtype="u1")) # --------------------------------------------------------------------------- # # Writing (leaf-first) a plan into the file's reserved tail # --------------------------------------------------------------------------- # def _write_level(level_group: Any, data: _LevelData, cur_count: int) -> None: offs = level_group[MEMBER_OFFSETS] base = int(offs[cur_count]) k = len(data.counts) # 1) child elements first (leaf-first ordering). _write_values(level_group[MEMBER_VALUES], data.child, base) # 2) this level's MASK. if data.mask is not None: m = level_group[MEMBER_MASK] extend_to(m, cur_count + k) if k: m[cur_count : cur_count + k] = np.asarray(data.mask, dtype=bool) # 3) this level's OFFSETS last. extend_to(offs, cur_count + k + 1) if k: new_offsets = base + np.cumsum(np.asarray(data.counts, dtype=_U8), dtype=_U8) offs[cur_count + 1 : cur_count + k + 1] = new_offsets def _write_values(obj: Any, data: Any, cur_count: int) -> None: if isinstance(data, _LeafData): n = int(data.array.shape[0]) extend_to(obj, cur_count + n) if n: obj[cur_count : cur_count + n] = data.array elif isinstance(data, _StringData): _write_string(obj, data, cur_count) elif isinstance(data, _LevelData): _write_level(obj, data, cur_count) def _write_string(sv_group: Any, data: _StringData, cur_count: int) -> None: svoffs = sv_group[MEMBER_OFFSETS] chars = sv_group[MEMBER_CHARS] base = int(svoffs[cur_count]) ne = len(data.byte_counts) nb = int(data.buffer.shape[0]) extend_to(chars, base + nb) if nb: chars[base : base + nb] = data.buffer if data.mask is not None: m = sv_group[MEMBER_MASK] extend_to(m, cur_count + ne) if ne: m[cur_count : cur_count + ne] = np.asarray(data.mask, dtype=bool) extend_to(svoffs, cur_count + ne + 1) if ne: new_offsets = base + np.cumsum( np.asarray(data.byte_counts, dtype=_U8), dtype=_U8 ) svoffs[cur_count + 1 : cur_count + ne + 1] = new_offsets
[docs] def append_list_column(level_group: Any, rows: list[Any], n_old: int) -> None: """Append *rows* (a list of per-row list values) to a list column group.""" data = _encode_level(level_group, rows) _write_level(level_group, data, n_old)
# --------------------------------------------------------------------------- # # Reading # --------------------------------------------------------------------------- #
[docs] def read_list_column(level_group: Any, count: int) -> list[Any]: """Read entries ``[0, count)`` of a list column as a list of (list | None).""" offs = level_group[MEMBER_OFFSETS][0 : count + 1] mask = None if MEMBER_MASK in level_group: mask = decode_bool(level_group[MEMBER_MASK][0:count]) child_count = int(offs[count]) child_values = _read_values(level_group[MEMBER_VALUES], child_count) out: list[Any] = [] for i in range(count): if mask is not None and not mask[i]: out.append(None) else: out.append(child_values[int(offs[i]) : int(offs[i + 1])]) return out
def _read_values(obj: Any, count: int) -> list[Any]: if isinstance(obj, h5py.Dataset): return _read_leaf(obj, count) cls = read_str_attr(obj, ATTR_CLASS) if cls == CLASS_STRING_VALUES: return _read_string(obj, count) if cls == CLASS_LIST_COLUMN: return read_list_column(obj, count) raise ConformanceError(f"{obj.name!r}: VALUES group has unexpected CLASS {cls!r}") def _read_leaf(ds: Any, count: int) -> list[Any]: raw = ds[0:count] dtype = ds.dtype if is_bool_dtype(dtype): return list(decode_bool(raw)) has_fill = ds.id.get_create_plist().fill_value_defined() == 2 if FixedString.is_fixed_string(dtype): vals = FixedString.from_dtype(dtype).decode(raw) if has_fill: miss = missing.is_missing(raw, ds.fillvalue) return [None if miss[i] else vals[i] for i in range(count)] return list(vals) if has_fill: miss = missing.is_missing(raw, ds.fillvalue) return [None if miss[i] else raw[i] for i in range(count)] return list(raw) def _read_string(sv_group: Any, count: int) -> list[Any]: offs = sv_group[MEMBER_OFFSETS][0 : count + 1] nb = int(offs[count]) chars = sv_group[MEMBER_CHARS][0:nb] mask = None if MEMBER_MASK in sv_group: mask = decode_bool(sv_group[MEMBER_MASK][0:count]) out: list[Any] = [] for j in range(count): if mask is not None and not mask[j]: out.append(None) else: out.append(bytes(chars[int(offs[j]) : int(offs[j + 1])]).decode("utf-8")) return out # --------------------------------------------------------------------------- # # Validation (H5Col consistency rules 10 and 11) # --------------------------------------------------------------------------- #
[docs] def validate_list_column(level_group: Any, count: int) -> None: """Validate a list column subtree at *count* entries (recursively).""" cls = read_str_attr(level_group, ATTR_CLASS) if cls != CLASS_LIST_COLUMN: raise ConformanceError( f"{level_group.name!r}: list column CLASS must be {CLASS_LIST_COLUMN!r}" ) kind = read_str_attr(level_group, ATTR_KIND) if kind != KIND_OFFSETS: raise ConformanceError( f"{level_group.name!r}: list column KIND must be {KIND_OFFSETS!r}, " f"got {kind!r}" ) allowed = {MEMBER_OFFSETS, MEMBER_VALUES, MEMBER_MASK} extra = set(level_group.keys()) - allowed if extra: raise ConformanceError( f"{level_group.name!r}: unexpected list column members {sorted(extra)}" ) if MEMBER_OFFSETS not in level_group: raise ConformanceError(f"{level_group.name!r}: missing OFFSETS") if MEMBER_VALUES not in level_group: raise ConformanceError(f"{level_group.name!r}: missing VALUES") offs = level_group[MEMBER_OFFSETS] _check_offsets(offs, count, level_group.name) child_count = int(offs[count]) if MEMBER_MASK in level_group: _check_mask_and_nulls(level_group[MEMBER_MASK], offs, count, level_group.name) _validate_values(level_group[MEMBER_VALUES], child_count)
def _check_offsets(ds: Any, count: int, where: str) -> None: if not isinstance(ds, h5py.Dataset) or ds.ndim != 1: raise ConformanceError(f"{where!r}/OFFSETS must be a rank-1 dataset") if not (ds.dtype.kind == "u" and ds.dtype.itemsize == 8): raise ConformanceError(f"{where!r}/OFFSETS must be uint64") if ds.shape[0] < count + 1: raise ConformanceError( f"{where!r}/OFFSETS extent {ds.shape[0]} < entry count + 1 ({count + 1})" ) o = ds[0 : count + 1] if int(o[0]) != 0: raise ConformanceError(f"{where!r}/OFFSETS[0] must be 0") if count and bool(np.any(o[1:] < o[:-1])): raise ConformanceError( f"{where!r}/OFFSETS must be monotonically non-decreasing" ) def _check_mask_and_nulls(m: Any, offs: Any, count: int, where: str) -> None: if not isinstance(m, h5py.Dataset) or m.ndim != 1: raise ConformanceError(f"{where!r}/MASK must be a rank-1 dataset") if not is_bool_dtype(m.dtype): raise ConformanceError(f"{where!r}/MASK must be the H5Col boolean datatype") if m.shape[0] < count: raise ConformanceError( f"{where!r}/MASK extent {m.shape[0]} < entry count ({count})" ) if count == 0: return mv = decode_bool(m[0:count]) ov = offs[0 : count + 1] for i in range(count): if not mv[i] and int(ov[i + 1]) != int(ov[i]): raise ConformanceError( f"{where!r}: null entry {i} must have OFFSETS[i+1] == OFFSETS[i]" ) def _validate_values(obj: Any, count: int) -> None: if isinstance(obj, h5py.Dataset): try: reject_vlen(obj.dtype) # rule 11 except SchemaError as exc: raise ConformanceError(str(exc)) from exc if obj.ndim != 1: raise ConformanceError(f"{obj.name!r}: leaf VALUES must be rank-1") if obj.shape[0] < count: raise ConformanceError( f"{obj.name!r}: leaf VALUES extent {obj.shape[0]} < element count " f"({count})" ) return cls = read_str_attr(obj, ATTR_CLASS) if cls == CLASS_STRING_VALUES: _validate_string_values(obj, count) elif cls == CLASS_LIST_COLUMN: validate_list_column(obj, count) else: raise ConformanceError( f"{obj.name!r}: VALUES group has unexpected CLASS {cls!r}" ) def _validate_string_values(g: Any, count: int) -> None: allowed = {MEMBER_OFFSETS, MEMBER_CHARS, MEMBER_MASK} extra = set(g.keys()) - allowed if extra: raise ConformanceError( f"{g.name!r}: unexpected STRING_VALUES members {sorted(extra)}" ) if MEMBER_OFFSETS not in g or MEMBER_CHARS not in g: raise ConformanceError(f"{g.name!r}: STRING_VALUES needs OFFSETS and CHARS") offs = g[MEMBER_OFFSETS] _check_offsets(offs, count, g.name) chars = g[MEMBER_CHARS] if not (chars.dtype.kind == "u" and chars.dtype.itemsize == 1) or chars.ndim != 1: raise ConformanceError(f"{g.name!r}/CHARS must be a rank-1 uint8 dataset") nb = int(offs[count]) if chars.shape[0] < nb: raise ConformanceError( f"{g.name!r}/CHARS extent {chars.shape[0]} < byte count ({nb})" ) if MEMBER_MASK in g: _check_mask_and_nulls(g[MEMBER_MASK], offs, count, g.name)