diff --git a/src/npe2/io_utils.py b/src/npe2/io_utils.py index 5bddae3f..109f596a 100644 --- a/src/npe2/io_utils.py +++ b/src/npe2/io_utils.py @@ -3,6 +3,7 @@ from collections.abc import Sequence from typing import ( TYPE_CHECKING, + Any, Literal, cast, overload, @@ -157,20 +158,33 @@ def _read( "No readers to try. Expected an exception before this point." ) + tried_reader = False for rdr in chosen_compatible_readers: read_func = rdr.exec( kwargs={"path": paths, "stack": stack, "_registry": _pm.commands} ) if read_func is not None: + tried_reader = True # if the reader function raises an exception here, we don't try to catch it - if layer_data := read_func(paths, stack=stack): + layer_data = read_func(paths, stack=stack) + if plugin_name and _is_null_layer_sentinel(layer_data): + # we don't return null layers if the user selected a plugin, + # so that we can raise a meaningful error + continue + if layer_data: return (layer_data, rdr) if return_reader else layer_data if plugin_name: - raise ValueError( - f"Reader {plugin_name!r} was selected to open " - + f"{paths!r}, but returned no data." - ) + if tried_reader: + raise ValueError( + f"Reader {plugin_name!r} was selected to open " + + f"{paths!r}, but returned no data." + ) + else: + raise ValueError( + f"Reader {plugin_name!r} was selected to open " + + f"{paths!r}, but refused the file." + ) raise ValueError(f"No readers returned data for {paths!r}") @@ -256,6 +270,29 @@ def _get_compatible_readers_by_choice( return chosen_compatible_readers +def _is_null_layer_sentinel(layer_data: Any) -> bool: + """Checks if the layer data returned from a reader function indicates an + empty file. The sentinel value used for this is ``[(None,)]``. + + Parameters + ---------- + layer_data : LayerData + The layer data returned from a reader function to check + + Returns + ------- + bool + True, if the layer_data indicates an empty file, False otherwise + """ + return ( + isinstance(layer_data, list) + and len(layer_data) == 1 + and isinstance(layer_data[0], tuple) + and len(layer_data[0]) == 1 + and layer_data[0][0] is None + ) + + @overload def _write( path: str, diff --git a/tests/test__io_utils.py b/tests/test__io_utils.py index 4c101ae7..08733c68 100644 --- a/tests/test__io_utils.py +++ b/tests/test__io_utils.py @@ -78,21 +78,67 @@ def read(paths): return read # "gooby-again" isn't used even though given plugin starts with the same name - # if an error is thrown here, it means we selected the wrong plugin - io_utils._read(["some.fzzy"], plugin_name=short_name, stack=False, _pm=pm) + # we check that the thrown error is from "gooby" NOT "gooby-again" + with pytest.raises( + ValueError, match=f"Reader {short_name!r} was selected .* but returned no data" + ): + io_utils._read(["some.fzzy"], plugin_name=short_name, stack=False, _pm=pm) + + +def test_read_fails_with_refused_reader(): + pm = PluginManager() + plugin_name = "always-fails" + plugin = DynamicPlugin(plugin_name, plugin_manager=pm) + plugin.register() + + @plugin.contribute.reader(filename_patterns=["*.fzzy"]) + def get_read(path): + return None + + with pytest.raises( + ValueError, match=f"Reader {plugin_name!r} was selected .* refused the file" + ): + io_utils._read(["some.fzzy"], plugin_name=plugin_name, stack=False, _pm=pm) + + with pytest.raises(ValueError, match="No readers returned data"): + io_utils._read(["some.fzzy"], stack=False, _pm=pm) -def test_read_fails(): +def test_read_fails_with_null_layer(): pm = PluginManager() plugin_name = "always-fails" plugin = DynamicPlugin(plugin_name, plugin_manager=pm) plugin.register() + def reader_func(path): + return [(None,)] + @plugin.contribute.reader(filename_patterns=["*.fzzy"]) def get_read(path): + return reader_func + + with pytest.raises( + ValueError, match=f"Reader {plugin_name!r} was selected .* returned no data" + ): + io_utils._read(["some.fzzy"], plugin_name=plugin_name, stack=False, _pm=pm) + + +def test_read_fails_with_reader_returning_none(): + pm = PluginManager() + plugin_name = "none-reader" + plugin = DynamicPlugin(plugin_name, plugin_manager=pm) + plugin.register() + + def reader_func(path): return None - with pytest.raises(ValueError, match=f"Reader {plugin_name!r} was selected"): + @plugin.contribute.reader(filename_patterns=["*.fzzy"]) + def get_read(path): + return reader_func + + with pytest.raises( + ValueError, match=f"Reader {plugin_name!r} was selected .* returned no data" + ): io_utils._read(["some.fzzy"], plugin_name=plugin_name, stack=False, _pm=pm) with pytest.raises(ValueError, match="No readers returned data"): @@ -117,7 +163,10 @@ def test_read_with_no_compatible_reader(): def test_read_with_reader_contribution_plugin(uses_sample_plugin): paths = ["some.fzzy"] chosen_reader = f"{SAMPLE_PLUGIN_NAME}.some_reader" - assert read(paths, stack=False, plugin_name=chosen_reader) == [(None,)] + with pytest.raises( + ValueError, match=f"Reader {chosen_reader!r} was selected .* returned no data" + ): + read(paths, stack=False, plugin_name=chosen_reader) # if the wrong contribution is passed we get useful error message chosen_reader = f"{SAMPLE_PLUGIN_NAME}.not_a_reader"