From 02b85cb273aab2f36c9b7e711f939aa82c6d7a18 Mon Sep 17 00:00:00 2001 From: waaake Date: Sun, 10 Nov 2024 12:05:20 +0100 Subject: [PATCH 1/7] [core] Plugins: Introduction of NodePluginManager The NodePluginManager provides a way for registering and managing node plugins within Meshroom. This also provides a way for other components to interact with the plugins to understand whether a plugin is available or not. --- meshroom/_plugins/__init__.py | 8 + meshroom/_plugins/base.py | 92 +++++++++++ meshroom/_plugins/node.py | 283 +++++++++++++++++++++++++++++++++ meshroom/core/__init__.py | 152 ++++-------------- meshroom/core/graph.py | 13 +- meshroom/core/node.py | 107 +++++++++++-- meshroom/ui/app.py | 3 +- meshroom/ui/reconstruction.py | 7 +- tests/test_compatibility.py | 12 +- tests/test_plugins.py | 67 ++++++++ tests/test_templatesVersion.py | 5 +- 11 files changed, 600 insertions(+), 149 deletions(-) create mode 100644 meshroom/_plugins/__init__.py create mode 100644 meshroom/_plugins/base.py create mode 100644 meshroom/_plugins/node.py create mode 100644 tests/test_plugins.py diff --git a/meshroom/_plugins/__init__.py b/meshroom/_plugins/__init__.py new file mode 100644 index 0000000000..773cb528af --- /dev/null +++ b/meshroom/_plugins/__init__.py @@ -0,0 +1,8 @@ +""" Plugins. +""" +# Plugins +from .node import NodePluginManager +from .base import Status, Pluginator + + +__all__ = ["NodePluginManager", "Status", "Pluginator"] diff --git a/meshroom/_plugins/base.py b/meshroom/_plugins/base.py new file mode 100644 index 0000000000..6da8991ec7 --- /dev/null +++ b/meshroom/_plugins/base.py @@ -0,0 +1,92 @@ +""" Base functionality for Plugins. +""" +# Types +from typing import List + +# STD +from contextlib import contextmanager +import enum +import importlib +import inspect +import logging +import pkgutil + + +class Status(enum.IntEnum): + """ Enum describing the state of the plugin. + """ + + # UNLOADED or NOT Available - Describing that the plugin is not available in the current set of plugins + UNLOADED = -1 + + # ERRORED describes that the plugin exists but could not be loaded due to errors with the structure + ERRORED = 0 + + # LOADED describes that the plugin is currently loaded and is fully functional + LOADED = 1 + + +class Pluginator: + """ The common plugin utilities. + """ + + @staticmethod + @contextmanager + def add_to_path(_p): + """ A Context Manager to add the provided path to Python's sys.path temporarily. + """ + import sys # pylint: disable=import-outside-toplevel + old_path = sys.path + sys.path = sys.path[:] + sys.path.insert(0, _p) + try: + yield + finally: + sys.path = old_path + + @staticmethod + def get(folder, packageName, classType) -> List: + """ Returns Array of Plugin, each holding the plugin and the module it belongs to. + + Args: + folder (str): Path to the Directory. + packageName (str): Name of the package to import. + classType (desc.Node | BaseSubmitter): The base type of plugin which is being imported. + """ + pluginTypes = [] + errors = [] + + # temporarily add folder to python path + with Pluginator.add_to_path(folder): + # import node package + package = importlib.import_module(packageName) + packageName = package.packageName if hasattr(package, 'packageName') else package.__name__ + packageVersion = getattr(package, "__version__", None) + + for importer, pluginName, ispkg in pkgutil.iter_modules(package.__path__): + pluginModuleName = '.' + pluginName + + try: + pluginMod = importlib.import_module(pluginModuleName, package=package.__name__) + plugins = [plugin for name, plugin in inspect.getmembers(pluginMod, inspect.isclass) + if plugin.__module__ == '{}.{}'.format(package.__name__, pluginName) + and issubclass(plugin, classType)] + if not plugins: + logging.warning("No class defined in plugin: {}".format(pluginModuleName)) + + # Update the package name and version on the plugin + for p in plugins: + p.packageName = packageName + p.packageVersion = packageVersion + + # Extend all of the plugins + pluginTypes.extend(plugins) + except Exception as exc: + errors.append(' * {}: {}'.format(pluginName, str(exc))) + + if errors: + logging.warning('== The following "{package}" plugins could not be loaded ==\n' + '{errorMsg}\n' + .format(package=packageName, errorMsg='\n'.join(errors))) + + return pluginTypes diff --git a/meshroom/_plugins/node.py b/meshroom/_plugins/node.py new file mode 100644 index 0000000000..d5e9d08953 --- /dev/null +++ b/meshroom/_plugins/node.py @@ -0,0 +1,283 @@ +""" Node plugins. +""" +# Types +from typing import Dict, List +from types import ModuleType + +# STD +import importlib +import logging +import pkgutil +import sys + +# Internal +from meshroom.core import desc + +# Plugins +from .base import Status, Pluginator + + +class NodeDescriptor(object): + """ Class to describe a Node Plugin. + """ + + _DEFAULT_VERSION = "0" + + def __init__(self, name: str, descriptor: desc.Node) -> None: + """ Constructor. + + Args: + name (str): Name of the Node. + descriptor (desc.Node): The Node descriptor. + """ + super().__init__() + + # Node descriptions + self._name: str = name + self._descriptor: desc.Node = descriptor + + # Update the Node Descriptor's plugin + self._descriptor.plugin = self + + # Module descriptions + self._module: ModuleType = sys.modules.get(self._descriptor.__module__) + self._version = getattr(self._module, "__version__", self._DEFAULT_VERSION) + self._path = self._module.__file__ + + self._errors: List[str] = self._validate() + + # Properties + name = property(lambda self: self._name) + descriptor = property(lambda self: self._descriptor) + category = property(lambda self: self._descriptor.category) + errors = property(lambda self: self._errors) + documentation = property(lambda self: self._descriptor.documentation) + version = property(lambda self: self._version) + path = property(lambda self: self._path) + + @property + def status(self) -> Status: + """ Returns the status of the plugin. + """ + # If no errors -> then it is loaded and available + return Status(not self._errors) + + def __repr__(self): + """ Represents the Instance. + """ + return f"NodeDescriptor::{self._name} at {hex(id(self))}" + + def _validate(self) -> List[str]: + """ Check that the node has a valid description before being loaded. For the description to be valid, the + default value of every parameter needs to correspond to the type of the parameter. + + An empty returned list means that every parameter is valid, and so is the node's description. If it is not + valid, the returned list contains the names of the invalid parameters. In case of nested parameters (parameters + in groups or lists, for example), the name of the parameter follows the name of the parent attributes. + + For example, + If the attribute "x", contained in group "group", is invalid, then it will be added to the list as "group:x". + + Returns: + errors (list): the list of invalid parameters if there are any, empty list otherwise. + """ + errors = [] + + for param in self._descriptor.inputs: + err = param.checkValueTypes() + if err: + errors.append(err) + + for param in self._descriptor.outputs: + # Ignore the output attributes with None as the value + if param.value is None: + continue + + err = param.checkValueTypes() + if err: + errors.append(err) + + # Return any errors while validating the input and output attributes + return errors + + # Public + def reload(self) -> None: + """ Reloads the Node. + """ + # Reload the Module + updated = importlib.reload(self._module) + + # Get the Descriptor + descriptor = getattr(updated, self._name) + + # Cannot find the current class on the updated module ? + if not descriptor: + return + + # Update the descriptor and call for validation + self._module = updated + + self._descriptor = descriptor + self._descriptor.plugin = self + + # Update the errors if any that may have been introduced + self._errors = self._validate() + + +class NodePluginManager(object): + """ A Singleton class Managing the Node plugins for Meshroom. + """ + # Static class instance to ensure we have only one created at all times + _instance = None + + # The core class to which the Node plugins belong + _CLASS_TYPE = desc.Node + + def __new__(cls): + # Verify that the instance we have is of the current type + if not isinstance(cls._instance, cls): + # Create an instance to work with + cls._instance = object.__new__(cls) + + # Init the class parameters + # The class parameters need to be initialised outside __init__ as when Cls() gets invoked __init__ gets + # called as well, so even when we get the same instance back, the params are updated for this and every + # other instance and that what will affect attrs in all places where the current instance is being used + cls._instance.init() + + # Return the instantiated instance + return cls._instance + + def init(self) -> None: + """ Constructor for members. + """ + self._descriptors: Dict[str: NodeDescriptor] = {} # pylint: disable=attribute-defined-outside-init + + # Properties + descriptors = property(lambda self: self._descriptors) + + # Public + def registered(self, name: str) -> bool: + """ Returns whether the plugin has been registered already or not. + + Args: + name (str): Name of the plugin. + + Returns: + bool. True if the plugin was registered, else False. + """ + return name in self._descriptors + + def status(self, name: str) -> Status: + """ Returns the current status of the plugin. + + Args: + name (str): Name of the plugin. + + Returns: + Status. The current status of the plugin. + """ + # Fetch the plugin Descriptor + plugin = self._descriptors.get(name) + + if not plugin: + return Status.UNLOADED + + # Return the status from the plugin itself + return plugin.status + + def errors(self, name) -> List[str]: + """ Returns the Errors on the plugins if there are any. + + Args: + name (str): Name of the plugin. + + Returns: + list. the list of invalid parameters if there are any, empty list otherwise. + """ + # Fetch the plugin Descriptor + plugin = self._descriptors.get(name) + + if not plugin: + return [] + + # Return any errors from the plugin side + return plugin.errors + + def registerNode(self, descriptor: desc.Node) -> bool: + """ Registers a Node into Meshroom. + + Args: + descriptor (desc.Node): The Node descriptor. + + Returns: + bool. Returns True if the node is registered. False if it is already registered. + """ + # Plugin name + name = descriptor.__name__ + + # Already registered ? + if self.registered(name): + return False + + # Register it + self.register(name, descriptor) + + return True + + def unregisterNode(self, descriptor: desc.Node) -> None: + """ Unregisters the Node from the Registered Set of Nodes. + + Args: + descriptor (desc.Node): The Node descriptor. + """ + # Plugin name + name = descriptor.__name__ + + # Ensure that we have this node already present + assert name in self._descriptors + # Delete the instance + del self._descriptors[name] + + def register(self, name: str, descriptor: desc.Node) -> None: + """ Registers a Node within meshroom. + + Args: + name (str): Name of the Node Plugin. + descriptor (desc.Node): The Node descriptor. + """ + self._descriptors[name] = NodeDescriptor(name, descriptor) + + def descriptor(self, name: str) -> desc.Node: + """ Returns the Node Desc for the provided name. + + Args: + name (str): Name of the plugin. + + Returns: + desc.Node. The Node Desc instance. + """ + # Returns the plugin for the provided name + plugin = self._descriptors.get(name) + + # Plugin not found with the name + if not plugin: + return None + + # Return the Node Descriptor for the plugin + return plugin.descriptor + + def load(self, directory) -> None: + """ Loads Node from the provided directory. + """ + for _, package, ispkg in pkgutil.walk_packages([directory]): + if not ispkg: + continue + + # Get the plugins from the provided directory and the python package + descriptors = Pluginator.get(directory, package, self._CLASS_TYPE) + + for descriptor in descriptors: + self.registerNode(descriptor) + + logging.debug('Nodes loaded [{}]: {}'.format(package, ', '.join([d.__name__ for d in descriptors]))) diff --git a/meshroom/core/__init__.py b/meshroom/core/__init__.py index 3bd30d5bbd..c14621a52b 100644 --- a/meshroom/core/__init__.py +++ b/meshroom/core/__init__.py @@ -1,12 +1,8 @@ import hashlib -from contextlib import contextmanager -import importlib -import inspect import os import tempfile import uuid import logging -import pkgutil import sys @@ -19,7 +15,7 @@ pass from meshroom.core.submitter import BaseSubmitter -from . import desc +from meshroom import _plugins # Setup logging logging.basicConfig(format='[%(asctime)s][%(levelname)s] %(message)s', level=logging.INFO) @@ -29,10 +25,15 @@ cacheFolderName = 'MeshroomCache' defaultCacheFolder = os.environ.get('MESHROOM_CACHE', os.path.join(tempfile.gettempdir(), cacheFolderName)) -nodesDesc = {} submitters = {} pipelineTemplates = {} +# Manages plugins for Meshroom Nodes +pluginManager = _plugins.NodePluginManager() + +# Plugin States +PluginStatus = _plugins.Status + def hashValue(value): """ Hash 'value' using sha1. """ @@ -40,100 +41,6 @@ def hashValue(value): return hashObject.hexdigest() -@contextmanager -def add_to_path(p): - import sys - old_path = sys.path - sys.path = sys.path[:] - sys.path.insert(0, p) - try: - yield - finally: - sys.path = old_path - - -def loadPlugins(folder, packageName, classType): - """ - """ - - pluginTypes = [] - errors = [] - - # temporarily add folder to python path - with add_to_path(folder): - # import node package - package = importlib.import_module(packageName) - packageName = package.packageName if hasattr(package, 'packageName') else package.__name__ - packageVersion = getattr(package, "__version__", None) - - for importer, pluginName, ispkg in pkgutil.iter_modules(package.__path__): - pluginModuleName = '.' + pluginName - - try: - pluginMod = importlib.import_module(pluginModuleName, package=package.__name__) - plugins = [plugin for name, plugin in inspect.getmembers(pluginMod, inspect.isclass) - if plugin.__module__ == '{}.{}'.format(package.__name__, pluginName) - and issubclass(plugin, classType)] - if not plugins: - logging.warning("No class defined in plugin: {}".format(pluginModuleName)) - - importPlugin = True - for p in plugins: - if classType == desc.Node: - nodeErrors = validateNodeDesc(p) - if nodeErrors: - errors.append(" * {}: The following parameters do not have valid default values/ranges: {}" - .format(pluginName, ", ".join(nodeErrors))) - importPlugin = False - break - p.packageName = packageName - p.packageVersion = packageVersion - if importPlugin: - pluginTypes.extend(plugins) - except Exception as e: - errors.append(' * {}: {}'.format(pluginName, str(e))) - - if errors: - logging.warning('== The following "{package}" plugins could not be loaded ==\n' - '{errorMsg}\n' - .format(package=packageName, errorMsg='\n'.join(errors))) - return pluginTypes - - -def validateNodeDesc(nodeDesc): - """ - Check that the node has a valid description before being loaded. For the description - to be valid, the default value of every parameter needs to correspond to the type - of the parameter. - An empty returned list means that every parameter is valid, and so is the node's description. - If it is not valid, the returned list contains the names of the invalid parameters. In case - of nested parameters (parameters in groups or lists, for example), the name of the parameter - follows the name of the parent attributes. For example, if the attribute "x", contained in group - "group", is invalid, then it will be added to the list as "group:x". - - Args: - nodeDesc (desc.Node): description of the node - - Returns: - errors (list): the list of invalid parameters if there are any, empty list otherwise - """ - errors = [] - - for param in nodeDesc.inputs: - err = param.checkValueTypes() - if err: - errors.append(err) - - for param in nodeDesc.outputs: - if param.value is None: - continue - err = param.checkValueTypes() - if err: - errors.append(err) - - return errors - - class Version(object): """ Version provides convenient properties and methods to manipulate and compare versions. @@ -149,7 +56,10 @@ def __init__(self, *args): self.status = str() elif len(args) == 1: versionName = args[0] - if isinstance(versionName, str): + if not versionName: # If this was initialised with Version(None) or Version("") + self.components = tuple() + self.status = str() + elif isinstance(versionName, str): self.components, self.status = Version.toComponents(versionName) elif isinstance(versionName, (list, tuple)): self.components = tuple([int(v) for v in versionName]) @@ -278,36 +188,36 @@ def nodeVersion(nodeDesc, default=None): return moduleVersion(nodeDesc.__module__, default) -def registerNodeType(nodeType): +def registerNodeType(nodeType, module=None): """ Register a Node Type based on a Node Description class. After registration, nodes of this type can be instantiated in a Graph. """ - global nodesDesc - if nodeType.__name__ in nodesDesc: - logging.error("Node Desc {} is already registered.".format(nodeType.__name__)) - nodesDesc[nodeType.__name__] = nodeType + # Register the node in plugin manager + registered = pluginManager.registerNode(nodeType, module=module) + # The plugin was already registered + if not registered: + return -def unregisterNodeType(nodeType): - """ Remove 'nodeType' from the list of register node types. """ - global nodesDesc - assert nodeType.__name__ in nodesDesc - del nodesDesc[nodeType.__name__] + # Plugin Name + name = nodeType.__name__ + # Check the status of the plugin to identify if we have any errors on it while loading ? + if pluginManager.status(name) == PluginStatus.ERRORED: + errors = ", ".join(pluginManager.errors(name)) + logging.warning(f"[PluginManager] {name}: The following parameters do not have valid default values/ranges: {errors}.") -def loadNodes(folder, packageName): - return loadPlugins(folder, packageName, desc.Node) + +def unregisterNodeType(nodeType): + """ Remove 'nodeType' from the list of register node types. """ + # Unregister the node from plugin manager + pluginManager.unregisterNode(nodeType) def loadAllNodes(folder): - global nodesDesc - for importer, package, ispkg in pkgutil.walk_packages([folder]): - if ispkg: - nodeTypes = loadNodes(folder, package) - for nodeType in nodeTypes: - registerNodeType(nodeType) - logging.debug('Nodes loaded [{}]: {}'.format(package, ', '.join([nodeType.__name__ for nodeType in nodeTypes]))) + # Load plugins from the node's plugin manager + pluginManager.load(folder) def registerSubmitter(s): @@ -318,7 +228,7 @@ def registerSubmitter(s): def loadSubmitters(folder, packageName): - return loadPlugins(folder, packageName, BaseSubmitter) + return _plugins.Pluginator.get(folder, packageName, BaseSubmitter) def loadPipelineTemplates(folder): diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index 54f069fdd8..949dd92e8b 100644 --- a/meshroom/core/graph.py +++ b/meshroom/core/graph.py @@ -16,7 +16,7 @@ from meshroom.core import Version from meshroom.core.attribute import Attribute, ListAttribute, GroupAttribute from meshroom.core.exception import GraphCompatibilityError, StopGraphVisit, StopBranchVisit -from meshroom.core.node import nodeFactory, Status, Node, CompatibilityNode +from meshroom.core.node import nodeFactory, Status, Node, CompatibilityNode, IncompatiblePluginNode # Replace default encoder to support Enums @@ -444,7 +444,7 @@ def createUniqueNodeName(nodeNames, inputName): # Second pass to update all the links in the input/output attributes for every node with the new names for nodeName, nodeData in updatedData.items(): nodeType = nodeData.get("nodeType", None) - nodeDesc = meshroom.core.nodesDesc[nodeType] + nodeDesc = meshroom.core.pluginManager.descriptor(nodeType) inputs = nodeData.get("inputs", {}) outputs = nodeData.get("outputs", {}) @@ -762,7 +762,14 @@ def addNewNode(self, nodeType, name=None, position=None, **kwargs): if name and name in self._nodes.keys(): name = self._createUniqueNodeName(name) - n = self.addNode(Node(nodeType, position=position, **kwargs), uniqueName=name) + # The Node Type to Construcct + Type = Node + + if meshroom.core.pluginManager.status(nodeType) == meshroom.core.PluginStatus.ERRORED: + Type = IncompatiblePluginNode + + # Construct the Node + n = self.addNode(Type(nodeType, position=position, **kwargs), uniqueName=name) n.updateInternals() return n diff --git a/meshroom/core/node.py b/meshroom/core/node.py index 1b8806e2e4..b21cb5cd70 100644 --- a/meshroom/core/node.py +++ b/meshroom/core/node.py @@ -1,5 +1,8 @@ #!/usr/bin/env python # coding:utf-8 +# Types +from typing import List + import atexit import copy import datetime @@ -495,9 +498,9 @@ def __init__(self, nodeType, position=None, parent=None, uid=None, **kwargs): self._nodeType = nodeType self.nodeDesc = None - # instantiate node description if nodeType is valid - if nodeType in meshroom.core.nodesDesc: - self.nodeDesc = meshroom.core.nodesDesc[nodeType]() + # instantiate node description if nodeType has been registered + if meshroom.core.pluginManager.registered(nodeType): + self.nodeDesc = meshroom.core.pluginManager.descriptor(nodeType)() self.packageName = self.packageVersion = "" self._internalFolder = "" @@ -1356,7 +1359,7 @@ def hasSequenceOutputAttribute(self): False otherwise. """ for attr in self._attributes: - if attr.enabled and attr.isOutput and (attr.desc.semantic == "sequence" or + if attr.enabled and attr.isOutput and (attr.desc.semantic == "sequence" or attr.desc.semantic == "imageList"): return True return False @@ -1593,6 +1596,7 @@ class CompatibilityIssue(Enum): VersionConflict = 2 # mismatch between node's description version and serialized node data DescriptionConflict = 3 # mismatch between node's description attributes and serialized node data UidConflict = 4 # mismatch between computed UIDs and UIDs stored in serialized node data + PluginIssue = 5 # Issue with interpreting the plugin due to any issues with interpreting the plugin class CompatibilityNode(BaseNode): @@ -1754,8 +1758,9 @@ def _addAttribute(self, name, val, isOutput, internalAttr=False): self._attributes.add(attribute) return matchDesc - @property - def issueDetails(self): + def _issueDetails(self) -> str: + """ Returns Issue Details. + """ if self.issue == CompatibilityIssue.UnknownNodeType: return "Unknown node type: '{}'.".format(self.nodeType) elif self.issue == CompatibilityIssue.VersionConflict: @@ -1766,8 +1771,14 @@ def issueDetails(self): return "Node attributes do not match node description." elif self.issue == CompatibilityIssue.UidConflict: return "Node UID differs from the expected one." - else: - return "Unknown error." + elif self.issue == CompatibilityIssue.PluginIssue: + return "Error interpreting the Node Plugin." + + return "Unknown error." + + @property + def issueDetails(self) -> str: + return self._issueDetails() @property def inputs(self): @@ -1852,6 +1863,74 @@ def upgrade(self): issueDetails = Property(str, issueDetails.fget, constant=True) +class IncompatiblePluginNode(CompatibilityNode): + """ Fallback BaseNode subclass to instantiate Nodes having compatibility issues with current type description. + CompatibilityNode creates an 'empty-shell' exposing the deserialized node as-is, + with all its inputs and precomputed outputs. + """ + + def __init__(self, nodeType, position=None, issue=CompatibilityIssue.PluginIssue, parent=None, **kwargs): + super(IncompatiblePluginNode, self).__init__(nodeType, {}, position=position, issue=issue, parent=parent) + + self.packageName = self.nodeDesc.packageName + self.packageVersion = self.nodeDesc.packageVersion + self._internalFolder = self.nodeDesc.internalFolder + + # Fetch the errors for the plugin + self._nodeErrors = self.nodeDesc.plugin.errors + + # Add the Attributes + for attrDesc in self.nodeDesc.inputs: + # Don't add any invalid attributes + if attrDesc.invalid: + continue + + self._attributes.add(attributeFactory(attrDesc, kwargs.get(attrDesc.name, None), isOutput=False, node=self)) + + for attrDesc in self.nodeDesc.outputs: + # Don't add any invalid attributes + if attrDesc.invalid: + continue + + self._attributes.add(attributeFactory(attrDesc, kwargs.get(attrDesc.name, None), isOutput=True, node=self)) + + for attrDesc in self.nodeDesc.internalInputs: + # Don't add any invalid attributes + if attrDesc.invalid: + continue + + self._internalAttributes.add(attributeFactory(attrDesc, kwargs.get(attrDesc.name, None), isOutput=False, + node=self)) + + @property + def issueDetails(self) -> str: + """ Returns any issue details for the Node. + """ + # The basic issue detail + details = [self._issueDetails()] + + # Add the Node Parametric error details + details.extend(self._errorDetails()) + + return "\n".join(details) + + # Protected + def _errorDetails(self) -> List[str]: + """ Returns the details of the Parametric errors on the Node. + """ + errors = ["Following parameters have invalid default values/ranges:"] + + # Add the parameters from the node Errors + errors.extend([f"* Param {param}" for param in self._nodeErrors]) + + return errors + + # Properties + # An Incompatible Plguin Node should not be upgraded but only reloaded + canUpgrade = Property(bool, lambda _: False, constant=True) + issueDetails = Property(str, issueDetails.fget, constant=True) + + def nodeFactory(nodeDict, name=None, template=False, uidConflict=False): """ Create a node instance by deserializing the given node data. @@ -1885,11 +1964,11 @@ def nodeFactory(nodeDict, name=None, template=False, uidConflict=False): compatibilityIssue = None - nodeDesc = None - try: - nodeDesc = meshroom.core.nodesDesc[nodeType] - except KeyError: - # Unknown node type + # Returns the desc.Node inherited class or None if the plugin was not registered + nodeDesc = meshroom.core.pluginManager.descriptor(nodeType) + + # Node plugin was not registered + if not nodeDesc: compatibilityIssue = CompatibilityIssue.UnknownNodeType # Unknown node type should take precedence over UID conflict, as it cannot be resolved @@ -1909,7 +1988,7 @@ def nodeFactory(nodeDict, name=None, template=False, uidConflict=False): # do not perform that check for internal attributes because there is no point in # raising compatibility issues if their number differs: in that case, it is only useful # if some internal attributes do not exist or are invalid - if not template and (sorted([attr.name for attr in nodeDesc.inputs + if not template and (sorted([attr.name for attr in nodeDesc.inputs if not isinstance(attr, desc.PushButtonParam)]) != sorted(inputs.keys()) or sorted([attr.name for attr in nodeDesc.outputs if not attr.isDynamicValue]) != sorted(outputs.keys())): diff --git a/meshroom/ui/app.py b/meshroom/ui/app.py index 884b885111..f931ffe1b2 100644 --- a/meshroom/ui/app.py +++ b/meshroom/ui/app.py @@ -11,7 +11,7 @@ from PySide6.QtWidgets import QApplication import meshroom -from meshroom.core import nodesDesc +from meshroom.core import pluginManager from meshroom.core.taskManager import TaskManager from meshroom.common import Property, Variant, Signal, Slot @@ -240,6 +240,7 @@ def __init__(self, args): components.registerTypes() # expose available node types that can be instantiated + nodesDesc = pluginManager.descriptors self.engine.rootContext().setContextProperty("_nodeTypes", {n: {"category": nodesDesc[n].category} for n in sorted(nodesDesc.keys())}) # instantiate Reconstruction object diff --git a/meshroom/ui/reconstruction.py b/meshroom/ui/reconstruction.py index 3f1acb6cff..4d4050cf6d 100755 --- a/meshroom/ui/reconstruction.py +++ b/meshroom/ui/reconstruction.py @@ -496,7 +496,8 @@ def initActiveNodes(self): # Create all possible entries for category, _ in self.activeNodeCategories.items(): self._activeNodes.add(ActiveNode(category, parent=self)) - for nodeType, _ in meshroom.core.nodesDesc.items(): + + for nodeType in meshroom.core.pluginManager.descriptors: self._activeNodes.add(ActiveNode(nodeType, parent=self)) def clearActiveNodes(self): @@ -648,7 +649,9 @@ def setupTempCameraInit(self, node, attrName): if not sfmFile or not os.path.isfile(sfmFile): self.tempCameraInit = None return - nodeDesc = meshroom.core.nodesDesc["CameraInit"]() + + # The camera init node should always exist + nodeDesc = meshroom.core.pluginManager.descriptor("CameraInit")() views, intrinsics = nodeDesc.readSfMData(sfmFile) tmpCameraInit = Node("CameraInit", viewpoints=views, intrinsics=intrinsics) tmpCameraInit.locked = True diff --git a/tests/test_compatibility.py b/tests/test_compatibility.py index 00505352e7..247689b7e9 100644 --- a/tests/test_compatibility.py +++ b/tests/test_compatibility.py @@ -197,7 +197,7 @@ def test_description_conflict(): Test compatibility behavior for conflicting node descriptions. """ # copy registered node types to be able to restore them - originalNodeTypes = copy.copy(meshroom.core.nodesDesc) + originalNodeTypes = copy.copy(meshroom.core.pluginManager.descriptors) nodeTypes = [SampleNodeV1, SampleNodeV2, SampleNodeV3, SampleNodeV4, SampleNodeV5] nodes = [] @@ -224,7 +224,7 @@ def test_description_conflict(): # offset node types register to create description conflicts # each node type name now reference the next one's implementation for i, nt in enumerate(nodeTypes[:-1]): - meshroom.core.nodesDesc[nt.__name__] = nodeTypes[i+1] + meshroom.core.pluginManager.register(nt.__name__, nodeTypes[i+1]) # reload file g = loadGraph(graphFile) @@ -306,7 +306,7 @@ def test_description_conflict(): raise ValueError("Unexpected node type: " + srcNode.nodeType) # restore original node types - meshroom.core.nodesDesc = originalNodeTypes + meshroom.core.pluginManager._descriptors = originalNodeTypes # pylint: disable=protected-access def test_upgradeAllNodes(): @@ -331,8 +331,8 @@ def test_upgradeAllNodes(): unregisterNodeType(SampleNodeV2) unregisterNodeType(SampleInputNodeV2) # replace SampleNodeV1 by SampleNodeV2 and SampleInputNodeV1 by SampleInputNodeV2 - meshroom.core.nodesDesc[SampleNodeV1.__name__] = SampleNodeV2 - meshroom.core.nodesDesc[SampleInputNodeV1.__name__] = SampleInputNodeV2 + meshroom.core.pluginManager.register(SampleNodeV1.__name__, SampleNodeV2) + meshroom.core.pluginManager.register(SampleInputNodeV1.__name__, SampleInputNodeV2) # reload file g = loadGraph(graphFile) @@ -369,7 +369,7 @@ def test_conformUpgrade(): g.save(graphFile) # replace SampleNodeV5 by SampleNodeV6 - meshroom.core.nodesDesc[SampleNodeV5.__name__] = SampleNodeV6 + meshroom.core.pluginManager.register(SampleNodeV5.__name__, SampleNodeV6) # reload file g = loadGraph(graphFile) diff --git a/tests/test_plugins.py b/tests/test_plugins.py new file mode 100644 index 0000000000..818ec08737 --- /dev/null +++ b/tests/test_plugins.py @@ -0,0 +1,67 @@ +""" Test for Meshroom Plugins. +""" +#!/usr/bin/env python +# coding:utf-8 + +from meshroom.core import _plugins +from meshroom.core import desc, registerNodeType, unregisterNodeType + + +class SampleNode(desc.Node): + """ Sample Node for unit testing """ + + category = "Sample" + + inputs = [ + desc.File(name='input', label='Input', description='', value='',), + desc.StringParam(name='paramA', label='ParamA', description='', value='', invalidate=False) # No impact on UID + ] + outputs = [ + desc.File(name='output', label='Output', description='', value=desc.Node.internalFolder) + ] + + +def test_plugin_management(): + """ Tests the plugin manager for registering and unregistering node. + """ + # Sample Node name + name = SampleNode.__name__ + + # Register the node + registerNodeType(SampleNode) + + # Since the Node Plugin Manager is a singleton instance + # We should still be able to instantiate and have a look at out registered plugins directly + pluginManager = _plugins.NodePluginManager() + + # Assert that the plugin we have registered above is indeed registered + assert pluginManager.registered(name) + + # Assert that the plugin can only be registered once + assert not pluginManager.registerNode(SampleNode) + + # And once un-registered, it should no longer be present in the pluginManager + unregisterNodeType(SampleNode) + + # Assert that the plugin we have registered above is indeed registered + assert not pluginManager.registered(name) + assert name not in pluginManager.descriptors + +def test_descriptor(): + """ Tests the Descriptor and NodeDescriptor instances. + """ + # Register the node + registerNodeType(SampleNode) + + # Since the Node Plugin Manager is a singleton instance + # We should still be able to instantiate and have a look at out registered plugins directly + pluginManager = _plugins.NodePluginManager() + + # Assert the descriptor is same as the Plugin NodeType + assert pluginManager.descriptor(SampleNode.__name__).__name__ == SampleNode.__name__ + + # Assert that the category of the NodeDescriptor is correct for the registered plugin + assert pluginManager.descriptors.get(SampleNode.__name__).category == "Sample" + + # Finally unregister the plugin + unregisterNodeType(SampleNode) diff --git a/tests/test_templatesVersion.py b/tests/test_templatesVersion.py index 402a228ac4..b11aa5c72f 100644 --- a/tests/test_templatesVersion.py +++ b/tests/test_templatesVersion.py @@ -34,9 +34,10 @@ def test_templateVersions(): for _, nodeData in graphData.items(): nodeType = nodeData["nodeType"] - assert nodeType in meshroom.core.nodesDesc + # Assert that the plugin (nodeType) is indeed registered to be used + assert meshroom.core.pluginManager.registered(nodeType) - nodeDesc = meshroom.core.nodesDesc[nodeType] + nodeDesc = meshroom.core.pluginManager.descriptor(nodeType) currentNodeVersion = meshroom.core.nodeVersion(nodeDesc) inputs = nodeData.get("inputs", {}) From 7ce7fb205c280fa2f32741e1ef583b5c34e8877e Mon Sep 17 00:00:00 2001 From: waaake Date: Mon, 11 Nov 2024 09:29:58 +0530 Subject: [PATCH 2/7] [ui] Plugins: Added UI for Node Plugin Manager The Plugin Manager UI lets users see the available loaded Node Plugins. Each of the plugins have a detailed descriptive view which shows up when the label of the plugin name is clicked in the UI Node Plugin Manager allows browsing the Python Packages consisting the Node Plugins to load them in the current instance of Meshroom. --- meshroom/core/__init__.py | 4 +- meshroom/ui/app.py | 10 +- meshroom/ui/plugins.py | 120 +++++++++ meshroom/ui/qml/Application.qml | 32 ++- meshroom/ui/qml/GraphEditor/GraphEditor.qml | 57 ++-- meshroom/ui/qml/GraphEditor/PluginManager.qml | 251 ++++++++++++++++++ meshroom/ui/qml/GraphEditor/qmldir | 1 + 7 files changed, 450 insertions(+), 25 deletions(-) create mode 100644 meshroom/ui/plugins.py create mode 100644 meshroom/ui/qml/GraphEditor/PluginManager.qml diff --git a/meshroom/core/__init__.py b/meshroom/core/__init__.py index c14621a52b..e71dc72a30 100644 --- a/meshroom/core/__init__.py +++ b/meshroom/core/__init__.py @@ -188,13 +188,13 @@ def nodeVersion(nodeDesc, default=None): return moduleVersion(nodeDesc.__module__, default) -def registerNodeType(nodeType, module=None): +def registerNodeType(nodeType): """ Register a Node Type based on a Node Description class. After registration, nodes of this type can be instantiated in a Graph. """ # Register the node in plugin manager - registered = pluginManager.registerNode(nodeType, module=module) + registered = pluginManager.registerNode(nodeType) # The plugin was already registered if not registered: diff --git a/meshroom/ui/app.py b/meshroom/ui/app.py index f931ffe1b2..e77a8ab77e 100644 --- a/meshroom/ui/app.py +++ b/meshroom/ui/app.py @@ -11,11 +11,11 @@ from PySide6.QtWidgets import QApplication import meshroom -from meshroom.core import pluginManager from meshroom.core.taskManager import TaskManager from meshroom.common import Property, Variant, Signal, Slot from meshroom.ui import components +from meshroom.ui.plugins import NodesPluginManager from meshroom.ui.components.clipboard import ClipboardHelper from meshroom.ui.components.filepath import FilepathHelper from meshroom.ui.components.scene3D import Scene3DHelper, Transformations3DHelper @@ -239,16 +239,16 @@ def __init__(self, args): self.engine.addImportPath(qmlDir) components.registerTypes() - # expose available node types that can be instantiated - nodesDesc = pluginManager.descriptors - self.engine.rootContext().setContextProperty("_nodeTypes", {n: {"category": nodesDesc[n].category} for n in sorted(nodesDesc.keys())}) - # instantiate Reconstruction object self._undoStack = commands.UndoStack(self) self._taskManager = TaskManager(self) self._activeProject = Reconstruction(undoStack=self._undoStack, taskManager=self._taskManager, defaultPipeline=args.pipeline, parent=self) self._activeProject.setSubmitLabel(args.submitLabel) + + # The Plugin manager for UI to communicate with + self._pluginManager = NodesPluginManager(parent=self) self.engine.rootContext().setContextProperty("_reconstruction", self._activeProject) + self.engine.rootContext().setContextProperty("_pluginator", self._pluginManager) # those helpers should be available from QML Utils module as singletons, but: # - qmlRegisterUncreatableType is not yet available in PySide2 diff --git a/meshroom/ui/plugins.py b/meshroom/ui/plugins.py new file mode 100644 index 0000000000..29c5a41b37 --- /dev/null +++ b/meshroom/ui/plugins.py @@ -0,0 +1,120 @@ +""" UI Component for the Plugin System. +""" +# STD +import urllib.parse as _parser + +# Qt +from PySide2.QtCore import Slot, QObject, Property, Signal + +# Internal +from meshroom.core import pluginManager +from meshroom.common import BaseObject, DictModel + + +class Plugin(BaseObject): + """ Representation of a Plugin in UI. + """ + + def __init__(self, descriptor): + """ Constructor. + + Args: + descriptor (NodeDescriptor): A Plugin descriptor. + """ + super().__init__() + + self._descriptor = descriptor + + # Any Node errors + self._nodeErrors = self._errors() + + def _errors(self) -> str: + """ + """ + if not self._descriptor.errors: + return "" + + errors = ["Following parameters have invalid default values/ranges:"] + + # Add the parameters from the node Errors + errors.extend([f"* Param {param}" for param in self._descriptor.errors]) + + return "\n".join(errors) + + @Slot() + def reload(self): + """ Reloads the plugin descriptor. + """ + self._descriptor.reload() + + # Update the Node errors + self._nodeErrors = self._errors() + + name = Property(str, lambda self: self._descriptor.name, constant=True) + documentation = Property(str, lambda self: self._descriptor.documentation, constant=True) + loaded = Property(bool, lambda self: bool(self._descriptor.status), constant=True) + version = Property(str, lambda self: self._descriptor.version, constant=True) + path = Property(str, lambda self: self._descriptor.path, constant=True) + errors = Property(str, lambda self: self._nodeErrors, constant=True) + category = Property(str, lambda self: self._descriptor.category, constant=True) + + + +class NodesPluginManager(QObject): + """ UI Plugin Manager Component. Serves as a Bridge between the core Nodes' Plugin Manager and how the + users interact with it. + """ + + def __init__(self, parent=None): + """ Constructor. + + Keyword Args: + parent (QObject): The Parent for the Plugin Manager. + """ + super().__init__(parent=parent) + + # The core Plugin Manager + self._manager = pluginManager + + # The plugins as a Model which can be communicated to the frontend + self._plugins = DictModel(keyAttrName='name', parent=self) + + # Reset the plugins model + self._reset() + + # Signals + pluginsChanged = Signal() + + # Properties + plugins = Property(BaseObject, lambda self: self._plugins, notify=pluginsChanged) + + # Protected + def _reset(self): + """ Requeries and Resets the Plugins Model from the core Plugin Manager for UI refreshes. + """ + plugins = [Plugin(desc) for desc in self._manager.descriptors.values()] + + # Reset the plugins model + self._plugins.reset(plugins) + + # Public + @Slot(str) + def load(self, directory): + """ Load plugins from a given directory, which serves as a package of Meshroom Node Modules. + + Args: + directory (str): Path to the plugin package to import. + """ + # The incoming directory to this method from the QML FolderDialog component is of the format + # file:///path/to/a/python/package + # Cleanup the provided directory url and convert to a usable Posix path + uri = _parser.urlparse(directory) + + # Load the plugin(s) from the provided directory package + self._manager.load(_parser.unquote(uri.path)) + + # Reset the plugins model + self._reset() + + # Emit that the plugins have now been updated + self.pluginsChanged.emit() diff --git a/meshroom/ui/qml/Application.qml b/meshroom/ui/qml/Application.qml index d547b9a9e7..511ba4a854 100644 --- a/meshroom/ui/qml/Application.qml +++ b/meshroom/ui/qml/Application.qml @@ -433,6 +433,17 @@ Page { uigraph: _reconstruction } + PluginManager { + id: pluginManager + manager: _pluginator + + // When a plugin package has been browsed + onBrowsed: { + // Load Plugins + _pluginator.load(directory) + } + } + // Actions Action { @@ -923,6 +934,25 @@ Page { border.color: Qt.darker(activePalette.window, 1.15) } } + + // Button to Launch Plugin Manager + ToolButton { + id: pluginManagerButton + visible: true + text: MaterialIcons.build + font.family: MaterialIcons.fontFamily + font.pointSize: 12 + onClicked: { + pluginManager.open() + } + ToolTip.text: "Plugin Manager" + ToolTip.visible: hovered + + background: Rectangle { + color: pluginManagerButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.15) + border.color: Qt.darker(activePalette.window, 1.15) + } + } } footer: ToolBar { @@ -1156,7 +1186,7 @@ Page { visible: graphEditorPanel.currentTab === 0 uigraph: _reconstruction - nodeTypesModel: _nodeTypes + nodeTypesModel: _pluginator.plugins onNodeDoubleClicked: function(mouse, node) { _reconstruction.setActiveNode(node); diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index bbbeaa9f11..db3ecc4a39 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -13,9 +13,43 @@ import Utils 1.0 Item { id: root - property variant uigraph: null /// Meshroom UI graph (UIGraph) - readonly property variant graph: uigraph ? uigraph.graph : null /// Core graph contained in the UI graph - property variant nodeTypesModel: null /// The list of node types that can be instantiated + property variant uigraph: null /// Meshroom ui graph (UIGraph) + readonly property variant graph: uigraph ? uigraph.graph : null /// core graph contained in ui graph + property variant nodeTypesModel: null /// the list of node types that can be instantiated + + readonly property var nodeCategories: { + // Map to hold the node category: node type + let categories = {} + + for (var i = 0; i < nodeTypesModel.count; i++) { + // The node at the index + let node = nodeTypesModel.at(i) + + // Node Category + let category = node.category; + + // Setup an array to allow node type(s) to be added to the category + if (categories[category] === undefined) { + categories[category] = [] + } + // Add the nodeType to the category which will show up in the Menu + categories[category].push(node.name) + } + + return categories + } + + readonly property var nodeTypes: { + // An array to hold the node Types + let types = [] + + for (var i = 0; i < nodeTypesModel.count; i++) { + types.push(nodeTypesModel.at(i).name) + } + + return types + } + property real maxZoom: 2.0 property real minZoom: 0.1 @@ -226,7 +260,7 @@ Item { Menu { id: newNodeMenu property point spawnPosition - property variant menuKeys: Object.keys(root.nodeTypesModel).concat(Object.values(MeshroomApp.pipelineTemplateNames)) + property variant menuKeys: nodeTypes.concat(Object.values(MeshroomApp.pipelineTemplateNames)) height: searchBar.height + nodeMenuRepeater.height + instantiator.height function createNode(nodeType) { @@ -254,21 +288,10 @@ Item { } function parseCategories() { - // Organize nodes based on their category - // {"category1": ["node1", "node2"], "category2": ["node3", "node4"]} - let categories = {}; - for (const [name, data] of Object.entries(root.nodeTypesModel)) { - let category = data["category"]; - if (categories[category] === undefined) { - categories[category] = [] - } - categories[category].push(name) - } - // Add a "Pipelines" category, filled with the list of templates to create pipelines from the menu - categories["Pipelines"] = MeshroomApp.pipelineTemplateNames + nodeCategories["Pipelines"] = MeshroomApp.pipelineTemplateNames - return categories + return nodeCategories } onVisibleChanged: { diff --git a/meshroom/ui/qml/GraphEditor/PluginManager.qml b/meshroom/ui/qml/GraphEditor/PluginManager.qml new file mode 100644 index 0000000000..28227412f9 --- /dev/null +++ b/meshroom/ui/qml/GraphEditor/PluginManager.qml @@ -0,0 +1,251 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.11 +import Qt.labs.platform 1.0 as Platform +import MaterialIcons 2.2 +import Controls 1.0 +import Utils 1.0 + +/** + * PluginManager displays available plugins within Meshroom context and allows loading new ones. +*/ +Dialog { + id: root + + // the Plugin Manager instance + property var manager + // alias to underlying plugin model + readonly property var pluginsModel: manager ? manager.plugins : undefined + + // Does not allow any other element outside the dialog to be interacted with till the time this window is open + modal: true + + // Positioning of the Dialog in the screen + x: parent.width / 2 - width / 2 + y: parent.height / 2 - height / 2 + + // Bounds + height: 600 + + // Signals + signal browsed(var directory) + + title: "Node Plugin Manager" + + ColumnLayout { + anchors.fill: parent + spacing: 16 + + ListView { + id: listView + + // Bounds + Layout.preferredWidth: 600 + Layout.fillHeight: true + implicitHeight: contentHeight + + clip: true + model: pluginsModel + + ScrollBar.vertical: MScrollBar { id: scrollbar } + + spacing: 4 + headerPositioning: ListView.OverlayHeader + header: Pane { + z: 2 + width: ListView.view.width + padding: 6 + background: Rectangle { color: Qt.darker(parent.palette.window, 1.15) } + RowLayout { + id: headerLabel + width: parent.width + Label { text: "Plugin"; Layout.preferredWidth: 170; font.bold: true } + Label { text: "Status"; Layout.preferredWidth: 70; font.bold: true } + Label { text: "Version"; Layout.preferredWidth: 70; font.bold: true } + } + } + + delegate: RowLayout { + id: pluginDelegate + + property var plugin: object + + width: ListView.view.width - 12 + anchors.horizontalCenter: parent != null ? parent.horizontalCenter : undefined + + Label { + Layout.preferredWidth: 180 + text: pluginDelegate.plugin ? pluginDelegate.plugin.name : "" + + // Mouse Area to allow clicking on the label + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + metadataPane.plugin = pluginDelegate.plugin + metadataPane.visible = true + } + } + + } + Label { + id: status + Layout.preferredWidth: 70 + text: pluginDelegate.plugin && pluginDelegate.plugin.loaded ? MaterialIcons.check : MaterialIcons.clear + color: pluginDelegate.plugin && pluginDelegate.plugin.loaded ? "#4CAF50" : "#F44336" + font.family: MaterialIcons.fontFamily + font.pointSize: 14 + font.bold: true + } + Label { + id: version + Layout.preferredWidth: 70 + text: pluginDelegate.plugin ? pluginDelegate.plugin.version : "" + } + } + } + + // Bottom pane for showing plugin related information + Pane { + id: metadataPane + + // the plugin to display info for + property var plugin + + // Hidden as default + visible: false + + // Bounds + anchors.topMargin: 0 + Layout.fillWidth: true + Layout.preferredHeight: 200 + + // Clip additional content + clip: true + + background: Rectangle { color: Qt.darker(parent.palette.window, 1.15) } + + // Header + Label { id: infoLabel; text: ( metadataPane.plugin ? metadataPane.plugin.name : "Plugin" ) + " Info"; font.bold: true; } + + MaterialToolButton { + id: paneCloser + text: MaterialIcons.close + + // Alignment + anchors.right: parent.right + anchors.verticalCenter: infoLabel.verticalCenter + + onClicked: { metadataPane.visible = false } + } + + ScrollView { + // Bounds + anchors.top: paneCloser.bottom + width: parent.width + height: parent.height - paneCloser.height + + // clip anything going beyond the boundary + clip: true + + background: Rectangle { color: Qt.darker(parent.palette.window, 1.65) } + + Column { + width: parent.width + + RowLayout { + Label { text: "Name:"; Layout.preferredWidth: 100; font.bold: true } + TextArea { text: metadataPane.plugin ? metadataPane.plugin.name : ""; readOnly: true } + } + + // File Path + RowLayout { + Label { text: "File Path:"; Layout.preferredWidth: 100; font.bold: true } + TextArea { + text: metadataPane.plugin ? metadataPane.plugin.path : "" + Layout.preferredWidth: 450 + wrapMode: Text.WordWrap + readOnly: true + selectByMouse: true + } + } + + // Load Status + RowLayout { + Label { text: "Status:"; Layout.preferredWidth: 100; font.bold: true } + TextArea { text: metadataPane.plugin && metadataPane.plugin.loaded ? "Loaded" : "Errored"; readOnly: true } + } + + // Empty + RowLayout { } + + // Load Status + RowLayout { + Label { text: "Documentation:"; Layout.preferredWidth: 100; font.bold: true } + TextArea { + text: metadataPane.plugin ? metadataPane.plugin.documentation : "" + Layout.preferredWidth: 450 + wrapMode: Text.WordWrap + readOnly: true + } + } + + // Empty + RowLayout { } + + RowLayout { + Label { text: "Errors:"; Layout.preferredWidth: 100; font.bold: true } + TextArea { + text: metadataPane.plugin ? metadataPane.plugin.errors : "" + Layout.preferredWidth: 450 + wrapMode: Text.WordWrap + readOnly: true + selectByMouse: true + } + } + } + } + } + } + + /// Buttons footer + footer: DialogButtonBox { + position: DialogButtonBox.Footer + + // Plugin Browser + Button { + text: "Browse" + + onClicked: { + // Show the dialog to allow browsing of plugins package + loadDialog.open() + } + } + + // Close the plugin manager + Button { + text: "Close" + + onClicked: { + root.close() + } + } + } + + /// The widget should only get closed when either Esc is pressed or Close button is clicked + closePolicy: Popup.CloseOnEscape + + // Folder selecting dialog + Platform.FolderDialog { + id: loadDialog + options: Platform.FileDialog.DontUseNativeDialog + + title: "Browse Plugin Package" + acceptLabel: "Select" + + onAccepted: { + // Emit that a directory has been browsed -> for the loading to occur + root.browsed(loadDialog.folder) + } + } +} diff --git a/meshroom/ui/qml/GraphEditor/qmldir b/meshroom/ui/qml/GraphEditor/qmldir index 4a4d4ca460..a4d40f6613 100644 --- a/meshroom/ui/qml/GraphEditor/qmldir +++ b/meshroom/ui/qml/GraphEditor/qmldir @@ -10,6 +10,7 @@ AttributeEditor 1.0 AttributeEditor.qml AttributeItemDelegate 1.0 AttributeItemDelegate.qml CompatibilityBadge 1.0 CompatibilityBadge.qml CompatibilityManager 1.0 CompatibilityManager.qml +PluginManager 1.0 PluginManager.qml singleton GraphEditorSettings 1.0 GraphEditorSettings.qml TaskManager 1.0 TaskManager.qml ScriptEditor 1.0 ScriptEditor.qml \ No newline at end of file From 4449e372f752bf957d600b3a4f6b4f813c6e85af Mon Sep 17 00:00:00 2001 From: waaake Date: Mon, 25 Nov 2024 10:05:08 +0530 Subject: [PATCH 3/7] [desc] Notion of an Invalid Param Descriptor: Added invalid attribute on a Paramter Descriptor to hold the status of whether the parameter has an issues with the value types, if so this gets skipped when adding an Incompatible Plugin Node. --- meshroom/core/desc/attribute.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/meshroom/core/desc/attribute.py b/meshroom/core/desc/attribute.py index e1f6c4f339..1c041bd4eb 100644 --- a/meshroom/core/desc/attribute.py +++ b/meshroom/core/desc/attribute.py @@ -33,6 +33,9 @@ def __init__(self, name, label, description, value, advanced, semantic, group, e self._isDynamicValue = (self._value is None) self._valueType = None + # Holds the state if the attribute description is invalid due to any of it's params + self._invalid = False + def getInstanceType(self): """ Return the correct Attribute instance corresponding to the description. """ # Import within the method to prevent cyclic dependencies @@ -103,6 +106,9 @@ def matchDescription(self, value, strict=True): # instanceType # Attribute instance corresponding to the description instanceType = Property(Variant, lambda self: self.getInstanceType(), constant=True) + # invalid: + # This property is used to identify whether this parameter has all the correct values or is invalid + invalid = Property(bool, lambda self: self._invalid, constant=True) class ListAttribute(Attribute): @@ -222,6 +228,8 @@ def checkValueTypes(self): if name: invalidParams.append(name) if invalidParams: + # The parameter is no longer valid + self._invalid = True # In group "group", if parameters "x" and "y" (with "y" in nested group "subgroup") are invalid, the # returned string will be: "group:x, group:subgroup:y" return self.name + ":" + str(", " + self.name + ":").join(invalidParams) @@ -296,6 +304,8 @@ def checkValueTypes(self): # Some File values are functions generating a string: check whether the value is a string or if it # is a function (but there is no way to check that the function's output is indeed a string) if not isinstance(self.value, str) and not callable(self.value): + # The parameter is no longer valid + self._invalid = True return self.name return "" @@ -324,6 +334,9 @@ def validateValue(self, value): def checkValueTypes(self): if not isinstance(self.value, bool): + # The parameter is no longer valid + self._invalid = True + return self.name return "" @@ -352,6 +365,9 @@ def validateValue(self, value): def checkValueTypes(self): if not isinstance(self.value, int) or (self.range and not all([isinstance(r, int) for r in self.range])): + # The parameter is no longer valid + self._invalid = True + return self.name return "" @@ -381,6 +397,9 @@ def validateValue(self, value): def checkValueTypes(self): if not isinstance(self.value, float) or (self.range and not all([isinstance(r, float) for r in self.range])): + # The parameter is no longer valid + self._invalid = True + return self.name return "" @@ -462,17 +481,26 @@ def validateValue(self, value): def checkValueTypes(self): # Check that the values have been provided as a list if not isinstance(self._values, list): + # The parameter is no longer valid + self._invalid = True + return self.name # If the choices are not exclusive, check that 'value' is a list, and check that it does not contain values that # are not available elif not self.exclusive and (not isinstance(self._value, list) or not all(val in self._values for val in self._value)): + # The parameter is no longer valid + self._invalid = True + return self.name # If the choices are exclusive, the value should NOT be a list but it can contain any value that is not in the # list of possible ones elif self.exclusive and isinstance(self._value, list): + # The parameter is no longer valid + self._invalid = True + return self.name return "" @@ -504,6 +532,9 @@ def validateValue(self, value): def checkValueTypes(self): if not isinstance(self.value, str): + # The parameter is no longer valid + self._invalid = True + return self.name return "" From a9fc39c81c4f7f6815dccf12a5502972a9e25543 Mon Sep 17 00:00:00 2001 From: waaake Date: Mon, 25 Nov 2024 10:10:41 +0530 Subject: [PATCH 4/7] [core] Notion of Reload: Added backend support for reloading a Node Reloading forces the python reload of the module a plugin and if the reload is successful -> the update gets propagated to the node graph holding any instances of the given node type. --- meshroom/_plugins/node.py | 42 ++++++++++++++++++--- meshroom/core/graph.py | 55 ++++++++++++++++++++++++++++ meshroom/core/node.py | 77 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 168 insertions(+), 6 deletions(-) diff --git a/meshroom/_plugins/node.py b/meshroom/_plugins/node.py index d5e9d08953..9bbeeca625 100644 --- a/meshroom/_plugins/node.py +++ b/meshroom/_plugins/node.py @@ -7,6 +7,7 @@ # STD import importlib import logging +import os import pkgutil import sys @@ -37,12 +38,15 @@ def __init__(self, name: str, descriptor: desc.Node) -> None: self._descriptor: desc.Node = descriptor # Update the Node Descriptor's plugin - self._descriptor.plugin = self + self._descriptor.plugin: NodeDescriptor = self # Module descriptions self._module: ModuleType = sys.modules.get(self._descriptor.__module__) - self._version = getattr(self._module, "__version__", self._DEFAULT_VERSION) - self._path = self._module.__file__ + self._version: str = getattr(self._module, "__version__", self._DEFAULT_VERSION) + self._path: str = self._module.__file__ + + # When the plugin was last modified ? + self._mtime: float = os.path.getmtime(self._path) self._errors: List[str] = self._validate() @@ -101,9 +105,29 @@ def _validate(self) -> List[str]: return errors # Public - def reload(self) -> None: - """ Reloads the Node. + def modified(self) -> bool: + """ Returns True if the plugin module has been modified after it was loaded. + """ + # A Module is modified if the modification time of the file is greater than the modification time + # of the file when it was last loaded in the Descriptor + return os.path.getmtime(self._path) > self._mtime + + def reload(self, force: bool=False) -> bool: + """ Reloads the Node. Defaults to only loading the plugin if it has been modified. + Use force=True to load the plugin what so ever. + + Args: + force (bool): Set to True to force load the Node Plugin. + + Returns: + bool. True when the plugin was reloaded successfully, else False. """ + # If the plugin's source is not modified and there is no force operation to reload + # ignore reloading the plugin, as it could end up changing the graph topology if the plugin + # has been instanced in the graph + if not force and not self.modified(): + return False + # Reload the Module updated = importlib.reload(self._module) @@ -123,6 +147,14 @@ def reload(self) -> None: # Update the errors if any that may have been introduced self._errors = self._validate() + # Update the version as it may have updated with the update + self._version = getattr(self._module, "__version__", self._DEFAULT_VERSION) + + # Update the Modifcation time of the descriptor + self._mtime = os.path.getmtime(self._path) + + return True + class NodePluginManager(object): """ A Singleton class Managing the Node plugins for Meshroom. diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index 949dd92e8b..ea7aafb6d3 100644 --- a/meshroom/core/graph.py +++ b/meshroom/core/graph.py @@ -824,6 +824,61 @@ def upgradeNode(self, nodeName): return upgradedNode, inEdges, outEdges, outListAttributes + def reloadNode(self, node): + """ Reloads the provided Node instance in the Graph. This could be triggered after the Node Plugin has + received an update. + + Args: + node (Node): Node Instance. + + Returns: + Node. The reloaded (newly created) node. + """ + nodeName = node.getName() + upgradedNode = node.reload() + + with GraphModification(self): + inEdges, outEdges, outListAttributes = self.removeNode(nodeName) + self.addNode(upgradedNode, nodeName) + for dst, src in outEdges.items(): + # Re-create the entries in ListAttributes that were completely removed during the call to "removeNode" + # If they are not re-created first, adding their edges will lead to errors + # 0 = attribute name, 1 = attribute index, 2 = attribute value + if dst in outListAttributes.keys(): + listAttr = self.attribute(outListAttributes[dst][0]) + if isinstance(outListAttributes[dst][2], list): + listAttr[outListAttributes[dst][1]:outListAttributes[dst][1]] = outListAttributes[dst][2] + else: + listAttr.insert(outListAttributes[dst][1], outListAttributes[dst][2]) + + # Fetch the source and destination attributes for the nodes + # There is a high chance that one of these might not exist (possibly the source) + # as a node's source could could have removed or made invalid ? + source = self.attribute(src) + destination = self.attribute(dst) + + # Both the Source and Destination Attribute instances should exist in the graph for being connected + # with an Edge, if not -> Move to the next attribute connection + if not (source and destination): + continue + + try: + self.addEdge(source, destination) + except (KeyError, ValueError) as e: + logging.warning("Failed to restore edge {} -> {}: {}".format(src, dst, str(e))) + + return upgradedNode + + def reloadNodes(self, nodeType): + """ Reloads all the Nodes which are of the provide NodeType from the graph. + + Args: + nodeType (str): The Node Type. + """ + for node in list(self._nodes.values())[:]: + if node.nodeType == nodeType: + self.reloadNode(node) + def upgradeAllNodes(self): """ Upgrade all upgradable CompatibilityNode instances in the graph. """ nodeNames = [name for name, n in self._compatibilityNodes.items() if n.canUpgrade] diff --git a/meshroom/core/node.py b/meshroom/core/node.py index b21cb5cd70..a4edf06d3d 100644 --- a/meshroom/core/node.py +++ b/meshroom/core/node.py @@ -1,6 +1,7 @@ #!/usr/bin/env python # coding:utf-8 # Types +from __future__ import annotations from typing import List import atexit @@ -17,7 +18,6 @@ import uuid from collections import namedtuple from enum import Enum -from typing import Callable, Optional import meshroom from meshroom.common import Signal, Variant, Property, BaseObject, Slot, ListModel, DictModel @@ -1377,6 +1377,39 @@ def has3DOutputAttribute(self): return True return False + def reload(self) -> BaseNode: + """ Reloads the current Node. + """ + + # Protected + def _upgradeAttributeValues(self, node, attrValues, intAttrValues, version): + """ Upgrade the attribute values when a Node gets reloaded or upgraded. + This basically carries forward any Attribute values which can be. And Alongside it, all of the internal + attributes as well. + + Args: + node (BaseNode): Node instance. + attrValues (dict): Attribute Name to it's value map. + intAttrValues (dict): Attribute Name to it's value map. + """ + # Use upgrade method of the node description itself if available + try: + upgradedAttrValues = node.nodeDesc.upgradeAttributeValues(attrValues, version) + except Exception as ex: + logging.error("Error in the upgrade implementation of the node: {}.\n{}".format(self.name, repr(ex))) + upgradedAttrValues = attrValues + + # Incase the output from the Descriptor's upgrade method does not conform with the requirement + if not isinstance(upgradedAttrValues, dict): + logging.error("Error in the upgrade implementation of the node: {}. The return type is incorrect.".format(self.name)) + upgradedAttrValues = attrValues + + # Upgrade the values of the Attributes which can be preserved during a node reload + node.upgradeAttributeValues(upgradedAttrValues) + + # Upgrade the values of the internal attributes, like color, notes etc. + node.upgradeInternalAttributeValues(intAttrValues) + name = Property(str, getName, constant=True) defaultLabel = Property(str, getDefaultLabel, constant=True) nodeType = Property(str, nodeType.fget, constant=True) @@ -1586,6 +1619,26 @@ def _updateChunks(self): else: self._chunks[0].range = desc.Range() + # Override + def reload(self) -> Node | IncompatiblePluginNode: + """ Return a new Node instance based on original node type. If the registered Plugins has errors + Then an IncompatiblePluginNode instance is returned. + """ + # The plugin still has errors + if self.nodeDesc.plugin.errors: + return IncompatiblePluginNode(self._nodeType, position=self.position) + + node = Node(self.nodeType, position=self.position) + + # # convert attributes from a list of tuples into a dict + attrValues = {key: value.value for (key, value) in self._attributes.objects.items() if value.isInput} + intAttrValues = {key: value.value for (key, value) in self._internalAttributes.objects.items()} + + # Upgrade the attributes + self._upgradeAttributeValues(node, attrValues, intAttrValues, Version(self.nodeDesc.plugin.version)) + + return node + class CompatibilityIssue(Enum): """ @@ -1925,6 +1978,25 @@ def _errorDetails(self) -> List[str]: return errors + # Override + def reload(self) -> Node | IncompatiblePluginNode: + """ Return a new Node instance based on original node type with common inputs initialized. + """ + # The plugin still has errors + if self.nodeDesc.plugin.errors: + return IncompatiblePluginNode(self._nodeType, position=self.position) + + node = Node(self.nodeType, position=self.position) + + # convert attributes from a list of tuples into a dict + attrValues = {key: value for (key, value) in self.inputs.items()} + intAttrValues = {key: value for (key, value) in self.internalInputs.items()} + + # Upgrade Attribute values + self._upgradeAttributeValues(node, attrValues, intAttrValues, self.version) + + return node + # Properties # An Incompatible Plguin Node should not be upgraded but only reloaded canUpgrade = Property(bool, lambda _: False, constant=True) @@ -1971,6 +2043,9 @@ def nodeFactory(nodeDict, name=None, template=False, uidConflict=False): if not nodeDesc: compatibilityIssue = CompatibilityIssue.UnknownNodeType + if nodeDesc and nodeDesc.plugin.errors: + return IncompatiblePluginNode(nodeType, position=position, **inputs, **internalInputs, **outputs) + # Unknown node type should take precedence over UID conflict, as it cannot be resolved if uidConflict and nodeDesc: compatibilityIssue = CompatibilityIssue.UidConflict From fbd786b469d19637b3dbd186c6098b530bbc4052 Mon Sep 17 00:00:00 2001 From: waaake Date: Mon, 25 Nov 2024 10:34:08 +0530 Subject: [PATCH 5/7] [ui] Interaction of Reload from Plugin Manager: Exposed Reloading the Node from Plugin Manager UI Plugin manager has Reload button for each of the pluginto allow reloading the plugin and the nodes if instanced in the graph. The reload also prompts user before reloading --- meshroom/ui/graph.py | 10 ++- meshroom/ui/plugins.py | 27 +++--- meshroom/ui/qml/Application.qml | 3 +- meshroom/ui/qml/GraphEditor/PluginManager.qml | 83 ++++++++++++++++--- 4 files changed, 96 insertions(+), 27 deletions(-) diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 3fd8b9ff67..21b7d3b3a8 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -748,7 +748,7 @@ def duplicateNodesFrom(self, nodes): uniqueNodesToDuplicate = list(dict.fromkeys(nodesToDuplicate)) duplicates = self.duplicateNodes(uniqueNodesToDuplicate) return duplicates - + @Slot(Edge, result=bool) def canExpandForLoop(self, currentEdge): """ Check if the list attribute can be expanded by looking at all the edges connected to it. """ @@ -846,7 +846,7 @@ def replaceEdge(self, edge, newSrc, newDst): self.removeEdge(edge) self.addEdge(newSrc, newDst) return self._graph.edge(newDst) - + @Slot(Attribute, result=Edge) def getEdge(self, dst): return self._graph.edge(dst) @@ -881,6 +881,12 @@ def upgradeAllNodes(self): for node in sortedNodes: self.upgradeNode(node) + @Slot(str) + def reloadNodes(self, nodeType): + """ Reload all the Nodes belonging to the Node Type. + """ + self._graph.reloadNodes(nodeType) + @Slot() def forceNodesStatusUpdate(self): """ Force re-evaluation of graph's nodes status. """ diff --git a/meshroom/ui/plugins.py b/meshroom/ui/plugins.py index 29c5a41b37..e2e5ff7786 100644 --- a/meshroom/ui/plugins.py +++ b/meshroom/ui/plugins.py @@ -1,8 +1,5 @@ """ UI Component for the Plugin System. """ -# STD -import urllib.parse as _parser - # Qt from PySide2.QtCore import Slot, QObject, Property, Signal @@ -29,7 +26,7 @@ def __init__(self, descriptor): self._nodeErrors = self._errors() def _errors(self) -> str: - """ + """ Returns the Error Description for the Node Plugin if there are any. """ if not self._descriptor.errors: return "" @@ -41,15 +38,23 @@ def _errors(self) -> str: return "\n".join(errors) - @Slot() - def reload(self): + @Slot(result=bool) + def reload(self) -> bool: """ Reloads the plugin descriptor. + + Returns: + bool. The reload status. """ - self._descriptor.reload() + # The plugin descriptor reloads only if the file was modified after it was last loaded + if not self._descriptor.reload(): + return False # Update the Node errors self._nodeErrors = self._errors() + # Plugin was modified + return True + name = Property(str, lambda self: self._descriptor.name, constant=True) documentation = Property(str, lambda self: self._descriptor.documentation, constant=True) loaded = Property(bool, lambda self: bool(self._descriptor.status), constant=True) @@ -59,7 +64,6 @@ def reload(self): category = Property(str, lambda self: self._descriptor.category, constant=True) - class NodesPluginManager(QObject): """ UI Plugin Manager Component. Serves as a Bridge between the core Nodes' Plugin Manager and how the users interact with it. @@ -105,13 +109,8 @@ def load(self, directory): Args: directory (str): Path to the plugin package to import. """ - # The incoming directory to this method from the QML FolderDialog component is of the format - # file:///path/to/a/python/package - # Cleanup the provided directory url and convert to a usable Posix path - uri = _parser.urlparse(directory) - # Load the plugin(s) from the provided directory package - self._manager.load(_parser.unquote(uri.path)) + self._manager.load(directory) # Reset the plugins model self._reset() diff --git a/meshroom/ui/qml/Application.qml b/meshroom/ui/qml/Application.qml index 511ba4a854..d4f220f195 100644 --- a/meshroom/ui/qml/Application.qml +++ b/meshroom/ui/qml/Application.qml @@ -436,11 +436,12 @@ Page { PluginManager { id: pluginManager manager: _pluginator + uigraph: _reconstruction // When a plugin package has been browsed onBrowsed: { // Load Plugins - _pluginator.load(directory) + _pluginator.load(Filepath.urlToString(directory)) } } diff --git a/meshroom/ui/qml/GraphEditor/PluginManager.qml b/meshroom/ui/qml/GraphEditor/PluginManager.qml index 28227412f9..ad0fd2bc97 100644 --- a/meshroom/ui/qml/GraphEditor/PluginManager.qml +++ b/meshroom/ui/qml/GraphEditor/PluginManager.qml @@ -12,6 +12,8 @@ import Utils 1.0 Dialog { id: root + // the UIGraph instance + property var uigraph // the Plugin Manager instance property var manager // alias to underlying plugin model @@ -43,7 +45,7 @@ Dialog { Layout.preferredWidth: 600 Layout.fillHeight: true implicitHeight: contentHeight - + clip: true model: pluginsModel @@ -59,7 +61,7 @@ Dialog { RowLayout { id: headerLabel width: parent.width - Label { text: "Plugin"; Layout.preferredWidth: 170; font.bold: true } + Label { text: "Plugin"; Layout.preferredWidth: 160; font.bold: true } Label { text: "Status"; Layout.preferredWidth: 70; font.bold: true } Label { text: "Version"; Layout.preferredWidth: 70; font.bold: true } } @@ -74,7 +76,7 @@ Dialog { anchors.horizontalCenter: parent != null ? parent.horizontalCenter : undefined Label { - Layout.preferredWidth: 180 + Layout.preferredWidth: 200 text: pluginDelegate.plugin ? pluginDelegate.plugin.name : "" // Mouse Area to allow clicking on the label @@ -83,14 +85,13 @@ Dialog { cursorShape: Qt.PointingHandCursor onClicked: { metadataPane.plugin = pluginDelegate.plugin - metadataPane.visible = true + metadataPane.visible = true } } - } Label { id: status - Layout.preferredWidth: 70 + Layout.preferredWidth: 90 text: pluginDelegate.plugin && pluginDelegate.plugin.loaded ? MaterialIcons.check : MaterialIcons.clear color: pluginDelegate.plugin && pluginDelegate.plugin.loaded ? "#4CAF50" : "#F44336" font.family: MaterialIcons.fontFamily @@ -99,9 +100,31 @@ Dialog { } Label { id: version - Layout.preferredWidth: 70 + Layout.preferredWidth: 50 text: pluginDelegate.plugin ? pluginDelegate.plugin.version : "" } + + // Reload the Plugin + MaterialToolButton { + id: reloader + + text: MaterialIcons.refresh + ToolTip.text: "Reload Plugin" + + onClicked: { + if (pluginDelegate.plugin) { + // Dialog items for updates + confirmationDialog.statusItem = status + confirmationDialog.versionItem = version + + // Plugin for + confirmationDialog.plugin = pluginDelegate.plugin + + // Show the confirmation dialog to the user + confirmationDialog.open() + } + } + } } } @@ -124,7 +147,7 @@ Dialog { clip: true background: Rectangle { color: Qt.darker(parent.palette.window, 1.15) } - + // Header Label { id: infoLabel; text: ( metadataPane.plugin ? metadataPane.plugin.name : "Plugin" ) + " Info"; font.bold: true; } @@ -151,7 +174,7 @@ Dialog { background: Rectangle { color: Qt.darker(parent.palette.window, 1.65) } Column { - width: parent.width + width: parent.width RowLayout { Label { text: "Name:"; Layout.preferredWidth: 100; font.bold: true } @@ -166,7 +189,7 @@ Dialog { Layout.preferredWidth: 450 wrapMode: Text.WordWrap readOnly: true - selectByMouse: true + selectByMouse: true } } @@ -248,4 +271,44 @@ Dialog { root.browsed(loadDialog.folder) } } + + // A Confirmation Dialog to prompt user of the awareness of the reload process + MessageDialog { + id: confirmationDialog + + focus: true + modal: true + header.visible: false + + text: "Reloading a Plugin will affect all the Node instances of the plugin in the graph.\nDo you want to proceed with reloading?" + helperText: "Warning: This operation cannot be undone." + standardButtons: Dialog.Yes | Dialog.Cancel + + property var plugin: null // plugin to reload + property var statusItem: null // status item to update the text for once the plugin has been reloaded + property var versionItem: null // version item to update the text for once the plugin has been reloaded + + onAccepted: { + // All of the items required before calling for a reload + if (!plugin || !statusItem || !versionItem) return + + // Reload + let ret = plugin.reload() + + // Return if the plugin was not reloaded + if (!ret) return + + // Reload the Node instances in the graph + root.uigraph.reloadNodes(plugin.name) + + // Update the status and version, in case they have changed + statusItem.text = plugin.loaded ? MaterialIcons.check : MaterialIcons.clear + statusItem.color = plugin.loaded ? "#4CAF50" : "#F44336" + + versionItem.text = plugin.version + + // Update the plugin details view + metadataPane.plugin = plugin + } + } } From a8e569c5b2a1f5da7afcfdbe795cc52c5c8cf822 Mon Sep 17 00:00:00 2001 From: waaake Date: Mon, 25 Nov 2024 10:38:37 +0530 Subject: [PATCH 6/7] [tests] Updated Tests for including Reloading of a Node --- tests/test_plugins.py | 154 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 152 insertions(+), 2 deletions(-) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 818ec08737..4ad0b43ef4 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,10 +1,17 @@ -""" Test for Meshroom Plugins. -""" #!/usr/bin/env python # coding:utf-8 +""" Test for Meshroom Plugins. +""" +# STD +import os +import shutil +import tempfile +# Internal from meshroom.core import _plugins from meshroom.core import desc, registerNodeType, unregisterNodeType +from meshroom.core.graph import Graph +from meshroom.core.node import Node, IncompatiblePluginNode class SampleNode(desc.Node): @@ -47,6 +54,7 @@ def test_plugin_management(): assert not pluginManager.registered(name) assert name not in pluginManager.descriptors + def test_descriptor(): """ Tests the Descriptor and NodeDescriptor instances. """ @@ -65,3 +73,145 @@ def test_descriptor(): # Finally unregister the plugin unregisterNodeType(SampleNode) + + +def _setup_temp_package(directory, name): + """ Sets up a temporary meshroom package structure which can be loaded as plugins. + """ + package = os.path.join(directory, name) + + # Create the base package in the directory + os.makedirs(package) + + # The very first file that we need is probably empty __init__.py + init = os.path.join(package, "__init__.py") + + # The second thing we need is a directory inside + packageDir = os.path.join(package, "TesingPackage") + + # Third would be another init for the package + packinit = os.path.join(packageDir, "__init__.py") + + # Then comes the main module which will hold the plugin + pluginMod = os.path.join(packageDir, "TestInput.py") + + # Now start constructing stuff here + os.makedirs(packageDir) + + with open(init, "w") as f: + f.write("__version__ =\"1.0\"") + + with open(packinit, "w") as f: + f.write("__version__ =\"1.0\"") + + contents = """ +from meshroom.core import desc + + +class SampleTroubledNode(desc.Node): + \""" Sample Node for unit testing a reload process. + Defaults to having an invalid input param value. Which gets updated later on. + \""" + + category = "Sample" + + inputs = [ + # A Float param having the value as a string will cause the plugin to be loaded in an Error state + desc.FloatParam(name='paramA', label='ParamA', description='', value='4.0') + ] + outputs = [ + desc.File(name='output', label='Output', description='', value=desc.Node.internalFolder) + ] + """ + + with open(pluginMod, "w") as f: + f.write(contents) + + return package + +def _correctify_plugin(pluginPath): + contents = """ +from meshroom.core import desc + + +class SampleTroubledNode(desc.Node): + \""" Sample Node for unit testing a reload process. + Defaults to having an invalid input param value. Which gets updated later on. + \""" + + category = "Sample" + + inputs = [ + # A Float param having the value as a string will cause the plugin to be loaded in an Error state + desc.FloatParam(name='paramA', label='ParamA', description='', value=4.0) + ] + outputs = [ + desc.File(name='output', label='Output', description='', value=desc.Node.internalFolder) + ] + """ + + with open(pluginPath, "w") as f: + f.write(contents) + + +def _cleaup_package(directory): + """ Cleans up the Package + """ + shutil.rmtree(directory) + +def test_reload_with_graph(): + """ Tests Reloading of a plugin and how does the change propagate to a graph. + """ + # Create a temp directory + directory = tempfile.mkdtemp() + + package = _setup_temp_package(directory, "MTest") + + # Since the Node Plugin Manager is a singleton instance + # We should still be able to instantiate and have a look at out registered plugins directly + pluginManager = _plugins.NodePluginManager() + + pluginManager.load(package) + + # Sample Node name + name = "SampleTroubledNode" + + # Assert that the plugin we have registered above is indeed registered + assert pluginManager.registered(name) + + # But the status of the plugin would be errored + assert pluginManager.status(name) == _plugins.Status.ERRORED + + # Graph for usage + g = Graph("") + + # Create Nodes in the Graph + n = g.addNewNode(name) + + # Assert that the node is of an Incompatible Plugin type as the plugin had errors while loading + assert isinstance(n, IncompatiblePluginNode) + + descriptor = pluginManager.descriptors.get(name) + # Test that the plugin would not get reloaded + # unless either the source has been modified or the plugin is forced to be loaded + assert not descriptor.reload() + + # Modify the Source of the Troubled Node before we reload the plugin + # This updates the source of the plugin and ensures that the plugin can now be loaded as expected + _correctify_plugin(os.path.join(package, "TesingPackage", "TestInput.py")) + + # Reload the plugin as the source has been modified + assert descriptor.reload() + + # Now the plugin has been reloaded + assert pluginManager.status(name) == _plugins.Status.LOADED + + # Now reload the nodes of the provided type in the graph + g.reloadNodes(name) + + # Get all the nodes and assert that they have been upgraded to Standard Nodes + for node in g.nodesOfType(name): + assert isinstance(node, Node) + + # Once the tests are concluded -> Cleanup + _cleaup_package(package) From 8b9c9b938e0115d43e831c68207cabbfe072d201 Mon Sep 17 00:00:00 2001 From: waaake Date: Thu, 28 Nov 2024 19:09:05 +0530 Subject: [PATCH 7/7] [ui] Qt6 Compatibility: Updated Plugin Manager Components to work with Qt6 --- meshroom/core/node.py | 2 +- meshroom/ui/plugins.py | 2 +- meshroom/ui/qml/Application.qml | 2 +- meshroom/ui/qml/GraphEditor/PluginManager.qml | 6 +++--- tests/test_compatibility.py | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/meshroom/core/node.py b/meshroom/core/node.py index a4edf06d3d..94437488e5 100644 --- a/meshroom/core/node.py +++ b/meshroom/core/node.py @@ -2,7 +2,7 @@ # coding:utf-8 # Types from __future__ import annotations -from typing import List +from typing import Callable, List, Optional import atexit import copy diff --git a/meshroom/ui/plugins.py b/meshroom/ui/plugins.py index e2e5ff7786..1642eda770 100644 --- a/meshroom/ui/plugins.py +++ b/meshroom/ui/plugins.py @@ -1,7 +1,7 @@ """ UI Component for the Plugin System. """ # Qt -from PySide2.QtCore import Slot, QObject, Property, Signal +from PySide6.QtCore import Slot, QObject, Property, Signal # Internal from meshroom.core import pluginManager diff --git a/meshroom/ui/qml/Application.qml b/meshroom/ui/qml/Application.qml index d4f220f195..8ee988dacd 100644 --- a/meshroom/ui/qml/Application.qml +++ b/meshroom/ui/qml/Application.qml @@ -439,7 +439,7 @@ Page { uigraph: _reconstruction // When a plugin package has been browsed - onBrowsed: { + onBrowsed: (directory) => { // Load Plugins _pluginator.load(Filepath.urlToString(directory)) } diff --git a/meshroom/ui/qml/GraphEditor/PluginManager.qml b/meshroom/ui/qml/GraphEditor/PluginManager.qml index ad0fd2bc97..2b75fe2c11 100644 --- a/meshroom/ui/qml/GraphEditor/PluginManager.qml +++ b/meshroom/ui/qml/GraphEditor/PluginManager.qml @@ -1,6 +1,6 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.11 +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts import Qt.labs.platform 1.0 as Platform import MaterialIcons 2.2 import Controls 1.0 diff --git a/tests/test_compatibility.py b/tests/test_compatibility.py index 247689b7e9..a4ae3eb8c7 100644 --- a/tests/test_compatibility.py +++ b/tests/test_compatibility.py @@ -408,7 +408,7 @@ def test_failsOnNodeDescriptionCompatibilityIssue(self, graphSavedOnDisk): graph.save() # Replace saved node description by V2 - meshroom.core.nodesDesc[SampleNodeV1.__name__] = SampleNodeV2 + meshroom.core.pluginManager.register(SampleNodeV1.__name__, SampleNodeV2) with pytest.raises(GraphCompatibilityError): loadGraph(graph.filepath, strictCompatibility=True)