Skip to content
48 changes: 42 additions & 6 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,32 @@ 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):
return (layer_data, rdr) if return_reader else layer_data
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
return (layer_data, rdr) if return_reader else layer_data
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we still check if layer_data isn't None/False before this return?

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.

I'm pushing this change, and adding a test to see if that is the behavior we want. I think that what you say makes sense

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.

I think this change is ok. Technically the reader should never be returning falsy things, but many probably do, so I think this is more defensive.


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 +269,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
37 changes: 32 additions & 5 deletions tests/test__io_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,14 @@ 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():
def test_read_fails_with_refused_reader():
pm = PluginManager()
plugin_name = "always-fails"
plugin = DynamicPlugin(plugin_name, plugin_manager=pm)
Expand All @@ -92,13 +95,34 @@ def test_read_fails():
def get_read(path):
return None

with pytest.raises(ValueError, match=f"Reader {plugin_name!r} was selected"):
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_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_with_incompatible_reader(uses_sample_plugin):
paths = ["some.notfzzy"]
chosen_reader = f"{SAMPLE_PLUGIN_NAME}"
Expand All @@ -117,7 +141,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
Loading