From b69be8879eb2a29f07b4b247d7d92ed7a15f1f3a Mon Sep 17 00:00:00 2001 From: smsutherland Date: Thu, 13 Nov 2025 14:13:49 -0500 Subject: [PATCH 1/8] add @error_handler to key-bound dataset operations --- src/h5forest/bindings/dataset_funcs.py | 3 +++ 1 file changed, 3 insertions(+) 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() From 1dfd05e35b22d6fb991f0f10d5bd87db954543eb Mon Sep 17 00:00:00 2001 From: smsutherland Date: Thu, 13 Nov 2025 14:17:01 -0500 Subject: [PATCH 2/8] add optional dependency on hdf5plugin Allows h5forest to work in environments with a non-standard hdf5 plugin directory. --- pyproject.toml | 4 +++- src/h5forest/node.py | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7246874..46898ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,8 +52,10 @@ dependencies = [ "ruamel.yaml>=0.18", ] -# Add special dependencies for development [project.optional-dependencies] +hdf5plugin = ["hdf5plugin"] + +# Add special dependencies for development dev = ["ruff", ] test = [ "pytest>=6.0", diff --git a/src/h5forest/node.py b/src/h5forest/node.py index ff3ce30..66f92b2 100644 --- a/src/h5forest/node.py +++ b/src/h5forest/node.py @@ -21,6 +21,12 @@ 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: """ From 16991baefef6fbf6bca44456c343eee862f1c5ec Mon Sep 17 00:00:00 2001 From: smsutherland Date: Thu, 13 Nov 2025 14:38:11 -0500 Subject: [PATCH 3/8] transform OSError into more useful message "Cannot open dataset, try `pip install h5forest[hdf5plugin]`" --- src/h5forest/errors.py | 24 ++++++++++++++++++++++++ src/h5forest/node.py | 5 +++++ 2 files changed, 29 insertions(+) diff --git a/src/h5forest/errors.py b/src/h5forest/errors.py index 6d91af0..6d5ab4d 100644 --- a/src/h5forest/errors.py +++ b/src/h5forest/errors.py @@ -59,3 +59,27 @@ def wrapper(*args, **kwargs): H5Forest().print(error_msg) return wrapper + + +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 Exception which recommends + users install h5py with hdf5plugin. + + Args: + func (function): + The function to wrap. + """ + + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except OSError as e: + raise Exception( + "Cannot open dataset, try `pip install h5forest[hdf5plugin]`" + ) from e + + return wrapper diff --git a/src/h5forest/node.py b/src/h5forest/node.py index 66f92b2..0377821 100644 --- a/src/h5forest/node.py +++ b/src/h5forest/node.py @@ -19,6 +19,7 @@ import h5py import numpy as np +from h5forest.errors import handle_plugins from h5forest.progress import ProgressBar try: @@ -348,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). @@ -410,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. @@ -469,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. @@ -528,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. From 304ecfd4e00939f4709e3905a4fe59da0570b408 Mon Sep 17 00:00:00 2001 From: smsutherland Date: Thu, 13 Nov 2025 15:27:14 -0500 Subject: [PATCH 4/8] add hdf5plugin description to pyproject.toml --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 46898ab..0bb2a55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ dependencies = [ ] [project.optional-dependencies] +# Handle cases of non-standard or non-existant hdf5 plugin locations hdf5plugin = ["hdf5plugin"] # Add special dependencies for development From eabe18f1d86c959d1f629d8b6fa36adeaf94306f Mon Sep 17 00:00:00 2001 From: smsutherland Date: Thu, 13 Nov 2025 15:37:50 -0500 Subject: [PATCH 5/8] test handle_plugins --- src/h5forest/errors.py | 5 ++++- tests/unit/test_errors.py | 28 +++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/h5forest/errors.py b/src/h5forest/errors.py index 6d5ab4d..ba39f44 100644 --- a/src/h5forest/errors.py +++ b/src/h5forest/errors.py @@ -61,6 +61,9 @@ def wrapper(*args, **kwargs): return wrapper +class PluginError(Exception): ... + + def handle_plugins(func): """ Wrap a function in a try/except block to catch the OSError @@ -78,7 +81,7 @@ def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except OSError as e: - raise Exception( + raise PluginError( "Cannot open dataset, try `pip install h5forest[hdf5plugin]`" ) from e diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index 1649420..53969e8 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -4,7 +4,7 @@ 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 +167,29 @@ def test_function(): # Should still handle the exception gracefully mock_forest.print.assert_called_once() assert result is None + + def test_handle_plugins_convert(self): + """Test that handle_plugins properly converts OSErrors.""" + + @handle_plugins + def test_function(): + raise OSError("test") + + try: + test_function() + except PluginError as e: + assert e.args == ( + "Cannot open dataset, try `pip install h5forest[hdf5plugin]`", + ) + + 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",) From f9f30db684f05cf14f2253f59403b24b189e908f Mon Sep 17 00:00:00 2001 From: smsutherland Date: Thu, 13 Nov 2025 15:52:53 -0500 Subject: [PATCH 6/8] Fix documentation of handle_plugins --- src/h5forest/errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/h5forest/errors.py b/src/h5forest/errors.py index ba39f44..bdab247 100644 --- a/src/h5forest/errors.py +++ b/src/h5forest/errors.py @@ -69,7 +69,7 @@ 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 Exception which recommends + The error is transformed into an PluginError which recommends users install h5py with hdf5plugin. Args: From 1c64f986ffd934ba5948b72a8e9d7f990ab67638 Mon Sep 17 00:00:00 2001 From: smsutherland Date: Thu, 13 Nov 2025 16:07:54 -0500 Subject: [PATCH 7/8] more descriptive error message for hdf5plugin --- src/h5forest/errors.py | 3 ++- tests/unit/test_errors.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/h5forest/errors.py b/src/h5forest/errors.py index bdab247..81d48eb 100644 --- a/src/h5forest/errors.py +++ b/src/h5forest/errors.py @@ -82,7 +82,8 @@ def wrapper(*args, **kwargs): return func(*args, **kwargs) except OSError as e: raise PluginError( - "Cannot open dataset, try `pip install h5forest[hdf5plugin]`" + "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/tests/unit/test_errors.py b/tests/unit/test_errors.py index 53969e8..0ce9093 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -179,7 +179,8 @@ def test_function(): test_function() except PluginError as e: assert e.args == ( - "Cannot open dataset, try `pip install h5forest[hdf5plugin]`", + "Cannot open dataset, try `pip install h5forest[hdf5plugin]`. " + "HDF5 plugins may be missing or in a non-standard location.", ) def test_handle_plugins_untouched(self): From c92de8f45370133153bb3f2cb8bb12fe6440d565 Mon Sep 17 00:00:00 2001 From: smsutherland Date: Thu, 13 Nov 2025 17:21:12 -0500 Subject: [PATCH 8/8] only recommend installing with hdf5plugin if hdf5plugin does not exist --- src/h5forest/errors.py | 5 +++++ tests/unit/test_errors.py | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/h5forest/errors.py b/src/h5forest/errors.py index 81d48eb..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 @@ -78,6 +79,10 @@ def handle_plugins(func): """ 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: diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index 0ce9093..cc17e94 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -1,5 +1,6 @@ """Tests for h5forest.errors module.""" +import sys from unittest.mock import Mock, patch import pytest @@ -168,13 +169,23 @@ def test_function(): mock_forest.print.assert_called_once() assert result is None - def test_handle_plugins_convert(self): + 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: @@ -183,6 +194,27 @@ def test_function(): "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"""