diff --git a/pyproject.toml b/pyproject.toml index 7246874..0bb2a55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,8 +52,11 @@ dependencies = [ "ruamel.yaml>=0.18", ] -# Add special dependencies for development [project.optional-dependencies] +# Handle cases of non-standard or non-existant hdf5 plugin locations +hdf5plugin = ["hdf5plugin"] + +# Add special dependencies for development dev = ["ruff", ] test = [ "pytest>=6.0", diff --git a/src/h5forest/bindings/dataset_funcs.py b/src/h5forest/bindings/dataset_funcs.py index a38d7e3..da9d72e 100644 --- a/src/h5forest/bindings/dataset_funcs.py +++ b/src/h5forest/bindings/dataset_funcs.py @@ -157,6 +157,7 @@ def minimum_maximum(event): def run_operation(use_chunks): """Run the min/max operation after user confirmation.""" + @error_handler def run_in_thread(): # Get the value string vmin, vmax = node.get_min_max() @@ -197,6 +198,7 @@ def mean(event): def run_operation(use_chunks): """Run the mean operation after user confirmation.""" + @error_handler def run_in_thread(): # Get the value string vmean = node.get_mean() @@ -237,6 +239,7 @@ def std(event): def run_operation(use_chunks): """Run the std operation after user confirmation.""" + @error_handler def run_in_thread(): # Get the value string vstd = node.get_std() diff --git a/src/h5forest/errors.py b/src/h5forest/errors.py index 6d91af0..22bcf81 100644 --- a/src/h5forest/errors.py +++ b/src/h5forest/errors.py @@ -1,6 +1,7 @@ """A module containing functions for graceful error handling.""" import traceback +from importlib.util import find_spec from pathlib import Path @@ -59,3 +60,35 @@ def wrapper(*args, **kwargs): H5Forest().print(error_msg) return wrapper + + +class PluginError(Exception): ... + + +def handle_plugins(func): + """ + Wrap a function in a try/except block to catch the OSError + which occurs when h5py is missing its plugins. + + The error is transformed into an PluginError which recommends + users install h5py with hdf5plugin. + + Args: + func (function): + The function to wrap. + """ + + def wrapper(*args, **kwargs): + # If hdf5plugin exists, don't convert any errors. + if find_spec("hdf5plugin"): + return func(*args, **kwargs) + + try: + return func(*args, **kwargs) + except OSError as e: + raise PluginError( + "Cannot open dataset, try `pip install h5forest[hdf5plugin]`. " + "HDF5 plugins may be missing or in a non-standard location." + ) from e + + return wrapper diff --git a/src/h5forest/node.py b/src/h5forest/node.py index ff3ce30..0377821 100644 --- a/src/h5forest/node.py +++ b/src/h5forest/node.py @@ -19,8 +19,15 @@ import h5py import numpy as np +from h5forest.errors import handle_plugins from h5forest.progress import ProgressBar +try: + import hdf5plugin as _ # noqa: F401 hdf5plugin is not used directly. Importing it is enough. +except ImportError: + # hdf5plugin is optional. Do nothing if it does not exist. + pass + class Node: """ @@ -342,6 +349,7 @@ def get_attr_text(self): self._attr_text = self._get_attr_text() return self._attr_text + @handle_plugins def get_value_text(self, start_index=None, end_index=None): """ Return the value text for the node (optionally in a range). @@ -404,6 +412,7 @@ def get_value_text(self, start_index=None, end_index=None): # Combine path and data for output return str(data_subset) + truncated + @handle_plugins def get_min_max(self): """ Return the minimum and maximum values of the dataset. @@ -463,6 +472,7 @@ def get_min_max(self): return min_val, max_val + @handle_plugins def get_mean(self): """ Return the mean of the dataset values. @@ -522,6 +532,7 @@ def get_mean(self): # Return the mean return val_sum / (self.size) + @handle_plugins def get_std(self): """ Return the standard deviation of the dataset values. diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index 1649420..cc17e94 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -1,10 +1,11 @@ """Tests for h5forest.errors module.""" +import sys from unittest.mock import Mock, patch import pytest -from h5forest.errors import error_handler +from h5forest.errors import PluginError, error_handler, handle_plugins # Module-level function for testing error_handler with function context @@ -167,3 +168,61 @@ def test_function(): # Should still handle the exception gracefully mock_forest.print.assert_called_once() assert result is None + + def test_handle_plugins_convert_without_hdf5plugin(self): + """Test that handle_plugins properly converts OSErrors.""" + + @handle_plugins + def test_function(): + raise OSError("test") + + # handle_plugins check if hdf5plugin exists using + # importlib.util.find_spec. find_spec first checks in sys.modules, + # and returns the __spec__ of the module. If we just set it to False, + # Then it looks like the module is unavailable. + # Note that we can't set it to None, + # otherwise find_spec will throw a ValueError + mock_hdf5plugin = Mock() + mock_hdf5plugin.__spec__ = False + sys.modules["hdf5plugin"] = mock_hdf5plugin + + try: + test_function() + except PluginError as e: + assert e.args == ( + "Cannot open dataset, try `pip install h5forest[hdf5plugin]`. " + "HDF5 plugins may be missing or in a non-standard location.", + ) + + def test_handle_plugins_convert_with_hdf5plugin(self): + """Test that handle_plugins properly converts OSErrors.""" + + @handle_plugins + def test_function(): + raise OSError("test") + + # handle_plugins check if hdf5plugin exists using + # importlib.util.find_spec. find_spec first checks in sys.modules, + # and returns the __spec__ of the module. If we just set it to True, + # Then it looks like the module is available. + # Note that hdf5plugin is never actually imported, so this works fine. + mock_hdf5plugin = Mock() + mock_hdf5plugin.__spec__ = True + sys.modules["hdf5plugin"] = mock_hdf5plugin + + try: + test_function() + except OSError as e: + assert e.args == ("test",) + + def test_handle_plugins_untouched(self): + """Test that handle_plugins leaves non-OSError exceptions""" + + @handle_plugins + def test_function(): + raise RuntimeError("test") + + try: + test_function() + except RuntimeError as e: + assert e.args == ("test",)