Repository: legend-exp/pygama (language: python)

Issue:
`lgdo` cleanup
* Add type annotations
* Enable Sphinx autodoc capability to parse type annotations -> remove type hints in docstrings as they are automatically added to the documentation
* Use `logging` everywhere
* Raise more exceptions

@jasondet I know the diff is humongous, I will try to point you to the critical things I would like to have your approval/feedback on. I might have made mistakes with the argument types. I also think we should decide again when to print a warning message (strictly only when there's no room for undefined behavior) and when to raise an exception.

PS: the docs for `lgdo` look great now.


Discussion / hints:

Fix patch:
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 857ec5044..b43f3dee7 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,8 +1,6 @@
-Before submitting a pull request, please make sure you've read and understood
-[pygama developer's
-guide](https://legend-exp.github.io/pygama/main/developer.html). In
-particular, do not forget to:
+Before submitting a pull request, please make sure you've read and understood the pygama developer's guide: https://legend-exp.github.io/pygama/main/developer.html. In particular, do not forget to:
 
-* Update existing or add new **tests**
-* Update existing or add new **documentation**
-* Address any issue reported by GitHub bots
+- [ ] Follow our **coding conventions**
+- [ ] Update existing or add new **tests**
+- [ ] Update existing or add new **documentation**
+- [ ] Address any issue reported by GitHub bots
diff --git a/docs/source/conf.py b/docs/source/conf.py
index c73ec3741..c115832a9 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -64,6 +64,7 @@
 # enforce consistent usage of NumPy-style docstrings
 napoleon_numpy_docstring = True
 napoleon_google_docstring = False
+napoleon_use_rtype = False
 
 # intersphinx
 intersphinx_mapping = {
@@ -81,6 +82,9 @@
 # sphinx-autodoc
 # Include __init__() docstring in class docstring
 autoclass_content = 'both'
+autodoc_typehints = 'both'
+autodoc_typehints_description_target = 'documented_params'
+autodoc_typehints_format = 'short'
 
 # sphinx-multiversion
 
diff --git a/src/pygama/lgdo/__init__.py b/src/pygama/lgdo/__init__.py
index 48a4e4fad..2798f8f04 100644
--- a/src/pygama/lgdo/__init__.py
+++ b/src/pygama/lgdo/__init__.py
@@ -1,18 +1,18 @@
 """
 Pygama works with "LEGEND Data Objects" (LGDO) defined in the `LEGEND data
 format specification <https://github.com/legend-exp/legend-data-format-specs>`_.
-This submodule serves as the Python implementation of that specification. The
+This subpackage serves as the Python implementation of that specification. The
 general strategy for the implementation is to dress standard Python and NumPy
 objects with an ``attr`` dictionary holding LGDO metadata, plus some convenience
 functions. The basic data object classes are:
 
 * :class:`.Scalar`: typed Python scalar. Access data via the :attr:`value`
   attribute
-* :class:`.Array`: basic NumPy :class:`ndarray`. Access data via the
+* :class:`.Array`: basic :class:`numpy.ndarray`. Access data via the
   :attr:`nda` attribute.
-* :class:`.FixedSizeArray`: basic NumPy :class:`ndarray`. Access data via the
+* :class:`.FixedSizeArray`: basic :class:`numpy.ndarray`. Access data via the
   :attr:`nda` attribute.
-* :class:`.ArrayOfEqualSizedArrays`: multi-dimensional NumPy :class:`ndarray`.
+* :class:`.ArrayOfEqualSizedArrays`: multi-dimensional :class:`numpy.ndarray`.
   Access data via the :attr:`nda` attribute.
 * :class:`.VectorOfVectors`: a variable length array of variable length arrays.
   Implemented as a pair of :class:`.Array`: :attr:`flattened_data` holding the
@@ -29,13 +29,12 @@
 `h5py <https://www.h5py.org>`_.
 """
 
-from .array import Array
-from .arrayofequalsizedarrays import ArrayOfEqualSizedArrays
-from .fixedsizearray import FixedSizeArray
-from .lgdo_utils import *
-from .lh5_store import LH5Store, load_dfs, load_nda, ls
-from .scalar import Scalar
-from .struct import Struct
-from .table import Table
-from .vectorofvectors import VectorOfVectors
-from .waveform_table import WaveformTable
+from pygama.lgdo.array import Array
+from pygama.lgdo.arrayofequalsizedarrays import ArrayOfEqualSizedArrays
+from pygama.lgdo.fixedsizearray import FixedSizeArray
+from pygama.lgdo.lh5_store import LH5Store, load_dfs, load_nda, ls
+from pygama.lgdo.scalar import Scalar
+from pygama.lgdo.struct import Struct
+from pygama.lgdo.table import Table
+from pygama.lgdo.vectorofvectors import VectorOfVectors
+from pygama.lgdo.waveform_table import WaveformTable
diff --git a/src/pygama/lgdo/array.py b/src/pygama/lgdo/array.py
index 34c6c112c..16777698b 100644
--- a/src/pygama/lgdo/array.py
+++ b/src/pygama/lgdo/array.py
@@ -1,46 +1,59 @@
+"""
+Implements a LEGEND Data Object representing an n-dimensional array and
+corresponding utilities.
+"""
+from __future__ import annotations
+
+import logging
+from typing import Any
+
 import numpy as np
 
-from .lgdo_utils import *
+from pygama.lgdo.lgdo_utils import get_element_type
+
+log = logging.getLogger(__name__)
 
 
 class Array:
-    """Holds an ndarray and attributes
+    r"""Holds an :class:`numpy.ndarray` and attributes.
 
-    Array (and the other various array types) holds an "nda" instead of deriving
-    from ndarray for the following reasons:
+    :class:`Array` (and the other various array types) holds an `nda` instead
+    of deriving from :class:`numpy.ndarray` for the following reasons:
 
-    - it keeps management of the nda totally under the control of the user. The
-      user can point it to another object's buffer, grab the nda and toss the
-      Array, etc.
-    - it allows the management code to send just the nda's the central routines
-      for data manpulation. Keeping lgdo's out of that code allows for more
-      standard, reusable, and (we expect) performant python
-    - it allows the first axis of the nda to be treated as "special" for storage
-      in Tables
+    - It keeps management of the `nda` totally under the control of the user. The
+      user can point it to another object's buffer, grab the `nda` and toss the
+      :class:`Array`, etc.
+    - It allows the management code to send just the `nda`'s the central routines
+      for data manpulation. Keeping LGDO's out of that code allows for more
+      standard, reusable, and (we expect) performant Python.
+    - It allows the first axis of the `nda` to be treated as "special" for storage
+      in :class:`.Table`\ s.
 
     """
 
-
-    def __init__(self, nda=None, shape=(), dtype=None, fill_val=None, attrs={}):
+    def __init__(self, nda: np.ndarray = None, shape: tuple[int, ...] = (),
+                 dtype: np.dtype = None, fill_val: float | int = None,
+                 attrs: dict[str, Any] = None) -> None:
         """
         Parameters
         ----------
-        nda : ndarray (optional)
-            An ndarray to be used for this object's internal array. Note: the
-            array is used directly, not copied. If not supplied, internal memory
-            is newly allocated based on the shape and dtype arguments.
-        shape : tuple of ints (optional)
+        nda
+            An :class:`numpy.ndarray` to be used for this object's internal
+            array. Note: the array is used directly, not copied. If not
+            supplied, internal memory is newly allocated based on the shape and
+            dtype arguments.
+        shape
             A numpy-format shape specification for shape of the internal
-            ndarray. Required if nda is None, otherwise unused.
-        dtype : numpy dtype (optional)
-            Specifies the type of the data in the array. Required if nda is
-            None, otherwise unused.
-        fill_val : scalar or None
-            If None, memory is allocated without initialization. Otherwise, the
-            array is allocated with all elements set to the corresponding fill
-            value. If nda is not None, this parameter is ignored
-        attrs : dict (optional)
-            A set of user attributes to be carried along with this lgdo
+            ndarray. Required if `nda` is ``None``, otherwise unused.
+        dtype
+            Specifies the type of the data in the array. Required if `nda` is
+            ``None``, otherwise unused.
+        fill_val
+            If ``None``, memory is allocated without initialization. Otherwise,
+            the array is allocated with all elements set to the corresponding
+            fill value. If `nda` is not ``None``, this parameter is ignored.
+        attrs
+            A set of user attributes to be carried along with this LGDO.
         """
         if nda is None:
             if fill_val is None: nda = np.empty(shape, dtype=dtype)
@@ -48,45 +61,44 @@ def __init__(self, nda=None, shape=(), dtype=None, fill_val=None, attrs={}):
             else: nda = np.full(shape, fill_val, dtype=dtype)
         self.nda = nda
         self.dtype = self.nda.dtype
-        self.attrs = dict(attrs)
-        if 'datatype' in self.attrs:
-            if self.attrs['datatype'] != self.form_datatype():
-                print(type(self).__name__ + ': Warning: datatype does not match nda!')
-                print('datatype: ', self.attrs['datatype'])
-                print('form_datatype(): ', self.form_datatype())
-                print('dtype:', self.dtype)
-        else: self.attrs['datatype'] = self.form_datatype()
 
+        self.attrs = {} if attrs is None else dict(attrs)
 
-    def datatype_name(self):
-        """The name for this lgdo's datatype attribute"""
+        if 'datatype' in self.attrs:
+            if self.attrs['datatype'] != self.form_datatype():
+                raise RuntimeError('datatype does not match nda! '
+                                   f'datatype: {self.attrs["datatype"]} '
+                                   f'form_datatype(): {self.form_datatype()} '
+                                   f'dtype: {self.dtype}')
+        else:
+            self.attrs['datatype'] = self.form_datatype()
+
+    def datatype_name(self) -> str:
+        """The name for this LGDO's datatype attribute."""
         return 'array'
 
-
-    def form_datatype(self):
-        """Return this lgdo's datatype attribute string"""
+    def form_datatype(self) -> str:
+        """Return this LGDO's datatype attribute string."""
         dt = self.datatype_name()
         nD = str(len(self.nda.shape))
         et = get_element_type(self)
         return dt + '<' + nD + '>{' + et + '}'
 
-
-    def __len__(self):
-        """Provides __len__ for this array-like class"""
+    def __len__(self) -> int:
         return len(self.nda)
 
-
-    def resize(self, new_size):
-        """Resize the array to new_size (int)"""
+    def resize(self, new_size: int) -> None:
+        """Resize the array to `new_size`."""
         new_shape = (new_size,) + self.nda.shape[1:]
         self.nda.resize(new_shape, refcheck=True)
 
-    def __str__(self):
-        """Convert to string (e.g. for printing)"""
+    def __str__(self) -> str:
         tmp_attrs = self.attrs.copy()
         datatype = tmp_attrs.pop('datatype')
         string = datatype + " = " + str(self.nda)
-        if len(tmp_attrs) > 0: string += '\n attrs = ' + str(tmp_attrs)
+        if len(tmp_attrs) > 0:
+            string += '\n attrs = ' + str(tmp_attrs)
         return string
 
-    def __repr__(self): return str(self)
+    def __repr__(self) -> str:
+        return str(self)
diff --git a/src/pygama/lgdo/arrayofequalsizedarrays.py b/src/pygama/lgdo/arrayofequalsizedarrays.py
index 31ae3de6f..c9d0da03d 100644
--- a/src/pygama/lgdo/arrayofequalsizedarrays.py
+++ b/src/pygama/lgdo/arrayofequalsizedarrays.py
@@ -1,42 +1,54 @@
-from .array import Array
-from .lgdo_utils import *
+"""
+Implements a LEGEND Data Object representing an array of equal-sized arrays and
+corresponding utilities.
+"""
+from __future__ import annotations
+
+from typing import Any
+
+import numpy
+
+from pygama.lgdo.array import Array
+from pygama.lgdo.lgdo_utils import get_element_type
 
 
 class ArrayOfEqualSizedArrays(Array):
-    """
-    An array of equal-sized arrays
+    """An array of equal-sized arrays.
 
     Arrays of equal size within a file but could be different from application
     to application. Canonical example: array of same-length waveforms.
-
-    If shape is not "1D array of arrays of shape given by axes 1-N" (of nda)
-    then specify the dimensionality split in the constructor.
     """
 
-
-    def __init__(self, dims=None, nda=None, shape=(), dtype=None, fill_val=None, attrs={}):
+    def __init__(self, dims: tuple[int, ...] = None, nda: numpy.ndarray = None,
+                 shape: tuple[int, ...] = (), dtype: numpy.dtype = None,
+                 fill_val: int | float = None, attrs: dict[str, Any] = None) -> None:
         """
         Parameters
         ----------
-        dims : tuple of ints (optional)
+        dims
             specifies the dimensions required for building the
-            ArrayOfEqualSizedArrays' datatype attribute
-        nda : ndarray (optional)
+            :class:`ArrayOfEqualSizedArrays`' `datatype` attribute.
+        nda
             An ndarray to be used for this object's internal array. Note: the
             array is used directly, not copied. If not supplied, internal memory
             is newly allocated based on the shape and dtype arguments.
-        shape : tuple of ints (optional)
+        shape
             A numpy-format shape specification for shape of the internal
             ndarray. Required if nda is None, otherwise unused.
-        dtype : numpy dtype (optional)
+        dtype
             Specifies the type of the data in the array. Required if nda is
             None, otherwise unused.
-        fill_val : scalar or None
-            If None, memory is allocated without initialization. Otherwise, the
-            array is allocated with all elements set to the corresponding fill
-            value. If nda is not None, this parameter is ignored
-        attrs : dict (optional)
-            A set of user attributes to be carried along with this lgdo
+        fill_val
+            If ``None``, memory is allocated without initialization. Otherwise,
+            the array is allocated with all elements set to the corresponding
+            fill value. If `nda` is not ``None``, this parameter is ignored.
+        attrs
+            A set of user attributes to be carried along with this LGDO.
+
+        Notes
+        -----
+        If shape is not "1D array of arrays of shape given by axes 1-N" (of
+        `nda`) then specify the dimensionality split in the constructor.
 
         See Also
         --------
@@ -45,21 +57,18 @@ def __init__(self, dims=None, nda=None, shape=(), dtype=None, fill_val=None, att
         self.dims = dims
         super().__init__(nda=nda, shape=shape, dtype=dtype, fill_val=fill_val, attrs=attrs)
 
-
-    def datatype_name(self):
-        """The name for this lgdo's datatype attribute"""
+    def datatype_name(self) -> str:
+        """Returns the name for this LGDO's datatype attribute."""
         return 'array_of_equalsized_arrays'
 
-
-    def form_datatype(self):
-        """Return this lgdo's datatype attribute string"""
+    def form_datatype(self) -> str:
+        """Return this LGDO's datatype attribute string."""
         dt = self.datatype_name()
         nD = str(len(self.nda.shape))
-        if self.dims is not None: nD = ','.join([str(i) for i in self.dims])
+        if self.dims is not None:
+            nD = ','.join([str(i) for i in self.dims])
         et = get_element_type(self)
         return dt + '<' + nD + '>{' + et + '}'
 
-
-    def __len__(self):
-        """Provides __len__ for this array-like class"""
+    def __len__(self) -> int:
         return len(self.nda)
diff --git a/src/pygama/lgdo/fixedsizearray.py b/src/pygama/lgdo/fixedsizearray.py
index 124bae15a..f820394ba 100644
--- a/src/pygama/lgdo/fixedsizearray.py
+++ b/src/pygama/lgdo/fixedsizearray.py
@@ -1,24 +1,37 @@
-from .array import Array
+"""
+Implements a LEGEND Data Object representing an n-dimensional array of fixed
+size and corresponding utilities.
+"""
+from __future__ import annotations
+
+from typing import Any
+
+import numpy
+
+from pygama.lgdo.array import Array
 
 
 class FixedSizeArray(Array):
-    """
-    An array of fixed-size arrays
+    """An array of fixed-size arrays.
 
     Arrays with guaranteed shape along axes > 0: for example, an array of
     vectors will always length 3 on axis 1, and it will never change from
     application to application.  This data type is used for optimized memory
     handling on some platforms. We are not that sophisticated so we are just
-    storing this identification for lgdo validity, i.e. for now this class is
-    just an alias for Array, but keeps track of the datatype name.
+    storing this identification for LGDO validity, i.e. for now this class is
+    just an alias for :class:`~.Array`, but keeps track of the datatype name.
     """
 
-
-    def __init__(self, nda=None, shape=(), dtype=None, fill_val=None, attrs={}):
-        """ See Array.__init__ for optional args """
+    def __init__(self, nda: numpy.ndarray = None, shape: tuple[int, ...] = (),
+                 dtype: numpy.dtype = None, fill_val: int | float = None,
+                 attrs: dict[str, Any] = None) -> None:
+        """
+        See Also
+        --------
+        :class:`.Array`
+        """
         super().__init__(nda=nda, shape=shape, dtype=dtype, fill_val=fill_val, attrs=attrs)
 
-
-    def datatype_name(self):
-        """The name for this object's lh5 datatype attribute"""
+    def datatype_name(self) -> str:
+        """The name for this object's HDF5 datatype attribute."""
         return 'fixedsize_array'
diff --git a/src/pygama/lgdo/lgdo_utils.py b/src/pygama/lgdo/lgdo_utils.py
index 696a4fdd5..023149f18 100644
--- a/src/pygama/lgdo/lgdo_utils.py
+++ b/src/pygama/lgdo/lgdo_utils.py
@@ -1,65 +1,78 @@
+"""
+Implements utilities for LEGEND Data Objects.
+"""
+from __future__ import annotations
+
+import logging
+
 import numpy as np
 
+log = logging.getLogger(__name__)
+
 
-def get_element_type(obj):
-    """Get the lgdo element type of a scalar or array
+def get_element_type(obj: object) -> str:
+    """Get the LGDO element type of a scalar or array.
 
-    For use in lgdo datatype attributes
+    For use in LGDO datatype attributes.
 
     Parameters
     ----------
-    obj : any python object
-        if a str, will automatically return "string"
-        if the object has a dtype, that will be used for determining the element type
-        otherwise will attempt to case the type of the object to a dtype
+    obj
+        if a ``str``, will automatically return ``string`` if the object has
+        a :class:`numpy.dtype`, that will be used for determining the element
+        type otherwise will attempt to case the type of the object to a
+        :class:`numpy.dtype`.
 
     Returns
     -------
-    el_type : str
+    element_type
         A string stating the determined element type of the object.
     """
 
     # special handling for strings
-    if isinstance(obj, str): return 'string'
+    if isinstance(obj, str):
+        return 'string'
 
     # the rest use dtypes
     dt = obj.dtype if hasattr(obj, 'dtype') else np.dtype(type(obj))
     kind = dt.kind
 
-    if kind == 'b': return 'bool'
-    if kind == 'V': return 'blob'
-    if kind in ['i', 'u', 'f']: return 'real'
-    if kind == 'c': return 'complex'
-    if kind in ['S', 'U']: return 'string'
+    if kind == 'b':
+        return 'bool'
+    if kind == 'V':
+        return 'blob'
+    if kind in ['i', 'u', 'f']:
+        return 'real'
+    if kind == 'c':
+        return 'complex'
+    if kind in ['S', 'U']:
+        return 'string'
 
     # couldn't figure it out
-    print('Cannot determine lgdo element_type for object of type', type(obj).__name__)
-    return None
+    raise ValueError('cannot determine lgdo element_type for object of type', type(obj).__name__)
 
 
-def parse_datatype(datatype):
-    """Parse datatype string and return type, dims, elements
+def parse_datatype(datatype: str) -> tuple[str, tuple[int, ...], str | list[str]]:
+    """Parse datatype string and return type, dimensions and elements.
 
     Parameters
     ----------
-    datatype : str
-        a lgdo-formatted datatype string
+    datatype
+        a LGDO-formatted datatype string.
 
     Returns
     -------
-    (type, dims, elements) : tuple
-        type : str
-            the datatype name
-        dims : tuple(ints) or None
-            if not None, a tuple of dimensions for the lgdo. Note this is
-            not the same as the np shape of the underlying data object. See the
-            lgdo specification for more information. Also see
-            ArrayOfEqualSizedArrays and LH5Store.read_object() for example code
-        elements: str or list of str's
-            for numeric objects, the element type
-            for struct-like  objects, the list of fields in the struct
+    element_type
+        the datatype name dims if not ``None``, a tuple of dimensions for the
+        LGDO. Note this is not the same as the NumPy shape of the underlying
+        data object. See the LGDO specification for more information. Also see
+        :class:`.ArrayOfEqualSizedArrays` and
+        :meth:`.lh5_store.LH5Store.read_object` for example code elements for
+        numeric objects, the element type for struct-like  objects, the list of
+        fields in the struct.
     """
-    if '{' not in datatype: return 'scalar', None, datatype
+    if '{' not in datatype:
+        return 'scalar', None, datatype
 
     # for other datatypes, need to parse the datatype string
     from parse import parse
@@ -68,4 +81,5 @@ def parse_datatype(datatype):
         datatype, dims = parse('{}<{}>', datatype)
         dims = [int(i) for i in dims.split(',')]
         return datatype, tuple(dims), element_description
-    else: return datatype, None, element_description.split(',')
+    else:
+        return datatype, None, element_description.split(',')
diff --git a/src/pygama/lgdo/lh5_store.py b/src/pygama/lgdo/lh5_store.py
index 49795419b..c8f8096af 100644
--- a/src/pygama/lgdo/lh5_store.py
+++ b/src/pygama/lgdo/lh5_store.py
@@ -1,3 +1,9 @@
+"""
+This module implements routines from reading and writing LEGEND Data Objects in
+HDF5 files.
+"""
+from __future__ import annotations
+
 import fnmatch
 import glob
 import logging
@@ -5,96 +11,106 @@
 import sys
 from bisect import bisect_left, bisect_right
 from collections import defaultdict
+from typing import Any, Union
 
 import h5py
 import numpy as np
 import pandas as pd
 
-from .array import Array
-from .arrayofequalsizedarrays import ArrayOfEqualSizedArrays
-from .fixedsizearray import FixedSizeArray
-from .lgdo_utils import *
-from .scalar import Scalar
-from .struct import Struct
-from .table import Table
-from .vectorofvectors import VectorOfVectors
-from .waveform_table import WaveformTable
+from pygama.lgdo.array import Array
+from pygama.lgdo.arrayofequalsizedarrays import ArrayOfEqualSizedArrays
+from pygama.lgdo.fixedsizearray import FixedSizeArray
+from pygama.lgdo.lgdo_utils import parse_datatype
+from pygama.lgdo.scalar import Scalar
+from pygama.lgdo.struct import Struct
+from pygama.lgdo.table import Table
+from pygama.lgdo.vectorofvectors import VectorOfVectors
+from pygama.lgdo.waveform_table import WaveformTable
+
+LGDO = Union[Array, Scalar, Struct, VectorOfVectors]
 
 log = logging.getLogger(__name__)
 
 
 class LH5Store:
     """
-    DOCME
+    Class to represent a store of LEGEND HDF5 files. The two main methods
+    implemented by the class are :meth:`read_object` and :meth:`write_object`.
+
+    Examples
+    --------
+    >>> from pygama.lgdo import LH5Store
+    >>> store = LH5Store()
+    >>> obj, _ = store.read_object("/geds/waveform", "file.lh5")
+    >>> type(obj)
+    pygama.lgdo.waveform_table.WaveformTable
     """
 
-    def __init__(self, base_path='', keep_open=False):
+    def __init__(self, base_path: str = '', keep_open: bool = False) -> None:
         """
         Parameters
         ----------
-        base_path : str
-            directory path to prepend to LH5 files
-        keep_open : bool
-            whether to keep files open by storing the h5py objects as class
-            attributes
+        base_path
+            directory path to prepend to LH5 files.
+        keep_open
+            whether to keep files open by storing the :mod:`h5py` objects as
+            class attributes.
         """
         self.base_path = base_path
         self.keep_open = keep_open
         self.files = {}
 
-
-    def gimme_file(self, lh5_file, mode='r', verbosity=0):
-        """
-        Returns a h5py file object from the store or creates a new one
+    def gimme_file(self, lh5_file: str | h5py.File, mode: str = 'r') -> h5py.File:
+        """Returns a :mod:`h5py` file object from the store or creates a new one.
 
         Parameters
         ----------
-        lh5_file : str
-            LH5 file name
-        mode : str, default='r'
-            mode in which to open file. See :class:`h5py.File` documentation
-        verbosity : bool
-            verbosity
-
-        Returns
-        -------
-        file_obj : h5py.File
+        lh5_file
+            LH5 file name.
+        mode
+            mode in which to open file. See :class:`h5py.File` documentation.
         """
-        if isinstance(lh5_file, h5py.File): return lh5_file
-        if lh5_file in self.files.keys(): return self.files[lh5_file]
-        if self.base_path != '': full_path = self.base_path + '/' + lh5_file
-        else: full_path = lh5_file
+        if isinstance(lh5_file, h5py.File):
+            return lh5_file
+        if lh5_file in self.files.keys():
+            return self.files[lh5_file]
+        if self.base_path != '':
+            full_path = os.path.join(self.base_path, lh5_file)
+        else:
+            full_path = lh5_file
         if mode != 'r':
             directory = os.path.dirname(full_path)
             if directory != '' and not os.path.exists(directory):
-                if verbosity > 0: print(f'making path {directory}')
+                log.debug(f'making path {directory}')
                 os.makedirs(directory)
         if mode == 'r' and not os.path.exists(full_path):
             raise FileNotFoundError(f'file {full_path} not found')
-        if verbosity > 0 and mode != 'r' and os.path.exists(full_path):
-            log.info(f'opening existing file {full_path} in mode {mode}...')
+        if mode != 'r' and os.path.exists(full_path):
+            log.debug(f"opening existing file {full_path} in mode '{mode}'")
         h5f = h5py.File(full_path, mode)
-        if self.keep_open: self.files[lh5_file] = h5f
+        if self.keep_open:
+            self.files[lh5_file] = h5f
         return h5f
 
-
-    def gimme_group(self, group, base_group, grp_attrs=None, overwrite=False, verbosity=0):
+    def gimme_group(self, group: str,
+                    base_group: h5py.Group,
+                    grp_attrs: dict[str, Any] = None,
+                    overwrite: bool = False) -> h5py.Group:
         """
-        Returns an existing h5py group from a base group or creates a new one.
-        Can also set (or replace) group attributes
+        Returns an existing :class:`h5py` group from a base group or creates a
+        new one. Can also set (or replace) group attributes.
 
         Parameters
         ----------
-        group : str
-            name of the HDF5 group
-        base_group : h5py.File or h5py.Group
-            HDF5 group to be used as a base
-        grp_attrs : dict, default None
-            HDF5 group attributes
-        overwrite : bool, default False
-            whether overwrite group attributes, ignored is grp_attrs is None
-        verbosity : bool
-            verbosity
+        group
+            name of the HDF5 group.
+        base_group
+            HDF5 group to be used as a base.
+        grp_attrs
+            HDF5 group attributes.
+        overwrite
+            whether overwrite group attributes, ignored is `grp_attrs` is
+            ``None``.
         """
         if not isinstance(group, h5py.Group):
             if group in base_group: group = base_group[group]
@@ -104,82 +120,86 @@ def gimme_group(self, group, base_group, grp_attrs=None, overwrite=False, verbos
                 return group
         if grp_attrs is not None and len(set(grp_attrs.items()) ^ set(group.attrs.items())) > 0:
             if not overwrite:
-                raise RuntimeWarning('grp_attrs != group.attrs but overwrite not set, ignoring grp_attrs')
+                raise RuntimeError('grp_attrs != group.attrs but overwrite not set')
             else:
-                if verbosity > 0: print(f'overwriting {group}.attrs...')
+                log.debug(f'overwriting {group}.attrs...')
                 for key in group.attrs.keys(): group.attrs.pop(key)
                 group.attrs.update(grp_attrs)
         return group
 
 
-    def get_buffer(self, name, lh5_file, size=None, field_mask=None):
-        """
-        Returns an lh5 object appropriate for use as a pre-allocated buffer
-        in a read loop. Sets size to size if object has a size.
+    def get_buffer(self, name: str,
+                   lh5_file: str | h5py.File | list[str | h5py.File],
+                   size: int = None,
+                   field_mask: dict[str, bool] | list[str] | tuple[str] = None) -> LGDO:
+        """Returns an LH5 object appropriate for use as a pre-allocated buffer
+        in a read loop. Sets size to `size` if object has a size.
         """
         obj, n_rows = self.read_object(name, lh5_file, n_rows=0, field_mask=field_mask)
         if hasattr(obj, 'resize') and size is not None: obj.resize(new_size=size)
         return obj
 
-
-    def read_object(self, name, lh5_file, start_row=0, n_rows=sys.maxsize, idx=None,
-                    field_mask=None, obj_buf=None, obj_buf_start=0, verbosity=0):
-        """
-        Read LH5 object data from a file
+    def read_object(self, name: str,
+                    lh5_file: str | h5py.File | list[str | h5py.File],
+                    start_row: int = 0,
+                    n_rows: int = sys.maxsize,
+                    idx: np.ndarray | list | tuple | list[np.ndarray | list | tuple] = None,
+                    field_mask: dict[str, bool] | list[str] | tuple[str] = None,
+                    obj_buf: LGDO = None,
+                    obj_buf_start: int = 0) -> tuple[LGDO, int]:
+        """Read LH5 object data from a file.
 
         Parameters
         ----------
-        name : str
-            Name of the lh5 object to be read (including its group path)
-        lh5_file : str or h5py File object, or a list of either
+        name
+            Name of the LH5 object to be read (including its group path).
+        lh5_file
             The file(s) containing the object to be read out. If a list of
             files, array-like object data will be concatenated into the output
-            object
-        start_row : int (optional)
+            object.
+        start_row
             Starting entry for the object read (for array-like objects). For a
             list of files, only applies to the first file.
-        n_rows : int (optional)
+        n_rows
             The maximum number of rows to read (for array-like objects). The
             actual number of rows read will be returned as one of the return
-            values (see below)
-        idx : index array or index tuple, or a list of index arrays/tuples (optional)
-            For numpy-style "fancying indexing" for the read. Used to read out
-            rows that pass some selection criteria. Only selection along the 1st
-            axis is supported, so tuple arguments must be one-tuples.  If n_rows
-            is not false, idx will be truncated to n_rows before reading. To use
-            with a list of files, can pass in a list of idx's (one for each
+            values (see below).
+        idx
+            For NumPy-style "fancying indexing" for the read. Used to read out
+            rows that pass some selection criteria. Only selection along the first
+            axis is supported, so tuple arguments must be one-tuples.  If `n_rows`
+            is not false, `idx` will be truncated to `n_rows` before reading. To use
+            with a list of files, can pass in a list of `idx`'s (one for each
             file) or use a long contiguous list (e.g. built from a previous
-            identical read). If used in conjunction with start_row and n_rows,
-            will be sliced to obey those constraints, where n_rows is
-            interpreted as the (max) number of -selected- values (in idx) to be
+            identical read). If used in conjunction with `start_row` and `n_rows`,
+            will be sliced to obey those constraints, where `n_rows` is
+            interpreted as the (max) number of *selected* values (in `idx`) to be
             read out.
-        field_mask : dict or defaultdict { str : bool } or list/tuple (optional)
+        field_mask
             For tables and structs, determines which fields get written out.
             Only applies to immediate fields of the requested objects. If a dict
-            is used, a defaultdict will be made with the default set to the
+            is used, a default dict will be made with the default set to the
             opposite of the first element in the dict. This way if one specifies
-            a few fields at "false", all but those fields will be read out,
-            while if one specifies just a few fields as "true", only those
+            a few fields at ``False``, all but those fields will be read out,
+            while if one specifies just a few fields as ``True``, only those
             fields will be read out. If a list is provided, the listed fields
-            will be set to "true", while the rest will default to "false".
-        obj_buf : lh5 object (optional)
-            Read directly into memory provided in obj_buf. Note: the buffer will
-            be expanded to accommodate the data requested. To maintain the
-            buffer length, send in n_rows = len(obj_buf)
-        obj_buf_start : int (optional)
-            Start location in obj_buf for read. For concatenating data to
-            array-like objects
-        verbosity : bool (optional)
-            Turn on verbosity for debugging
+            will be set to ``True``, while the rest will default to ``False``.
+        obj_buf
+            Read directly into memory provided in `obj_buf`. Note: the buffer
+            will be expanded to accommodate the data requested. To maintain the
+            buffer length, send in ``n_rows = len(obj_buf)``.
+        obj_buf_start
+            Start location in ``obj_buf`` for read. For concatenating data to
+            array-like objects.
 
         Returns
         -------
-        (object, n_rows_read) : tuple
-            object is the read-out object
-            n_rows_read is the number of rows successfully read out. Essential
-            for arrays when the amount of data is smaller than the object
-            buffer.  For scalars and structs n_rows_read will be "1". For tables
-            it is redundant with table.loc
+        (object, n_rows_read)
+            `object` is the read-out object `n_rows_read` is the number of rows
+            successfully read out. Essential for arrays when the amount of data
+            is smaller than the object buffer.  For scalars and structs
+            `n_rows_read` will be``1``. For tables it is redundant with
+            ``table.loc``.
         """
         # Handle list-of-files recursively
         if not isinstance(lh5_file, (str, h5py._hl.files.File)):
@@ -209,8 +229,7 @@ def read_object(self, name, lh5_file, start_row=0, n_rows=sys.maxsize, idx=None,
                                                           idx=idx_i,
                                                           field_mask=field_mask,
                                                           obj_buf=obj_buf,
-                                                          obj_buf_start=obj_buf_start,
-                                                          verbosity=verbosity)
+                                                          obj_buf_start=obj_buf_start)
                 n_rows_read += n_rows_read_i
                 if n_rows_read >= n_rows or obj_buf == None:
                     return obj_buf, n_rows_read
@@ -219,13 +238,12 @@ def read_object(self, name, lh5_file, start_row=0, n_rows=sys.maxsize, idx=None,
             return obj_buf, n_rows_read
 
         # start read from single file. fail if the object is not found
-        if verbosity > 0: print("reading", name, "from", lh5_file)
+        log.debug(f"reading '{name}' from {lh5_file}")
 
         # get the file from the store
-        h5f = self.gimme_file(lh5_file, 'r', verbosity=verbosity)
+        h5f = self.gimme_file(lh5_file, 'r')
         if not h5f or name not in h5f:
-            print('LH5Store:', name, "not in", lh5_file)
-            return None, 0
+            raise KeyError(f"'{name}' not in {lh5_file}")
 
         # make idx a proper tuple if it's not one already
         if not (isinstance(idx, tuple) and len(idx) == 1):
@@ -233,8 +251,8 @@ def read_object(self, name, lh5_file, start_row=0, n_rows=sys.maxsize, idx=None,
 
         # get the object's datatype
         if 'datatype' not in h5f[name].attrs:
-            print('LH5Store:', name, 'in file', lh5_file, 'is missing the datatype attribute')
-            return None, 0
+            raise RuntimeError(f"'{name}' in file {lh5_file} is missing the datatype attribute")
+
         datatype = h5f[name].attrs['datatype']
         datatype, shape, elements = parse_datatype(datatype)
 
@@ -249,11 +267,9 @@ def read_object(self, name, lh5_file, start_row=0, n_rows=sys.maxsize, idx=None,
             elif isinstance(field_mask, (list, tuple)):
                 field_mask = defaultdict(lambda : False, { field : True for field in field_mask} )
             elif not isinstance(field_mask, defaultdict):
-                print('bad field_mask of type', type(field_mask).__name__)
-                return None, 0
+                raise RuntimeError('bad field_mask of type', type(field_mask).__name__)
         elif field_mask is not None:
-            print(f'Warning: datatype {datatype} does not accept a field_mask')
-
+            raise RuntimeError(f'datatype {datatype} does not accept a field_mask')
 
         # Scalar
         # scalars are dim-0 datasets
@@ -276,7 +292,7 @@ def read_object(self, name, lh5_file, start_row=0, n_rows=sys.maxsize, idx=None,
             # fields. If implemented, get_buffer() above should probably also
             # (optionally?) prep buffers for each field
             if obj_buf is not None:
-                print("obj_buf not implemented for structs.  Returning new object")
+                raise NotImplementedError("obj_buf not implemented for LGOD Structs")
 
             # loop over fields and read
             obj_dict = {}
@@ -290,8 +306,7 @@ def read_object(self, name, lh5_file, start_row=0, n_rows=sys.maxsize, idx=None,
                                                       h5f,
                                                       start_row=start_row,
                                                       n_rows=n_rows,
-                                                      idx=idx,
-                                                      verbosity=verbosity)
+                                                      idx=idx)
             # modify datatype in attrs if a field_mask was used
             attrs = dict(h5f[name].attrs)
             if field_mask is not None:
@@ -320,9 +335,8 @@ def read_object(self, name, lh5_file, start_row=0, n_rows=sys.maxsize, idx=None,
                 fld_buf = None
                 if obj_buf is not None:
                     if not isinstance(obj_buf, Table) or field not in obj_buf:
-                        print("obj_buf for Table", name,
-                              "not formatted correctly. returning new object")
-                        obj_buf = None
+                        raise ValueError(f"obj_buf for LGDO Table '{name}' not formatted correctly")
+
                     else: fld_buf = obj_buf[field]
                 col_dict[field], n_rows_read = self.read_object(name+'/'+field,
                                                                 h5f,
@@ -330,25 +344,24 @@ def read_object(self, name, lh5_file, start_row=0, n_rows=sys.maxsize, idx=None,
                                                                 n_rows=n_rows,
                                                                 idx=idx,
                                                                 obj_buf=fld_buf,
-                                                                obj_buf_start=obj_buf_start,
-                                                                verbosity=verbosity)
+                                                                obj_buf_start=obj_buf_start)
                 if obj_buf is not None and obj_buf_start+n_rows_read > len(obj_buf):
-                    obj_buf.resize(obj_buf_start+n_rows_read, do_warn=(verbosity>0))
+                    obj_buf.resize(obj_buf_start+n_rows_read)
                 rows_read.append(n_rows_read)
             # warn if all columns don't read in the same number of rows
             n_rows_read = rows_read[0]
             for n in rows_read[1:]:
                 if n != n_rows_read:
-                    print('table', name, 'got strange n_rows_read', n)
-                    print(n_rows_read, 'was expected')
+                    log.warning(f"Table '{name}' got strange n_rows_read = {n}, {n_rows_read} was expected")
 
             # modify datatype in attrs if a field_mask was used
             attrs = dict(h5f[name].attrs)
             if field_mask is not None:
                 selected_fields = []
                 for field in elements:
-                    if field_mask[field]: selected_fields.append(field)
-                attrs['datatype'] =  'table' + '{' + ','.join(selected_fields) + '}'
+                    if field_mask[field]:
+                        selected_fields.append(field)
+                attrs['datatype'] = 'table' + '{' + ','.join(selected_fields) + '}'
 
             # fields have been read out, now return a table
             if obj_buf is None:
@@ -369,35 +382,33 @@ def read_object(self, name, lh5_file, start_row=0, n_rows=sys.maxsize, idx=None,
                 obj_buf.resize(do_warn=True)
                 # set (write) loc to end of tree
                 obj_buf.loc = obj_buf_start+n_rows_read
-                #check attributes
+                # check attributes
                 if set(obj_buf.attrs.keys()) != set(attrs.keys()):
-                    print('warning: attrs mismatch')
-                    print('obj_buf.attrs:', obj_buf.attrs)
-                    print('h5f['+name+'].attrs:', attrs)
+                    raise RuntimeError(
+                        f"attrs mismatch. obj_buf.attrs: "
+                        f"{obj_buf.attrs}, h5f[{name}].attrs: {attrs}")
                 return obj_buf, n_rows_read
 
         # VectorOfVectors
         # read out vector of vectors of different size
         if elements.startswith('array'):
-            if obj_buf is not None:
-                if not isinstance(obj_buf, VectorOfVectors):
-                    print("obj_buf for", name, "not a VectorOfVectors. returning new object")
-                    obj_buf = None
+            if obj_buf is not None and not isinstance(obj_buf, VectorOfVectors):
+                raise ValueError(f"obj_buf for '{name}' not a LGDO VectorOfVectors")
+
             if idx is not None:
-                print("warning: fancy indexed readout not implemented for vector of vectors, ignoring idx")
+                raise NotImplementedError("fancy indexed readout not implemented for VectorOfVectors")
                 # TODO: implement idx: first pull out all of cumulative length,
                 # use it to build an idx for the data_array, then rebuild
                 # cumulative length
 
             # read out cumulative_length
             cumulen_buf = None if obj_buf is None else obj_buf.cumulative_length
-            cumulative_length, n_rows_read = self.read_object(name+'/cumulative_length',
+            cumulative_length, n_rows_read = self.read_object(f"{name}/cumulative_length",
                                                               h5f,
                                                               start_row=start_row,
                                                               n_rows=n_rows,
                                                               obj_buf=cumulen_buf,
-                                                              obj_buf_start=obj_buf_start,
-                                                              verbosity=verbosity)
+                                                              obj_buf_start=obj_buf_start)
             # get a view of just what was read out for cleaner code below
             this_cumulen_nda = cumulative_length.nda[obj_buf_start:obj_buf_start+n_rows_read]
 
@@ -407,13 +418,18 @@ def read_object(self, name, lh5_file, start_row=0, n_rows=sys.maxsize, idx=None,
                 # need to read out the cumulen sample -before- the first sample
                 # read above in order to get the starting row of the first
                 # vector to read out in flattened_data
-                da_start = h5f[name+'/cumulative_length'][start_row-1]
+                da_start = h5f[f"{name}/cumulative_length"][start_row-1]
 
                 # check limits for values that will be used subsequently
                 if this_cumulen_nda[-1] < da_start:
-                    print("warning: cumulative_length non-increasing between entries",
-                          start_row, "and", start_row+n_rows_read, "??")
-                    print(this_cumulen_nda[-1], da_start, start_row, n_rows_read)
+                    log.debug(
+                        f"this_cumulen_nda[-1] = {this_cumulen_nda[-1]}, "
+                        f"da_start = {da_start}, "
+                        f"start_row = {start_row}, "
+                        f"n_rows_read = {n_rows_read}")
+                    raise RuntimeError(
+                        f"cumulative_length non-increasing between entries "
+                        f"{start_row} and {start_row+n_rows_read} ??")
 
             # determine the number of rows for the flattened_data readout
             da_nrows = this_cumulen_nda[-1] if n_rows_read > 0 else 0
@@ -444,15 +460,15 @@ def read_object(self, name, lh5_file, start_row=0, n_rows=sys.maxsize, idx=None,
                 if len(da_buf) < dab_size: da_buf.resize(dab_size)
 
             # now read
-            flattened_data, dummy_rows_read = self.read_object(name+'/flattened_data',
+            flattened_data, dummy_rows_read = self.read_object(f"{name}/flattened_data",
                                                                h5f,
                                                                start_row=da_start,
                                                                n_rows=da_nrows,
                                                                idx=idx,
                                                                obj_buf=da_buf,
-                                                               obj_buf_start=da_buf_start,
-                                                               verbosity=verbosity)
-            if obj_buf is not None: return obj_buf, n_rows_read
+                                                               obj_buf_start=da_buf_start)
+            if obj_buf is not None:
+                return obj_buf, n_rows_read
             return VectorOfVectors(flattened_data=flattened_data,
                                    cumulative_length=cumulative_length,
                                    attrs=h5f[name].attrs), n_rows_read
@@ -465,7 +481,7 @@ def read_object(self, name, lh5_file, start_row=0, n_rows=sys.maxsize, idx=None,
         if 'array' in datatype:
             if obj_buf is not None:
                 if not isinstance(obj_buf, Array):
-                    print("obj_buf for", name, "not an Array. returning new object")
+                    raise ValueError(f"obj_buf for '{name}' not an LGDO Array")
                     obj_buf = None
 
             # compute the number of rows to read
@@ -474,10 +490,11 @@ def read_object(self, name, lh5_file, start_row=0, n_rows=sys.maxsize, idx=None,
             ds_n_rows = h5f[name].shape[0]
             if idx is not None:
                 if len(idx[0]) > 0 and idx[0][-1] >= ds_n_rows:
-                    print("warning: idx indexed past the end of the array in the file. Culling...")
+                    log.warning("idx indexed past the end of the array in the file. Culling...")
                     n_rows_to_read = bisect_left(idx[0], ds_n_rows)
                     idx = (idx[0][:n_rows_to_read],)
-                if len(idx[0]) == 0: print("warning: idx empty after culling.")
+                if len(idx[0]) == 0:
+                    log.warning("idx empty after culling.")
                 n_rows_to_read = len(idx[0])
             else: n_rows_to_read = ds_n_rows - start_row
             if n_rows_to_read > n_rows: n_rows_to_read = n_rows
@@ -503,10 +520,11 @@ def read_object(self, name, lh5_file, start_row=0, n_rows=sys.maxsize, idx=None,
 
             # special handling for bools
             # (c and Julia store as uint8 so cast to bool)
-            if elements == 'bool': nda = nda.astype(np.bool)
+            if elements == 'bool':
+                nda = nda.astype(np.bool)
 
             # Finally, set attributes and return objects
-            attrs=h5f[name].attrs
+            attrs = h5f[name].attrs
             if obj_buf is None:
                 if datatype == 'array':
                     return Array(nda=nda, attrs=attrs), n_rows_to_read
@@ -518,36 +536,55 @@ def read_object(self, name, lh5_file, start_row=0, n_rows=sys.maxsize, idx=None,
                                                    attrs=attrs), n_rows_to_read
             else:
                 if set(obj_buf.attrs.keys()) != set(attrs.keys()):
-                    print('warning: attrs mismatch')
-                    print('obj_buf.attrs:', obj_buf.attrs)
-                    print('h5f['+name+'].attrs:', attrs)
+                    raise RuntimeError(
+                        f"attrs mismatch. "
+                        f"obj_buf.attrs: {obj_buf.attrs}, "
+                        f"h5f[{name}].attrs: {attrs}")
                 return obj_buf, n_rows_to_read
 
+        raise RuntimeError("don't know how to read datatype {datatype}")
 
-        print('LH5Store: don\'t know how to read datatype', datatype)
-        return None
-
-
-    def write_object(self, obj, name, lh5_file, group='/', start_row=0, n_rows=None,
-                     wo_mode='append', write_start=0, verbosity=0):
-        """Write an object into an lh5_file
-
-        obj should be a LH5 object. if object is array-like, writes n_rows
-        starting from start_row in obj.
-
-        wo_modes:
-
-          - 'write_safe' or 'w': only proceed with writing if the object does
-            not already exist in the file
-          - 'append' or 'a': append along axis 0 (the first dimension) of
-            array-like objects and array-like subfields of structs. Scalar
-            objects get overwritten
-          - 'overwrite' or 'o': replace data in the file if present, starting
-            from write_start. Note: overwriting with write_start = end of
-            array is the same as append
-          - 'overwrite_file' or 'of': delete file if present prior to writing to
-            it. write_start should be 0 (it's ignored)
+    def write_object(self,
+                     obj: LGDO,
+                     name: str,
+                     lh5_file: str | h5py.File,
+                     group: str | h5py.Group = '/',
+                     start_row: int = 0,
+                     n_rows: int = None,
+                     wo_mode: str = 'append',
+                     write_start: int = 0) -> None:
+        """Write an LGDO into an LH5 file.
 
+        Parameters
+        ----------
+        obj
+            LH5 object. if object is array-like, writes `n_rows` starting from
+            `start_row` in `obj`.
+        name
+            name of the object in the output HDF5 file.
+        lh5_file
+            HDF5 file name or :class:`h5py.File` object.
+        group
+            HDF5 group name or :class:`h5py.Group` object in which `obj` should
+            be written.
+        start_row
+            first row in `obj` to be written.
+        n_rows
+            number of rows in `obj` to be written.
+        wo_mode
+            - ``write_safe`` or ``w``: only proceed with writing if the
+              object does not already exist in the file.
+            - ``append`` or ``a``: append along axis 0 (the first dimension)
+              of array-like objects and array-like subfields of structs.
+              :class:`~.lgdo.scalar.Scalar` objects get overwritten.
+            - ``overwrite`` or ``o``: replace data in the file if present,
+              starting from `write_start`. Note: overwriting with `write_start` =
+              end of array is the same as ``append``.
+            - ``overwrite_file`` or ``of``: delete file if present prior to
+              writing to it. `write_start` should be 0 (its ignored).
+        write_start
+            row in the output file (if already existing) to start overwriting
+            from.
         """
         if wo_mode == 'write_safe':  wo_mode = 'w'
         if wo_mode == 'append':  wo_mode = 'a'
@@ -556,24 +593,22 @@ def write_object(self, obj, name, lh5_file, group='/', start_row=0, n_rows=None,
             wo_mode = 'of'
             write_start = 0
         if wo_mode != 'w' and wo_mode != 'a' and wo_mode != 'o' and wo_mode != 'of':
-            print(f'Unknown wo_mode {wo_mode}')
-            return
+            raise ValueError(f"unknown wo_mode '{wo_mode}'")
 
         # "mode" is for the h5df.File and wo_mode is for this function
         # In hdf5, 'a' is really "modify" -- in addition to appending, you can
         # change any object in the file. So we use file:append for
         # write_object:overwrite.
         mode = 'w' if wo_mode == 'of' else 'a'
-        lh5_file = self.gimme_file(lh5_file, mode=mode, verbosity=verbosity)
-        group = self.gimme_group(group, lh5_file, verbosity=verbosity)
+        lh5_file = self.gimme_file(lh5_file, mode=mode)
+        group = self.gimme_group(group, lh5_file)
         if wo_mode == 'w' and name in group:
-            print(f"can't overwrite {name} in wo_mode write_safe")
-            return
+            raise RuntimeError(f"can't overwrite '{name}' in wo_mode 'write_safe'")
 
         # struct or table or waveform table
         if isinstance(obj, Struct):
-            group = self.gimme_group(name, group, grp_attrs=obj.attrs, overwrite=(wo_mode=='o'), verbosity=verbosity)
-            fields = obj.keys()
+            group = self.gimme_group(name, group, grp_attrs=obj.attrs,
+                                     overwrite=(wo_mode=='o'))
             for field in obj.keys():
                 self.write_object(obj[field],
                                   field,
@@ -582,21 +617,21 @@ def write_object(self, obj, name, lh5_file, group='/', start_row=0, n_rows=None,
                                   start_row=start_row,
                                   n_rows=n_rows,
                                   wo_mode=wo_mode,
-                                  write_start=write_start,
-                                  verbosity=verbosity)
+                                  write_start=write_start)
             return
 
         # scalars
         elif isinstance(obj, Scalar):
-            if verbosity > 0 and name in group:
-                print('overwriting {name} in {group}')
+            if name in group:
+                log.debug(f"overwriting '{name}' in '{group}'")
             ds = group.create_dataset(name, shape=(), data=obj.value)
             ds.attrs.update(obj.attrs)
             return
 
         # vector of vectors
         elif isinstance(obj, VectorOfVectors):
-            group = self.gimme_group(name, group, grp_attrs=obj.attrs, overwrite=(wo_mode=='o'), verbosity=verbosity)
+            group = self.gimme_group(name, group, grp_attrs=obj.attrs,
+                                     overwrite=(wo_mode=='o'))
             if n_rows is None or n_rows > obj.cumulative_length.nda.shape[0] - start_row:
                 n_rows = obj.cumulative_length.nda.shape[0] - start_row
 
@@ -605,8 +640,10 @@ def write_object(self, obj, name, lh5_file, group='/', start_row=0, n_rows=None,
             offset = 0 # declare here because we have to subtract it off at the end
             if (wo_mode == 'a' or wo_mode == 'o') and 'cumulative_length' in group:
                 len_cl = len(group['cumulative_length'])
-                if wo_mode == 'a': write_start = len_cl
-                if len_cl > 0: offset = group['cumulative_length'][write_start-1]
+                if wo_mode == 'a':
+                    write_start = len_cl
+                if len_cl > 0:
+                    offset = group['cumulative_length'][write_start-1]
             # Add offset to obj.cumulative_length itself to avoid memory allocation.
             # Then subtract it off after writing!
             obj.cumulative_length.nda += offset
@@ -617,8 +654,7 @@ def write_object(self, obj, name, lh5_file, group='/', start_row=0, n_rows=None,
                               start_row=start_row,
                               n_rows=n_rows,
                               wo_mode=wo_mode,
-                              write_start=write_start,
-                              verbosity=verbosity)
+                              write_start=write_start)
             obj.cumulative_length.nda -= offset
 
             # now write data array. Only write rows with data.
@@ -631,8 +667,7 @@ def write_object(self, obj, name, lh5_file, group='/', start_row=0, n_rows=None,
                               start_row=da_start,
                               n_rows=da_n_rows,
                               wo_mode=wo_mode,
-                              write_start=offset,
-                              verbosity=verbosity)
+                              write_start=offset)
             return
 
         # if we get this far, must be one of the Array types
@@ -641,13 +676,14 @@ def write_object(self, obj, name, lh5_file, group='/', start_row=0, n_rows=None,
                 n_rows = obj.nda.shape[0] - start_row
             nda = obj.nda[start_row:start_row+n_rows]
             # hack to store bools as uint8 for c / Julia compliance
-            if nda.dtype.name == 'bool': nda = nda.astype(np.uint8)
+            if nda.dtype.name == 'bool':
+                nda = nda.astype(np.uint8)
             # need to create dataset from ndarray the first time for speed
             # creating an empty dataset and appending to that is super slow!
             if (wo_mode != 'a' and write_start == 0) or name not in group:
                 maxshape = (None,) + nda.shape[1:]
                 if wo_mode == 'o' and name in group:
-                    log.info(f'overwriting {name} in {group}')
+                    log.debug(f'overwriting {name} in {group}')
                     del group[name]
                 ds = group.create_dataset(name, data=nda, maxshape=maxshape)
                 ds.attrs.update(obj.attrs)
@@ -656,30 +692,30 @@ def write_object(self, obj, name, lh5_file, group='/', start_row=0, n_rows=None,
             # Now append or overwrite
             ds = group[name]
             old_len = ds.shape[0]
-            if wo_mode == 'a': write_start = old_len
+            if wo_mode == 'a':
+                write_start = old_len
             add_len = write_start + nda.shape[0] - old_len
             ds.resize(old_len + add_len, axis=0)
             ds[write_start:] = nda
             return
 
         else:
-            print('LH5Store: do not know how to write', name, 'of type', type(obj).__name__)
-            return
+            raise RuntimeError(f"do not know how to write '{name}' of type '{type(obj).__name__}'")
 
+    def read_n_rows(self, name: str, lh5_file: str | h5py.File) -> int | None:
+        """Look up the number of rows in an Array-like object called `name` in
+        `lh5_file`.
 
-    def read_n_rows(self, name, lh5_file):
-        """Look up the number of rows in an Array-like object called name
-        in lh5_file. Return None if it is a scalar/struct."""
+        Return ``None`` if it is a :class:`.Scalar` or a :class:`.Struct`."""
         # this is basically a stripped down version of read_object
         h5f = self.gimme_file(lh5_file, 'r')
         if not h5f or name not in h5f:
-            print('LH5Store:', name, "not in", lh5_file)
-            return None
+            raise KeyError(f"'{name}' not in {lh5_file}")
 
         # get the datatype
         if 'datatype' not in h5f[name].attrs:
-            print('LH5Store:', name, 'in file', lh5_file, 'is missing the datatype attribute')
-            return None, 0
+            raise RuntimeError(f"'{name}' in file {lh5_file} is missing the datatype attribute")
+
         datatype = h5f[name].attrs['datatype']
         datatype, shape, elements = parse_datatype(datatype)
 
@@ -687,145 +723,151 @@ def read_n_rows(self, name, lh5_file):
         if datatype == 'scalar':
             return None
 
-        # recursively build a struct, return as a dictionary
+        # structs don't have rows
         if datatype == 'struct':
             return None
 
-        # read a table into a dataframe
+        # tables should have elements with all the same length
         if datatype == 'table':
             # read out each of the fields
             rows_read = None
             for field in elements:
-                fld_buf = None
                 n_rows_read = self.read_n_rows(name+'/'+field, h5f)
                 if not rows_read: rows_read = n_rows_read
                 elif rows_read != n_rows_read:
-                    print('table', name, 'got strange n_rows_read', rows_read)
-                    print(n_rows_read, 'was expected')
+                    log.warning(
+                        f"table '{name}' got strange n_rows_read = {rows_read}, "
+                        f"{n_rows_read} was expected")
             return rows_read
 
-        # read out vector of vectors of different size
+        # length of vector of vectors is the length of its cumulative_length
         if elements.startswith('array'):
-            cumulen_buf = None
-            return self.read_n_rows(name+'/cumulative_length', h5f)
+            return self.read_n_rows(f"{name}/cumulative_length", h5f)
 
-        # read out all arrays by slicing
+        # return array length (without reading the array!)
         if 'array' in datatype:
             # compute the number of rows to read
             return h5f[name].shape[0]
 
-        print('LH5Store: don\'t know how to read datatype', datatype)
-        return None
+        raise RuntimeError(f"don't know how to read datatype '{datatype}'")
+
 
-def ls(lh5_file, lh5_group=''):
-    """Return a list of lh5 groups in the input file and group, similar
-    to ls or h5ls. Supports wildcards in group names.
+def ls(lh5_file: str, lh5_group: str = '') -> list[str]:
+    """Return a list of LH5 groups in the input file and group, similar
+    to ``ls`` or ``h5ls``. Supports wildcards in group names.
 
     Parameters
     ----------
-    lh5_file : str
-        name of file
-    lh5_group : str
-        group to search. add a '/' to the end of the group name if you want to
-        list all objects inside that group
-
-    Returns
-    -------
-    groups : list of strs
-        List of names of groups found
+    lh5_file
+        name of file.
+    lh5_group
+        group to search. add a ``/`` to the end of the group name if you want to
+        list all objects inside that group.
     """
 
     lh5_st = LH5Store()
     # To use recursively, make lh5_file a h5group instead of a string
     if isinstance(lh5_file, str):
         lh5_file = lh5_st.gimme_file(lh5_file, 'r')
-        if lh5_group.startswith('/'): lh5_group = lh5_group[1:]
+        if lh5_group.startswith('/'):
+            lh5_group = lh5_group[1:]
 
-    if lh5_group=='': lh5_group='*'
+    if lh5_group == '':
+        lh5_group = '*'
 
     splitpath = lh5_group.split('/', 1)
     matchingkeys = fnmatch.filter(lh5_file.keys(), splitpath[0])
 
-    if len(splitpath)==1:
+    if len(splitpath) == 1:
         return matchingkeys
     else:
         ret = []
         for key in matchingkeys:
-            ret.extend([key + '/' + path for path in ls(lh5_file[key], splitpath[1])])
+            ret.extend([f"{key}/{path}" for path in ls(lh5_file[key], splitpath[1])])
         return ret
 
 
-def load_nda(f_list, par_list, lh5_group='', idx_list=None, verbose=True):
-    """ Build a dictionary of ndarrays from lh5 data
+def load_nda(f_list: str | list[str],
+             par_list: list[str],
+             lh5_group: str = '',
+             idx_list: list[np.ndarray | list | tuple] = None
+             ) -> dict[str, np.ndarray]:
+    r"""Build a dictionary of :class:`numpy.ndarray`\ s from LH5 data.
 
-    Given a list of files, a list of lh5 table parameters, and an optional group
-    path, return a numpy array with all values for each parameter.
+    Given a list of files, a list of LH5 table parameters, and an optional
+    group path, return a NumPy array with all values for each parameter.
 
     Parameters
     ----------
-    f_list : str or list of str's
-        A list of files. Can contain wildcards
-    par_list : list of str's
-        A list of parameters to read from each file
-    lh5_group : str (optional)
-        Optional group path within which to find the specified parameters
-    idx_list : list of index arrays, or a list of such lists
-        For fancy-indexed reads. Must be one idx array for each file in f_list
-    verbose : bool
-        Print info on loaded data
+    f_list
+        A list of files. Can contain wildcards.
+    par_list
+        A list of parameters to read from each file.
+    lh5_group
+        group path within which to find the specified parameters.
+    idx_list
+        for fancy-indexed reads. Must be one index array for each file in
+        `f_list`.
 
     Returns
     -------
-    par_data : dict
-        A dictionary of the parameter data keyed by the elements of par_list.
+    par_data
+        A dictionary of the parameter data keyed by the elements of `par_list`.
         Each entry contains the data for the specified parameter concatenated
-        over all files in f_list
+        over all files in `f_list`.
     """
     if isinstance(f_list, str):
         f_list = [f_list]
         if idx_list is not None:
             idx_list = [idx_list]
     if idx_list is not None and len(f_list) != len(idx_list):
-        print(f"load_nda: f_list len ({len(f_list)}) != idx_list len ({len(idx_list)})!")
-        return None
+        raise ValueError(f"f_list length ({len(f_list)}) != idx_list length ({len(idx_list)})!")
 
     # Expand wildcards
     f_list = [f for f_wc in f_list for f in sorted(glob.glob(os.path.expandvars(f_wc)))]
-    if verbose:
-        print("loading data for", *f_list)
 
     sto = LH5Store()
-    par_data = {par : [] for par in par_list}
+    par_data = {par: [] for par in par_list}
     for ii, f in enumerate(f_list):
         f = sto.gimme_file(f, 'r')
         for par in par_list:
             if f'{lh5_group}/{par}' not in f:
-                print(f'{lh5_group}/{par} not in file {f_list[ii]}')
-                return None
-            if idx_list is None: data, _ = sto.read_object(f'{lh5_group}/{par}', f)
-            else: data, _ = sto.read_object(f'{lh5_group}/{par}', f, idx=idx_list[ii])
-            if not data: continue
+                raise RuntimeError(f"'{lh5_group}/{par}' not in file {f_list[ii]}")
+
+            if idx_list is None:
+                data, _ = sto.read_object(f'{lh5_group}/{par}', f)
+            else:
+                data, _ = sto.read_object(f'{lh5_group}/{par}', f, idx=idx_list[ii])
+            if not data:
+                continue
             par_data[par].append(data.nda)
-    par_data = {par : np.concatenate(par_data[par]) for par in par_list}
+    par_data = {par: np.concatenate(par_data[par]) for par in par_list}
     return par_data
 
 
-def load_dfs(f_list, par_list, lh5_group='', idx_list=None, verbose=True):
-    """ Build a pandas dataframe from lh5 data
+def load_dfs(f_list: str | list[str],
+             par_list: list[str],
+             lh5_group: str = '',
+             idx_list: list[np.ndarray | list | tuple] = None
+             ) -> pd.DataFrame:
+    """Build a :class:`pandas.DataFrame` from LH5 data.
 
-    Given a list of files (can use wildcards), a list of lh5 columns, and
-    optionally the group path, return a pandas DataFrame with all values for
-    each parameter.
+    Given a list of files (can use wildcards), a list of LH5 columns, and
+    optionally the group path, return a :class:`pandas.DataFrame` with all
+    values for each parameter.
 
-    See :func:`load_nda` for parameter specification
+    See Also
+    --------
+    :func:`load_nda`
 
     Returns
     -------
-    df : pandas.DataFrame
-        Contains columns for each parameter in par_list, and rows containing all
-        data for the associated parameters concatenated over all files in f_list
+    dataframe
+        contains columns for each parameter in `par_list`, and rows containing
+        all data for the associated parameters concatenated over all files in
+        `f_list`.
     """
-    return pd.DataFrame( load_nda(f_list, par_list, lh5_group=lh5_group, idx_list=idx_list, verbose=verbose) )
+    return pd.DataFrame(load_nda(f_list, par_list, lh5_group=lh5_group, idx_list=idx_list))
 
 
 class LH5Iterator:
@@ -834,59 +876,74 @@ class LH5Iterator:
     at a time. This also accepts an entry list/mask to enable event selection,
     and a field mask.
 
-    This class can be used either for random access: ::
+    This class can be used either for random access:
 
-        lh5_obj, n_rows = lh5_it.read(entry)
+    >>> lh5_obj, n_rows = lh5_it.read(entry)
 
     to read the block of entries starting at entry. In case of multiple files
     or the use of an event selection, entry refers to a global event index
     across files and does not count events that are excluded by the selection.
 
-    This can also be used as an iterator: ::
+    This can also be used as an iterator:
 
-        for lh5_obj, entry, n_rows in LH5Iterator(...):
-            # do the thing!
+    >>> for lh5_obj, entry, n_rows in LH5Iterator(...):
+    >>>    # do the thing!
 
     This is intended for if you are reading a large quantity of data but
     want to limit your memory usage (particularly when reading in waveforms!).
-    The lh5_obj that is read by this class is reused in order to avoid
+    The ``lh5_obj`` that is read by this class is reused in order to avoid
     reallocation of memory; this means that if you want to hold on to data
     between reads, you will have to copy it somewhere!
     """
-    def __init__(self, lh5_files, group, base_path='', entry_list=None,
-                 entry_mask=None, field_mask=None, buffer_len=3200):
+    def __init__(self, lh5_files: str | list[str],
+                 group: str,
+                 base_path: str = '',
+                 entry_list: list[int] | list[list[int]] = None,
+                 entry_mask: list[bool] | list[list[bool]] = None,
+                 field_mask: dict[str, bool] | list[str] | tuple[str] = None,
+                 buffer_len: int = 3200) -> None:
         """
         Parameters
         ----------
-        lh5_files : str or list
-            File or files to read from. May include wildcards and env vars
-        group : str
-            HDF5 group to read
-        base_path : str
-            HDF5 path to prepend
-        entry_list : list-like or nested list-like of ints (optional)
-            List of entry numbers to read. If a nested list is provided,
+        lh5_files
+            file or files to read from. May include wildcards and environment
+            variables.
+        group
+            HDF5 group to read.
+        base_path
+            HDF5 path to prepend.
+        entry_list
+            list of entry numbers to read. If a nested list is provided,
             expect one top-level list for each file, containing a list of
-            local entries. If a list of ints is provided, use global entries
-        entry_mask : array of bools or list of arrays of bools (optional)
-            Mask of entries to read. If a list of arrays is provided, expect
-            one for each file. Ignore if a selection list is provided...
-        field_mask : dict or defaultdict { str : bool } or list/tuple (optional)
-            Mask of which fields to read. See read_object for more details.
-        buffer_len : int (default 3200)
-            Number of entries to read at a time while iterating through files
+            local entries. If a list of ints is provided, use global entries.
+        entry_mask
+            mask of entries to read. If a list of arrays is provided, expect
+            one for each file. Ignore if a selection list is provided.
+        field_mask
+            mask of which fields to read. See :meth:`LH5Store.read_object` for
+            more details.
+        buffer_len
+            number of entries to read at a time while iterating through files.
         """
         self.lh5_st = LH5Store(base_path=base_path, keep_open=True)
 
         # List of files, with wildcards and env vars expanded
-        if isinstance(lh5_files, str): lh5_files = [lh5_files]
+        if isinstance(lh5_files, str):
+            lh5_files = [lh5_files]
         self.lh5_files = [f for f_wc in lh5_files for f in sorted(glob.glob(os.path.expandvars(f_wc)))]
         # Map to last row in each file
         self.file_map = np.array([self.lh5_st.read_n_rows(group, f) for f in self.lh5_files], 'int64').cumsum()
         self.group = group
         self.buffer_len = buffer_len
 
-        self.lh5_buffer = self.lh5_st.get_buffer(self.group, self.lh5_files[0], size=self.buffer_len, field_mask=field_mask) if len(self.lh5_files)>0 else None
+        if len(self.lh5_files) > 0:
+            self.lh5_buffer = self.lh5_st.get_buffer(self.group,
+                                                     self.lh5_files[0],
+                                                     size=self.buffer_len,
+                                                     field_mask=field_mask)
+        else:
+            None
+
         self.n_rows = 0
         self.current_entry = 0
 
@@ -917,7 +974,7 @@ def __init__(self, lh5_files, group, base_path='', entry_list=None,
             if isinstance(entry_mask, np.ndarray):
                 self.entry_list = []
                 f_start = 0
-                for i_file, f_end in enumerate(self.file_map):
+                for f_end in self.file_map:
                     self.entry_list.append(list(np.nonzero(entry_mask[f_start:f_end])[0]))
                     f_start = f_end
             else:
@@ -929,20 +986,29 @@ def __init__(self, lh5_files, group, base_path='', entry_list=None,
         self.entry_map = self.file_map if self.entry_list is None else \
             np.array([len(elist) for elist in self.entry_list]).cumsum()
 
-
-    def read(self, entry):
+    def read(self, entry: int) -> tuple[LGDO, int]:
         """Read the next chunk of events, starting at entry. Return the
-        lh5 buffer and number of rows read"""
+        LH5 buffer and number of rows read."""
         i_file = np.searchsorted(self.entry_map, entry, 'right')
         local_entry = entry
-        if i_file>0: local_entry -= self.entry_map[i_file-1]
+        if i_file > 0:
+            local_entry -= self.entry_map[i_file-1]
         self.n_rows = 0
 
         while(self.n_rows < self.buffer_len and i_file < len(self.file_map)):
             # Loop through files
             local_idx = self.entry_list[i_file] if self.entry_list is not None else None
             i_local = local_idx[local_entry] if local_idx is not None else local_entry
-            self.lh5_buffer, n_rows = self.lh5_st.read_object(self.group, self.lh5_files[i_file], start_row = i_local, n_rows = self.buffer_len - self.n_rows, idx = local_idx, field_mask = self.field_mask, obj_buf = self.lh5_buffer, obj_buf_start = self.n_rows )
+            self.lh5_buffer, n_rows = self.lh5_st.read_object(
+                self.group,
+                self.lh5_files[i_file],
+                start_row=i_local,
+                n_rows=self.buffer_len-self.n_rows,
+                idx=local_idx,
+                field_mask=self.field_mask,
+                obj_buf=self.lh5_buffer,
+                obj_buf_start=self.n_rows
+            )
 
             self.n_rows += n_rows
             i_file += 1
@@ -951,13 +1017,13 @@ def read(self, entry):
         self.current_entry = entry
         return (self.lh5_buffer, self.n_rows)
 
-    def __len__(self):
-        """Total number of entries"""
-        return self.entry_map[-1] if len(self.entry_map)>0 else 0
+    def __len__(self) -> int:
+        """Return the total number of entries."""
+        return self.entry_map[-1] if len(self.entry_map) > 0 else 0
 
-    def __iter__(self):
-        """Loop through entries in blocks of size buffer_len"""
-        entry=0
+    def __iter__(self) -> tuple[LGDO, int, int]:
+        """Loop through entries in blocks of size buffer_len."""
+        entry = 0
         while entry < len(self):
             buf, n_rows = self.read(entry)
             yield (buf, entry, n_rows)
diff --git a/src/pygama/lgdo/scalar.py b/src/pygama/lgdo/scalar.py
index 61230401a..9e901c451 100644
--- a/src/pygama/lgdo/scalar.py
+++ b/src/pygama/lgdo/scalar.py
@@ -1,52 +1,64 @@
+"""Implements a LEGEND Data Object representing a scalar and corresponding utilities."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
 import numpy as np
 
-from .lgdo_utils import *
+from pygama.lgdo.lgdo_utils import get_element_type
 
+log = logging.getLogger(__name__)
 
-class Scalar:
-    """
-    Holds just a value and some attributes (datatype, units, ...)
 
-    TODO: do scalars need proper numpy dtypes?
-    """
+class Scalar:
+    """Holds just a scalar value and some attributes (datatype, units, ...)."""
+    # TODO: do scalars need proper numpy dtypes?
 
-    def __init__(self, value, attrs={}):
+    def __init__(self, value: int | float,
+                 attrs: dict[str, Any] = None) -> None:
         """
         Parameters
         ----------
-        value : scalar-like
-            The value for this scalar
-        attrs : dict (optional)
-            A set of user attributes to be carried along with this lgdo
+        value
+            the value for this scalar.
+        attrs
+            a set of user attributes to be carried along with this LGDO.
         """
         if not np.isscalar(value):
-            print('Cannot instantiate a Scalar with a non-scalar value...')
-            print('Setting value = 0')
-            value = 0
+            raise ValueError('cannot instantiate a Scalar with a non-scalar value')
+
         self.value = value
-        self.attrs = dict(attrs)
+        self.attrs = {} if attrs is None else dict(attrs)
+
         if 'datatype' in self.attrs:
             if self.attrs['datatype'] != self.form_datatype():
-                print('Scalar: Warning: datatype does not match value!')
-                print('datatype: ', self.attrs['datatype'])
-                print('type(value): ', type(value).__name__)
-        else: self.attrs['datatype'] = get_element_type(self.value)
-
-    def datatype_name(self):
-        """The name for this lgdo's datatype attribute"""
-        if hasattr(self.value, 'datatype_name'): return self.value.datatype_name
-        else: return get_element_type(self.value)
-
-    def form_datatype(self):
-        """Return this lgdo's datatype attribute string"""
+                raise ValueError(
+                    f"datatype ({self.attrs['datatype']}) does "
+                    f"not match value type ({type(value).__name__})!")
+        else:
+            self.attrs['datatype'] = get_element_type(self.value)
+
+    def datatype_name(self) -> str:
+        """Returns the name for this LGDO's datatype attribute."""
+        if hasattr(self.value, 'datatype_name'):
+            return self.value.datatype_name
+        else:
+            return get_element_type(self.value)
+
+    def form_datatype(self) -> str:
+        """Return this LGDO's datatype attribute string."""
         return self.datatype_name()
 
-    def __str__(self):
-        """Convert to string (e.g. for printing)"""
+    def __str__(self) -> str:
+        """Convert to string (e.g. for printing)."""
         string = str(self.value)
         tmp_attrs = self.attrs.copy()
         tmp_attrs.pop('datatype')
-        if len(tmp_attrs) > 0: string += '\n' + str(tmp_attrs)
+        if len(tmp_attrs) > 0:
+            string += '\n' + str(tmp_attrs)
         return string
 
-    def __repr__(self): return str(self)
+    def __repr__(self) -> str:
+        return str(self)
diff --git a/src/pygama/lgdo/struct.py b/src/pygama/lgdo/struct.py
index 50f22854c..db3cfb3fb 100644
--- a/src/pygama/lgdo/struct.py
+++ b/src/pygama/lgdo/struct.py
@@ -1,65 +1,82 @@
+"""
+Implements a LEGEND Data Object representing a struct and corresponding
+utilities.
+"""
+from __future__ import annotations
+
+import logging
+from typing import Any, Union
+
+from pygama.lgdo.array import Array
+from pygama.lgdo.scalar import Scalar
+from pygama.lgdo.vectorofvectors import VectorOfVectors
+
+log = logging.getLogger(__name__)
+
+LGDO = Union[Scalar, Array, VectorOfVectors]
+
+
 class Struct(dict):
-    """
-    A dictionary of lgdo's with an optional set of attributes.
+    """A dictionary of LGDO's with an optional set of attributes.
 
-    After instantiation, add fields using add_field() to keep datatype updated,
-    or call update_datatype() after adding.
+    After instantiation, add fields using :meth:`add_field` to keep the
+    datatype updated, or call :meth:`update_datatype` after adding.
     """
     # TODO: overload setattr to require add_field for setting?
 
-
-    def __init__(self, obj_dict={}, attrs={}):
+    def __init__(self, obj_dict: dict[str, LGDO | Struct] = None,
+                 attrs: dict[str, Any] = None) -> None:
         """
         Parameters
         ----------
-        obj_dict : dict { str : lgdo } (optional)
-            Instantiate this struct using the supplied named lgdo's.
-            Note: no copy is performed, the objects are used directly.
-        attrs : dict (optional)
-            A set of user attributes to be carried along with this lgdo
+        obj_dict
+            instantiate this Struct using the supplied named LGDO's.  Note: no
+            copy is performed, the objects are used directly.
+        attrs
+            a set of user attributes to be carried along with this LGDO.
         """
-        self.update(obj_dict)
-        self.attrs = dict(attrs)
+        if obj_dict is not None:
+            self.update(obj_dict)
+
+        self.attrs = {} if attrs is None else dict(attrs)
+
         if 'datatype' in self.attrs:
             if self.attrs['datatype'] != self.form_datatype():
-                print(type(self).__name__ + ': Warning: datatype does not match obj_dict!')
-                print('datatype: ', self.attrs['datatype'])
-                print('obj_dict.keys(): ', obj_dict.keys())
-                print('form_datatype(): ', self.form_datatype())
-                print('will be updated to the latter.')
+                raise ValueError(
+                    "datatype does not match obj_dict! "
+                    f"datatype: {self.attrs['datatype']}, "
+                    f"obj_dict.keys(): {obj_dict.keys()}, "
+                    f"form_datatype(): {self.form_datatype()}")
         self.update_datatype()
 
-
-    def datatype_name(self):
-        """The name for this lgdo's datatype attribute"""
+    def datatype_name(self) -> str:
+        """The name for this LGDO's datatype attribute."""
         return 'struct'
 
-
-    def form_datatype(self):
-        """Return this lgdo's datatype attribute string"""
+    def form_datatype(self) -> str:
+        """Return this LGDO's datatype attribute string."""
         return self.datatype_name() + '{' + ','.join(self.keys()) + '}'
 
-
-    def update_datatype(self):
+    def update_datatype(self) -> None:
         self.attrs['datatype'] = self.form_datatype()
 
-
-    def add_field(self, name, obj):
+    def add_field(self, name: str, obj: LGDO | Struct) -> None:
         self[name] = obj
         self.update_datatype()
 
-
-    def __len__(self):
-        """Structs are considered length=1 """
+    def __len__(self) -> int:
+        """As a convention for Structs, always return 1."""
         return 1
 
-    def __str__(self):
-        """Convert to string (e.g. for printing)"""
+    def __str__(self) -> str:
+        """Convert to string (e.g. for printing)."""
         tmp_attrs = self.attrs.copy()
         datatype = tmp_attrs.pop('datatype')
-        string = datatype + " = " + super().__repr__() #__repr__ instead of __str__ to avoid infinite loop
-        if len(tmp_attrs) > 0: string += '\nattrs = ' + str(tmp_attrs)
+        # __repr__ instead of __str__ to avoid infinite loop
+        string = datatype + " = " + super().__repr__()
+        if len(tmp_attrs) > 0:
+            string += '\nattrs = ' + str(tmp_attrs)
         return string
 
-    def __repr__(self):
+    def __repr__(self) -> str:
         return str(self)
diff --git a/src/pygama/lgdo/table.py b/src/pygama/lgdo/table.py
index 0ce18db3e..8252e7696 100644
--- a/src/pygama/lgdo/table.py
+++ b/src/pygama/lgdo/table.py
@@ -1,114 +1,129 @@
-import numpy as np
+"""
+Implements a LEGEND Data Object representing a special struct of arrays of
+equal length and corresponding utilities.
+"""
+from __future__ import annotations
+
+import logging
+from typing import Any, Union
+
 import pandas as pd
 
-from .struct import Struct
-from .vectorofvectors import VectorOfVectors
+from pygama.lgdo.array import Array
+from pygama.lgdo.scalar import Scalar
+from pygama.lgdo.struct import Struct
+from pygama.lgdo.vectorofvectors import VectorOfVectors
+
+LGDO = Union[Scalar, Struct, Array, VectorOfVectors]
+
+log = logging.getLogger(__name__)
 
 
 class Table(Struct):
-    """
-    A special struct of array or subtable "columns" of equal length.
+    """A special struct of arrays or subtable columns of equal length.
 
-    Holds onto an internal read/write location "loc" that is useful in managing
-    table I/O using functions like push_row(), is_full(), and clear()
+    Holds onto an internal read/write location ``loc`` that is useful in
+    managing table I/O using functions like :meth:`push_row`, :meth:`is_full`,
+    and :meth:`clear`.
 
-    Note: if you write to a table and don't fill it up to its total size, be
-    sure to resize it before passing to data processing functions, as they will
-    call len(table) to access valid data, which returns table.size.
+    Note
+    ----
+    If you write to a table and don't fill it up to its total size, be sure to
+    resize it before passing to data processing functions, as they will call
+    :meth:`__len__` to access valid data, which returns the ``size`` attribute.
     """
     # TODO: overload getattr to allow access to fields as object attributes?
 
-
-    def __init__(self, size=None, col_dict={}, attrs={}):
-        """
+    def __init__(self, size: int = None, col_dict: dict[str, LGDO] = None,
+                 attrs: dict[str, Any] = None) -> None:
+        r"""
         Parameters
         ----------
-        size : int (optional)
-            Sets the number of rows in the table. Arrays in col_dict will be
-            resized to match size if both are not None.  If size is left as
-            None, the number of table rows is determined from the length of the
-            first array in col_dict. If neither is provided, a default length of
-            1024 is used.
-        col_dict : dict (optional)
-            Instantiate this table using the supplied named array-like lgdo's.
+        size
+            sets the number of rows in the table. :class:`~.Array`\ s in
+            `col_dict will be resized to match size if both are not ``None``.
+            If `size` is left as ``None``, the number of table rows is
+            determined from the length of the first array in `col_dict`. If
+            neither is provided, a default length of 1024 is used.
+        col_dict
+            instantiate this table using the supplied named array-like LGDO's.
             Note 1: no copy is performed, the objects are used directly.
-            Note 2: if size is not None, all arrays will be resized to match it
-            Note 3: if the arrays have different lengths, all will be resized to
-            match the length of the first array
-        attrs : dict (optional)
-            A set of user attributes to be carried along with this lgdo
+            Note 2: if `size` is not ``None``, all arrays will be resized to
+            match it.  Note 3: if the arrays have different lengths, all will
+            be resized to match the length of the first array.
+        attrs
+            A set of user attributes to be carried along with this LGDO.
 
         Notes
         -----
-        self.loc is initialized to 0
+        the :attr:`loc` attribute is initialized to 0.
         """
         super().__init__(obj_dict=col_dict, attrs=attrs)
 
         # if col_dict is not empty, set size according to it
         # if size is also supplied, resize all fields to match it
         # otherwise, warn if the supplied fields have varying size
-        if len(col_dict) > 0:
+        if col_dict is not None and len(col_dict) > 0:
             do_warn = True if size is None else False
             self.resize(new_size=size, do_warn=do_warn)
 
         # if no col_dict, just set the size (default to 1024)
-        else: self.size = size if size is not None else 1024
+        else:
+            self.size = size if size is not None else 1024
 
         # always start at loc=0
         self.loc = 0
 
-
-    def datatype_name(self):
-        """The name for this lgdo's datatype attribute"""
+    def datatype_name(self) -> str:
+        """The name for this LGDO's datatype attribute."""
         return 'table'
 
-
-    def __len__(self):
-        """Provides __len__ for this array-like class"""
+    def __len__(self) -> int:
+        """Provides ``__len__`` for this array-like class."""
         return self.size
 
-
-    def resize(self, new_size = None, do_warn = False):
+    def resize(self, new_size: int = None, do_warn: bool = False) -> None:
         # if new_size = None, use the size from the first field
         for field, obj in self.items():
-            if new_size is None: new_size = len(obj)
+            if new_size is None:
+                new_size = len(obj)
             elif len(obj) != new_size:
                 if do_warn:
-                    print('warning: resizing field', field,
-                          'with size', len(obj), '!=', new_size)
-                if isinstance(obj, Table): obj.resize(new_size, do_warn)
-                else: obj.resize(new_size)
+                    log.warning(f'warning: resizing field {field}',
+                                f'with size {len(obj)} != {new_size}')
+                if isinstance(obj, Table):
+                    obj.resize(new_size)
+                else:
+                    obj.resize(new_size)
         self.size = new_size
 
-
-    def push_row(self):
+    def push_row(self) -> None:
         self.loc += 1
 
-
-    def is_full(self):
+    def is_full(self) -> bool:
         return self.loc >= self.size
 
-
-    def clear(self):
+    def clear(self) -> None:
         self.loc = 0
 
+    def add_field(self, name: str, obj: LGDO,
+                  use_obj_size: bool = False, do_warn=True) -> None:
+        """Add a field (column) to the table.
 
-    def add_field(self, name, obj, use_obj_size=False, do_warn=True):
-        """ Add a field (column) to the table.
-
-        Use the name "field" here to match the terminology used in Struct.
+        Use the name "field" here to match the terminology used in
+        :class:`.Struct`.
 
         Parameters
         ----------
-        name : str
+        name
             the name for the field in the table.
-        obj : lgdo
-            the object to be added to the table
-        use_obj_size : bool (optional)
-            if true, resize the table to match the length of obj
-        do_warn : bool (optional)
-            print or don't print useful info. Passed to self.resize() when
-            use_obj_size is True
+        obj
+            the object to be added to the table.
+        use_obj_size
+            if ``True``, resize the table to match the length of `obj`.
+        do_warn
+            print or don't print useful info. Passed to :meth:`resize` when
+            `use_obj_size` is ``True``.
         """
         if not hasattr(obj, '__len__'):
             raise TypeError('cannot add field of type', type(obj).__name__)
@@ -118,63 +133,66 @@ def add_field(self, name, obj, use_obj_size=False, do_warn=True):
         # check / update sizes
         if self.size != len(obj):
             new_size = len(obj) if use_obj_size else self.size
-            self.resize(new_size=new_size, do_warn=do_warn)
+            self.resize(new_size=new_size)
 
-
-    def add_column(self, name, obj, use_obj_size=False, do_warn=True):
-        """Alias for add_field using table terminology 'column'"""
+    def add_column(self, name: str, obj: LGDO, use_obj_size: bool = False,
+                   do_warn: bool = True) -> None:
+        """Alias for :meth:`.add_field` using table terminology 'column'."""
         self.add_field(name, obj, use_obj_size=use_obj_size, do_warn=do_warn)
 
+    def join(self, other_table: Table, cols: list[str] = None,
+             do_warn: bool = True) -> None:
+        """Add the columns of another table to this table.
 
-    def join(self, other_table, cols=None, do_warn=True):
-        """Add the columns of other_table to this table
-
-        Note: following the join, both tables have access to other_table's
-        fields (but other_table doesn't have access to this table's fields). No
-        memory is allocated in this process. Other_table can go out of scope and
-        this table will retain access to the joined data.
+        Notes
+        -----
+        Following the join, both tables have access to `other_table`'s fields
+        (but `other_table` doesn't have access to this table's fields). No
+        memory is allocated in this process. `other_table` can go out of scope
+        and this table will retain access to the joined data.
 
         Parameters
         ----------
-        other_table : Table
-            the table whose columns are to be joined into this table
-        cols : list or None (optional)
-            a list of names of columns from other_table to be joined into this
-            table
-        do_warn : bool (optional)
-            set to False to turn off error warnings associated with mismatched
-            loc parameter or add_column() warnings
+        other_table
+            the table whose columns are to be joined into this table.
+        cols
+            a list of names of columns from `other_table` to be joined into
+            this table.
+        do_warn
+            set to ``False`` to turn off warnings associated with mismatched
+            `loc` parameter or :meth:`add_column` warnings.
         """
         if other_table.loc != self.loc and do_warn:
-            print(f'warning: other_table.loc ({other_table.loc}) != self.loc({self.loc})')
-        if cols is None: cols = other_table.keys()
+            log.warning(f"other_table.loc ({other_table.loc}) != self.loc({self.loc})")
+        if cols is None:
+            cols = other_table.keys()
         for name in cols:
             self.add_column(name, other_table[name], do_warn=do_warn)
 
+    def get_dataframe(self, cols: list[str] = None,
+                      copy: bool = False) -> pd.DataFrame:
+        """Get a :class:`pandas.DataFrame` from the data in the table.
 
-    def get_dataframe(self, cols=None, copy=False):
-        """Get a pandas dataframe from the data in the table.
-
-        Note: the requested data must be array-like, with the nda attribute.
+        Notes
+        -----
+        The requested data must be array-like, with the ``nda`` attribute.
 
         Parameters
         ----------
-        cols : list of strs (optional)
-            A list of column names specifying the subset of the table's columns
-            to be added to the dataframe
-        copy : bool (optional)
-            When true, the dataframe allocates new memory and copies data into
-            it. Otherwise, the raw nda's from the table are used directly.
-
-        Returns
-        -------
-        dataframe : pandas DataFrame
+        cols
+            a list of column names specifying the subset of the table's columns
+            to be added to the dataframe.
+        copy
+            When ``True``, the dataframe allocates new memory and copies data
+            into it. Otherwise, the raw ``nda``'s from the table are used directly.
         """
         df = pd.DataFrame(copy=copy)
-        if cols is None: cols = self.keys()
+        if cols is None:
+            cols = self.keys()
         for col in cols:
-            if not hasattr(self[col],'nda'):
-                print(f'column {col} does not have an nda, skipping...')
-            else: df[col] = self[col].nda
+            if not hasattr(self[col], 'nda'):
+                raise ValueError(f'column {col} does not have an nda')
+            else:
+                df[col] = self[col].nda
 
         return df
diff --git a/src/pygama/lgdo/vectorofvectors.py b/src/pygama/lgdo/vectorofvectors.py
index 77d130b4b..3f6d843cc 100644
--- a/src/pygama/lgdo/vectorofvectors.py
+++ b/src/pygama/lgdo/vectorofvectors.py
@@ -1,52 +1,64 @@
+"""
+Implements a LEGEND Data Object representing a variable-length array of
+variable-length arrays and corresponding utilities.
+"""
+from __future__ import annotations
+
+import logging
+from typing import Any
+
 import numpy as np
 
-from .array import Array
-from .lgdo_utils import *
+from pygama.lgdo.array import Array
+from pygama.lgdo.lgdo_utils import get_element_type
+
+log = logging.getLogger(__name__)
 
 
 class VectorOfVectors:
-    """
-    A variable-length array of variable-length arrays
+    """A variable-length array of variable-length arrays.
 
     For now only a 1D vector of 1D vectors is supported. Internal representation
-    is as two ndarrays, one to store the flattened data contiguosly and one to
-    store the cumulative sum of lengths of each vector.
+    is as two NumPy arrays, one to store the flattened data contiguosly and one
+    to store the cumulative sum of lengths of each vector.
     """
 
-
-    def __init__(self, flattened_data=None, cumulative_length=None, shape_guess=None, dtype=None, attrs={}):
+    def __init__(self, flattened_data: Array = None,
+                 cumulative_length: Array = None, shape_guess: tuple[int, int] = None,
+                 dtype: np.dtype = None, attrs: dict[str, Any] = None) -> None:
         """
         Parameters
         ----------
-        flattened_data : lgdo.Array (optional)
-            If not None, used as the internal memory array for flattened_data.
-            Otherwise, an internal flattened_data is allocated based on
-            shape_guess and dtype
-        cumulative_length : lgdo.Array (optional)
-            If not None, used as the internal memory array for
-            cumulative_length. Should be dtype uint32. If cumulative_length is
-            None, an internal cumulative_length is allocated based on the first
-            element of shape_guess
-        shape_guess : 2-tuple of ints (optional)
-            A numpy-format shape specification, required if either of
-            flattened_data or cumulative_length are not supplied.
-            The first element should not be a guess and sets the number of
-            vectors to be stored. The second element is a guess or approximation
-            of the typical length of a stored vector, used to set the initial
-            length of flattened_data if it was not supplied.
-        dtype : numpy dtype
-            Sets the type of data stored in flattened_data. Required if
-            flattened_data is None
-        attrs : dict (optional)
-            A set of user attributes to be carried along with this lgdo
+        flattened_data
+            If not ``None``, used as the internal memory array for
+            `flattened_data`.  Otherwise, an internal `flattened_data` is
+            allocated based on `shape_guess` and `dtype`.
+        cumulative_length
+            If not ``None``, used as the internal memory array for
+            `cumulative_length`. Should be `dtype` :any:`numpy.uint32`. If
+            `cumulative_length` is ``None``, an internal `cumulative_length` is
+            allocated based on the first element of `shape_guess`.
+        shape_guess
+            A NumPy-format shape specification, required if either of
+            `flattened_data` or `cumulative_length` are not supplied.  The
+            first element should not be a guess and sets the number of vectors
+            to be stored. The second element is a guess or approximation of the
+            typical length of a stored vector, used to set the initial length
+            of `flattened_data` if it was not supplied.
+        dtype
+            Sets the type of data stored in `flattened_data`. Required if
+            `flattened_data` is ``None``.
+        attrs
+            A set of user attributes to be carried along with this LGDO.
         """
         if cumulative_length is None:
             self.cumulative_length = Array(shape=(shape_guess[0],), dtype='uint32', fill_val=0)
-        else: self.cumulative_length = cumulative_length
+        else:
+            self.cumulative_length = cumulative_length
         if flattened_data is None:
             length = np.prod(shape_guess)
             if dtype is None:
-                print('VectorOfVectors: Warning: flattened_data and dtype cannot both be None!')
+                raise ValueError('flattened_data and dtype cannot both be None!')
             else:
                 self.flattened_data = Array(shape=(length,), dtype=dtype)
                 self.dtype = np.dtype(dtype)
@@ -57,46 +69,47 @@ def __init__(self, flattened_data=None, cumulative_length=None, shape_guess=None
             else:
                 self.dtype = np.dtype(dtype)
 
-        self.attrs = dict(attrs)
+        self.attrs = {} if attrs is None else dict(attrs)
+
         if 'datatype' in self.attrs:
             if self.attrs['datatype'] != self.form_datatype():
-                print('VectorOfVectors: Warning: datatype does not match dtype!')
-                print('datatype: ', self.attrs['datatype'])
-                print('form_datatype(): ', self.form_datatype())
-        else: self.attrs['datatype'] = self.form_datatype()
-
+                log.warning(
+                    f"datatype does not match dtype! "
+                    f"datatype: {self.attrs['datatype']}, "
+                    f"form_datatype(): {self.form_datatype()}")
+        else:
+            self.attrs['datatype'] = self.form_datatype()
 
-    def datatype_name(self):
-        """The name for this lgdo's datatype attribute"""
+    def datatype_name(self) -> str:
+        """The name for this LGDO's datatype attribute."""
         return 'array'
 
-
-    def __len__(self):
-        """Provides __len__ for this array-like class"""
+    def __len__(self) -> int:
+        """Provides ``__len__`` for this array-like class."""
         return len(self.cumulative_length)
 
-
-    def resize(self, new_size):
+    def resize(self, new_size: int) -> None:
         self.cumulative_length.resize(new_size)
 
-
-    def form_datatype(self):
-        """Return this lgdo's datatype attribute string"""
+    def form_datatype(self) -> str:
+        """Return this LGDO's datatype attribute string."""
         et = get_element_type(self)
         return 'array<1>{array<1>{' + et + '}}'
 
+    def set_vector(self, i_vec: int, nda: np.ndarray) -> None:
+        """Insert vector `nda` at location `i_vec`.
 
-    def set_vector(self, i_vec, nda):
-        """Insert vector nda at location i_vec.
-
-        self.flattened_data is doubled in length until nda can be appended to it.
+        Notes
+        -----
+        `flattened_data` is doubled in length until `nda` can be appended to
+        it.
         """
-        if i_vec<0 or i_vec>len(self.cumulative_length.nda)-1:
-            print('VectorOfVectors: Error: bad i_vec', i_vec)
-            return
+        if i_vec < 0 or i_vec > len(self.cumulative_length.nda)-1:
+            raise ValueError('bad i_vec', i_vec)
+
         if len(nda.shape) != 1:
-            print('VectorOfVectors: Error: nda had bad shape', nda.shape)
-            return
+            raise ValueError('nda had bad shape', nda.shape)
+
         start = 0 if i_vec == 0 else self.cumulative_length.nda[i_vec-1]
         end = start + len(nda)
         while end >= len(self.flattened_data.nda):
@@ -104,11 +117,11 @@ def set_vector(self, i_vec, nda):
         self.flattened_data.nda[start:end] = nda
         self.cumulative_length.nda[i_vec] = end
 
-    def __iter__(self):
+    def __iter__(self) -> VectorOfVectors:
         self.index = 0
         return self
 
-    def __next__(self):
+    def __next__(self) -> np.ndarray:
         try:
             if self.index == 0:
                 start = 0
@@ -122,16 +135,18 @@ def __next__(self):
         self.index += 1
         return result
 
-    def __getitem__(self, index):
+    def __getitem__(self, index: int) -> list:
         return list(self)[index]
 
-    def __str__(self):
-        """Convert to string (e.g. for printing)"""
+    def __str__(self) -> str:
+        """Convert to string (e.g. for printing)."""
         nda = list(self)
         string = str(nda)
         tmp_attrs = self.attrs.copy()
         tmp_attrs.pop('datatype')
-        if len(tmp_attrs) > 0: string += '\n' + str(tmp_attrs)
+        if len(tmp_attrs) > 0:
+            string += '\n' + str(tmp_attrs)
         return string
 
-    def __repr__(self): return str(self)
+    def __repr__(self) -> str:
+        return str(self)
diff --git a/src/pygama/lgdo/waveform_table.py b/src/pygama/lgdo/waveform_table.py
index 0f2d2fbcd..f268be004 100644
--- a/src/pygama/lgdo/waveform_table.py
+++ b/src/pygama/lgdo/waveform_table.py
@@ -1,69 +1,96 @@
+"""
+Implements a LEGEND Data Object representing a special
+:class:`~.lgdo.table.Table` to store blocks of one-dimensional time-series
+data.
+"""
+from __future__ import annotations
+
+import logging
+from typing import Any
+
 import numpy as np
 
-from .array import Array
-from .arrayofequalsizedarrays import ArrayOfEqualSizedArrays
-from .table import Table
-from .vectorofvectors import VectorOfVectors
+from pygama.lgdo.array import Array
+from pygama.lgdo.arrayofequalsizedarrays import ArrayOfEqualSizedArrays
+from pygama.lgdo.table import Table
+from pygama.lgdo.vectorofvectors import VectorOfVectors
+
+log = logging.getLogger(__name__)
 
 
 class WaveformTable(Table):
-    """
-    An lgdo for storing blocks of (1D) time-series data.
-
-    A WaveformTable is an lgdo Table with the 3 columns t0, dt, and values:
-
-    * t0[i] is a time offset (relative to a user-defined global reference) for
-      the sample in values[i][0]. Implemented as an lgdo Array with optional
-      attribute "units"
-    * dt[i] is the sampling period for the waveform at values[i]. Implemented as
-      an lgdo Array with optional attribute "units"
-    * values[i] is the i'th waveform in the table. Internally, the waveforms
-      "values" may be either an lgdo ArrayOfEqualSizedArrays<1,1> or as an lgdo
-      VectorOfVectors that supports waveforms of unequal length. Can optionally
-      be given a "units" attribute
-
-    Note that on-disk and in-memory versions could be different e.g. if a
-    compression routine is used.
+    r"""An LGDO for storing blocks of (1D) time-series data.
+
+    A :class:`WaveformTable` is an LGDO :class:`~.lgdo.table.Table` with the 3
+    columns ``t0``, ``dt``, and ``values``:
+
+    * ``t0[i]`` is a time offset (relative to a user-defined global reference)
+      for the sample in ``values[i][0]``. Implemented as an LGDO
+      :class:`.Array` with optional attribute ``units``.
+    * ``dt[i]`` is the sampling period for the waveform at ``values[i]``.
+      Implemented as an LGDO :class:`.Array` with optional attribute ``units``.
+    * ``values[i]`` is the ``i``'th waveform in the table. Internally, the
+      waveforms values may be either an LGDO :class:`.ArrayOfEqualSizedArrays`\
+      ``<1,1>`` or as an LGDO :class:`.VectorOfVectors` that supports
+      waveforms of unequal length. Can optionally be given a ``units``
+      attribute.
+
+    Note
+    ----
+    On-disk and in-memory versions could be different e.g. if a compression
+    routine is used.
     """
 
-    def __init__(self, size=None, t0=0, t0_units=None, dt=1, dt_units=None,
-                 values=None, values_units=None, wf_len=None, dtype=None, attrs={}):
-        """
+    def __init__(self,
+                 size: int = None,
+                 t0: float | Array | np.ndarray = 0,
+                 t0_units: str = None,
+                 dt: float | Array | np.ndarray = 1,
+                 dt_units: str = None,
+                 values: ArrayOfEqualSizedArrays | VectorOfVectors | np.ndarray = None,
+                 values_units: str = None,
+                 wf_len: int = None,
+                 dtype: np.dtype = None,
+                 attrs: dict[str, Any] = None) -> None:
+        r"""
         Parameters
         ----------
-        size : int or None (optional)
-            sets the number of rows in the table. If None, the size will be
-            determined from the first among t0, dt, or values to return a valid
-            length. If not None, t0, dt, and values will be resized as necessary
-            to match size. If size is None and t0, dt, and values are all
-            non-array-like, a default size of 1024 is used.
-        t0 : float, array-like, or lgdo.Array (optional)
-            t0 values to be used (or broadcast) to the t0 column. 0 by default.
-        t0_units : str or None
-            units for the t0 values. If not none and t0 is an lgdo Array,
-            overrides what's in t0.
-        dt : float, array-like, or lgdo.Array (optional)
-            dt values to be used (or broadcast) to the t0 column
-        dt_units : str or None
-            units for the dt values. If not none and dt is an lgdo Array,
-            overrides what's in dt.
-        values : 2D ndarray, lgdo VectorOfVectors, lgdo ArrayOfEqualSizedArrays, or None (optional)
-            The waveform data to be stored in the table. If None (the default) a
-            block of data is prepared based on the wf_len and dtype arguments.
-        values_units : str or None
-            units for the waveform values. If not none and values is an lgdo
-            Array, overrides what's in values
-        wf_len : int or None (optional)
-            The length of the waveforms in each entry of a table. If None (the
-            default), unequal lengths are assumed and VectorOfVectors is used
-            for the values column. Ignored if values is a 2D ndarray, in which
-            case values.shape[1] is used
-        dtype : numpy dtype or None (optional)
-            The numpy dtype of the waveform data. If values is not None, this
-            argument is ignored. If both values and dtype are None, np.float64
-            is used.
-        attrs : dict (optional)
-            A set of user attributes to be carried along with this lgdo
+        size
+            sets the number of rows in the table. If ``None``, the size will be
+            determined from the first among `t0`, `dt`, or `values` to return a
+            valid length. If not ``None``, `t0`, `dt`, and values will be
+            resized as necessary to match `size`. If `size` is ``None`` and
+            `t0`, `dt`, and `values` are all non-array-like, a default size of
+            1024 is used.
+        t0
+            :math:`t_0` values to be used (or broadcast) to the `t0` column.
+        t0_units
+            units for the :math:`t_0` values. If not ``None`` and `t0` is an
+            LGDO :class:`.Array`, overrides what's in `t0`.
+        dt
+            :math:`\delta t` values (sampling period) to be used (or
+            broadcasted) to the `t0` column.
+        dt_units
+            units for the `dt` values. If not ``None`` and `dt` is an LGDO
+            :class:`.Array`, overrides what's in `dt`.
+        values
+            The waveform data to be stored in the table. If ``None`` a block of
+            data is prepared based on the `wf_len` and `dtype` arguments.
+        values_units
+            units for the waveform values. If not ``None`` and `values` is an
+            LGDO :class:`.Array`, overrides what's in `values`.
+        wf_len
+            The length of the waveforms in each entry of a table. If ``None``
+            (the default), unequal lengths are assumed and
+            :class:`.VectorOfVectors` is used for the `values` column. Ignored
+            if `values` is a 2D ndarray, in which case ``values.shape[1]`` is
+            used.
+        dtype
+            The NumPy :class:`numpy.dtype` of the waveform data. If `values` is
+            not ``None``, this argument is ignored. If both `values` and
+            `dtype` are ``None``, :class:`numpy.float64` is used.
+        attrs
+            A set of user attributes to be carried along with this LGDO.
         """
 
         if size is None:
@@ -92,7 +119,7 @@ def __init__(self, size=None, t0=0, t0_units=None, dt=1, dt_units=None,
             if isinstance(values, np.ndarray):
                 try:
                     wf_len = values.shape[1]
-                except:
+                except Exception:
                     wf_len = None
             if wf_len is None: # VectorOfVectors
                 shape_guess = (size, 100)
@@ -122,50 +149,57 @@ def __init__(self, size=None, t0=0, t0_units=None, dt=1, dt_units=None,
         super().__init__(size=size, col_dict=col_dict, attrs=attrs)
 
     @property
-    def values(self):
+    def values(self) -> ArrayOfEqualSizedArrays | VectorOfVectors:
         return self['values']
 
     @property
-    def values_units(self):
+    def values_units(self) -> str:
         return self.values.attrs.get('units', None)
+
     @values_units.setter
-    def values_units(self, units):
+    def values_units(self, units) -> None:
         self.values.attrs['units'] = f'{units}'
 
     @property
-    def wf_len(self):
-        if isinstance(self.values, VectorOfVectors): return -1
+    def wf_len(self) -> int:
+        if isinstance(self.values, VectorOfVectors):
+            return -1
         return self.values.nda.shape[1]
+
     @wf_len.setter
-    def wf_len(self, wf_len):
-        if isinstance(self.values, VectorOfVectors): return
+    def wf_len(self, wf_len) -> None:
+        if isinstance(self.values, VectorOfVectors):
+            return
         shape = self.values.nda.shape
         shape = (shape[0], wf_len)
         self.values.nda.resize(shape, refcheck=True)
-    def resize_wf_len(self, new_len):
-        ''' alias for wf_len.setter
-        (for when we want to make it clear in the code that memory is being reallocated)
-        '''
+
+    def resize_wf_len(self, new_len: int) -> None:
+        """Alias for `wf_len.setter`, for when we want to make it clear in
+        the code that memory is being reallocated.
+        """
         self.wf_len = new_len
 
     @property
-    def t0(self):
+    def t0(self) -> Array:
         return self['t0']
 
     @property
-    def t0_units(self):
+    def t0_units(self) -> str:
         return self.t0.attrs.get('units', None)
+
     @t0_units.setter
-    def t0_units(self, units):
+    def t0_units(self, units: str) -> None:
         self.t0.attrs['units'] = f'{units}'
 
     @property
-    def dt(self):
+    def dt(self) -> Array:
         return self['dt']
 
     @property
-    def dt_units(self):
+    def dt_units(self) -> str:
         return self.dt.attrs.get('units', None)
+
     @dt_units.setter
-    def dt_units(self, units):
+    def dt_units(self, units: str) -> None:
         self.dt.attrs['units'] = f'{units}'
diff --git a/src/pygama/raw/__init__.py b/src/pygama/raw/__init__.py
index 91fa78273..2e5baa6ec 100644
--- a/src/pygama/raw/__init__.py
+++ b/src/pygama/raw/__init__.py
@@ -1,5 +1,5 @@
 """
-The primary function for data conversion into raw LH5 files is
+The primary function for data conversion into raw-tier LH5 files is
 :func:`.build_raw`. This is a one-to many function: one input DAQ file can
 generate one or more output raw files. Control of which data ends up in which
 files, and in which HDF5 groups inside of each file, is controlled via
diff --git a/src/pygama/raw/build_raw.py b/src/pygama/raw/build_raw.py
index b5a8667a2..d7324a8a6 100644
--- a/src/pygama/raw/build_raw.py
+++ b/src/pygama/raw/build_raw.py
@@ -30,38 +30,38 @@ def build_raw(in_stream: int, in_stream_type: str = None,
 
     Parameters
     ----------
-    in_stream : str
-        The name of the input stream to be converted. Typically a filename,
+    in_stream
+        the name of the input stream to be converted. Typically a filename,
         including path. Can use environment variables. Some streamers may be
         able to (eventually) accept e.g. streaming over a port as an input.
 
-    in_stream_type : {None, 'ORCA', 'FlashCam', 'LlamaDaq', 'Compass', 'MGDO'}
-        Type of stream used to write the input file.
+    in_stream_type : 'ORCA', 'FlashCam', 'LlamaDaq', 'Compass' or 'MGDO'
+        type of stream used to write the input file.
 
-    out_spec : str or dict or RawBufferLibrary or None
+    out_spec
         Specification for the output stream.
 
-        - If None, uses ``{in_stream}.lh5`` as the output filename.
-        - If a str not ending in ``.json``, interpreted as the output filename.
-        - If a str ending in ``.json``, interpreted as a filename containing
-          json-shorthand for the output specification (see :mod:`.raw_buffer`)
-        - If a JSON dict, should be a dict loaded from the json shorthand
+        - if None, uses ``{in_stream}.lh5`` as the output filename.
+        - if a str not ending in ``.json``, interpreted as the output filename.
+        - if a str ending in ``.json``, interpreted as a filename containing
+          json-shorthand for the output specification (see :mod:`.raw_buffer`).
+        - if a JSON dict, should be a dict loaded from the json shorthand
           notation for RawBufferLibraries (see :mod:`.raw_buffer`), which is
-          then used to build a :class:`.RawBufferLibrary`
-        - If a :class:`.RawBufferLibrary`, the mapping of data to output file /
+          then used to build a :class:`.RawBufferLibrary`.
+        - if a :class:`.RawBufferLibrary`, the mapping of data to output file /
           group is taken from that.
 
-    buffer_size : int
-        Default size to use for data buffering
+    buffer_size
+        default size to use for data buffering.
 
-    n_max : int
-        Maximum number of rows of data to process from the input file
+    n_max
+        maximum number of rows of data to process from the input file.
 
-    overwrite : bool
-        Sets whether to overwrite the output file(s) if it (they) already exist
+    overwrite
+        sets whether to overwrite the output file(s) if it (they) already exist.
 
-    **kwargs : kwargs
-        Sent to :class:`.RawBufferLibrary` generation as ``kw_dict``
+    **kwargs
+        sent to :class:`.RawBufferLibrary` generation as `kw_dict`.
     """
 
     # convert any environment variables in in_stream so that we can check for readability
diff --git a/src/pygama/raw/data_decoder.py b/src/pygama/raw/data_decoder.py
index 104c07571..b2395f362 100644
--- a/src/pygama/raw/data_decoder.py
+++ b/src/pygama/raw/data_decoder.py
@@ -4,27 +4,32 @@
 from __future__ import annotations
 
 from abc import ABC
+from typing import Union
 
 import numpy as np
 
 from pygama import lgdo
+from pygama.lgdo.lh5_store import LH5Store
+from pygama.raw.raw_buffer import RawBuffer
+
+LGDO = Union[lgdo.Scalar, lgdo.Struct, lgdo.Array, lgdo.VectorOfVectors]
 
 
 class DataDecoder(ABC):
-    r"""Decodes packets from a data stream
+    r"""Decodes packets from a data stream.
 
     Most decoders will repeatedly decode the same set of values from each
     packet.  The values that get decoded need to be described by a dict called
-    ``decoded_values`` that helps determine how to set up the buffers and write
+    `decoded_values` that helps determine how to set up the buffers and write
     them to file. :class:`~.lgdo.table.Table`\ s are made whose columns
-    correspond to the elements of decoded_values, and packet data gets pushed
+    correspond to the elements of `decoded_values`, and packet data gets pushed
     to the end of the table one row at a time. See
     :class:`~.fc.fc_event_decoder.FCEventDecoder` or
     :class:`~.orca.orca_digitizers.ORCAStruck3302` for an example.
 
     Some decoders (like for file headers) do not need to push to a table, so they
-    do not need ``decoded_values``. Such classes should still derive from
-    :class:`DataDecoder` and define how data gets formatted into LGDO's
+    do not need `decoded_values`. Such classes should still derive from
+    :class:`DataDecoder` and define how data gets formatted into LGDO's.
 
     Subclasses should define a method for decoding data to a buffer like
     ``decode_packet(packet, raw_buffer_list, packet_id)``.
@@ -35,7 +40,7 @@ class DataDecoder(ABC):
     call :meth:`.put_in_garbage`. User should set up an enum or bitbank of garbage
     codes to be stored along with the garbage packets.
     """
-    def __init__(self, garbage_length: int = 256, packet_size_guess: int = 1024):
+    def __init__(self, garbage_length: int = 256, packet_size_guess: int = 1024) -> None:
         self.garbage_table = lgdo.Table(garbage_length)
         shape_guess = (garbage_length, packet_size_guess)
         self.garbage_table.add_field('packets',
@@ -47,49 +52,51 @@ def __init__(self, garbage_length: int = 256, packet_size_guess: int = 1024):
         self.garbage_table.add_field('garbage_code',
                                      lgdo.Array(shape=garbage_length, dtype='uint32'))
 
-    def get_key_list(self) -> list:
+    def get_key_list(self) -> list[int | str]:
         """Overload with list of keys for this decoder, e.g. ``return
         range(n_channels)``.  The default version works for decoders with
-        single / no keys"""
+        single / no keys."""
         return [None]
 
-    def get_decoded_values(self, key: int | str = None):
+    def get_decoded_values(self, key: int | str = None) -> dict:
         """Get decoded values (optionally for a given key, typically a channel).
 
-        .. note::
-            Must overload for your decoder if it has key-specific decoded
-            values. Must also implement ``key = None`` returns a "default"
-            ``decoded_values``. Otherwise, just returns
-            :attr:`self.decoded_values`, which should be defined in the
-            constructor.
+        Notes
+        -----
+        Must overload for your decoder if it has key-specific decoded values.
+        Must also implement ``key = None`` returns a "default"
+        `decoded_values`. Otherwise, just returns :attr:`self.decoded_values`,
+        which should be defined in the constructor.
         """
         if key is None:
             return self.decoded_values if hasattr(self, 'decoded_values') else None
 
-        raise NotImplementedError("you need to implement key-specific get_decoded_values for", type(self).__name__)
+        raise NotImplementedError(
+            "you need to implement key-specific get_decoded_values for",
+            type(self).__name__)
 
-    def make_lgdo(self, key: int | str = None, size: int = None):
+    def make_lgdo(self, key: int | str = None, size: int = None) -> LGDO:
         """Make an LGDO for this :class:`DataDecoder` to fill.
 
         This default version of this function allocates a
-        :class:`~.lgdo.table.Table` using the ``decoded_values`` for key. If a
+        :class:`~.lgdo.table.Table` using the `decoded_values` for key. If a
         different type of LGDO object is required for this decoder, overload
         this function.
 
         Parameters
         ----------
-        key : int or str or else
-            used to initialize the LGDO for a particular key (e.g.  to have
+        key
+            used to initialize the LGDO for a particular `key` (e.g.  to have
             different trace lengths for different channels of a piece of
             hardware). Leave as ``None`` if such specialization is not
-            necessary
-        size : int
-            the size to be allocated for the LGDO, if applicable
+            necessary.
+        size
+            the size to be allocated for the LGDO, if applicable.
 
         Returns
         -------
-        data_obj : lgdo
-            the newly allocated LGDO
+        data_obj
+            the newly allocated LGDO.
         """
 
         if not hasattr(self, 'decoded_values'):
@@ -134,7 +141,7 @@ def make_lgdo(self, key: int | str = None, size: int = None):
                 continue
 
             # Parse datatype for remaining lgdos
-            datatype, shape, elements = lgdo.parse_datatype(datatype)
+            datatype, shape, elements = lgdo.lgdo_utils.parse_datatype(datatype)
 
             # ArrayOfEqualSizedArrays
             if datatype == 'array_of_equalsized_arrays':
@@ -184,6 +191,6 @@ def get_max_rows_in_packet(self) -> int:
         return 1
 
     def buffer_is_full(self, rb: RawBuffer) -> bool:
-        """Returns whether the buffer is too full to read in another packet"""
+        """Returns whether the buffer is too full to read in another packet."""
 
         return len(rb.lgdo) - rb.loc < self.get_max_rows_in_packet()
diff --git a/src/pygama/raw/data_streamer.py b/src/pygama/raw/data_streamer.py
index e189d3f57..445802dc3 100644
--- a/src/pygama/raw/data_streamer.py
+++ b/src/pygama/raw/data_streamer.py
@@ -26,7 +26,7 @@ class DataStreamer(ABC):
     see below.
     """
 
-    def __init__(self):
+    def __init__(self) -> None:
         self.rb_lib = None
         self.chunk_mode = None
         self.n_bytes_read = 0
@@ -36,40 +36,42 @@ def __init__(self):
     def open_stream(self, stream_name: str, rb_lib: RawBufferLibrary = None,
                     buffer_size: int = 8192, chunk_mode: str = 'any_full',
                     out_stream: str = '') -> tuple[list[RawBuffer], int]:
-        """Open and initialize a data stream.
+        r"""Open and initialize a data stream.
 
         Open the stream, read in the header, set up the buffers.
 
         Call ``super().initialize([args])`` from derived class after loading
         header info to run this default version that sets up buffers in
-        ``rb_lib`` using the stream's decoders.
+        `rb_lib` using the stream's decoders.
 
-        .. note::
-            this default version has no actual return value! You must overload
-            this function, set :attr:`self.n_bytes_read` to the header packet
-            size, and return the header data.
+        Notes
+        -----
+        this default version has no actual return value! You must overload this
+        function, set :attr:`self.n_bytes_read` to the header packet size, and
+        return the header data.
 
         Parameters
         ----------
-        stream_name : str
-            typically a filename or e.g. a port for streaming
-        rb_lib : RawBufferLibrary
-            A library of buffers for readout from the data stream. rb_lib will
-            have its lgdo's initialized during this function
-        buffer_size : int
-            length of buffers to be read out in read_chunk (for buffers with
-            variable length)
-        chunk_mode : 'any_full', 'only_full', or 'single_packet'
-            sets the mode use for read_chunk
-        out_stream : str
-            optional name of output stream for default rb_lib generation
+        stream_name
+            typically a filename or e.g. a port for streaming.
+        rb_lib
+            a library of buffers for readout from the data stream. `rb_lib`
+            will have its LGDO's initialized during this function.
+        buffer_size
+            length of buffers to be read out in :meth:`read_chunk` (for buffers
+            with variable length).
+        chunk_mode : 'any_full', 'only_full' or 'single_packet'
+            sets the mode use for :meth:`read_chunk`.
+        out_stream
+            optional name of output stream for default `rb_lib` generation.
 
         Returns
         -------
-        header_data: list(RawBuffer), int
-            header_data is a list of RawBuffer's containing any file header
-            data, ready for writing to file or further processing. It's not a
-            RawBufferList since the buffers may have a different format
+        header_data
+            header_data is a list of :class:`.RawBuffer`\ 's containing any
+            file header data, ready for writing to file or further processing.
+            It's not a :class:`.RawBufferList` since the buffers may have a
+            different format.
         """
         # call super().initialize([args]) to run this default code
         # after loading header info, then follow it with the return call.
@@ -130,7 +132,7 @@ def open_stream(self, stream_name: str, rb_lib: RawBufferLibrary = None,
             if dec_name not in dec_names:
                 log.warning("no decoder named {dec_name} requested by rb_lib")
 
-    def close_stream(self):
+    def close_stream(self) -> None:
         """Close this data stream.
 
         .. note::
@@ -148,8 +150,8 @@ def read_packet(self) -> bool:
 
         Returns
         -------
-        still_has_data : bool
-            Returns true while there is still data to read
+        still_has_data
+            returns `True` while there is still data to read.
         """
         return True
 
@@ -158,18 +160,17 @@ def read_chunk(self, chunk_mode_override: str = None, rp_max: int = 1000000,
         """Reads a chunk of data into raw buffers.
 
         Reads packets until at least one buffer is too full to perform another
-        read.
-
-        .. note::
-            user is responsible for resetting / clearing the raw buffers prior
-            to calling :meth:`.read_chunk` again.
+        read. Default version just calls :meth:`.read_packet` over and over.
+        Overload as necessary.
 
-        Default version just calls :meth:`.read_packet` over and over. Overload
-        as necessary.
+        Notes
+        -----
+        user is responsible for resetting / clearing the raw buffers prior to
+        calling :meth:`.read_chunk` again.
 
         Parameters
         ----------
-        chunk_mode_override : 'any_full', 'only_full', 'single_packet', or None
+        chunk_mode_override : 'any_full', 'only_full' or 'single_packet'
             - ``None`` : do not override self.chunk_mode
             - ``any_full`` : returns all raw buffers with data as soon as any one
               buffer gets full
@@ -177,14 +178,14 @@ def read_chunk(self, chunk_mode_override: str = None, rp_max: int = 1000000,
               nearly full) during the read. This minimizes the number of write calls.
             - ``single_packet`` : returns all raw buffers with data after a single
               read is performed. This is useful for streaming data out as soon
-              as it is read in (e.g. for diagnostics or in-line analysis)
-        rp_max : int
+              as it is read in (e.g. for diagnostics or in-line analysis).
+        rp_max
             maximum number of packets to read before returning anyway, even if
-            one of the other conditions is not met
-        clear_full_buffers : bool
-            automatically clear any buffers that report themselves as being full
-            prior to reading the chunk. Set to False if clearing manually for a
-            minor speed-up
+            one of the other conditions is not met.
+        clear_full_buffers
+            automatically clear any buffers that report themselves as being
+            full prior to reading the chunk. Set to `False` if clearing
+            manually for a minor speed-up.
 
         Returns
         -------
@@ -227,8 +228,9 @@ def read_chunk(self, chunk_mode_override: str = None, rp_max: int = 1000000,
     def get_decoder_list(self) -> list:
         """Returns a list of decoder objects for this data stream.
 
-        .. note::
-            Needs to be overloaded. Gets called during :meth:`.open_stream`.
+        Notes
+        -----
+        Needs to be overloaded. Gets called during :meth:`.open_stream`.
         """
         return []
 
@@ -238,7 +240,7 @@ def build_default_rb_lib(self, out_stream: str = '') -> RawBufferLibrary:
 
         A :class:`.RawBufferList` containing a single :class:`.RawBuffer` is
         built for each decoder name returned by :meth:`.get_decoder_list`. Each
-        buffer's ``out_name`` is set to the decoder name. The lgdo's do not get
+        buffer's `out_name` is set to the decoder name. The LGDO's do not get
         initialized.
         """
         rb_lib = RawBufferLibrary()
diff --git a/src/pygama/raw/fc/fc_config_decoder.py b/src/pygama/raw/fc/fc_config_decoder.py
index ca2972fa4..1dd1d5c71 100644
--- a/src/pygama/raw/fc/fc_config_decoder.py
+++ b/src/pygama/raw/fc/fc_config_decoder.py
@@ -1,9 +1,13 @@
+from __future__ import annotations
+
 import logging
 
+import fcutils
 import numpy as np
 
 from pygama import lgdo
 from pygama.raw.data_decoder import DataDecoder
+from pygama.raw.raw_buffer import RawBuffer
 
 log = logging.getLogger(__name__)
 
@@ -11,10 +15,11 @@
 class FCConfigDecoder(DataDecoder):
     """Decode FlashCam config data.
 
-    .. note::
-        Derives from :class:`~.raw.data_decoder.DataDecoder` in anticipation of
-        possible future functionality. Currently the base class interface is
-        not used.
+    Note
+    ----
+    Derives from :class:`~.raw.data_decoder.DataDecoder` in anticipation of
+    possible future functionality. Currently the base class interface is not
+    used.
 
     Example
     -------
@@ -26,11 +31,11 @@ class FCConfigDecoder(DataDecoder):
     >>> type(config)
     pygama.lgdo.struct.Struct
     """
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, **kwargs) -> None:
         super().__init__(*args, **kwargs)
         self.config = lgdo.Struct()
 
-    def decode_config(self, fcio):
+    def decode_config(self, fcio: fcutils.fcio) -> lgdo.Struct:
         config_names = [
             'nsamples', # samples per channel
             'nadcs', # number of adc channels
@@ -52,7 +57,8 @@ def decode_config(self, fcio):
             self.config.add_field(name, lgdo.Scalar(value))
         return self.config
 
-    def make_lgdo(self, key=None, size=None):
+    def make_lgdo(self, key: int = None, size: int = None) -> lgdo.Struct:
         return self.config
 
-    def buffer_is_full(self, rb): return rb.loc > 0
+    def buffer_is_full(self, rb: RawBuffer) -> bool:
+        return rb.loc > 0
diff --git a/src/pygama/raw/fc/fc_event_decoder.py b/src/pygama/raw/fc/fc_event_decoder.py
index 899a24383..6e0fd94e9 100644
--- a/src/pygama/raw/fc/fc_event_decoder.py
+++ b/src/pygama/raw/fc/fc_event_decoder.py
@@ -1,6 +1,12 @@
+from __future__ import annotations
+
 import copy
 import logging
+from typing import Any
+
+import fcutils
 
+from pygama import lgdo
 from pygama.raw.data_decoder import DataDecoder
 
 log = logging.getLogger(__name__)
@@ -127,7 +133,7 @@ class FCEventDecoder(DataDecoder):
     """
     Decode FlashCam digitizer event data.
     """
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, **kwargs) -> None:
         # these are read for every event (decode_event)
         self.decoded_values = copy.deepcopy(fc_decoded_values)
         super().__init__(*args, **kwargs)
@@ -135,42 +141,45 @@ def __init__(self, *args, **kwargs):
         self.fc_config = None
         self.max_numtraces = 1
 
-    def get_key_list(self): return range(self.fc_config['nadcs'].value)
+    def get_key_list(self) -> range:
+        return range(self.fc_config['nadcs'].value)
 
-    def get_decoded_values(self, channel=None):
+    def get_decoded_values(self, channel: int = None) -> dict[str, dict[str, Any]]:
         # FC uses the same values for all channels
         return self.decoded_values
 
-    def set_file_config(self, fc_config):
+    def set_file_config(self, fc_config: lgdo.Struct) -> None:
         """Access ``FCIOConfig`` members once when each file is opened.
 
         Parameters
         ----------
-        fc_config : lgdo.Struct
-            extracted via :meth:`~.fc_config_decoder.FCConfigDecoder.decode_config`
+        fc_config
+            extracted via :meth:`~.fc_config_decoder.FCConfigDecoder.decode_config`.
         """
         self.fc_config = fc_config
         self.decoded_values['waveform']['wf_len'] = self.fc_config['nsamples'].value
 
-    def decode_packet(self, fcio, evt_rbkd, packet_id):
+    def decode_packet(self, fcio: fcutils.fcio,
+                      evt_rbkd: lgdo.Table | dict[int, lgdo.Table],
+                      packet_id: int) -> bool:
         """Access ``FCIOEvent`` members for each event in the DAQ file.
 
         Parameters
         ----------
-        fcio : fcutils.fcio
-            The interface to the fcio data. Enters this function after a call
-            to ``fcio.get_record()`` so that data for ``packet_id`` ready to be
-            read out
-        evt_rbkd : lgdo.Table or dict
+        fcio
+            The interface to the ``fcio`` data. Enters this function after a
+            call to ``fcio.get_record()`` so that data for `packet_id` ready to
+            be read out.
+        evt_rbkd
             A single table for reading out all data, or a dictionary of tables
-            keyed by channel number
-        packet_id : int
-            The index of the packet in the fcio stream. Incremented by
-            :class:`~.raw.fc.fc_streamer.FCStreamer`
+            keyed by channel number.
+        packet_id
+            The index of the packet in the `fcio` stream. Incremented by
+            :class:`~.raw.fc.fc_streamer.FCStreamer`.
 
         Returns
         -------
-        n_bytes : int
+        n_bytes
             (estimated) number of bytes in the packet that was just decoded.
         """
         if fcio.numtraces > self.max_numtraces:
diff --git a/src/pygama/raw/fc/fc_status_decoder.py b/src/pygama/raw/fc/fc_status_decoder.py
index b191a24c0..15176934e 100644
--- a/src/pygama/raw/fc/fc_status_decoder.py
+++ b/src/pygama/raw/fc/fc_status_decoder.py
@@ -1,11 +1,15 @@
-from pygama.raw.data_decoder import DataDecoder
+from __future__ import annotations
+
+import fcutils
+
+from pygama.raw.data_decoder import DataDecoder, RawBuffer
 
 
 class FCStatusDecoder(DataDecoder):
     """
     Decode FlashCam digitizer status data.
     """
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, **kwargs) -> None:
         self.decoded_values = {
             'status': { # 0: Errors occurred, 1: no errors
               'dtype': 'int32',
@@ -60,7 +64,7 @@ def __init__(self, *args, **kwargs):
         }
         super().__init__(*args, **kwargs)
 
-    def decode_packet(self, fcio, status_rb, packet_id):
+    def decode_packet(self, fcio: fcutils.fcio, status_rb: RawBuffer, packet_id: int) -> bool:
         # aliases for brevity
         tbl = status_rb.lgdo
         ii = status_rb.loc
diff --git a/src/pygama/raw/fc/fc_streamer.py b/src/pygama/raw/fc/fc_streamer.py
index 95c5f95ea..c8eafdbbe 100644
--- a/src/pygama/raw/fc/fc_streamer.py
+++ b/src/pygama/raw/fc/fc_streamer.py
@@ -1,12 +1,15 @@
+from __future__ import annotations
+
 import logging
 
 import fcutils
 
+from pygama.raw.data_decoder import DataDecoder
 from pygama.raw.data_streamer import DataStreamer
 from pygama.raw.fc.fc_config_decoder import FCConfigDecoder
 from pygama.raw.fc.fc_event_decoder import FCEventDecoder
 from pygama.raw.fc.fc_status_decoder import FCStatusDecoder
-from pygama.raw.raw_buffer import RawBuffer
+from pygama.raw.raw_buffer import RawBuffer, RawBufferLibrary
 
 log = logging.getLogger(__name__)
 
@@ -16,7 +19,7 @@ class FCStreamer(DataStreamer):
     Decode FlashCam data, using the ``fcutils`` package to handle file access,
     and the FlashCam data decoders to save the results and write to output.
     """
-    def __init__(self):
+    def __init__(self) -> None:
         super().__init__()
         self.fcio = None
         self.config_decoder = FCConfigDecoder()
@@ -26,19 +29,16 @@ def __init__(self):
         self.event_rbkd = None
         self.status_rb = None
 
-
-
-    def get_decoder_list(self):
+    def get_decoder_list(self) -> list[DataDecoder]:
         dec_list = []
         dec_list.append(self.config_decoder)
         dec_list.append(self.status_decoder)
         dec_list.append(self.event_decoder)
         return dec_list
 
-
-
-    def open_stream(self, fcio_filename, rb_lib=None, buffer_size=8192,
-                    chunk_mode='any_full', out_stream=''):
+    def open_stream(self, fcio_filename: str, rb_lib: RawBufferLibrary = None,
+                    buffer_size: int = 8192, chunk_mode: str = 'any_full',
+                    out_stream: str = '') -> list[RawBuffer]:
         """Initialize the FlashCam data stream.
 
         Refer to the documentation for
@@ -47,9 +47,9 @@ def open_stream(self, fcio_filename, rb_lib=None, buffer_size=8192,
 
         Returns
         -------
-        header_data : list(RawBuffer)
+        header_data
             a list of length 1 containing the raw buffer holding the
-            :class:`~.fc_config_decoder.FCConfig` table
+            :class:`~.fc_config_decoder.FCConfig` table.
         """
         self.fcio = fcutils.fcio(fcio_filename)
         self.n_bytes_read = 0
@@ -86,11 +86,10 @@ def open_stream(self, fcio_filename, rb_lib=None, buffer_size=8192,
         rb.loc = 1 # we have filled this buffer
         return [rb]
 
-
-    def close_stream(self):
+    def close_stream(self) -> None:
         self.fcio = None # should cause close file in fcio.__dealloc__
 
-    def read_packet(self):
+    def read_packet(self) -> bool:
         rc = self.fcio.get_record()
         if rc == 0: return False # no more data
 
diff --git a/src/pygama/raw/raw_buffer.py b/src/pygama/raw/raw_buffer.py
index 286677e1e..5f70c6683 100644
--- a/src/pygama/raw/raw_buffer.py
+++ b/src/pygama/raw/raw_buffer.py
@@ -1,22 +1,23 @@
 r"""
-Manage data buffering for raw data conversion.
-
-This module manages LGDO buffers and their corresponding output streams. Allows
-for one-to-many mapping of input sreams to output streams.
+Utilities to manage data buffering for raw data conversion. This module manages
+LGDO buffers and their corresponding output streams. Allows for one-to-many
+mapping of input streams to output streams.
 
 Primary Classes
 ---------------
 :class:`.RawBuffer`: an LGDO (e.g. a table) along with buffer metadata, such as the
 current write location, the list of keys (e.g. channels) that write to it, the
-output stream it is associated with (if any), etc. Each data_decoder is
-associated with a :class:`.RawBuffer` of a particular format.
+output stream it is associated with (if any), etc. Each
+:class:`~.raw.data_decoder.DataDecoder` is associated with a
+:class:`.RawBuffer` of a particular format.
 
 :class:`.RawBufferList`: a collection of :class:`RawBuffer` with LGDO's that
-all have the same structure (same type, same fields, etc). A data_decoder will
-write its output to a :class:`.RawBufferList`.
+all have the same structure (same type, same fields, etc). A
+:class:`~.raw.data_decoder.DataDecoder` will write its output to a
+:class:`.RawBufferList`.
 
-:class:`.RawBufferLibrary`: a collection (dict) of :class:`RawBufferList`\ s,
-e.g. one for each data_decoder. Keyed by the decoder name.
+:class:`.RawBufferLibrary`: a dictionary of :class:`RawBufferList`\ s, e.g. one
+for each :class:`~.raw.data_decoder.DataDecoder`. Keyed by the decoder name.
 
 :class:`.RawBuffer` supports a JSON short-hand notation, see
 :meth:`.RawBufferLibrary.set_from_json_dict` for full specification.
@@ -61,41 +62,48 @@
 from __future__ import annotations
 
 import os
+from typing import Union
 
 from pygama import lgdo
+from pygama.lgdo.lh5_store import LH5Store
+
+LGDO = Union[lgdo.Scalar, lgdo.Struct, lgdo.Array, lgdo.VectorOfVectors]
 
 
 class RawBuffer:
-    """Base class to represent a buffer of raw data.
+    r"""Base class to represent a buffer of raw data.
 
-    A :class:`RawBuffer` is in essence a an lgdo object (typically a
+    A :class:`RawBuffer` is in essence a an LGDO object (typically a
     :class:`~.lgdo.table.Table`) to which decoded data will be written, along
     with some meta-data distinguishing what data goes into it, and where the
-    lgdo gets written out. Also holds on to the current location in the buffer
+    LGDO gets written out. Also holds on to the current location in the buffer
     for writing.
 
     Attributes
     ----------
-    lgdo : lgdo
-        the lgdo used as the actual buffer. Typically a table. Set to None upon
-        creation so that the user or a decoder can initialize it later.
-    key_list : list
+    lgdo
+        the LGDO used as the actual buffer. Typically a
+        :class:`~.lgdo.table.Table`. Set to ``None`` upon creation so that the
+        user or a decoder can initialize it later.
+    key_list
         a list of keys (e.g. channel numbers) identifying data to be written
         into this buffer. The key scheme is specific to the decoder with which
-        the RawBuffer is associated. This is called "key_list" instead of "keys"
-        to avoid confusion with the dict function "keys()", i.e. raw_buffer.lgdo.keys()
-    out_stream : str (optional)
-        the output stream to which the raw_buffer's lgdo should be sent or
-        written. A colon can be used to separate the stream name/address from an
-        in-stream path / port:
-        File example: '/path/filename.lh5:/group'
-        Socket example: '198.0.0.100:8000'
-    out_name : str (optional)
-        the name / identifier of the object in the output stream
+        the :class:`.RawBuffer` is associated. This is called `key_list`
+        instead of `keys` to avoid confusion with the dict function
+        :meth:`dict.keys`, i.e.  ``raw_buffer.lgdo.keys()``.
+    out_stream
+        the output stream to which the :class:`.RawBuffer`\ 's LGDO should be
+        sent or written. A colon (``,``) can be used to separate the stream
+        name/address from an in-stream path/port:
+        - file example: ``/path/filename.lh5:/group``
+        - socket example: ``198.0.0.100:8000``
+    out_name
+        the name or identifier of the object in the output stream.
     """
 
 
-    def __init__(self, lgdo=None, key_list: list = [], out_stream: str = '', out_name: str = ''):
+    def __init__(self, lgdo: LGDO = None, key_list: list[int | str] = [],
+                 out_stream: str = '', out_name: str = '') -> None:
         self.lgdo = lgdo
         self.key_list = key_list
         self.out_stream = out_stream
@@ -103,35 +111,31 @@ def __init__(self, lgdo=None, key_list: list = [], out_stream: str = '', out_nam
         self.loc = 0
         self.fill_safety = 1
 
-
-    def __len__(self):
+    def __len__(self) -> int:
         if self.lgdo is None: return 0
         if not hasattr(self.lgdo, '__len__'): return 1
         return len(self.lgdo)
 
-
     def is_full(self) -> bool:
         return (len(self) - self.loc) < self.fill_safety
 
-
-    def __str__(self):
+    def __str__(self) -> str:
         return f'RawBuffer {"{"} lgdo={self.lgdo}, key_list={self.key_list}, out_stream={self.out_stream}, out_name={self.out_name}, loc={self.loc}, fill_safety={self.fill_safety} {"}"}'
 
-
-    def __repr__(self): return str(self)
+    def __repr__(self) -> str:
+        return str(self)
 
 
 class RawBufferList(list):
     r"""A :class:`.RawBufferList` holds a collection of :class:`.RawBuffer`\ s
-    of identical structure (same format lgdo's with the same fields).
+    of identical structure (same format LGDO's with the same fields).
     """
-    def __init__(self):
+    def __init__(self) -> None:
         self.keyed_dict = None
 
-
-    def get_keyed_dict(self) -> dict:
+    def get_keyed_dict(self) -> dict[int | str, RawBuffer]:
         r"""Returns a dictionary of :class:`.RawBuffer`\ s built from the
-        buffers' ``key_lists``.
+        buffers' `key_lists`.
 
         Different keys may point to the same buffer. Requires the buffers in
         the :class:`.RawBufferList` to have non-overlapping key lists.
@@ -142,12 +146,13 @@ def get_keyed_dict(self) -> dict:
                 for key in rb.key_list: self.keyed_dict[key] = rb
         return self.keyed_dict
 
-
-    def set_from_json_dict(self, json_dict: dict, kw_dict: dict = {}) -> None:
+    def set_from_json_dict(self, json_dict: dict, kw_dict: dict[str, str] = {}) -> None:
         """Set up a :class:`.RawBufferList` from a dictionary written in JSON
         shorthand. See :meth:`.RawBufferLibrary.set_from_json_dict` for details.
 
-        .. note:: ``json_dict`` is changed by this function
+        Notes
+        -----
+        `json_dict` is changed by this function.
         """
         expand_rblist_json_dict(json_dict, kw_dict)
         for name in json_dict:
@@ -159,21 +164,20 @@ def set_from_json_dict(self, json_dict: dict, kw_dict: dict = {}) -> None:
             if 'out_name' in json_dict[name]:
                 rb.out_name = json_dict[name]['out_name']
             else: rb.out_name = name
-            self.append(rb);
-
+            self.append(rb)
 
     def get_list_of(self, attribute: str) -> list:
         """Return a list of values of :class:`.RawBuffer` attributes.
 
         Parameters
         ----------
-        attribute : str
-            The RawBuffer attribute queried to make the list
+        attribute
+            The :class:`.RawBuffer` attribute queried to make the list.
 
         Returns
         -------
-        values : list
-            The list of values of RawBuffer.attribute
+        values
+            The list of values of `RawBuffer.attribute`.
 
         Examples
         --------
@@ -186,7 +190,7 @@ def get_list_of(self, attribute: str) -> list:
             if val not in values: values.append(val)
         return values
 
-    def clear_full(self):
+    def clear_full(self) -> None:
         for rb in self:
             if rb.is_full(): rb.loc = 0
 
@@ -196,11 +200,11 @@ class RawBufferLibrary(dict):
     :class:`.RawBufferList`\ s associated with the names of decoders that can
     write to them.
     """
-    def __init__(self, json_dict: dict = None, kw_dict: dict = {}):
+    def __init__(self, json_dict: dict = None, kw_dict: dict[str, str] = {}) -> None:
         if json_dict is not None:
             self.set_from_json_dict(json_dict, kw_dict)
 
-    def set_from_json_dict(self, json_dict: dict, kw_dict: dict = {}) -> None:
+    def set_from_json_dict(self, json_dict: dict, kw_dict: dict[str, str] = {}) -> None:
         r"""Set up a :class:`.RawBufferLibrary` from a dictionary written in
         JSON shorthand.
 
@@ -233,44 +237,44 @@ def set_from_json_dict(self, json_dict: dict, kw_dict: dict = {}) -> None:
           corresponding name.  The same specifier can appear in ``out_path`` to
           write the key's data to its own output path.
         * You may also include keywords in your ``out_stream`` and ``out_name``
-          specification whose values get sent in via ``kwdict``. These get
+          specification whose values get sent in via `kw_dict`. These get
           evaluated simultaneously with the ``{key:xxx}`` specifiers.
         * Environment variables can also be used in ``out_stream``. They get
-          expanded after ``kw_dict`` is handled and thus can be used inside
-          ``kw_dict``
+          expanded after `kw_dict` is handled and thus can be used inside
+          `kw_dict`.
         * ``list_name`` can use the wildcard ``*`` to match any other
-          ``list_name`` known to a streamer
+          ``list_name`` known to a streamer.
         * ``out_stream`` and ``out_name`` can also include ``{name}``, to be
           replaced with the buffer's ``name``. In the case of
-          ``list_name="*"``, ``{name}`` evaluates to ``list_name``
+          ``list_name="*"``, ``{name}`` evaluates to ``list_name``.
 
         Parameters
         ----------
-        json_dict : dict
-            dict loaded from a json file written in the allowed shorthand.
-            Note: json_dict is changed by this function
-        kw_dict : dict
-            dict of keyword-value pairs for substitutions into the out_stream
-            and out_name fields
+        json_dict
+            loaded from a JSON file written in the allowed shorthand.
+            `json_dict` is changed by this function.
+        kw_dict
+            dictionary of keyword-value pairs for substitutions into the
+            ``out_stream`` and ``out_name`` fields.
         """
         for list_name in json_dict:
             if list_name not in self: self[list_name] = RawBufferList()
             self[list_name].set_from_json_dict(json_dict[list_name], kw_dict)
 
     def get_list_of(self, attribute: str, unique: bool = True) -> list:
-        """Return a list of values of :class:`.RawBuffer` attributes
+        """Return a list of values of :class:`.RawBuffer` attributes.
 
         Parameters
         ----------
-        attribute : str
-            The RawBuffer attribute queried to make the list
-        unique : bool
-            Remove duplicates
+        attribute
+            The :class:`.RawBuffer` attribute queried to make the list.
+        unique
+            whether to remove duplicates.
 
         Returns
         -------
-        values : list
-            The list of values of RawBuffer.attribute
+        values
+            The list of values of `RawBuffer.attribute`.
 
         Examples
         --------
@@ -286,13 +290,15 @@ def clear_full(self) -> None:
         for rb_list in self.values(): rb_list.clear_full()
 
 
-def expand_rblist_json_dict(json_dict: dict, kw_dict: dict) -> None:
+def expand_rblist_json_dict(json_dict: dict, kw_dict: dict[str, str]) -> None:
     """Expand shorthands in a JSON dictionary representing a
-    :class:`.RawBufferList`
+    :class:`.RawBufferList`.
 
-    See :meth:`.RawBufferLibrary.set_from_json_dict` for details
+    See :meth:`.RawBufferLibrary.set_from_json_dict` for details.
 
-    Note: The input JSON dictionary is changed by this function
+    Notes
+    -----
+    The input JSON dictionary is changed by this function.
     """
     # get the original list of groups because we are going to change the
     # dict.keys() of json_dict inside the next list. Note: we have to convert


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 4239315d0..892b422d2 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -5,5 +5,5 @@
 @pytest.fixture(scope="session")
 def lgnd_test_data():
     ldata = LegendTestData()
-    ldata.checkout('5de1d80')
+    ldata.checkout('968c9ba')
     return ldata