Repository: napari/napari

Issue:
Napari plugin does not show in add docker widget menu until closed and reopen
## 🐛 Bug

Napari plugin does not show in add docker widget menu until closed and reopen

## To Reproduce

Steps to reproduce the behavior:

1. open napari and install napari-demo plugin
2. open `add dock widget` menu and there is nothing immediately after installation
3. close and reopen napari, napari-demo should appear in `add docker widget` menu now

## Expected behavior

`add dock widget` should discover the plugin after installation without reopening

## Environment

running against latest master: https://github.com/napari/napari/commit/e61fb64c85e1dfd7213a78f7b420c9fc8e071cbe

the plugin does not work for 0.4.3 release due to API update in latest master version



Discussion:
Note this is the case when installing directly from the "Install Packages" dialog - agreed it would be nice to discover plugin right after install without restart. We could also temporarily add a message that said something like "restart to see plugin"
easy to do without restart, thanks for noting it

Resolving patch:
diff --git a/napari/_qt/dialogs/qt_plugin_dialog.py b/napari/_qt/dialogs/qt_plugin_dialog.py
--- a/napari/_qt/dialogs/qt_plugin_dialog.py
+++ b/napari/_qt/dialogs/qt_plugin_dialog.py
@@ -99,6 +99,9 @@ def uninstall(self, pkg_list: Sequence[str]):
             self._output_widget.clear()
         self.process.start()
 
+        for pkg in pkg_list:
+            plugin_manager.unregister(pkg)
+
 
 class PluginListItem(QFrame):
     def __init__(
diff --git a/napari/_qt/qt_main_window.py b/napari/_qt/qt_main_window.py
--- a/napari/_qt/qt_main_window.py
+++ b/napari/_qt/qt_main_window.py
@@ -390,12 +390,12 @@ def __init__(self, viewer, *, show: bool = True):
 
         SETTINGS.appearance.events.theme.connect(self._update_theme)
 
-        plugin_manager.events.disabled.connect(self._rebuild_dock_widget_menu)
+        plugin_manager.events.disabled.connect(self._rebuild_plugins_menu)
         plugin_manager.events.disabled.connect(self._rebuild_samples_menu)
-        plugin_manager.events.registered.connect(
-            self._rebuild_dock_widget_menu
-        )
+        plugin_manager.events.registered.connect(self._rebuild_plugins_menu)
         plugin_manager.events.registered.connect(self._rebuild_samples_menu)
+        plugin_manager.events.unregistered.connect(self._rebuild_plugins_menu)
+        plugin_manager.events.unregistered.connect(self._rebuild_samples_menu)
 
         viewer.events.status.connect(self._status_changed)
         viewer.events.help.connect(self._help_changed)
@@ -781,6 +781,13 @@ def _add_plugins_menu(self):
         """Add 'Plugins' menu to app menubar."""
         self.plugins_menu = self.main_menu.addMenu(trans._('&Plugins'))
 
+        plugin_manager.discover_widgets()
+        self._rebuild_plugins_menu()
+
+    def _rebuild_plugins_menu(self, event=None):
+
+        self.plugins_menu.clear()
+
         pip_install_action = QAction(
             trans._("Install/Uninstall Package(s)..."), self._qt_window
         )
@@ -796,29 +803,19 @@ def _add_plugins_menu(self):
             )
         )
         report_plugin_action.triggered.connect(self._show_plugin_err_reporter)
-        self.plugins_menu.addAction(report_plugin_action)
 
-        self._plugin_dock_widget_menu = QMenu(
-            trans._('Add Dock Widget'), self._qt_window
-        )
-
-        plugin_manager.discover_widgets()
-        self._rebuild_dock_widget_menu()
-
-        self.plugins_menu.addMenu(self._plugin_dock_widget_menu)
-
-    def _rebuild_dock_widget_menu(self, event=None):
+        self.plugins_menu.addAction(report_plugin_action)
 
-        self._plugin_dock_widget_menu.clear()
+        self.plugins_menu.addSeparator()
 
         # Add a menu item (QAction) for each available plugin widget
         for hook_type, (plugin_name, widgets) in plugin_manager.iter_widgets():
             multiprovider = len(widgets) > 1
             if multiprovider:
                 menu = QMenu(plugin_name, self._qt_window)
-                self._plugin_dock_widget_menu.addMenu(menu)
+                self.plugins_menu.addMenu(menu)
             else:
-                menu = self._plugin_dock_widget_menu
+                menu = self.plugins_menu
 
             for wdg_name in widgets:
                 key = (plugin_name, wdg_name)
diff --git a/napari/plugins/_plugin_manager.py b/napari/plugins/_plugin_manager.py
--- a/napari/plugins/_plugin_manager.py
+++ b/napari/plugins/_plugin_manager.py
@@ -41,13 +41,31 @@ class PluginHookOption(TypedDict):
 
 
 class NapariPluginManager(PluginManager):
+    """PluginManager subclass for napari-specific functionality.
+
+    Events
+    ------
+    registered (value: str)
+        Emitted after plugin named `value` has been registered.
+    unregistered (value: str)
+        Emitted after plugin named `value` has been unregistered.
+    enabled (value: str)
+        Emitted after plugin named `value` has been removed from the block list.
+    disabled (value: str)
+        Emitted after plugin named `value` has been added to the block list.
+    """
+
     ENTRY_POINT = 'napari.plugin'
 
     def __init__(self):
         super().__init__('napari', discover_entry_point=self.ENTRY_POINT)
 
         self.events = EmitterGroup(
-            source=self, registered=None, enabled=None, disabled=None
+            source=self,
+            registered=None,
+            unregistered=None,
+            enabled=None,
+            disabled=None,
         )
         self._blocked: EventedSet[str] = EventedSet()
         self._blocked.events.changed.connect(self._on_blocked_change)
@@ -84,15 +102,33 @@ def register(
             self.events.registered(value=name)
         return name
 
+    def unregister(
+        self,
+        name_or_object: Any,
+    ) -> Optional[Any]:
+
+        if isinstance(name_or_object, str):
+            _name = name_or_object
+        else:
+            _name = self.get_name(name_or_object)
+
+        plugin = super().unregister(name_or_object)
+
+        # remove widgets, sample data
+        for _dict in (
+            self._dock_widgets,
+            self._sample_data,
+            self._function_widgets,
+        ):
+            _dict.pop(_name, None)
+
+        self.events.unregistered(value=_name)
+
+        return plugin
+
     def _on_blocked_change(self, event):
         # things that are "added to the blocked list" become disabled
         for item in event.added:
-            for _dict in (
-                self._dock_widgets,
-                self._sample_data,
-                self._function_widgets,
-            ):
-                _dict.pop(item, None)
             self.events.disabled(value=item)
 
         # things that are "removed from the blocked list" become enabled


Test patch:
diff --git a/napari/_qt/_tests/test_plugin_widgets.py b/napari/_qt/_tests/test_plugin_widgets.py
--- a/napari/_qt/_tests/test_plugin_widgets.py
+++ b/napari/_qt/_tests/test_plugin_widgets.py
@@ -82,17 +82,23 @@ def test_plugin_widgets(monkeypatch, napari_plugin_manager):
 def test_plugin_widgets_menus(test_plugin_widgets, make_napari_viewer):
     """Test the plugin widgets get added to the window menu correctly."""
     viewer = make_napari_viewer()
-    actions = viewer.window._plugin_dock_widget_menu.actions()
+    # only take the plugin actions
+    actions = viewer.window.plugins_menu.actions()
+    for cnt, action in enumerate(actions):
+        if action.text() == "":
+            # this is the separator
+            break
+    actions = actions[cnt + 1 :]
     assert len(actions) == 3
     expected_text = ['TestP1', 'TestP2: Widg3', 'TestP3: magic']
     assert [a.text() for a in actions] == expected_text
 
-    # the first item in the menu is a submenu (for "Test plugin1")
+    # the first item of the plugins is a submenu (for "Test plugin1")
     assert actions[0].menu()
     subnames = ['Widg1', 'Widg2']
     assert [a.text() for a in actions[0].menu().actions()] == subnames
 
-    # the other items in the menu are not submenus
+    # the other items for the plugins are not submenus
     assert not actions[1].menu()
     assert not actions[2].menu()
 
@@ -100,7 +106,13 @@ def test_plugin_widgets_menus(test_plugin_widgets, make_napari_viewer):
 def test_making_plugin_dock_widgets(test_plugin_widgets, make_napari_viewer):
     """Test that we can create dock widgets, and they get the viewer."""
     viewer = make_napari_viewer()
-    actions = viewer.window._plugin_dock_widget_menu.actions()
+    # only take the plugin actions
+    actions = viewer.window.plugins_menu.actions()
+    for cnt, action in enumerate(actions):
+        if action.text() == "":
+            # this is the separator
+            break
+    actions = actions[cnt + 1 :]
 
     # trigger the 'TestP2: Widg3' action
     actions[1].trigger()
@@ -132,7 +144,13 @@ def test_making_function_dock_widgets(test_plugin_widgets, make_napari_viewer):
     import magicgui
 
     viewer = make_napari_viewer()
-    actions = viewer.window._plugin_dock_widget_menu.actions()
+    # only take the plugin actions
+    actions = viewer.window.plugins_menu.actions()
+    for cnt, action in enumerate(actions):
+        if action.text() == "":
+            # this is the separator
+            break
+    actions = actions[cnt + 1 :]
 
     # trigger the 'TestP3: magic' action
     actions[2].trigger()
@@ -157,7 +175,13 @@ def test_making_function_dock_widgets(test_plugin_widgets, make_napari_viewer):
 def test_clear_all_plugin_widgets(test_plugin_widgets, make_napari_viewer):
     """Test the the 'Remove Dock Widgets' menu item clears added widgets."""
     viewer = make_napari_viewer()
-    actions = viewer.window._plugin_dock_widget_menu.actions()
+    # only take the plugin actions
+    actions = viewer.window.plugins_menu.actions()
+    for cnt, action in enumerate(actions):
+        if action.text() == "":
+            # this is the separator
+            break
+    actions = actions[cnt + 1 :]
     actions[1].trigger()
     actions[0].menu().actions()[1].trigger()
     assert len(viewer.window._dock_widgets) == 2
diff --git a/napari/plugins/_tests/test_plugins_misc.py b/napari/plugins/_tests/test_plugins_misc.py
--- a/napari/plugins/_tests/test_plugins_misc.py
+++ b/napari/plugins/_tests/test_plugins_misc.py
@@ -36,10 +36,12 @@ def test_plugin_events(napari_plugin_manager):
     tnpm: NapariPluginManager = napari_plugin_manager
 
     register_events = []
+    unregister_events = []
     enable_events = []
     disable_events = []
 
     tnpm.events.registered.connect(lambda e: register_events.append(e))
+    tnpm.events.unregistered.connect(lambda e: unregister_events.append(e))
     tnpm.events.enabled.connect(lambda e: enable_events.append(e))
     tnpm.events.disabled.connect(lambda e: disable_events.append(e))
 
@@ -53,6 +55,10 @@ class Plugin:
     assert not enable_events
     assert not disable_events
 
+    tnpm.unregister(Plugin)
+    assert len(unregister_events) == 1
+    assert unregister_events[0].value == 'Plugin'
+
     tnpm.set_blocked('Plugin')
     assert len(disable_events) == 1
     assert disable_events[0].value == 'Plugin'