Skip to content
47 changes: 42 additions & 5 deletions src/npe2/io_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from collections.abc import Sequence
from typing import (
TYPE_CHECKING,
Any,
Literal,
cast,
overload,
Expand Down Expand Up @@ -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}")


Expand Down Expand Up @@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it always list or can it be like a tuple?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I think technically speaking it could be a tuple, but this function was reproduced from napari, so it's what we've always used to validate the return type of readers. I'd say we keep it like this for now, and change it later if need be. Looking at the docs for readers, we always refer to a list also.

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,
Expand Down
59 changes: 54 additions & 5 deletions tests/test__io_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand All @@ -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"
Expand Down