diff --git a/meshroom/core/__init__.py b/meshroom/core/__init__.py index a48eda3d1c..0ec63ec797 100644 --- a/meshroom/core/__init__.py +++ b/meshroom/core/__init__.py @@ -7,6 +7,7 @@ from pathlib import Path import pkgutil import sys +import threading import traceback import uuid @@ -34,6 +35,28 @@ pluginManager: NodePluginManager = NodePluginManager() submitters: dict[str, BaseSubmitter] = {} pipelineTemplates: dict[str, str] = {} +_pluginLoadingContext = threading.local() + + +def registerMenuAction(label: str, function: callable, tooltip: str = ""): + """ + Register a menu action for the plugin currently being loaded. + + This function is intended to be called from a plugin's Python code (e.g. its + ``__init__.py``) during the plugin loading phase. It associates a menu entry + with a Python callable so that the action can be triggered from Meshroom's + Plugins menu. + + Args: + label: the text to display in the menu item. + function: the Python callable to invoke when the menu item is triggered. + tooltip: an optional tooltip for the menu item. + """ + plugin = getattr(_pluginLoadingContext, "currentPlugin", None) + if plugin is None: + logging.warning("registerMenuAction called outside of plugin loading context.") + return + plugin.addMenuAction(label, function, tooltip) def hashValue(value) -> str: @@ -338,12 +361,25 @@ def loadAllNodes(folder) -> list[Plugin]: for _, package, ispkg in pkgutil.iter_modules([folder]): if ispkg: plugin = Plugin(package, folder) + _pluginLoadingContext.currentPlugin = plugin nodePlugins = loadNodes(folder, package) + _pluginLoadingContext.currentPlugin = None if nodePlugins: for node in nodePlugins: plugin.addNodePlugin(node) nodesStr = ', '.join([node.nodeDescriptor.__name__ for node in nodePlugins]) logging.debug(f'Nodes loaded [{package}]: {nodesStr}') + # Call the plugin's register() hook if it defines one. + # This is the reliable way to register menu actions because it is called + # explicitly on every load, even when the module is already cached. + pkg_module = sys.modules.get(package) + if pkg_module is not None: + register_fn = getattr(pkg_module, "register", None) + if callable(register_fn): + try: + register_fn(plugin) + except Exception as exc: + logging.error(f"Error calling register() for plugin '{package}': {exc}") plugins.append(plugin) return plugins diff --git a/meshroom/core/plugins.py b/meshroom/core/plugins.py index e9f3ab7054..127d59f337 100644 --- a/meshroom/core/plugins.py +++ b/meshroom/core/plugins.py @@ -7,6 +7,7 @@ import os import re import sys +import uuid from enum import Enum from inspect import getfile @@ -296,6 +297,7 @@ def __init__(self, name: str, path: str): self._nodePlugins: dict[str: NodePlugin] = {} self._templates: dict[str: str] = {} + self._menuActions: list[dict] = [] self._configEnv: dict[str: str] = {} self._configFullEnv: dict[str: str] = {} self._processEnv: ProcessEnv = ProcessEnv(path, self._configEnv) @@ -326,6 +328,11 @@ def templates(self): """ Return the list of templates associated to the plugin. """ return self._templates + @property + def menuActions(self): + """ Return the list of menu actions associated to the plugin. """ + return self._menuActions + @property def processEnv(self): """ Return the environment required to successfully execute processes. """ @@ -385,6 +392,28 @@ def loadTemplates(self): if file.endswith(".mg"): self._templates[os.path.splitext(file)[0]] = os.path.join(self.path, file) + def addMenuAction(self, label: str, function: callable, tooltip: str = ""): + """ + Register a menu action with a Python callable for this plugin. + + Args: + label: the text to display in the menu item. + function: the Python callable to invoke when the menu item is triggered. + tooltip: an optional tooltip for the menu item. + """ + if not label or not label.strip(): + logging.warning(f"Skipping menu action without label in plugin {self.name}.") + return + if not callable(function): + logging.warning(f"Skipping menu action '{label}' in plugin {self.name}: 'function' is not callable.") + return + self._menuActions.append({ + "label": label, + "function": function, + "tooltip": tooltip, + "actionId": str(uuid.uuid4()), + }) + def loadConfig(self): """ Load the plugin's configuration file if it exists and saves all its environment variables @@ -726,3 +755,40 @@ def unregisterNode(self, nodePlugin: NodePlugin): else: nodePlugin.status = NodePluginStatus.NOT_LOADED del self._nodePlugins[name] + + def getAllMenuActions(self) -> list[dict]: + """ + Return a list of all menu actions from all loaded plugins. + Each entry in the list is a dictionary with the following keys: + - 'label' (str): the text to display in the menu item. + - 'tooltip' (str): a tooltip for the menu item. + - 'actionId' (str): a unique identifier for the action. + - 'pluginName' (str): the name of the plugin providing this action. + """ + actions = [] + for plugin in self._plugins.values(): + for menuAction in plugin.menuActions: + actions.append({ + "label": menuAction["label"], + "tooltip": menuAction["tooltip"], + "actionId": menuAction["actionId"], + "pluginName": plugin.name, + }) + return actions + + def executeMenuAction(self, actionId: str): + """ + Execute the menu action identified by 'actionId'. + + Args: + actionId: the unique identifier of the menu action to execute. + """ + for plugin in self._plugins.values(): + for menuAction in plugin.menuActions: + if menuAction["actionId"] == actionId: + try: + menuAction["function"]() + except Exception as exc: + logging.error(f"Error executing menu action '{menuAction['label']}': {exc}") + return + logging.warning(f"No menu action found with ID: {actionId}") diff --git a/meshroom/ui/app.py b/meshroom/ui/app.py index 665f3f2dac..96b5a70343 100644 --- a/meshroom/ui/app.py +++ b/meshroom/ui/app.py @@ -385,6 +385,7 @@ def _pipelineTemplateNames(self): def reloadTemplateList(self): meshroom.core.initPipelines() self.pipelineTemplateFilesChanged.emit() + self.pluginMenuActionsChanged.emit() @Slot() def forceUIUpdate(self): @@ -763,6 +764,22 @@ def _submittersList(self): }) return submittersList + def _pluginMenuActions(self): + """ + Get the list of menu actions contributed by all loaded plugins. + Model provides: + label: the text to display in the menu item + tooltip: an optional tooltip for the menu item + actionId: the unique identifier for the action + pluginName: the name of the plugin providing this action + """ + return pluginManager.getAllMenuActions() + + @Slot(str) + def executePluginMenuAction(self, actionId): + """Execute the plugin menu action identified by 'actionId'.""" + pluginManager.executeMenuAction(actionId) + @Slot(str) def setDefaultSubmitter(self, name): logging.info(f"Submitter is now set to : {name}") @@ -776,6 +793,7 @@ def setDefaultSubmitter(self, name): pipelineTemplateFilesChanged = Signal() recentProjectFilesChanged = Signal() recentImportedImagesFoldersChanged = Signal() + pluginMenuActionsChanged = Signal() pipelineTemplateFiles = Property("QVariantList", _pipelineTemplateFiles, notify=pipelineTemplateFilesChanged) pipelineTemplateNames = Property("QVariantList", _pipelineTemplateNames, notify=pipelineTemplateFilesChanged) recentProjectFiles = Property("QVariantList", lambda self: self._recentProjectFiles, notify=recentProjectFilesChanged) @@ -783,3 +801,4 @@ def setDefaultSubmitter(self, name): default8bitViewerEnabled = Property(bool, _default8bitViewerEnabled, constant=True) defaultSequencePlayerEnabled = Property(bool, _defaultSequencePlayerEnabled, constant=True) submittersListModel = Property("QVariantList", _submittersList, constant=True) + pluginMenuActions = Property("QVariantList", _pluginMenuActions, notify=pluginMenuActionsChanged) diff --git a/meshroom/ui/qml/Application.qml b/meshroom/ui/qml/Application.qml index d6721abf56..7e39323c91 100644 --- a/meshroom/ui/qml/Application.qml +++ b/meshroom/ui/qml/Application.qml @@ -1043,6 +1043,25 @@ Page { shortcut: "F1" } } + Menu { + id: pluginsMenu + title: "Plugins" + visible: pluginsMenuItems.count > 0 + Repeater { + id: pluginsMenuItems + model: MeshroomApp.pluginMenuActions + MenuItem { + text: modelData["label"] + onTriggered: MeshroomApp.executePluginMenuAction(modelData["actionId"]) + ToolTip { + visible: parent.hovered && modelData["tooltip"] !== "" + text: modelData["tooltip"] + x: pluginsMenu.implicitWidth + y: 0 + } + } + } + } } Rectangle { diff --git a/meshroom/ui/scene.py b/meshroom/ui/scene.py index 47f102393e..1ddfa39028 100755 --- a/meshroom/ui/scene.py +++ b/meshroom/ui/scene.py @@ -449,6 +449,7 @@ def _reloadPlugins(self): reloadedNodes: list[str] = [] errorNodes: list[str] = [] for plugin in meshroom.core.pluginManager.getPlugins().values(): + plugin.loadMenuActions() for node in plugin.nodes.values(): if node.reload(): reloadedNodes.append(node.nodeDescriptor.__name__) @@ -461,6 +462,7 @@ def _reloadPlugins(self): @Slot(list) def _onPluginsReloaded(self, reloadedNodes: list, errorNodes: list): self._graph.reloadNodePlugins(reloadedNodes) + self.parent().pluginMenuActionsChanged.emit() if len(errorNodes) > 0: self.parent().showMessage(f"Some plugins failed to reload: {', '.join(errorNodes)}", "error") else: diff --git a/tests/plugins/meshroom/pluginA/__init__.py b/tests/plugins/meshroom/pluginA/__init__.py index e69de29bb2..f2da0900c8 100644 --- a/tests/plugins/meshroom/pluginA/__init__.py +++ b/tests/plugins/meshroom/pluginA/__init__.py @@ -0,0 +1,14 @@ +import webbrowser + + +def _openDocumentation(): + webbrowser.open("https://example.com/docs") + + +def _reportIssue(): + webbrowser.open("https://example.com/issues") + + +def register(plugin): + plugin.addMenuAction("Plugin Documentation", _openDocumentation, "Open plugin documentation") + plugin.addMenuAction("Report Issue", _reportIssue) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 95416e623c..c0ebbe1c85 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -369,3 +369,82 @@ def test_loadedConfigWithSomeExistingKeys(self): assert config[self.CONFIG_STRING[0]] != self.CONFIG_STRING[2] assert configFullEnv[self.CONFIG_STRING[0]] == self.CONFIG_STRING[2] + + +class TestPluginMenuActions: + """Test the registration and execution of plugin menu actions.""" + + def test_menuActionsRegistered(self): + """Check that menu actions registered via registerMenuAction are loaded for the plugin.""" + folder = os.path.join(os.path.dirname(__file__), "plugins") + with registeredPlugins(folder): + plugin = pluginManager.getPlugin("pluginA") + assert plugin + + actions = plugin.menuActions + assert len(actions) == 2 + + assert actions[0]["label"] == "Plugin Documentation" + assert actions[0]["tooltip"] == "Open plugin documentation" + assert callable(actions[0]["function"]) + assert "actionId" in actions[0] + + assert actions[1]["label"] == "Report Issue" + assert actions[1]["tooltip"] == "" + assert callable(actions[1]["function"]) + assert "actionId" in actions[1] + + def test_getAllMenuActionsAggregation(self): + """Check that getAllMenuActions aggregates actions from all plugins.""" + folder = os.path.join(os.path.dirname(__file__), "plugins") + with registeredPlugins(folder): + allActions = pluginManager.getAllMenuActions() + # pluginA has 2 registered actions; pluginB has none + pluginAActions = [a for a in allActions if a["pluginName"] == "pluginA"] + assert len(pluginAActions) == 2 + assert all("label" in a and "actionId" in a and "tooltip" in a for a in pluginAActions) + assert all(a["pluginName"] == "pluginA" for a in pluginAActions) + # The function callable must not be exposed through getAllMenuActions + assert all("function" not in a for a in pluginAActions) + + def test_executeMenuAction(self): + """Check that executeMenuAction calls the registered function.""" + folder = os.path.join(os.path.dirname(__file__), "plugins") + with registeredPlugins(folder): + plugin = pluginManager.getPlugin("pluginA") + assert plugin + + called = [] + + def my_action(): + called.append(True) + + plugin.addMenuAction("Test Action", my_action) + action = next(a for a in plugin.menuActions if a["label"] == "Test Action") + pluginManager.executeMenuAction(action["actionId"]) + assert called == [True] + + def test_noMenuActionsForNewPlugin(self): + """Check that a freshly created plugin has no menu actions.""" + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + plugin_no_menu = Plugin("noMenuPlugin", tmpdir) + assert plugin_no_menu.menuActions == [] + + def test_addMenuActionSkipsEmptyLabel(self): + """Check that addMenuAction skips entries without a valid label.""" + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + plugin = Plugin("testPlugin", tmpdir) + plugin.addMenuAction("", lambda: None) + plugin.addMenuAction(" ", lambda: None) + assert plugin.menuActions == [] + + def test_addMenuActionSkipsNonCallableFunction(self): + """Check that addMenuAction skips entries where function is not callable.""" + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + plugin = Plugin("testPlugin", tmpdir) + plugin.addMenuAction("My Action", "not_a_function") + plugin.addMenuAction("My Action", 42) + assert plugin.menuActions == []