Repository: napari/napari

Issue:
Handling of unexpected layer kwargs from plugins
## 🐛 How to handle mismatched kwargs from plugins?
When we introduced the option to force `viewer.open` to interpret layers as a specific type with the `layer_type` options, we had decided it would be an error if the user specified a specific layer type, but a plugin returned kwargs incompatible with that type.  So for instance:

if the user asks:  `viewer.open('path/to/image.tif', layer_type='labels')` and the plugin returns layer data of `[(data, {'rgb': False}, 'image')]` then we raise a `TypeError` because the `add_labels` method has no keyword `rgb`.  The plugin wasn't wrong, it just couldn't possibly anticipate what the user was going to do.

Yesterday, tifffile released version `2020.5.7`, which includes a tentative napari reader plugin (🎉), and before #1222 merged, it was causing an error in our `test_viewer_labels_io.test_open_labels` for exactly the scenario mentioned above.

1. modify where we apply the `prune_kwargs` logic, such that we simply strip any invalid kwargs returned from the plugins.  in other words "yes, that was a valid kwarg for the layer type you _thought_ you were returning, but the user has requested that we go in a different direction with this"...
2. pass the user-supplied `layer_type` argument to the plugins and make them deal with it (I dislike this option a lot... we shouldn't put that burden on the plugins)

if we go with 1... we might want to _only_ strip invalid kwargs when the user has specified `viewer.open(path, layer_type=...)`, and keep it as an error otherwise (for instance, if the plugin mistakenly returned a key in their `meta` dict that didn't match the `layer_type` that *they* specified.)


Discussion:

Resolving patch:
diff --git a/napari/components/add_layers_mixin.py b/napari/components/add_layers_mixin.py
--- a/napari/components/add_layers_mixin.py
+++ b/napari/components/add_layers_mixin.py
@@ -1,16 +1,17 @@
 import inspect
 import itertools
+import os
 from logging import getLogger
-from os import fspath
 from typing import Any, Dict, List, Optional, Sequence, Union
-from ..utils.colormaps import ensure_colormap_tuple
 
 import numpy as np
 
 from .. import layers
-from ..layers.image._image_utils import guess_multiscale, guess_labels
+from ..layers.image._image_utils import guess_labels, guess_multiscale
 from ..plugins.io import read_data_with_plugins
+from ..types import FullLayerData, LayerData
 from ..utils import colormaps
+from ..utils.colormaps import ensure_colormap_tuple
 from ..utils.misc import (
     ensure_iterable,
     ensure_sequence_of_iterables,
@@ -789,7 +790,7 @@ def open(
             A list of any layers that were added to the viewer.
         """
         paths = [path] if isinstance(path, str) else path
-        paths = [fspath(path) for path in paths]  # PathObjects -> str
+        paths = [os.fspath(path) for path in paths]  # PathObjects -> str
         if not isinstance(paths, (tuple, list)):
             raise ValueError(
                 "'path' argument must be a string, list, or tuple"
@@ -849,29 +850,29 @@ def _add_layers_with_plugins(
         """
         layer_data = read_data_with_plugins(path_or_paths, plugin=plugin)
 
+        # 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.
+        if isinstance(path_or_paths, str):
+            filenames = itertools.repeat(path_or_paths)
+        elif is_sequence(path_or_paths):
+            if len(path_or_paths) == len(layer_data):
+                filenames = iter(path_or_paths)
+            else:
+                # if a list of paths has been returned as a list of layer data
+                # without a 1:1 relationship between the two lists we iterate
+                # over the first name
+                filenames = itertools.repeat(path_or_paths[0])
+
         # add each layer to the viewer
         added: List[layers.Layer] = []  # for layers that get added
-        for data in layer_data:
-            # normalize layerdata and override layer_type if necessary
-            if len(data) == 1:
-                data = (data[0], {})
-            if len(data) == 2:
-                data = (data[0], data[1], guess_labels(data[0]))
-            if layer_type is not None:
-                data = (data[0], data[1], layer_type)
-            else:
-                layer_type = data[2]
-            # if user provided kwargs, use to override any meta dict values
-            # that were returned by the plugin
-            if kwargs:
-                valid_kwargs = prune_kwargs(kwargs, layer_type)
-                if len(data) == 1:
-                    data = (data[0], valid_kwargs)
-                elif len(data) > 1:
-                    data[1].update(valid_kwargs)
+        for data, filename in zip(layer_data, filenames):
+            basename, ext = os.path.splitext(os.path.basename(filename))
+            _data = _unify_data_and_user_kwargs(
+                data, kwargs, layer_type, fallback_name=basename
+            )
             # actually add the layer
-            new = self._add_layer_from_data(*data)
-            # some add_* methods return a List[Layer] others just a Layer
+            new = self._add_layer_from_data(*_data)
+            # some add_* methods return a List[Layer], others just a Layer
             # we want to always return a list
             added.extend(new if isinstance(new, list) else [new])
         return added
@@ -956,6 +957,108 @@ def _add_layer_from_data(
         return layer
 
 
+def _normalize_layer_data(data: LayerData) -> FullLayerData:
+    """Accepts any layerdata tuple, and returns a fully qualified tuple.
+
+    Parameters
+    ----------
+    data : LayerData
+        1-, 2-, or 3-tuple with (data, meta, layer_type).
+
+    Returns
+    -------
+    FullLayerData
+        3-tuple with (data, meta, layer_type)
+
+    Raises
+    ------
+    ValueError
+        If data has len < 1 or len > 3, or if the second item in ``data`` is
+        not a ``dict``, or the third item is not a valid layer_type ``str``
+    """
+    if not isinstance(data, tuple) and 0 < len(data) < 4:
+        raise ValueError("LayerData must be a 1-, 2-, or 3-tuple")
+    _data = list(data)
+    if len(_data) > 1:
+        if not isinstance(_data[1], dict):
+            raise ValueError(
+                "The second item in a LayerData tuple must be a dict"
+            )
+    else:
+        _data.append(dict())
+    if len(_data) > 2:
+        if _data[2] not in layers.NAMES:
+            raise ValueError(
+                "The third item in a LayerData tuple must be one of: "
+                f"{layers.NAMES!r}."
+            )
+    else:
+        _data.append(guess_labels(_data[0]))
+    return tuple(_data)  # type: ignore
+
+
+def _unify_data_and_user_kwargs(
+    data: LayerData,
+    kwargs: Optional[dict] = None,
+    layer_type: Optional[str] = None,
+    fallback_name: str = None,
+) -> FullLayerData:
+    """Merge data returned from plugins with options specified by user.
+
+    If ``data == (_data, _meta, _type)``.  Then:
+
+    - ``kwargs`` will be used to update ``_meta``
+    - ``layer_type`` will replace ``_type`` and, if provided, ``_meta`` keys
+        will be pruned to layer_type-appropriate kwargs
+    - ``fallback_name`` is used if ``not _meta.get('name')``
+
+    .. note:
+
+        If a user specified both layer_type and additional keyword arguments
+        to viewer.open(), it is their responsibility to make sure the kwargs
+        match the layer_type.
+
+    Parameters
+    ----------
+    data : LayerData
+        1-, 2-, or 3-tuple with (data, meta, layer_type) returned from plugin.
+    kwargs : dict, optional
+        User-supplied keyword arguments, to override those in ``meta`` supplied
+        by plugins.
+    layer_type : str, optional
+        A user-supplied layer_type string, to override the ``layer_type``
+        declared by the plugin.
+    fallback_name : str, optional
+        A name for the layer, to override any name in ``meta`` supplied by the
+        plugin.
+
+    Returns
+    -------
+    FullLayerData
+        Fully qualified LayerData tuple with user-provided overrides.
+    """
+    _data, _meta, _type = _normalize_layer_data(data)
+
+    if layer_type:
+        # the user has explicitly requested this be a certain layer type
+        # strip any kwargs from the plugin that are no longer relevant
+        _meta = prune_kwargs(_meta, layer_type)
+        _type = layer_type
+
+    if kwargs:
+        # if user provided kwargs, use to override any meta dict values that
+        # were returned by the plugin. We only prune kwargs if the user did
+        # *not* specify the layer_type. This means that if a user specified
+        # both layer_type and additional keyword arguments to viewer.open(),
+        # it is their responsibility to make sure the kwargs match the
+        # layer_type.
+        _meta.update(prune_kwargs(kwargs, _type) if not layer_type else kwargs)
+
+    if not _meta.get('name') and fallback_name:
+        _meta['name'] = fallback_name
+    return (_data, _meta, _type)
+
+
 def prune_kwargs(kwargs: Dict[str, Any], layer_type: str) -> Dict[str, Any]:
     """Return copy of ``kwargs`` with only keys valid for ``add_<layer_type>``
 
diff --git a/napari/layers/__init__.py b/napari/layers/__init__.py
--- a/napari/layers/__init__.py
+++ b/napari/layers/__init__.py
@@ -14,5 +14,5 @@
 from .surface import Surface
 from .vectors import Vectors
 
-NAMES = [subclass.__name__.lower() for subclass in all_subclasses(Layer)]
+NAMES = {subclass.__name__.lower() for subclass in all_subclasses(Layer)}
 del all_subclasses


Test patch:
diff --git a/napari/components/_tests/test_add_layers.py b/napari/components/_tests/test_add_layers.py
--- a/napari/components/_tests/test_add_layers.py
+++ b/napari/components/_tests/test_add_layers.py
@@ -9,11 +9,6 @@
 img = np.random.rand(10, 10)
 layer_data = [(lay[1], {}, lay[0].__name__.lower()) for lay in layer_test_data]
 
-plugin_returns = [
-    ([(img, {'name': 'foo'})], {'name': 'bar'}),
-    ([(img, {'blending': 'additive'}), (img,)], {'blending': 'translucent'}),
-]
-
 
 @pytest.mark.parametrize("layer_datum", layer_data)
 def test_add_layers_with_plugins(layer_datum):
@@ -33,7 +28,7 @@ def test_add_layers_with_plugins(layer_datum):
     MagicMock(return_value=[]),
 )
 def test_plugin_returns_nothing():
-    """Test that a plugin to returning nothing adds nothing to the Viewer."""
+    """Test that a plugin returning nothing adds nothing to the Viewer."""
     v = ViewerModel()
     v._add_layers_with_plugins('mock_path')
     assert not v.layers
@@ -44,14 +39,24 @@ def test_plugin_returns_nothing():
     MagicMock(return_value=[(img,)]),
 )
 def test_viewer_open():
-    """Test that a plugin to returning nothing adds nothing to the Viewer."""
+    """Test that a plugin to returning an image adds stuff to the viewer."""
     viewer = ViewerModel()
     assert len(viewer.layers) == 0
-    viewer.open('mock_path')
+    viewer.open('mock_path.tif')
     assert len(viewer.layers) == 1
+    # The name should be taken from the path name, stripped of extension
+    assert viewer.layers[0].name == 'mock_path'
 
-    viewer.open('mock_path', stack=True)
+    # stack=True also works... and very long names are truncated
+    viewer.open('mock_path.tif', stack=True)
     assert len(viewer.layers) == 2
+    assert viewer.layers[1].name.startswith('mock_path')
+
+
+plugin_returns = [
+    ([(img, {'name': 'foo'})], {'name': 'bar'}),
+    ([(img, {'blending': 'additive'}), (img,)], {'blending': 'translucent'}),
+]
 
 
 @pytest.mark.parametrize("layer_data, kwargs", plugin_returns)
@@ -69,3 +74,6 @@ def test_add_layers_with_plugins_and_kwargs(layer_data, kwargs):
         for layer in v.layers:
             for key, val in kwargs.items():
                 assert getattr(layer, key) == val
+                # if plugins don't provide "name", it falls back to path name
+                if 'name' not in kwargs:
+                    assert layer.name.startswith('mock_path')