Repository: napari/napari

Issue:
read_data_with_plugins returns empty list instead of None if no reader plugins were found
## 🐛 Bug

Unlike specified in the docstring: 

https://github.com/napari/napari/blob/79ab3589eb398ce56d2d0a16025cdb7f435ffaa5/napari/plugins/io.py#L54

currently, the function ``read_data_with_plugins`` would return ``[]`` if no reader plugins were found.

This has not affected anything until now, as ``read_data_with_plugins`` raises an exception in such cases. However, according to the following comment, this behavior will change in the future: 

https://github.com/napari/napari/blob/79ab3589eb398ce56d2d0a16025cdb7f435ffaa5/napari/plugins/io.py#L107-L108

## To Reproduce

Comment out the lines above and call ``read_data_with_plugins`` for an unsupported data type, inspect the return value.

## Expected behavior

The function should return ``None`` iff no reader plugins were found. The return value ``[]`` should be reserved for reading empty files.

## Additional context

This issue came up when addressing comments in #2206



Discussion:

Resolving patch:
diff --git a/napari/components/viewer_model.py b/napari/components/viewer_model.py
--- a/napari/components/viewer_model.py
+++ b/napari/components/viewer_model.py
@@ -793,7 +793,7 @@ def _add_layers_with_plugins(
         """
         from ..plugins.io import read_data_with_plugins
 
-        layer_data = read_data_with_plugins(path_or_paths, plugin=plugin)
+        layer_data = read_data_with_plugins(path_or_paths, plugin=plugin) or []
 
         # glean layer names from filename. These will be used as *fallback*
         # names, if the plugin does not return a name kwarg in their meta dict.
diff --git a/napari/plugins/hook_specifications.py b/napari/plugins/hook_specifications.py
--- a/napari/plugins/hook_specifications.py
+++ b/napari/plugins/hook_specifications.py
@@ -53,6 +53,8 @@ def napari_get_reader(path: Union[str, List[str]]) -> Optional[ReaderFunction]:
 
     This is the primary "**reader plugin**" function.  It accepts a path or
     list of paths, and returns a list of data to be added to the ``Viewer``.
+    The function may return ``[(None, )]`` to indicate that the file was read
+    successfully, but did not contain any data.
 
     The main place this hook is used is in :func:`Viewer.open()
     <napari.components.viewer_model.ViewerModel.open>`, via the
diff --git a/napari/plugins/io.py b/napari/plugins/io.py
--- a/napari/plugins/io.py
+++ b/napari/plugins/io.py
@@ -1,6 +1,6 @@
 import warnings
 from logging import getLogger
-from typing import List, Optional, Sequence, Union
+from typing import Any, List, Optional, Sequence, Union
 
 from napari_plugin_engine import (
     HookImplementation,
@@ -20,13 +20,13 @@ def read_data_with_plugins(
     path: Union[str, Sequence[str]],
     plugin: Optional[str] = None,
     plugin_manager: PluginManager = napari_plugin_manager,
-) -> List[LayerData]:
+) -> Optional[List[LayerData]]:
     """Iterate reader hooks and return first non-None LayerData or None.
 
     This function returns as soon as the path has been read successfully,
-    while catching any plugin exceptions, storing them for later retrievial,
-    providing useful error messages, and relooping until either layer data is
-    returned, or no valid readers are found.
+    while catching any plugin exceptions, storing them for later retrieval,
+    providing useful error messages, and re-looping until either a read
+    operation was successful, or no valid readers were found.
 
     Exceptions will be caught and stored as PluginErrors
     (in plugins.exceptions.PLUGIN_ERRORS)
@@ -51,7 +51,7 @@ def read_data_with_plugins(
         ``LayerData`` is a list tuples, where each tuple is one of
         ``(data,)``, ``(data, meta)``, or ``(data, meta, layer_type)`` .
 
-        If no reader plugins are (or they all error), returns ``None``
+        If no reader plugins were found (or they all failed), returns ``None``
 
     Raises
     ------
@@ -70,7 +70,12 @@ def read_data_with_plugins(
         reader = hook_caller._call_plugin(plugin, path=path)
         if not callable(reader):
             raise ValueError(f'Plugin {plugin!r} does not support file {path}')
-        return reader(path) or []
+        layer_data = reader(path)
+        # if the reader returns a "null layer" sentinel indicating an empty
+        # file, return an empty list, otherwise return the result or None
+        if _is_null_layer_sentinel(layer_data):
+            return []
+        return layer_data or None
 
     errors: List[PluginCallError] = []
     path = abspath_or_url(path)
@@ -114,7 +119,11 @@ def read_data_with_plugins(
         err_msg += 'See full error logs in "Plugins → Plugin Errors..."'
         logger.error(err_msg)
 
-    return layer_data or []
+    # if the reader returns a "null layer" sentinel indicating an empty file,
+    # return an empty list, otherwise return the result or None
+    if _is_null_layer_sentinel(layer_data):
+        return []
+    return layer_data or None
 
 
 def save_layers(
@@ -194,6 +203,29 @@ def save_layers(
     return written
 
 
+def _is_null_layer_sentinel(layer_data: Union[LayerData, 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
+    )
+
+
 def _write_multiple_layers_with_plugins(
     path: str,
     layers: List[Layer],


Test patch:
diff --git a/napari/plugins/_tests/test_reader_plugins.py b/napari/plugins/_tests/test_reader_plugins.py
--- a/napari/plugins/_tests/test_reader_plugins.py
+++ b/napari/plugins/_tests/test_reader_plugins.py
@@ -3,6 +3,7 @@
 from tempfile import NamedTemporaryFile
 
 import numpy as np
+import pytest
 
 from napari.components import ViewerModel
 from napari.plugins.io import read_data_with_plugins
@@ -18,6 +19,7 @@ def test_builtin_reader_plugin():
         tmp.seek(0)
         layer_data = read_data_with_plugins(tmp.name)
 
+        assert layer_data is not None
         assert isinstance(layer_data, list)
         assert len(layer_data) == 1
         assert isinstance(layer_data[0], tuple)
@@ -39,6 +41,7 @@ def test_builtin_reader_plugin_csv(tmpdir):
     io.write_csv(tmp, table, column_names=column_names)
     layer_data = read_data_with_plugins(tmp)
 
+    assert layer_data is not None
     assert isinstance(layer_data, list)
     assert len(layer_data) == 1
     assert isinstance(layer_data[0], tuple)
@@ -71,3 +74,30 @@ def test_builtin_reader_plugin_stacks():
     for tmp in tmps:
         tmp.close()
         os.unlink(tmp.name)
+
+
+def test_reader_plugin_can_return_null_layer_sentinel(
+    test_plugin_manager, add_implementation
+):
+    from napari_plugin_engine import napari_hook_implementation
+
+    from napari.plugins import hook_specifications
+
+    test_plugin_manager.project_name = 'napari'
+    test_plugin_manager.add_hookspecs(hook_specifications)
+
+    with pytest.raises(ValueError) as e:
+        read_data_with_plugins('')
+    assert 'No plugin found capable of reading' in str(e)
+
+    @napari_hook_implementation(tryfirst=True)
+    def napari_get_reader(path):
+        def _reader(path):
+            return [(None,)]
+
+        return _reader
+
+    add_implementation(napari_get_reader)
+    layer_data = read_data_with_plugins('', plugin_manager=test_plugin_manager)
+    assert layer_data is not None
+    assert len(layer_data) == 0