Source code for spyder.api.plugin_registration.registry

# -----------------------------------------------------------------------------
# Copyright (c) 2021- Spyder Project Contributors
#
# Released under the terms of the MIT License
# (see LICENSE.txt in the project root directory for details)
# -----------------------------------------------------------------------------

"""Global registry for internal and external Spyder plugins."""

from __future__ import annotations

# Standard library imports
import logging
import sys
from typing import Any, Union, TYPE_CHECKING

if sys.version_info < (3, 10):
    from typing_extensions import TypeAlias
else:
    from typing import TypeAlias  # noqa: ICN003

# Third-party library imports
from qtpy.QtCore import QObject, Signal

# Local imports
from spyder import dependencies
from spyder.api.translations import _
from spyder.config.base import running_under_pytest
from spyder.config.manager import CONF
from spyder.api.config.mixins import SpyderConfigurationAccessor
from spyder.api.plugin_registration._confpage import PluginsConfigPage
from spyder.api.exceptions import SpyderAPIError
from spyder.api.plugins import Plugins, SpyderDockablePlugin, SpyderPluginV2
from spyder.utils.icon_manager import ima

if TYPE_CHECKING:
    from qtpy.QtGui import QIcon

    import spyder.app.mainwindow


SpyderPluginClass: TypeAlias = Union[SpyderPluginV2, SpyderDockablePlugin]
"""Type alias for the set of supported classes for Spyder plugin objects."""

ALL_PLUGINS: list[str] = [
    getattr(Plugins, attr)
    for attr in dir(Plugins)
    if not attr.startswith("_") and attr != "All"
]
"""List of all Spyder internal plugins."""

logger = logging.getLogger(__name__)


[docs] class PreferencesAdapter(SpyderConfigurationAccessor): """Class with constants for the plugin manager preferences page.""" # Fake class constants used to register the configuration page CONF_WIDGET_CLASS = PluginsConfigPage """:meta private:""" NAME = "plugin_registry" CONF_VERSION = None ADDITIONAL_CONF_OPTIONS = None ADDITIONAL_CONF_TABS = None CONF_SECTION = "" """:meta private:""" def apply_plugin_settings(self, _unused): pass def apply_conf(self, _unused): pass
[docs] class SpyderPluginRegistry(QObject, PreferencesAdapter): """ Global Spyder plugin registry. This class handles a plugin's initialization/teardown lifetime, including notifications when a plugin is available or about to be torn down. This registry alleviates the limitations of a topological sort-based plugin initialization by enabling plugins to have bidirectional dependencies instead of unidirectional ones. .. caution:: A plugin should not depend on other plugin to perform its initialization, since this could cause deadlocks. .. note:: This class should be instantiated as a singleton. """ sig_plugin_ready: Signal = Signal(str, bool) """ Signal used to let the main window know that a plugin is ready for use. Parameters ---------- plugin_name: str Name of the plugin that has become available. omit_conf: bool ``True`` if the plugin configuration does not need to be written; ``False`` otherwise. """
[docs] def __init__(self) -> None: """ Create a global registry for internal and external Spyder plugins. Returns ------- None """ super().__init__() PreferencesAdapter.__init__(self) self.main: spyder.app.mainwindow.MainWindow | None = None """Reference to the Spyder main window.""" self.plugin_dependents: dict[str, dict[str, list[str]]] = {} """ Mapping of plugin names to the names of plugins depending on them. The second-level dictionary holds lists of dependencies for the plugin by category, under the keys ``"requires"`` and ``"optional"``. """ self.plugin_dependencies: dict[str, dict[str, list[str]]] = {} """ Mapping of plugin names to the names of plugins they depend on. The second-level dictionary holds lists of dependencies for the plugin by category, under the keys ``"requires"`` and ``"optional"``. """ self.plugin_registry: dict[str, SpyderPluginClass] = {} """Mapping of plugin names to plugin objects.""" self.plugin_availability: dict[str, bool] = {} """Mapping of plugin name to whether it is ready for use.""" self.enabled_plugins: set[str] = set() """Set of the names of all enabled plugins.""" self.internal_plugins: set[str] = set() """Set of the names of all internal plugins (part of Spyder itself).""" self.external_plugins: set[str] = set() """Set of the names of all external plugins (installed separately).""" self.all_internal_plugins: dict[ str, tuple[str, type[SpyderPluginClass]] ] = {} """Mapping of internal plugins to their name and plugin class. Includes all internal plugins that are part of Spyder's source tree, enabled or not. """ self.all_external_plugins: dict[ str, tuple[str, type[SpyderPluginClass]] ] = {} """Mapping of external plugins to their name and plugin class. Includes all externals plugins installed separately from Spyder, enabled or not. """ # This is used to allow disabling external plugins through Preferences self._external_plugins_conf_section = "external_plugins"
# ------------------------- PRIVATE API ----------------------------------- def _update_dependents(self, plugin: str, dependent_plugin: str, key: str): """Add `dependent_plugin` to the list of dependents of `plugin`.""" plugin_dependents = self.plugin_dependents.get(plugin, {}) plugin_strict_dependents = plugin_dependents.get(key, []) plugin_strict_dependents.append(dependent_plugin) plugin_dependents[key] = plugin_strict_dependents self.plugin_dependents[plugin] = plugin_dependents def _update_dependencies( self, plugin: str, required_plugin: str, key: str ): """Add `required_plugin` to the list of dependencies of `plugin`.""" plugin_dependencies = self.plugin_dependencies.get(plugin, {}) plugin_strict_dependencies = plugin_dependencies.get(key, []) plugin_strict_dependencies.append(required_plugin) plugin_dependencies[key] = plugin_strict_dependencies self.plugin_dependencies[plugin] = plugin_dependencies def _update_plugin_info( self, plugin_name: str, required_plugins: list[str], optional_plugins: list[str], ): """Update the dependencies and dependents of `plugin_name`.""" for plugin in required_plugins: self._update_dependencies(plugin_name, plugin, "requires") self._update_dependents(plugin, plugin_name, "requires") for plugin in optional_plugins: self._update_dependencies(plugin_name, plugin, "optional") self._update_dependents(plugin, plugin_name, "optional") def _instantiate_spyder_plugin( self, main_window: Any, PluginClass: type[SpyderPluginClass], external: bool, ) -> SpyderPluginClass: """Instantiate and register a Spyder plugin.""" required_plugins = list(set(PluginClass.REQUIRES)) optional_plugins = list(set(PluginClass.OPTIONAL)) plugin_name = PluginClass.NAME logger.debug(f"Registering plugin {plugin_name} - {PluginClass}") if PluginClass.CONF_FILE: CONF.register_plugin(PluginClass) for plugin in list(required_plugins): if plugin == Plugins.All: required_plugins = list(set(required_plugins + ALL_PLUGINS)) for plugin in list(optional_plugins): if plugin == Plugins.All: optional_plugins = list(set(optional_plugins + ALL_PLUGINS)) # Update plugin dependency information self._update_plugin_info( plugin_name, required_plugins, optional_plugins ) # Create and store plugin instance plugin_instance = PluginClass(main_window, configuration=CONF) self.plugin_registry[plugin_name] = plugin_instance # Connect plugin availability signal to notification system plugin_instance.sig_plugin_ready.connect( lambda: self.notify_plugin_availability( plugin_name, omit_conf=PluginClass.CONF_FILE ) ) # Initialize plugin instance plugin_instance.initialize() # Register plugins that are already available self._notify_plugin_dependencies(plugin_name) # Register the plugin name under the external or internal # plugin set if external: self.external_plugins |= {plugin_name} else: self.internal_plugins |= {plugin_name} if external: # These attributes come from spyder.app.find_plugins module = PluginClass._spyder_module_name package_name = PluginClass._spyder_package_name version = PluginClass._spyder_version description = plugin_instance.get_description() dependencies.add( module, package_name, description, version, None, kind=dependencies.PLUGIN, ) return plugin_instance def _notify_plugin_dependencies(self, plugin_name: str): """Notify a plugin of its available dependencies.""" plugin_instance = self.plugin_registry[plugin_name] plugin_dependencies = self.plugin_dependencies.get(plugin_name, {}) required_plugins = plugin_dependencies.get("requires", []) optional_plugins = plugin_dependencies.get("optional", []) for plugin in required_plugins + optional_plugins: if plugin in self.plugin_registry: if self.plugin_availability.get(plugin, False): logger.debug(f"Plugin {plugin} has already loaded") plugin_instance._on_plugin_available(plugin) def _notify_plugin_teardown(self, plugin_name: str): """Notify dependents of a plugin that is going to be unavailable.""" plugin_dependents = self.plugin_dependents.get(plugin_name, {}) required_plugins = plugin_dependents.get("requires", []) optional_plugins = plugin_dependents.get("optional", []) for plugin in required_plugins + optional_plugins: if plugin in self.plugin_registry: if self.plugin_availability.get(plugin, False): logger.debug( f"Notifying plugin {plugin} that " f"{plugin_name} is going to be turned off" ) plugin_instance = self.plugin_registry[plugin] plugin_instance._on_plugin_teardown(plugin_name) def _teardown_plugin(self, plugin_name: str): """Disconnect a plugin from its dependencies.""" plugin_instance = self.plugin_registry[plugin_name] plugin_dependencies = self.plugin_dependencies.get(plugin_name, {}) required_plugins = plugin_dependencies.get("requires", []) optional_plugins = plugin_dependencies.get("optional", []) for plugin in required_plugins + optional_plugins: if plugin in self.plugin_registry: if self.plugin_availability.get(plugin, False): logger.debug(f"Disconnecting {plugin_name} from {plugin}") plugin_instance._on_plugin_teardown(plugin) # -------------------------- PUBLIC API -----------------------------------
[docs] def register_plugin( self, main_window: spyder.app.mainwindow.MainWindow, PluginClass: type[SpyderPluginClass], *args: Any, external: bool = False, **kwargs: Any, ) -> SpyderPluginClass: """ Register a plugin into the Spyder plugin registry. Parameters ---------- main_window: spyder.app.mainwindow.MainWindow Reference to Spyder's main window. PluginClass: type[SpyderPluginClass] The plugin class to register and create. It must be one of :data:`SpyderPluginClass`. *args: Any, optional Arbitrary positional arguments passed to the plugin instance's initializer. .. deprecated:: 6.2 No longer needed following completion of the Spyder plugin API migration in Spyder 6.0. Passing ``*args`` will raise a :exc:`DeprecationWarning` in Spyder 6.2 and be removed in Spyder 7.0. external: bool, optional If ``True``, then the plugin is stored as an external plugin. Otherwise, it will be marked as an internal plugin (the default). **kwargs: Any, optional Arbitrary positional arguments passed to the plugin instance's initializer. .. deprecated:: 6.2 No longer needed following completion of the Spyder plugin API migration in Spyder 6.0. Passing ``**kwargs`` will raise a :exc:`DeprecationWarning` in Spyder 6.2 and be removed in Spyder 7.0. Returns ------- plugin: SpyderPluginClass The initialized instance of the registered plugin. Raises ------ TypeError If the ``PluginClass`` does not inherit from any of :data:`spyder.app.registry.SpyderPluginClass`. """ if not issubclass(PluginClass, SpyderPluginV2): raise TypeError( f"{PluginClass} does not inherit from SpyderPluginV2" ) # Register a Spyder plugin instance = self._instantiate_spyder_plugin( main_window, PluginClass, external ) return instance
[docs] def notify_plugin_availability( self, plugin_name: str, notify_main: bool = True, omit_conf: bool = False, ) -> None: """ Notify a plugin's dependents that the plugin is ready for use. Parameters ---------- plugin_name: str Name of the plugin that has become ready for use. notify_main: bool, optional If ``True``, then a signal is emitted to the main window to perform further registration steps (the default). Otherwise, no signal is emitted. omit_conf: bool, optional If ``True``, then the main window is instructed to not write the plugin configuration into the Spyder configuration file. Otherwise, configuration will be written as normal (the default). Returns ------- None """ logger.debug( f"Plugin {plugin_name} has finished loading, sending notifications" ) # Set plugin availability to True self.plugin_availability[plugin_name] = True # Notify the main window that the plugin is ready if notify_main: self.sig_plugin_ready.emit(plugin_name, omit_conf) # Notify plugin dependents plugin_dependents = self.plugin_dependents.get(plugin_name, {}) required_plugins = plugin_dependents.get("requires", []) optional_plugins = plugin_dependents.get("optional", []) for plugin in required_plugins + optional_plugins: if plugin in self.plugin_registry: plugin_instance = self.plugin_registry[plugin] plugin_instance._on_plugin_available(plugin_name) if plugin_name == Plugins.Preferences and not running_under_pytest(): plugin_instance = self.plugin_registry[plugin_name] plugin_instance.register_plugin_preferences(self)
[docs] def can_delete_plugin(self, plugin_name: str) -> bool: """ Check if a plugin with the given name can be deleted from the registry. Calls :meth:`spyder.api.plugins.SpyderPluginV2.can_close` to perform the check. Parameters ---------- plugin_name: str Name of the plugin to check for deletion. Returns ------- can_close: bool ``True`` if the plugin can be removed; ``False`` otherwise. """ plugin_instance = self.plugin_registry[plugin_name] # Determine if plugin can be closed return plugin_instance.can_close()
[docs] def dock_undocked_plugin( self, plugin_name: str, save_undocked: bool = False ) -> None: """ Dock plugin if undocked and save undocked state if requested. Parameters ---------- plugin_name: str Name of the plugin to undock. save_undocked : bool, optional ``True`` if the undocked state should be saved. If ``False``, the default, don't persist the undocked state. Returns ------- None """ plugin_instance = self.plugin_registry[plugin_name] if isinstance(plugin_instance, SpyderDockablePlugin): # Close undocked plugin if needed and save undocked state plugin_instance.close_window(save_undocked=save_undocked)
[docs] def delete_plugin( self, plugin_name: str, teardown: bool = True, check_can_delete: bool = True, ) -> bool: """ Remove and delete the plugin with the given name from the registry. Parameters ---------- plugin_name: str Name of the plugin to delete. teardown: bool, optional ``True`` if the teardown notification to other plugins should be sent when deleting the plugin (the default), ``False`` otherwise. check_can_delete: bool, optional ``True`` if the plugin should first check if it can be deleted (using :meth:`can_delete_plugin`) before closing and removing itself from the registry (the default). If this check then fails, the plugin's removal is aborted and this method returns ``False``. Otherwise, if this parameter is ``False``, the plugin is deleted unconditionally without a check. Returns ------- plugin_deleted: bool ``True`` if the registry was able to teardown and remove the plugin; ``False`` otherwise. Will always return ``True`` if ``check_can_delete`` is ``False``. """ logger.debug(f"Deleting plugin {plugin_name}") plugin_instance = self.plugin_registry[plugin_name] # Determine if plugin can be closed if check_can_delete: can_delete = self.can_delete_plugin(plugin_name) if not can_delete: return False if isinstance(plugin_instance, SpyderPluginV2): # Cleanly delete plugin widgets. This avoids segfaults with # PyQt 5.15 if isinstance(plugin_instance, SpyderDockablePlugin): try: plugin_instance.get_widget().close() plugin_instance.get_widget().deleteLater() except RuntimeError: pass else: container = plugin_instance.get_container() if container: try: container.close() container.deleteLater() except RuntimeError: pass # Delete plugin try: plugin_instance.deleteLater() except RuntimeError: pass if teardown: # Disconnect plugin from other plugins self._teardown_plugin(plugin_name) # Disconnect depending plugins from the plugin to delete self._notify_plugin_teardown(plugin_name) # Perform plugin closure tasks try: plugin_instance.on_close(True) except RuntimeError: pass # Delete plugin from the registry and auxiliary structures self.plugin_dependents.pop(plugin_name, None) self.plugin_dependencies.pop(plugin_name, None) if plugin_instance.CONF_FILE: # This must be done after on_close() so that plugins can modify # their (external) config therein. CONF.unregister_plugin(plugin_instance) for plugin in self.plugin_dependents: all_plugin_dependents = self.plugin_dependents[plugin] for key in {"requires", "optional"}: plugin_dependents = all_plugin_dependents.get(key, []) if plugin_name in plugin_dependents: plugin_dependents.remove(plugin_name) for plugin in self.plugin_dependencies: all_plugin_dependencies = self.plugin_dependencies[plugin] for key in {"requires", "optional"}: plugin_dependencies = all_plugin_dependencies.get(key, []) if plugin_name in plugin_dependencies: plugin_dependencies.remove(plugin_name) self.plugin_availability.pop(plugin_name) self.enabled_plugins -= {plugin_name} self.internal_plugins -= {plugin_name} self.external_plugins -= {plugin_name} # Remove the plugin from the registry self.plugin_registry.pop(plugin_name) return True
[docs] def dock_all_undocked_plugins(self, save_undocked: bool = False) -> None: """ Dock undocked plugins and save the undocked state if requested. Parameters ---------- save_undocked: bool, optional ``True`` if the undocked state should be saved. If ``False``, the default, don't persist the undocked state. Returns ------- None """ for plugin_name in set(self.external_plugins) | set( self.internal_plugins ): self.dock_undocked_plugin(plugin_name, save_undocked=save_undocked)
[docs] def can_delete_all_plugins( self, excluding: set[str] | None = None, ) -> bool: """ Determine if all plugins can be deleted (except any specified). Calls :meth:`spyder.api.plugins.SpyderPluginV2.can_close` to perform the check. Parameters ---------- excluding: set[str] | None, optional A set that lists plugins (by name) that will not be checked for deletion. If ``None`` (the default) or an empty set, no plugins are excluded from the check. Returns ------- bool ``True`` if all plugins can be closed. ``False`` otherwise. """ excluding = excluding or set({}) can_close = True # Check external plugins for plugin_name in set(self.external_plugins) | set( self.internal_plugins ): if plugin_name not in excluding: can_close &= self.can_delete_plugin(plugin_name) if not can_close: break return can_close
[docs] def delete_all_plugins( self, excluding: set[str] | None = None, close_immediately: bool = False, ) -> bool: """ Remove all plugins from the registry. The teardown mechanism will remove external plugins then internal ones. Parameters ---------- excluding: set[str] | None, optional A set that lists plugins (by name) that will not be deleted. If ``None`` (the default) or an empty set, no plugins are excluded from being deleted. close_immediately: bool, optional If ``True``, then the :meth:`~spyder.api.plugins.SpyderPluginV2.can_close` status will be ignored, and all plugins will be closed unconditionally. If ``False`` (the default), Spyder will not close any plugins if they report they cannot be closed, i.e. if :meth:`can_delete_all_plugins` returns ``False``. Returns ------- all_deleted: bool ``True`` if all the plugins were deleted. ``False`` otherwise. """ excluding = excluding or set({}) # Check if all the plugins can be closed can_close = self.can_delete_all_plugins(excluding=excluding) # Delete external plugins first, then internal plugins for plugins in [self.external_plugins, self.internal_plugins]: if not can_close and not close_immediately: return False # Delete Spyder plugins for plugin_name in set(plugins): if plugin_name not in excluding: plugin_instance = self.plugin_registry[plugin_name] if isinstance(plugin_instance, SpyderPluginV2): can_close &= self.delete_plugin( plugin_name, teardown=False, check_can_delete=False ) if not can_close and not close_immediately: break return can_close
[docs] def get_plugin(self, plugin_name: str) -> SpyderPluginClass: """ Get a reference to a plugin instance by its name. Parameters ---------- plugin_name: str Name of the plugin to retrieve the plugin object of. Returns ------- plugin: SpyderPluginClass The instance of the requested plugin. Raises ------ SpyderAPIError If the plugin name was not found in the registry. """ if plugin_name in self.plugin_registry: plugin_instance = self.plugin_registry[plugin_name] return plugin_instance else: raise SpyderAPIError( f"Plugin {plugin_name} was not found in the registry" )
[docs] def set_plugin_enabled(self, plugin_name: str) -> None: """ Add a plugin by name to the set of enabled plugins. Parameters ---------- plugin_name: str Name of the plugin to add. Returns ------- None """ self.enabled_plugins |= {plugin_name}
[docs] def is_plugin_enabled(self, plugin_name: str) -> bool: """ Determine if a given plugin is enabled and is going to be loaded. Parameters ---------- plugin_name: str Name of the plugin to check. Returns ------- plugin_enabled: bool ``True`` if the plugin is enabled and ``False`` if not. """ return plugin_name in self.enabled_plugins
[docs] def is_plugin_available(self, plugin_name: str) -> bool: """ Determine if a given plugin is loaded and ready for use. Parameters ---------- plugin_name: str Name of the plugin to check. Returns ------- plugin_available: bool ``True`` if the plugin is available and ``False`` if not. """ return self.plugin_availability.get(plugin_name, False)
[docs] def reset(self) -> None: """ Reset and empty the plugin registry. Returns ------- None """ self.plugin_dependents = {} self.plugin_dependencies = {} self.plugin_registry = {} self.plugin_availability = {} self.enabled_plugins = set() self.internal_plugins = set() self.external_plugins = set() try: self.sig_plugin_ready.disconnect() except (TypeError, RuntimeError): # Omit failures if there are no slots connected pass dependencies.DEPENDENCIES = []
[docs] def set_all_internal_plugins( self, all_plugins: dict[str, tuple[str, type[SpyderPluginClass]]] ) -> None: """ Set the :attr:`all_internal_plugins` attribute to the given plugins. .. deprecated:: 6.2 Will raise a :exc:`DeprecationWarning` in Spyder 6.2 and be removed in Spyder 7.0. Set the :attr:`all_internal_plugins` attribute directly instead. Parameters ---------- all_plugins : dict[str, tuple[str, type[SpyderPluginClass]]] Mapping of plugin name to plugin class to set the attribute to. Returns ------- None """ self.all_internal_plugins = all_plugins
[docs] def set_all_external_plugins( self, all_plugins: dict[str, tuple[str, type[SpyderPluginClass]]] ) -> None: """ Set the :attr:`all_external_plugins` attribute to the given plugins. .. deprecated:: 6.2 Will raise a :exc:`DeprecationWarning` in Spyder 6.2 and be removed in Spyder 7.0. Set the :attr:`all_external_plugins` attribute directly instead. Parameters ---------- all_plugins : dict[str, tuple[str, type[SpyderPluginClass]]] Mapping of plugin name to plugin class to set the attribute to. Returns ------- None """ self.all_external_plugins = all_plugins
[docs] def set_main(self, main: spyder.app.mainwindow.MainWindow) -> None: """ Set the reference to the Spyder main window for the plugin registry. .. deprecated:: 6.2 Will raise a :exc:`DeprecationWarning` in Spyder 6.2 and be removed in Spyder 7.0. Set the :attr:`main` attribute directly instead. Parameters ---------- main : spyder.app.mainwindow.MainWindow The Spyder main window instance to set the reference to. Returns ------- None """ self.main = main
[docs] def get_icon(self) -> QIcon: """Icon of the plugin registry, for use in the Spyder interface.""" return ima.icon("plugins")
[docs] def get_name(self) -> str: """Name of the plugin registry, translated to the locale language.""" return _("Plugins")
[docs] def __contains__(self, plugin_name: str) -> bool: """ Determine if a plugin with a given name is contained in the registry. Parameters ---------- plugin_name: str Name of the plugin to check. Returns ------- is_contained: bool If ``True``, `plugin_name` is contained in the registry; ``False`` otherwise. """ return plugin_name in self.plugin_registry
def __iter__(self): return iter(self.plugin_registry)
PLUGIN_REGISTRY: SpyderPluginRegistry = SpyderPluginRegistry() """The global Spyder plugin registry instance."""