Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Comment thread
WillJRoper marked this conversation as resolved.

# Add special dependencies for development
dev = ["ruff", ]
test = [
"pytest>=6.0",
Expand Down
3 changes: 3 additions & 0 deletions src/h5forest/bindings/dataset_funcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ def minimum_maximum(event):
def run_operation(use_chunks):
"""Run the min/max operation after user confirmation."""

@error_handler
Comment thread
WillJRoper marked this conversation as resolved.
def run_in_thread():
# Get the value string
vmin, vmax = node.get_min_max()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
33 changes: 33 additions & 0 deletions src/h5forest/errors.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""A module containing functions for graceful error handling."""

import traceback
from importlib.util import find_spec
from pathlib import Path


Expand Down Expand Up @@ -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
Comment thread
WillJRoper marked this conversation as resolved.
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:
Comment thread
WillJRoper marked this conversation as resolved.
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
11 changes: 11 additions & 0 deletions src/h5forest/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
61 changes: 60 additions & 1 deletion tests/unit/test_errors.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",)
Loading