diff --git a/.bandit b/.bandit new file mode 100644 index 0000000000..a52326bcea --- /dev/null +++ b/.bandit @@ -0,0 +1,43 @@ +[tool.bandit] +skips = [B101, B102, B105, B106, B107, B113, B202, B401, B402, B403, B404, B405, B406, B407, B408, B409, B410, B413, B307, B311, B507, B602, B603, B605, B607, B610, B611, B703] + +[tool.bandit.any_other_function_with_shell_equals_true] +no_shell = [ + "os.execl", + "os.execle", + "os.execlp", + "os.execlpe", + "os.execv", + "os.execve", + "os.execvp", + "os.execvpe", + "os.spawnl", + "os.spawnle", + "os.spawnlp", + "os.spawnlpe", + "os.spawnv", + "os.spawnve", + "os.spawnvp", + "os.spawnvpe", + "os.startfile" +] +shell = [ + "os.system", + "os.popen", + "os.popen2", + "os.popen3", + "os.popen4", + "popen2.popen2", + "popen2.popen3", + "popen2.popen4", + "popen2.Popen3", + "popen2.Popen4", + "commands.getoutput", + "commands.getstatusoutput" +] +subprocess = [ + "subprocess.Popen", + "subprocess.call", + "subprocess.check_call", + "subprocess.check_output" +] diff --git a/README.md b/README.md index fcd3bda39e..5e1ebce116 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,14 @@ You can create custom nodes in python and make them available in Meshroom using In a standard precompiled version of Meshroom, you can also directly add custom nodes in `lib/meshroom/nodes`. To be recognized by Meshroom, a custom folder with nodes should be a Python module (an `__init__.py` file is needed). +### Plugins + +Meshroom supports installing containerised plugins via Docker (with the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)) or [Anaconda](https://docs.anaconda.com/free/miniconda/index.html). + +To do so, make sure docker or anaconda is installed properly and available from the command line. +Then click on `File > Advanced > Install Plugin From URL` or `File > Advanced > Install Plugin From Local Folder` to begin the installation. + +To learn more about using or creating plugins, check the explanations [here](meshroom/plugins/README.md). ## License diff --git a/meshroom/core/__init__.py b/meshroom/core/__init__.py index 0f6af8706c..f6a8407c73 100644 --- a/meshroom/core/__init__.py +++ b/meshroom/core/__init__.py @@ -30,12 +30,19 @@ # make a UUID based on the host ID and current time sessionUid = str(uuid.uuid1()) -cacheFolderName = 'MeshroomCache' -defaultCacheFolder = os.environ.get('MESHROOM_CACHE', os.path.join(tempfile.gettempdir(), cacheFolderName)) nodesDesc = {} submitters = {} pipelineTemplates = {} +#meshroom paths +meshroomFolder = os.path.dirname(os.path.dirname(__file__)) +cacheFolderName = 'MeshroomCache' +defaultCacheFolder = os.environ.get('MESHROOM_CACHE', os.path.join(tempfile.gettempdir(), cacheFolderName)) + +#plugin paths +pluginsNodesFolder = os.path.join(meshroomFolder, "plugins") +pluginsPipelinesFolder = os.path.join(meshroomFolder, "pipelines") +pluginCatalogFile = os.path.join(meshroomFolder, "plugins", "catalog.json") def hashValue(value): """ Hash 'value' using sha1. """ @@ -331,26 +338,23 @@ def loadPipelineTemplates(folder): pipelineTemplates[os.path.splitext(file)[0]] = os.path.join(folder, file) def initNodes(): - meshroomFolder = os.path.dirname(os.path.dirname(__file__)) additionalNodesPath = os.environ.get("MESHROOM_NODES_PATH", "").split(os.pathsep) # filter empty strings additionalNodesPath = [i for i in additionalNodesPath if i] - nodesFolders = [os.path.join(meshroomFolder, 'nodes')] + additionalNodesPath + nodesFolders = [os.path.join(meshroomFolder, 'nodes')] + additionalNodesPath + [pluginsNodesFolder] for f in nodesFolders: loadAllNodes(folder=f) def initSubmitters(): - meshroomFolder = os.path.dirname(os.path.dirname(__file__)) subs = loadSubmitters(os.environ.get("MESHROOM_SUBMITTERS_PATH", meshroomFolder), 'submitters') for sub in subs: registerSubmitter(sub()) def initPipelines(): - meshroomFolder = os.path.dirname(os.path.dirname(__file__)) # Load pipeline templates: check in the default folder and any folder the user might have # added to the environment variable additionalPipelinesPath = os.environ.get("MESHROOM_PIPELINE_TEMPLATES_PATH", "").split(os.pathsep) additionalPipelinesPath = [i for i in additionalPipelinesPath if i] - pipelineTemplatesFolders = [os.path.join(meshroomFolder, 'pipelines')] + additionalPipelinesPath + pipelineTemplatesFolders = [os.path.join(meshroomFolder, 'pipelines')] + additionalPipelinesPath + [pluginsPipelinesFolder] for f in pipelineTemplatesFolders: loadPipelineTemplates(f) diff --git a/meshroom/core/node.py b/meshroom/core/node.py index b507a5201c..4bcd802e3d 100644 --- a/meshroom/core/node.py +++ b/meshroom/core/node.py @@ -51,6 +51,8 @@ class Status(Enum): KILLED = 5 SUCCESS = 6 INPUT = 7 # special status for input nodes + BUILD = 8 + FIRST_RUN = 9 class ExecMode(Enum): @@ -380,16 +382,16 @@ def saveStatistics(self): renameWritingToFinalPath(statisticsFilepathWriting, statisticsFilepath) def isAlreadySubmitted(self): - return self._status.status in (Status.SUBMITTED, Status.RUNNING) + return self._status.status in (Status.SUBMITTED, Status.RUNNING, Status.BUILD, Status.FIRST_RUN) def isAlreadySubmittedOrFinished(self): - return self._status.status in (Status.SUBMITTED, Status.RUNNING, Status.SUCCESS) + return self._status.status in (Status.SUBMITTED, Status.RUNNING, Status.SUCCESS, Status.BUILD, Status.FIRST_RUN) def isFinishedOrRunning(self): - return self._status.status in (Status.SUCCESS, Status.RUNNING) + return self._status.status in (Status.SUCCESS, Status.RUNNING, Status.BUILD, Status.FIRST_RUN) def isRunning(self): - return self._status.status == Status.RUNNING + return self._status.status in (Status.RUNNING, Status.BUILD, Status.FIRST_RUN) def isStopped(self): return self._status.status == Status.STOPPED @@ -401,36 +403,56 @@ def process(self, forceCompute=False): if not forceCompute and self._status.status == Status.SUCCESS: logging.info("Node chunk already computed: {}".format(self.name)) return - global runningProcesses - runningProcesses[self.name] = self - self._status.initStartCompute() - exceptionStatus = None - startTime = time.time() - self.upgradeStatusTo(Status.RUNNING) - self.statThread = stats.StatisticsThread(self) - self.statThread.start() - try: - self.node.nodeDesc.processChunk(self) - except Exception as e: - if self._status.status != Status.STOPPED: - exceptionStatus = Status.ERROR - raise - except (KeyboardInterrupt, SystemError, GeneratorExit) as e: - exceptionStatus = Status.STOPPED - raise - finally: - self._status.initEndCompute() - self._status.elapsedTime = time.time() - startTime - if exceptionStatus is not None: - self.upgradeStatusTo(exceptionStatus) - logging.info(' - elapsed time: {}'.format(self._status.elapsedTimeStr)) - # ask and wait for the stats thread to stop - self.statThread.stopRequest() - self.statThread.join() - self.statistics = stats.Statistics() - del runningProcesses[self.name] - - self.upgradeStatusTo(Status.SUCCESS) + + #if plugin node and if first call call meshroom_compute inside the env on 'host' so that the processchunk + # of the node will be ran into the env + if hasattr(self.node.nodeDesc, 'envFile') and self._status.status!=Status.FIRST_RUN: + try: + if not self.node.nodeDesc.isBuild(): + self.upgradeStatusTo(Status.BUILD) + self.node.nodeDesc.build() + self.upgradeStatusTo(Status.FIRST_RUN) + command = self.node.nodeDesc.getCommandLine(self) + #NOTE: docker returns 0 even if mount fail (it fails on the deamon side) + logging.info("Running plugin node with "+command) + status = os.system(command) + if status != 0: + raise RuntimeError("Error in node execution") + self.updateStatusFromCache() + except Exception as ex: + self.logger.exception(ex) + self.upgradeStatusTo(Status.ERROR) + else: + global runningProcesses + runningProcesses[self.name] = self + self._status.initStartCompute() + exceptionStatus = None + startTime = time.time() + self.upgradeStatusTo(Status.RUNNING) + self.statThread = stats.StatisticsThread(self) + self.statThread.start() + try: + self.node.nodeDesc.processChunk(self) + except Exception as e: + if self._status.status != Status.STOPPED: + exceptionStatus = Status.ERROR + raise + except (KeyboardInterrupt, SystemError, GeneratorExit) as e: + exceptionStatus = Status.STOPPED + raise + finally: + self._status.initEndCompute() + self._status.elapsedTime = time.time() - startTime + if exceptionStatus is not None: + self.upgradeStatusTo(exceptionStatus) + logging.info(' - elapsed time: {}'.format(self._status.elapsedTimeStr)) + # ask and wait for the stats thread to stop + self.statThread.stopRequest() + self.statThread.join() + self.statistics = stats.Statistics() + del runningProcesses[self.name] + + self.upgradeStatusTo(Status.SUCCESS) def stopProcess(self): if not self.isExtern(): @@ -1111,8 +1133,8 @@ def getGlobalStatus(self): return Status.INPUT chunksStatus = [chunk.status.status for chunk in self._chunks] - anyOf = (Status.ERROR, Status.STOPPED, Status.KILLED, - Status.RUNNING, Status.SUBMITTED) + anyOf = (Status.ERROR, Status.STOPPED, Status.KILLED, Status.RUNNING, Status.BUILD, Status.FIRST_RUN, + Status.SUBMITTED,) allOf = (Status.SUCCESS,) for status in anyOf: diff --git a/meshroom/core/plugin.py b/meshroom/core/plugin.py new file mode 100644 index 0000000000..c7db6c95f3 --- /dev/null +++ b/meshroom/core/plugin.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python +# coding:utf-8 + +""" +This file defines the nodes and logic needed for the plugin system in meshroom. +A plugin is a collection of node(s) of any type with their rutime environnement setup file attached. +We use the term 'environment' to abstract a docker container or a conda/virtual environment. +""" + +from enum import Enum +import json +import os, sys +import logging +import urllib +from distutils.dir_util import copy_tree, remove_tree +import subprocess +import venv +import inspect + +from meshroom.core import desc, hashValue +from meshroom.core import pluginsNodesFolder, pluginsPipelinesFolder, defaultCacheFolder, pluginCatalogFile +from meshroom.core import meshroomFolder +from meshroom.core.graph import loadGraph + +#where the executables are (eg meshroom compute) +meshroomBinDir = os.path.abspath(os.path.join(meshroomFolder, "..", "bin")) + +class EnvType(Enum): + """ + enum for the type of env used (by degree of encapsulation) + """ + NONE = 0 + PIP = 1 + VENV = 10 + CONDA = 20 + DOCKER = 30 + +#NOTE: could add the concept of dependencies between plugins +class PluginParams(): + """" + Class that holds parameters to install one plugin from a folder and optionally from a json structure + """ + def __init__(self, pluginUrl, jsonData=None): + #get the plugin name from folder + self.pluginName = os.path.basename(pluginUrl) + #default node and pipeline locations + self.nodesFolder = os.path.join(pluginUrl, "meshroomNodes") + self.pipelineFolder = os.path.join(pluginUrl, "meshroomPipelines") + #overwrite is json is passed + if jsonData is not None: + self.pluginName = jsonData["pluginName"] + #default node and pipeline locations + self.nodesFolder = os.path.join(pluginUrl, jsonData["nodesFolder"]) + if "pipelineFolder" in jsonData.keys(): + self.pipelineFolder = os.path.join(pluginUrl, jsonData["pipelineFolder"]) + +def _dockerImageExists(image_name, tag='latest'): + """ + Check if the desired image:tag exists + """ + try: + result = subprocess.run( ['docker', 'images', image_name, '--format', '{{.Repository}}:{{.Tag}}'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) + if result.returncode != 0: + return False + images = result.stdout.splitlines() + image_tag = f"{image_name}:{tag}" + return image_tag in images + except Exception as e: + return False + +def _cleanEnvVarsRez(): + """ + Used to unset all rez defined env that mess up with conda. + """ + cmd = "unset python; unset PYTHONPATH; " + return cmd + +def _condaEnvExist(envName): + """ + Checks if a specified env exists + """ + cmd = "conda list --name "+envName + return os.system(cmd) == 0 + +def _formatPluginName(pluginName): + """ + Replaces spaces for env naming + """ + return pluginName.replace(" ", "_") + +def getVenvExe(venvPath): + """ + Returns the path for the python in a virtual env + """ + if not os.path.isdir(venvPath): + raise ValueError("The specified path "+venvPath+" is not a directory") + if sys.platform == "win32": + executable = os.path.join(venvPath, 'Scripts', 'python.exe') + else: + executable = os.path.join(venvPath, 'bin', 'python') + if not os.path.isfile(executable): + raise FileNotFoundError(f"Python executable not found in the specified virtual environment: "+executable) + return executable + +def _venvExists(envName): + """ + Check if the following virtual env exists + """ + return os.path.isdir(os.path.join(defaultCacheFolder, envName)) + +def installPlugin(pluginUrl): + """ + Install plugin from an url or local path. + Regardless of the method, the content will be copied in the plugin folder of meshroom (which is added to the list of directory to load nodes from). + There are two options : + - having the following structure : + - [plugin folder] (will be the plugin name) + - meshroomNodes + - [code for your nodes] that contains relative path to a DockerFile|env.yaml|requirements.txt + - [...] + - meshroomPipelines + - [your meshroom templates] + - having a meshroomPlugin.json file at the root of the plugin folder + With this solution, you may have several separate node folders. + """ + logging.info("Installing plugin from "+pluginUrl) + try: + isLocal = True + #if git repo, clone the repo in cache + if urllib.parse.urlparse(pluginUrl).scheme in ('http', 'https','git'): + os.chdir(defaultCacheFolder) + os.system("git clone "+pluginUrl) + pluginName = pluginUrl.split('.git')[0].split('/')[-1] + pluginUrl = os.path.join(defaultCacheFolder, pluginName) + isLocal = False + #sanity check + if not os.path.isdir(pluginUrl): + ValueError("Invalid plugin path :"+pluginUrl) + #by default only one plugin, and with default file hierachy + pluginParamList=[PluginParams(pluginUrl)] + #location of the json file if any + paramFile=os.path.join(pluginUrl, "meshroomPlugin.json") + #load json for custom install if any + if os.path.isfile(paramFile): + jsonData=json.load(open(paramFile,"r")) + pluginParamList = [PluginParams(pluginUrl, jsonDataplugin) for jsonDataplugin in jsonData] + #for each plugin, run the 'install' + for pluginParam in pluginParamList: + intallFolder = os.path.join(pluginsNodesFolder, _formatPluginName(pluginParam.pluginName)) + logging.info("Installing "+pluginParam.pluginName+" from "+pluginUrl+" in "+intallFolder) + #check if folder valid + if not os.path.isdir(pluginParam.nodesFolder): + raise RuntimeError("Invalid node folder: "+pluginParam.nodesFolder) + #check if already installed + if os.path.isdir(intallFolder): + logging.warn("Plugin already installed, will overwrite") + if os.path.islink(intallFolder): + os.unlink(intallFolder) + else: + remove_tree(intallFolder) + #install via symlink if local, otherwise copy (usefull to develop) + if isLocal: + os.symlink(pluginParam.nodesFolder, intallFolder) + if os.path.isdir(pluginParam.pipelineFolder): + os.symlink(pluginParam.pipelineFolder, os.path.join(pluginsPipelinesFolder, pluginParam.pluginName)) + else: + copy_tree(pluginParam.nodesFolder, intallFolder) + if os.path.isdir(pluginParam.pipelineFolder): + copy_tree(pluginParam.pipelineFolder, os.path.join(pluginsPipelinesFolder, pluginParam.pluginName)) + #remove repo if was cloned + if not isLocal: + os.removedirs(pluginUrl) + #NOTE: could try to auto load the plugins to avoid restart and test files + except Exception as ex: + logging.error(ex) + return False + + return True + +def getCatalog(): + """ + Returns the plugin catalog + """ + jsonData=json.load(open(pluginCatalogFile,"r")) + return jsonData + +def getInstalledPlugin(): + """ + Returns the list of installed plugins + """ + installedPlugins = [os.path.join(pluginsNodesFolder, f) for f in os.listdir(pluginsNodesFolder)] + return installedPlugins + +def uninstallPlugin(pluginUrl): + """ + Uninstall a plugin + """ + #NOTE: could also remove the env files + if not os.path.exists(pluginUrl): + raise RuntimeError("Plugin "+pluginUrl+" is not installed") + if os.path.islink(pluginUrl): + os.unlink(pluginUrl) + else: + os.removedirs(pluginUrl) + +class PluginNode(desc.Node): + """ + Class to be used to make a plugin node, you need to overwrite envType and envFile + """ + + @property + def envType(cls): + """ + Dynamic env type + """ + raise NotImplementedError("You must specify one or several envtype in the node description") + + @property + def envFile(cls): + """ + Env file used to build the environement, you may overwrite this to custom the behaviour + """ + raise NotImplementedError("You must specify an env file") + + @property + def _envName(cls): + """ + Get the env name by hashing the env files, overwrite this to use a custom pre-build env + """ + with open(cls.envFile, 'r') as file: + envContent = file.read() + return "meshroom_plugin_"+hashValue(envContent) + + def isBuild(cls): + """ + Check if the env needs to be build. + """ + if cls.envType == EnvType.NONE: + return True + elif cls.envType == EnvType.PIP: + #NOTE: could find way to check for installed packages instead of rebuilding all the time + return False + elif cls.envType == EnvType.VENV: + return _venvExists(cls._envName) + elif cls.envType == EnvType.CONDA: + return _condaEnvExist(cls._envName) + elif cls.envType == EnvType.DOCKER: + return _dockerImageExists(cls._envName) + + def build(cls): + """ + Perform the needed steps to prepare the environement in which to run the node. + """ + if cls.envType == EnvType.NONE: + pass + elif cls.envType == EnvType.PIP: + #install packages in the same python as meshroom + logging.info("Installing packages from "+ cls.envFile) + buildCommand = sys.executable+" -m pip install "+ cls.envFile + logging.info("Building with "+buildCommand+" ...") + returnValue = os.system(buildCommand) + logging.info("Done") + elif cls.envType == EnvType.VENV: + #create venv in default cache folder + logging.info("Creating virtual env "+os.path.join(defaultCacheFolder, cls._envName)+" from "+cls.envFile) + envPath = os.path.join(defaultCacheFolder, cls._envName) + venv.create(envPath, with_pip=True) + logging.info("Installing dependencies") + envExe = getVenvExe(envPath) + returnValue = os.system(_cleanEnvVarsRez()+envExe+" -m pip install -r "+ cls.envFile) + venvPythonLibFolder = os.path.join(os.path.dirname(envExe), '..', 'lib') + venvPythonLibFolder = [os.path.join(venvPythonLibFolder, p) + for p in os.listdir(venvPythonLibFolder) if p.startswith("python")][0] + os.symlink(meshroomFolder,os.path.join(venvPythonLibFolder, 'site-packages', 'meshroom')) + logging.info("Done") + elif cls.envType == EnvType.CONDA: + #build a conda env from a yaml file + logging.info("Creating conda env "+cls._envName+" from "+cls.envFile) + makeEnvCommand = ( _cleanEnvVarsRez()+" conda config --set channel_priority strict ; " + +" conda env create -v -v --name "+cls._envName + +" --file "+cls.envFile+" ") + logging.info("Making conda env") + logging.info(makeEnvCommand) + returnValue = os.system(makeEnvCommand) + #find path to env's folder and add symlink to meshroom + condaPythonExecudable=subprocess.check_output(_cleanEnvVarsRez()+"conda run -n "+cls._envName + +" python -c \"import sys; print(sys.executable)\"", + shell=True).strip().decode('UTF-8') + condaPythonLibFolder=os.path.join(os.path.dirname(condaPythonExecudable), '..', 'lib') + condaPythonLibFolder=[os.path.join(condaPythonLibFolder, p) + for p in os.listdir(condaPythonLibFolder) if p.startswith("python")][0] + os.symlink(meshroomFolder,os.path.join(condaPythonLibFolder, 'meshroom')) + logging.info("Done making conda env") + elif cls.envType == EnvType.DOCKER: + #build docker image + logging.info("Creating image "+cls._envName+" from "+ cls.envFile) + buildCommand = "docker build -f "+cls.envFile+" -t "+cls._envName+" "+os.path.dirname(cls.envFile) + logging.info("Building with "+buildCommand+" ...") + returnValue = os.system(buildCommand) + logging.info("Done") + if returnValue != 0: + raise RuntimeError("Something went wrong during build") + + def getCommandLine(cls, chunk): + """ + Return the command line needed to enter the environment + meshroom_compute + Will make meshroom available in the environment. + """ + if chunk.node.isParallelized: + raise RuntimeError("Parallelisation not supported for plugin nodes") + if chunk.node.graph.filepath == "": + raise RuntimeError("The project needs to be saved to use plugin nodes") + saved_graph = loadGraph(chunk.node.graph.filepath) + if (str(chunk.node) not in [str(f) for f in saved_graph._nodes._objects] + or chunk.node._uids[0] != saved_graph.findNode(str(chunk.node))._uids[0] ): + raise RuntimeError("The changes needs to be saved to use plugin nodes") + + cmdPrefix = "" + # vars common to venv and conda, that will be passed when runing conda run or venv + meshroomCompute= meshroomBinDir+"/meshroom_compute" + meshroomComputeArgs = "--node "+chunk.node.name+" \""+chunk.node.graph.filepath+"\"" + pythonsetMeshroomPath = "export PYTHONPATH="+meshroomFolder+":$PYTHONPATH;" + + if cls.envType == EnvType.VENV: + envPath = os.path.join(defaultCacheFolder, cls._envName) + envExe = getVenvExe(envPath) + #make sure meshroom in in pythonpath and that we call the right python + cmdPrefix = _cleanEnvVarsRez()+pythonsetMeshroomPath+" "+envExe + " "+ meshroomCompute +" " + elif cls.envType == EnvType.CONDA: + #NOTE: system env vars are not passed to conda run, we installed it 'manually' before + cmdPrefix = _cleanEnvVarsRez()+" conda run --cwd "+os.path.join(meshroomFolder, "..")\ + +" --no-capture-output -n "+cls._envName+" "+" python "+meshroomCompute + elif cls.envType == EnvType.DOCKER: + #path to the selected plugin + classFile=inspect.getfile(chunk.node.nodeDesc.__class__) + pluginDir = os.path.abspath(os.path.realpath(os.path.join(os.path.dirname(classFile),".."))) + #path to the project/cache + projectDir=os.path.abspath(os.path.realpath(os.path.dirname(chunk.node.graph.filepath))) + mountCommand = (' --mount type=bind,source="'+projectDir+'",target="'+projectDir+'"' #mount with same file hierarchy + +' --mount type=bind,source="'+pluginDir+'",target=/meshroomPlugin,readonly' #mount the plugin folder (because of symbolic link, not necesseraly physically in meshroom's folder) + +' --mount type=bind,source="'+meshroomFolder+'",target=/meshroomFolder/meshroom,readonly' + +' --mount type=bind,source="'+meshroomBinDir+'",target=/meshroomBinDir,readonly') + #adds meshroom's code(& the plugin actual path that can be different (because of the dymbolic link)) to the python path + envCommand = " --env PYTHONPATH=/meshroomFolder --env MESHROOM_NODES_PATH=/meshroomPlugin" + #adds the gpu arg if needed + runtimeArg="" + if cls.gpu != desc.Level.NONE: + runtimeArg="--runtime=nvidia --gpus all" + #compose cl + cmdPrefix = "docker run -it --rm "+runtimeArg+" "+mountCommand+" "+envCommand+" "+cls._envName +" \"python /meshroomBinDir/meshroom_compute " + meshroomComputeArgs="--node "+chunk.node.name+" "+chunk.node.graph.filepath+"\"" + + command=cmdPrefix+" "+meshroomComputeArgs + + return command + +#class that call command line nodes in an env +class PluginCommandLineNode(PluginNode, desc.CommandLineNode): + def buildCommandLine(self, chunk): + cmd = super().buildCommandLine(chunk) + #the process in Popen does not seem to use the right python, even if meshroom_compute is call within the env + #so in the case of command line using python, we have to make sur it is using the correct python + if self.envType == EnvType.VENV: + envPath = os.path.join(defaultCacheFolder, self._envName) + envExe = getVenvExe(envPath) + cmd=cmd.replace("python", envExe) + return cmd diff --git a/meshroom/plugins/README.md b/meshroom/plugins/README.md new file mode 100644 index 0000000000..f7bdddc01a --- /dev/null +++ b/meshroom/plugins/README.md @@ -0,0 +1,40 @@ +# Intro + +A plugin is a set of one or several nodes that are not part of the Meshroom/Alicevision main project. +They are meant to facilitate the creation of custom pipeline, make distribution and installation of extra nodes easy, and allow the use of different level of isolation at the node level. +Each node within a plugin may use the same or differents environnement. + +# Making Meshroom Plugins + +To make a new plugin, make your node inheriting from `meshroom.core.plugins.PluginNode` +In your new node class, overwrite the variable `envFile` to point to the environment file (e.g. the `yaml` or `dockerfile`) that sets up your installation, end `envType` to specify the type of plugin. The path to this file should be relative to the path of the node, and within the same folder (or subsequent child folder) as the node definition. + +The code in `processChunk` in your node definition will be automatically executed within the envirenoment, using `meshroom_compute`. +A new status `FIRST_RUN` denotes the stage in between the environement startup and the execution of the node. + +Make sur your imports are lazy, in `processChunk`. +Several nodes share the same environment as long as they point to the same environment file. +Changing this file will trigger a rebuild on the environment. + +You may install plugin from a git repository or from a local folder. In the later case, you may edit the code directly from your source folder. + +By default, Meshroom will look for node definition in `[plugin folder]/meshroomNodes` and new pipelines in `[plugin folder]/meshroomPipelines` and assumes only one environement is needed. + +To modify this behavior, you may put a json file named `meshroomPlugin.json` at the root of your folder/repository. +The file must have the following structure: +``` +[ + { + "pluginName":"[YOUR_PLUGIN_NAME]", + "nodesFolder":"[YOUR_FOLDER_RELATIVE_TO_THE_ROOT_REPO_OR_FOLDER], + "pipelineFolder":"[YOUR_CUSTOM_PIEPILINE_FOLDER" + }, + { + "pluginName":"Dummy Plugin", + "nodesFolder":"dummy" + } +] +``` + +The environment of the nodes are going to be build the first time it is needed (status will be `BUILD`, in purple). + diff --git a/meshroom/plugins/catalog.json b/meshroom/plugins/catalog.json new file mode 100644 index 0000000000..556ac56429 --- /dev/null +++ b/meshroom/plugins/catalog.json @@ -0,0 +1,9 @@ +[ + { + "pluginName":"Meshroom Research", + "pluginUrl":"https://github.com/alicevision/MeshroomResearch/", + "description":"Meshroom-Research comprises a collection of plugins for Meshroom, mostly develloped in-house at MikrosImage", + "isCollection":true, + "nodeTypes":["Python", "Docker", "Conda"] + } +] \ No newline at end of file diff --git a/meshroom/ui/qml/GraphEditor/common.js b/meshroom/ui/qml/GraphEditor/common.js index 35c754ce29..3633b049b6 100644 --- a/meshroom/ui/qml/GraphEditor/common.js +++ b/meshroom/ui/qml/GraphEditor/common.js @@ -5,7 +5,9 @@ var statusColors = { "RUNNING": "#FF9800", "ERROR": "#F44336", "SUCCESS": "#4CAF50", - "STOPPED": "#E91E63" + "STOPPED": "#E91E63", + "BUILD": "#66207f", + "FIRST_RUN": "#A52A2A" } var statusColorsExternOverrides = { diff --git a/meshroom/ui/qml/Utils/Colors.qml b/meshroom/ui/qml/Utils/Colors.qml index 51af70e25c..6215bcf3ad 100644 --- a/meshroom/ui/qml/Utils/Colors.qml +++ b/meshroom/ui/qml/Utils/Colors.qml @@ -19,6 +19,8 @@ QtObject { readonly property color lime: "#CDDC39" readonly property color grey: "#555555" readonly property color lightgrey: "#999999" + readonly property color deeppurple: "#66207F" + readonly property color brown: "#A52A2A" readonly property var statusColors: { "NONE": "transparent", @@ -26,7 +28,9 @@ QtObject { "RUNNING": orange, "ERROR": red, "SUCCESS": green, - "STOPPED": pink + "STOPPED": pink, + "BUILD": deeppurple, + "FIRST_RUN": brown } readonly property var ghostColors: { diff --git a/meshroom/ui/qml/main.qml b/meshroom/ui/qml/main.qml index 167b65fc22..4c8dd46b22 100644 --- a/meshroom/ui/qml/main.qml +++ b/meshroom/ui/qml/main.qml @@ -455,6 +455,73 @@ ApplicationWindow { id: aboutDialog } + //File browser for plugin + Dialog { + id: pluginURLDialog + title: "Plugin URL" + height: 150 + width: 300 + standardButtons: StandardButton.Ok | StandardButton.Cancel + //focus: true + Column { + anchors.fill: parent + Text { + text: "Plugin URL" + height: 40 + } + TextField { + id: urlInput + width: parent.width * 0.75 + focus: true + } + } + onButtonClicked: { + if (clickedButton==StandardButton.Ok) { + console.log("Accepted " + clickedButton) + if (_reconstruction.installPlugin(urlInput.text)) { + pluginInstalledDialog.open() + } else { + pluginNotInstalledDialog.open() + } + } + } + } + + // dialogs for plugins + MessageDialog { + id: pluginInstalledDialog + title: "Plugin installed" + modal: true + canCopy: false + Label { + text: "Plugin installed, please restart meshroom for the changes to take effect" + } + } + + MessageDialog { + id: pluginNotInstalledDialog + title: "Plugin not installed" + modal: true + canCopy: false + Label { + text: "Something went wrong, plugin not installed" + } + } + + // plugin installation from path or url + Platform.FolderDialog { + id: intallPluginDialog + options: Platform.FolderDialog.DontUseNativeDialog + title: "Install Plugin" + onAccepted: { + if (_reconstruction.installPlugin(currentFolder.toString())) { + pluginInstalledDialog.open() + } else { + pluginNotInstalledDialog.open() + } + } + } + // Check if document has been saved function ensureSaved(callback) { @@ -830,6 +897,24 @@ ApplicationWindow { } } + Action { + id: installPluginFromFolderAction + text: "Install Plugin From Local Folder" + onTriggered: { + initFileDialogFolder(intallPluginDialog) + intallPluginDialog.open() + } + } + + Action { + id: installPluginFromURLAction + text: "Install Plugin From URL" + onTriggered: { + pluginURLDialog.open() + } + } + + MenuItem { action: removeImagesFromAllGroupsAction ToolTip.visible: hovered diff --git a/meshroom/ui/reconstruction.py b/meshroom/ui/reconstruction.py index 63254fb9ca..065f6d31bf 100755 --- a/meshroom/ui/reconstruction.py +++ b/meshroom/ui/reconstruction.py @@ -18,6 +18,7 @@ from meshroom.ui.graph import UIGraph from meshroom.ui.utils import makeProperty from meshroom.ui.components.filepath import FilepathHelper +from meshroom.core.plugin import installPlugin class Message(QObject): @@ -413,6 +414,16 @@ def __init__(self, nodeType, parent=None): nodeChanged = Signal() node = makeProperty(QObject, "_node", nodeChanged, resetOnDestroy=True) +def prepareUrlLocalFile(url): + if isinstance(url, (QUrl)): + # depending how the QUrl has been initialized, + # toLocalFile() may return the local path or an empty string + localFile = url.toLocalFile() + if not localFile: + localFile = url.toString() + else: + localFile = url + return localFile class Reconstruction(UIGraph): """ @@ -563,16 +574,14 @@ def load(self, filepath, setupProjectFile=True, publishOutputs=False): @Slot(QUrl, result=bool) @Slot(QUrl, bool, bool, result=bool) def loadUrl(self, url, setupProjectFile=True, publishOutputs=False): - if isinstance(url, (QUrl)): - # depending how the QUrl has been initialized, - # toLocalFile() may return the local path or an empty string - localFile = url.toLocalFile() - if not localFile: - localFile = url.toString() - else: - localFile = url + localFile = prepareUrlLocalFile(url) return self.load(localFile, setupProjectFile, publishOutputs) + @Slot(QUrl, result=bool) + def installPlugin(self, url): + localFile = prepareUrlLocalFile(url) + return installPlugin(localFile) + def onGraphChanged(self): """ React to the change of the internal graph. """ self._liveSfmManager.reset() @@ -886,12 +895,7 @@ def importImagesFromFolder(self, path, recursive=False): def importImagesUrls(self, imagePaths, recursive=False): paths = [] for imagePath in imagePaths: - if isinstance(imagePath, (QUrl)): - p = imagePath.toLocalFile() - if not p: - p = imagePath.toString() - else: - p = imagePath + p = prepareUrlLocalFile(imagePath) paths.append(p) self.importImagesFromFolder(paths) diff --git a/tests/nodes/plugins/Dockerfile b/tests/nodes/plugins/Dockerfile new file mode 100644 index 0000000000..463ebb3896 --- /dev/null +++ b/tests/nodes/plugins/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3 +RUN python -m pip install --no-cache-dir numpy +RUN python -m pip install --no-cache-dir psutil +#overwrides entry point otherwise will directly execute python +ENTRYPOINT [ "/bin/bash", "-l", "-c" ] \ No newline at end of file diff --git a/tests/nodes/plugins/__init__.py b/tests/nodes/plugins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/nodes/plugins/dummyNodes.py b/tests/nodes/plugins/dummyNodes.py new file mode 100644 index 0000000000..b0446c90f3 --- /dev/null +++ b/tests/nodes/plugins/dummyNodes.py @@ -0,0 +1,155 @@ + + +import os +from meshroom.core.plugin import PluginNode, PluginCommandLineNode, EnvType + +#Python nodes + +class DummyConda(PluginNode): + + category = 'Dummy' + documentation = ''' ''' + + envType = EnvType.CONDA + envFile = os.path.join(os.path.dirname(__file__), "env.yaml") + + inputs = [] + outputs = [] + + def processChunk(self, chunk): + import numpy as np + chunk.logManager.start("info") + chunk.logger.info(np.abs(-1)) + +class DummyDocker(PluginNode): + + category = 'Dummy' + documentation = ''' ''' + + envType = EnvType.DOCKER + envFile = os.path.join(os.path.dirname(__file__), "Dockerfile") + + inputs = [] + outputs = [] + + def processChunk(self, chunk): + import numpy as np + chunk.logManager.start("info") + chunk.logger.info(np.abs(-1)) + + +class DummyVenv(PluginNode): + + category = 'Dummy' + documentation = ''' ''' + + envType = EnvType.VENV + envFile = os.path.join(os.path.dirname(__file__), "requirements.txt") + + inputs = [] + outputs = [] + + def processChunk(self, chunk): + import numpy as np + chunk.logManager.start("info") + chunk.logger.info(np.abs(-1)) + +class DummyPip(PluginNode): + + category = 'Dummy' + documentation = ''' ''' + + envType = EnvType.PIP + envFile = os.path.join(os.path.dirname(__file__), "requirements.txt") + + inputs = [] + outputs = [] + + def processChunk(self, chunk): + import numpy as np + chunk.logManager.start("info") + chunk.logger.info(np.abs(-1)) + +class DummyNone(PluginNode): + + category = 'Dummy' + documentation = ''' ''' + + envType = EnvType.NONE + envFile = None + + inputs = [] + outputs = [] + + def processChunk(self, chunk): + import numpy as np + chunk.logManager.start("info") + chunk.logger.info(np.abs(-1)) + +#Command line node + +class DummyCondaCL(PluginCommandLineNode): + + category = 'Dummy' + documentation = ''' ''' + + envType = EnvType.CONDA + envFile = os.path.join(os.path.dirname(__file__), "env.yaml") + + inputs = [] + outputs = [] + + commandLine = "python -c \"import numpy as np; print(np.abs(-1))\"" + +class DummyDockerCL(PluginCommandLineNode): + + category = 'Dummy' + documentation = ''' ''' + + envType = EnvType.DOCKER + envFile = os.path.join(os.path.dirname(__file__), "Dockerfile") + + inputs = [] + outputs = [] + + commandLine = "python -c \"import numpy as np; print(np.abs(-1))\"" + + +class DummyVenvCL(PluginCommandLineNode): + + category = 'Dummy' + documentation = ''' ''' + + envType = EnvType.VENV + envFile = os.path.join(os.path.dirname(__file__), "requirements.txt") + + inputs = [] + outputs = [] + + commandLine = "python -c \"import numpy as np; print(np.abs(-1))\"" + +class DummyPipCL(PluginCommandLineNode): + + category = 'Dummy' + documentation = ''' ''' + + envType = EnvType.PIP + envFile = os.path.join(os.path.dirname(__file__), "requirements.txt") + + inputs = [] + outputs = [] + + commandLine = "python -c \"import numpy as np; print(np.abs(-1))\"" + +class DummyNoneCL(PluginCommandLineNode): + + category = 'Dummy' + documentation = ''' ''' + + envType = EnvType.NONE + envFile = None + + inputs = [] + outputs = [] + + commandLine = "python -c \"import numpy as np; print(np.abs(-1))\"" \ No newline at end of file diff --git a/tests/nodes/plugins/env.yaml b/tests/nodes/plugins/env.yaml new file mode 100644 index 0000000000..f09be04127 --- /dev/null +++ b/tests/nodes/plugins/env.yaml @@ -0,0 +1,8 @@ +name: dummy +channels: + - defaults + - conda-forge +dependencies: + - python + - numpy + - psutil \ No newline at end of file diff --git a/tests/nodes/plugins/meshroomPlugin.json b/tests/nodes/plugins/meshroomPlugin.json new file mode 100644 index 0000000000..b1d03bd6d4 --- /dev/null +++ b/tests/nodes/plugins/meshroomPlugin.json @@ -0,0 +1,6 @@ +[ + { + "pluginName":"Dummy", + "nodesFolder":"." + } +] \ No newline at end of file diff --git a/tests/nodes/plugins/requirements.txt b/tests/nodes/plugins/requirements.txt new file mode 100644 index 0000000000..386e3f591a --- /dev/null +++ b/tests/nodes/plugins/requirements.txt @@ -0,0 +1,2 @@ +numpy +psutil \ No newline at end of file diff --git a/tests/test_plugin_nodes.py b/tests/test_plugin_nodes.py new file mode 100644 index 0000000000..1df862553d --- /dev/null +++ b/tests/test_plugin_nodes.py @@ -0,0 +1,17 @@ +import logging +import os + +from meshroom.core.graph import Graph + +logging = logging.getLogger(__name__) + +def test_pluginNodes(): + if "CI" in os.environ: + return + graph = Graph('') + graph.addNewNode('DummyCondaNode') + graph.addNewNode('DummyDockerNode') + graph.addNewNode('DummyPipNode') + graph.addNewNode('DummyVenvNode') + + \ No newline at end of file