Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions meshroom/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pathlib import Path
import pkgutil
import sys
import threading
import traceback
import uuid

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
66 changes: 66 additions & 0 deletions meshroom/core/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import os
import re
import sys
import uuid

from enum import Enum
from inspect import getfile
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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. """
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}")
19 changes: 19 additions & 0 deletions meshroom/ui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ def _pipelineTemplateNames(self):
def reloadTemplateList(self):
meshroom.core.initPipelines()
self.pipelineTemplateFilesChanged.emit()
self.pluginMenuActionsChanged.emit()

@Slot()
def forceUIUpdate(self):
Expand Down Expand Up @@ -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}")
Expand All @@ -776,10 +793,12 @@ 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)
recentImportedImagesFolders = Property("QVariantList", _recentImportedImagesFolders, notify=recentImportedImagesFoldersChanged)
default8bitViewerEnabled = Property(bool, _default8bitViewerEnabled, constant=True)
defaultSequencePlayerEnabled = Property(bool, _defaultSequencePlayerEnabled, constant=True)
submittersListModel = Property("QVariantList", _submittersList, constant=True)
pluginMenuActions = Property("QVariantList", _pluginMenuActions, notify=pluginMenuActionsChanged)
19 changes: 19 additions & 0 deletions meshroom/ui/qml/Application.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions meshroom/ui/scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions tests/plugins/meshroom/pluginA/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
79 changes: 79 additions & 0 deletions tests/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == []
Loading