Repository: napari/napari

Issue:
Setting active layer from a plugin on PublicOnlyProxy works partially, but not in GUI
## 🐛 Bug

We’re writing a [classifier plugin](https://github.com/fractal-napari-plugins-collection/napari-feature-classifier/tree/classifier_refactor) that allows user to annotate a label layer and train classifiers based on those annotations. The way we show user annotations is by adding a label layer and coloring the objects based on user annotation.

When we add an annotation layer from our plugin, we don’t want to make it the selected layer (because we use layer selection to decide which label layer is currently being annotated).

Changing the active layer by setting `viewer.layers.selection.active` to the layer I want selected does not seem to work from a plugin (for layers that where not created by the plugin). I suspect it has something to do with the plugin not getting the actual viewer object, but a napari.utils._proxies.PublicOnlyProxy ⇒ only setting active state on that proxy.

It’s confusing though, because checking the active layer (via viewer.layers.selection.active) returns what I’d expect, but it’s not shown in the GUI.

## To Reproduce

Here's some sample code to reproduce this behavior:
```python
from pathlib import Path

import imageio
import napari
import napari.layers
import napari.viewer
import numpy as np
import pandas as pd
from magicgui.widgets import Container, Label
from napari.utils.notifications import show_info

def main():
    lbls = imageio.v2.imread(Path("sample_data/test_labels.tif"))
    lbls2 = np.zeros_like(lbls)
    lbls2[:, 3:, 2:] = lbls[:, :-3, :-2]
    lbls2 = lbls2 * 20

    labels = np.unique(lbls)[1:]
    labels_2 = np.unique(lbls2)[1:]

    viewer = napari.Viewer()
    lbls_layer = viewer.add_labels(lbls)
    lbls_layer2 = viewer.add_labels(lbls2)

    # Add the widget directly via code:
    label_selector_widget = LabelSelector(viewer)             # Comment out to reproduce issue
    viewer.window.add_dock_widget(label_selector_widget)      # Comment out to reproduce issue

    viewer.show(block=True)


class LabelSelector(Container):
    def __init__(
        self,
        viewer: napari.viewer.Viewer,
    ):
        self._viewer = viewer
        self.label = Label(label='Test')
        super().__init__(
            widgets=[
                self.label
            ]
        )
        self._last_selected_label_layer = self._viewer.layers[1]
        annotation_layer = self._viewer.add_labels(
            self._last_selected_label_layer.data,
            scale=self._last_selected_label_layer.scale,
            name="Annotations",
        )
        self._viewer.layers.selection.active = self._viewer.layers[0]
        print(f'Selected Layer at the end: {self._viewer.layers.selection.active}')
        print(f"Type of annotation layer: {type(annotation_layer)}")
        print(f"Type of first label layer: {type(self._viewer.layers[0])}")


if __name__ == "__main__":
    main()
```

If I run it as above (i.e. adding the dockwidget from Python), I get the expected behavior and the correct layer (the first one) is selected after the new Annotations layer was added:
<img width="1198" alt="Screenshot 2023-04-25 at 09 16 44" src="https://user-images.githubusercontent.com/18033446/234206865-8bd2fe29-a7c9-4a0b-aa73-659c51acdcbe.png">

The printing output is:
```
Selected Layer at the end: lbls
Type of annotation layer: <class 'napari.layers.labels.labels.Labels'>
Type of first label layer: <class 'napari.layers.labels.labels.Labels'>
```

If the two lines that are adding the widget manually are commented out:
```python
    # label_selector_widget = LabelSelector(viewer)
    # viewer.window.add_dock_widget(label_selector_widget)
```
and instead the dockwidget is added as a plugin, which is started from the GUI, we get this behavior:
![layer_selection_issue](https://user-images.githubusercontent.com/18033446/234207326-7e82a2c0-3285-4822-be2d-79f272276922.gif)
According to viewer.layers.selection.active, the first layer was selected. But the GUI does not show any layer selected.
The GUI still reacts to changes in the layer controls (i.e. changing opacity) and applies it to the layer that is selected behind the scenes. The user just isn't shown that it applies to that layer.

This is the print output:
```
Selected Layer at the end: lbls
Type of annotation layer: <class 'napari.layers.labels.labels.Labels'>
Type of first label layer: <class 'napari.utils._proxies.PublicOnlyProxy'>
```

## Expected behavior

I would expect the plugin flow to behave the same as when I manually add a widget: The GUI shows the selected layer.
Especially given that some parts of the GUI react to the layer selection (e.g. the layer controls), the actively selected layer should be shown.

## Environment

 - Please copy and paste the information at napari info option in help menubar here:
```
napari: 0.5.0a2.dev42+g9e911040
Platform: macOS-13.2.1-arm64-arm-64bit
System: MacOS 13.2.1
Python: 3.10.9 | packaged by conda-forge | (main, Feb 2 2023, 20:26:08) [Clang 14.0.6 ]
Qt: 5.15.6
PyQt5: 5.15.7
NumPy: 1.24.2
SciPy: 1.10.1
Dask: 2023.3.0
VisPy: 0.12.1
magicgui: 0.7.2
superqt: unknown
in-n-out: 0.1.7
app-model: 0.1.2
npe2: 0.6.2

OpenGL:
- GL version: 2.1 Metal - 83
- MAX_TEXTURE_SIZE: 16384

Screens:
- screen 1: resolution 2560x1440, scale 2.0
- screen 2: resolution 1512x982, scale 2.0

Settings path:
- /Users/joel/Library/Application Support/napari/classifier-dev-napari-main_2751af53b3d3e49c82e2e47937e51f1f537130c2/settings.yaml
```

 - Any other relevant information:
 I also tested it in napari 0.4.17 and in more recent napari nightly builds (0.5.0a2.dev71+g66df74d5) and always get the same behavior




Discussion:
Could you attach a tiff file or modify the example to not use `sample_data/test_labels.tif` 

Here problematic line:
```
    lbls = imageio.v2.imread(Path("sample_data/test_labels.tif"))
```

Ah, my bad, forgot that image. It doesn't matter what kind of image it is, I just used this dummy img here in a folder called sample_data:
![test_labels](https://user-images.githubusercontent.com/18033446/234227052-e3b125aa-4ecf-4dae-9d32-cab6c1e46a06.png)


Github doesn't allow upload of TIFs, so I converted it to PNG =>
```
lbls = imageio.v2.imread(Path("test_labels.png"))
```
It need to be 
```python
    lbls = imageio.v2.imread(Path("test_labels.png"))
    lbls= lbls.reshape((1, *lbls.shape))
```

And there is no plugin available in plugin menu. 
It looks like you have this installed in env. And may use different code to one from example. 

Thanks @Czaki 

> It looks like you have this installed in env. And may use different code to one from example.

I set it up as a plugin I had installed in my environment. The easiest may be to install the whole plugin from the classifier-refactor branch on our classifier repo here: https://github.com/fractal-napari-plugins-collection/napari-feature-classifier/tree/ca7a23a60ec4e8f37709ff54ad6fe1c72155aad2 (it also contains the example data as a TIF, so no more reshaping necessary, sorry about that)

That way, the dependencies get installed & the plugin registered. I then ran: `python layer_selection_plugin.py` from the plugin folder. When the add_dockwidget lines are commented out, it loads the plugin as it was registered and I'm pretty sure it uses the code as I pasted it in the example above.

The relevant part of the napari.yaml is this:
```
name: napari-feature-classifier
display_name: napari feature classifier
contributions:
  commands:
    - id: napari-feature-classifier.layer_selection_plugin
      python_name: napari_feature_classifier.layer_selection_plugin:LabelSelector
      title: Layer selection Test
  widgets:
    - command: napari-feature-classifier.layer_selection_plugin
      display_name: Layer selection Test
```

=> loading the layer_selection_plugin test case from that python file as copied in the original post above.
I could conform that there is a problem that we do not have mechanism to unwrap objects wrapped in proxy. We unwrap their parameters. 

Simplified script for reproduction:

```python
from pathlib import Path

import imageio
import napari
import napari.layers
import napari.viewer
import numpy as np

from magicgui.widgets import Container, Label
from napari.utils._proxies import PublicOnlyProxy

def main():
    lbls = imageio.v2.imread(Path("test_labels.png"))
    lbls= lbls.reshape((1, *lbls.shape))
    lbls2 = np.zeros_like(lbls)
    lbls2[:, 3:, 2:] = lbls[:, :-3, :-2]
    lbls2 = lbls2 * 20
    viewer = napari.Viewer()

    viewer.add_labels(lbls)
    viewer.add_labels(lbls2)

    # Add the widget directly via code:
    p_viewer = PublicOnlyProxy(viewer)
    label_selector_widget = LabelSelector(p_viewer)             # Comment out to reproduce issue
    viewer.window.add_dock_widget(label_selector_widget)      # Comment out to reproduce issue

    viewer.show(block=True)


class LabelSelector(Container):
    def __init__(
        self,
        viewer: napari.viewer.Viewer,
    ):
        self._viewer = viewer
        self.label = Label(label='Test')
        super().__init__(
            widgets=[
                self.label
            ]
        )
        self._last_selected_label_layer = self._viewer.layers[1]
        annotation_layer = self._viewer.add_labels(
            self._last_selected_label_layer.data,
            scale=self._last_selected_label_layer.scale,
            name="Annotations",
        )
        self._viewer.layers.selection.clear()
        self._viewer.layers.selection.add(self._viewer.layers[0])
        print(f'Selected Layer at the end: {self._viewer.layers.selection.active}')
        print(f"Type of annotation layer: {type(annotation_layer)}")
        print(f"Type of first label layer: {type(self._viewer.layers[0])}")


if __name__ == "__main__":
    main()
```

In general, fix this problem is simple (add unwrap in a specific place),  but this does not fix the whole class of the issues. Need to think more
@Czaki should we move this to 0.5.0 so as to not hold up 0.4.18?

Resolving patch:
diff --git a/napari/utils/_proxies.py b/napari/utils/_proxies.py
--- a/napari/utils/_proxies.py
+++ b/napari/utils/_proxies.py
@@ -119,6 +119,24 @@ def __setattr__(self, name: str, value: Any):
             typ = type(self.__wrapped__).__name__
             self._private_attr_warning(name, typ)
 
+        if isinstance(value, PublicOnlyProxy):
+            # if we want to set an attribute on a PublicOnlyProxy *and* the
+            # value that we want to set is itself a PublicOnlyProxy, we unwrap
+            # the value. This has two benefits:
+            #
+            # 1. Checking the attribute later will incur a significant
+            # performance cost, because _is_called_from_napari() will be
+            # checked on each attribute access and it involves inspecting the
+            # calling frame, which is expensive.
+            # 2. Certain equality checks fail when objects are
+            # PublicOnlyProxies. Notably, equality checks fail when such
+            # objects are included in a Qt data model. For example, plugins can
+            # grab a layer from the viewer; this layer will be wrapped by the
+            # PublicOnlyProxy, and then using this object to set the current
+            # layer selection will not propagate the selection to the Viewer.
+            # See https://github.com/napari/napari/issues/5767
+            value = value.__wrapped__
+
         setattr(self.__wrapped__, name, value)
         return None
 
@@ -134,7 +152,15 @@ def __dir__(self):
     @classmethod
     def create(cls, obj: Any) -> Union['PublicOnlyProxy', Any]:
         # restrict the scope of this proxy to napari objects
-        mod = getattr(type(obj), '__module__', None) or ''
+        if type(obj).__name__ == 'method':
+            # If the given object is a method, we check the module *of the
+            # object to which that method is bound*. Otherwise, the module of a
+            # method is just builtins!
+            mod = getattr(type(obj.__self__), '__module__', None) or ''
+        else:
+            # Otherwise, the module is of an object just given by the
+            # __module__ attribute.
+            mod = getattr(type(obj), '__module__', None) or ''
         if not mod.startswith('napari'):
             return obj
         if isinstance(obj, PublicOnlyProxy):
@@ -146,7 +172,20 @@ def create(cls, obj: Any) -> Union['PublicOnlyProxy', Any]:
 
 class CallablePublicOnlyProxy(PublicOnlyProxy[Callable]):
     def __call__(self, *args, **kwargs):
-        return self.__wrapped__(*args, **kwargs)
+        # if a PublicOnlyProxy is callable, then when we call it we:
+        # - unwrap the arguments, to avoid performance issues detailed in
+        #   PublicOnlyProxy.__setattr__,
+        # - call the unwrapped callable on the unwrapped arguments
+        # - wrap the result in a PublicOnlyProxy
+        args = [
+            arg.__wrapped__ if isinstance(arg, PublicOnlyProxy) else arg
+            for arg in args
+        ]
+        kwargs = {
+            k: v.__wrapped__ if isinstance(v, PublicOnlyProxy) else v
+            for k, v in kwargs.items()
+        }
+        return self.create(self.__wrapped__(*args, **kwargs))
 
 
 def in_main_thread_py() -> bool:


Test patch:
diff --git a/napari/utils/_tests/test_proxies.py b/napari/utils/_tests/test_proxies.py
--- a/napari/utils/_tests/test_proxies.py
+++ b/napari/utils/_tests/test_proxies.py
@@ -1,3 +1,4 @@
+from dataclasses import dataclass
 from unittest.mock import patch
 
 import numpy as np
@@ -5,6 +6,7 @@
 
 from napari.components.viewer_model import ViewerModel
 from napari.utils._proxies import PublicOnlyProxy, ReadOnlyWrapper
+from napari.utils.events.containers._set import EventedSet
 
 
 def test_ReadOnlyWrapper_setitem():
@@ -135,3 +137,42 @@ def test_receive_return_proxy_object():
 def test_viewer_method():
     viewer = PublicOnlyProxy(ViewerModel())
     assert viewer.add_points() is not None
+
+
+def test_unwrap_on_call():
+    """Check that PublicOnlyProxy'd arguments to methods of a
+    PublicOnlyProxy'd object are unwrapped before calling the method.
+    """
+    evset = EventedSet()
+    public_only_evset = PublicOnlyProxy(evset)
+    text = "aaa"
+    wrapped_text = PublicOnlyProxy(text)
+    public_only_evset.add(wrapped_text)
+    retrieved_text = list(evset)[0]
+
+    # check that the text in the set is not the version wrapped with
+    # PublicOnlyProxy
+    assert id(text) == id(retrieved_text)
+
+
+def test_unwrap_setattr():
+    """Check that objects added with __setattr__ of an object wrapped with
+    PublicOnlyProxy are unwrapped before setting the attribute.
+    """
+
+    @dataclass
+    class Sample:
+        attribute = "aaa"
+
+    sample = Sample()
+    public_only_sample = PublicOnlyProxy(sample)
+
+    text = "bbb"
+    wrapped_text = PublicOnlyProxy(text)
+
+    public_only_sample.attribute = wrapped_text
+    attribute = sample.attribute  # use original, not wrapped object
+
+    # check that the attribute in the unwrapped sample is itself not the
+    # wrapped text, but the original text.
+    assert id(text) == id(attribute)