mirror of
https://github.com/alicevision/Meshroom.git
synced 2025-08-06 10:18:42 +02:00
moved plugin logic to nodeDesc instead of class
added build badge changes for uid renaming added back menu after update fix for existing symlink
This commit is contained in:
parent
c2aac17c88
commit
370d1346b4
13 changed files with 429 additions and 244 deletions
|
@ -19,7 +19,7 @@ except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
from meshroom.core.submitter import BaseSubmitter
|
from meshroom.core.submitter import BaseSubmitter
|
||||||
from . import desc
|
from meshroom.core import desc
|
||||||
|
|
||||||
# Setup logging
|
# Setup logging
|
||||||
logging.basicConfig(format='[%(asctime)s][%(levelname)s] %(message)s', level=logging.INFO)
|
logging.basicConfig(format='[%(asctime)s][%(levelname)s] %(message)s', level=logging.INFO)
|
||||||
|
|
|
@ -717,9 +717,53 @@ class Node(object):
|
||||||
documentation = ''
|
documentation = ''
|
||||||
category = 'Other'
|
category = 'Other'
|
||||||
|
|
||||||
|
_isPlugin = True
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super(Node, self).__init__()
|
super(Node, self).__init__()
|
||||||
self.hasDynamicOutputAttribute = any(output.isDynamicValue for output in self.outputs)
|
self.hasDynamicOutputAttribute = any(output.isDynamicValue for output in self.outputs)
|
||||||
|
try:
|
||||||
|
self.envFile
|
||||||
|
self.envType
|
||||||
|
except:
|
||||||
|
self._isPlugin=False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def envType(cls):
|
||||||
|
from core.plugin import EnvType #lazy import for plugin to avoid circular dependency
|
||||||
|
return EnvType.NONE
|
||||||
|
|
||||||
|
@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()
|
||||||
|
from meshroom.core.plugin import getEnvName #lazy import as to avoid circular dep
|
||||||
|
return getEnvName(envContent)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def isPlugin(self):
|
||||||
|
"""
|
||||||
|
Tests if the node is a valid plugin node
|
||||||
|
"""
|
||||||
|
return self._isPlugin
|
||||||
|
|
||||||
|
@property
|
||||||
|
def isBuilt(self):
|
||||||
|
"""
|
||||||
|
Tests if the environnement is built
|
||||||
|
"""
|
||||||
|
from meshroom.core.plugin import isBuilt
|
||||||
|
return self._isPlugin and isBuilt(self)
|
||||||
|
|
||||||
def upgradeAttributeValues(self, attrValues, fromVersion):
|
def upgradeAttributeValues(self, attrValues, fromVersion):
|
||||||
return attrValues
|
return attrValues
|
||||||
|
@ -806,7 +850,17 @@ class CommandLineNode(Node):
|
||||||
if chunk.node.isParallelized and chunk.node.size > 1:
|
if chunk.node.isParallelized and chunk.node.size > 1:
|
||||||
cmdSuffix = ' ' + self.commandLineRange.format(**chunk.range.toDict())
|
cmdSuffix = ' ' + self.commandLineRange.format(**chunk.range.toDict())
|
||||||
|
|
||||||
return cmdPrefix + chunk.node.nodeDesc.commandLine.format(**chunk.node._cmdVars) + cmdSuffix
|
cmd=cmdPrefix + chunk.node.nodeDesc.commandLine.format(**chunk.node._cmdVars) + cmdSuffix
|
||||||
|
|
||||||
|
#the process in Popen does not seem to use the right python, even if meshroom_compute is called within the env
|
||||||
|
#so in the case of command line using python, we have to make sure it is using the correct python
|
||||||
|
from meshroom.core.plugin import EnvType, getVenvPath, getVenvExe #lazy import to prevent circular dep
|
||||||
|
if self.isPlugin and self.envType == EnvType.VENV:
|
||||||
|
envPath = getVenvPath(self._envName)
|
||||||
|
envExe = getVenvExe(envPath)
|
||||||
|
cmd=cmd.replace("python", envExe)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
def stopProcess(self, chunk):
|
def stopProcess(self, chunk):
|
||||||
# The same node could exists several times in the graph and
|
# The same node could exists several times in the graph and
|
||||||
|
|
|
@ -21,7 +21,6 @@ from meshroom.core import desc, stats, hashValue, nodeVersion, Version
|
||||||
from meshroom.core.attribute import attributeFactory, ListAttribute, GroupAttribute, Attribute
|
from meshroom.core.attribute import attributeFactory, ListAttribute, GroupAttribute, Attribute
|
||||||
from meshroom.core.exception import NodeUpgradeError, UnknownNodeTypeError
|
from meshroom.core.exception import NodeUpgradeError, UnknownNodeTypeError
|
||||||
|
|
||||||
|
|
||||||
def getWritingFilepath(filepath):
|
def getWritingFilepath(filepath):
|
||||||
return filepath + '.writing.' + str(uuid.uuid4())
|
return filepath + '.writing.' + str(uuid.uuid4())
|
||||||
|
|
||||||
|
@ -406,13 +405,14 @@ class NodeChunk(BaseObject):
|
||||||
|
|
||||||
#if plugin node and if first call call meshroom_compute inside the env on 'host' so that the processchunk
|
#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
|
# of the node will be ran into the env
|
||||||
if hasattr(self.node.nodeDesc, 'envFile') and self._status.status!=Status.FIRST_RUN:
|
if self.node.nodeDesc.isPlugin and self._status.status!=Status.FIRST_RUN:
|
||||||
try:
|
try:
|
||||||
if not self.node.nodeDesc.isBuild():
|
from meshroom.core.plugin import isBuilt, build, getCommandLine #lazy import to avoid circular dep
|
||||||
|
if not isBuilt(self.node.nodeDesc):
|
||||||
self.upgradeStatusTo(Status.BUILD)
|
self.upgradeStatusTo(Status.BUILD)
|
||||||
self.node.nodeDesc.build()
|
build(self.node.nodeDesc)
|
||||||
self.upgradeStatusTo(Status.FIRST_RUN)
|
self.upgradeStatusTo(Status.FIRST_RUN)
|
||||||
command = self.node.nodeDesc.getCommandLine(self)
|
command = getCommandLine(self)
|
||||||
#NOTE: docker returns 0 even if mount fail (it fails on the deamon side)
|
#NOTE: docker returns 0 even if mount fail (it fails on the deamon side)
|
||||||
logging.info("Running plugin node with "+command)
|
logging.info("Running plugin node with "+command)
|
||||||
status = os.system(command)
|
status = os.system(command)
|
||||||
|
@ -484,6 +484,7 @@ class NodeChunk(BaseObject):
|
||||||
elapsedTime = Property(float, lambda self: self._status.elapsedTime, notify=statusChanged)
|
elapsedTime = Property(float, lambda self: self._status.elapsedTime, notify=statusChanged)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Simple structure for storing node position
|
# Simple structure for storing node position
|
||||||
Position = namedtuple("Position", ["x", "y"])
|
Position = namedtuple("Position", ["x", "y"])
|
||||||
# Initialize default coordinates values to 0
|
# Initialize default coordinates values to 0
|
||||||
|
@ -1416,6 +1417,8 @@ class BaseNode(BaseObject):
|
||||||
hasSequenceOutput = Property(bool, hasSequenceOutputAttribute, notify=outputAttrEnabledChanged)
|
hasSequenceOutput = Property(bool, hasSequenceOutputAttribute, notify=outputAttrEnabledChanged)
|
||||||
has3DOutput = Property(bool, has3DOutputAttribute, notify=outputAttrEnabledChanged)
|
has3DOutput = Property(bool, has3DOutputAttribute, notify=outputAttrEnabledChanged)
|
||||||
|
|
||||||
|
isPlugin = Property(bool, lambda self: self.nodeDesc.isPlugin, constant=True)
|
||||||
|
isBuilt = Property(bool, lambda self: self.nodeDesc.isBuilt, constant=True)
|
||||||
|
|
||||||
class Node(BaseNode):
|
class Node(BaseNode):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -17,10 +17,11 @@ import subprocess
|
||||||
import venv
|
import venv
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
from meshroom.core import desc, hashValue
|
from meshroom.core import desc
|
||||||
from meshroom.core import pluginsNodesFolder, pluginsPipelinesFolder, defaultCacheFolder, pluginCatalogFile
|
from meshroom.core import pluginsNodesFolder, pluginsPipelinesFolder, pluginCatalogFile, defaultCacheFolder
|
||||||
from meshroom.core import meshroomFolder
|
from meshroom.core import meshroomFolder
|
||||||
from meshroom.core.graph import loadGraph
|
from meshroom.core.graph import loadGraph
|
||||||
|
from meshroom.core import hashValue
|
||||||
|
|
||||||
#where the executables are (eg meshroom compute)
|
#where the executables are (eg meshroom compute)
|
||||||
meshroomBinDir = os.path.abspath(os.path.join(meshroomFolder, "..", "bin"))
|
meshroomBinDir = os.path.abspath(os.path.join(meshroomFolder, "..", "bin"))
|
||||||
|
@ -54,6 +55,9 @@ class PluginParams():
|
||||||
if "pipelineFolder" in jsonData.keys():
|
if "pipelineFolder" in jsonData.keys():
|
||||||
self.pipelineFolder = os.path.join(pluginUrl, jsonData["pipelineFolder"])
|
self.pipelineFolder = os.path.join(pluginUrl, jsonData["pipelineFolder"])
|
||||||
|
|
||||||
|
def getEnvName(envContent):
|
||||||
|
return "meshroom_plugin_"+hashValue(envContent)
|
||||||
|
|
||||||
def _dockerImageExists(image_name, tag='latest'):
|
def _dockerImageExists(image_name, tag='latest'):
|
||||||
"""
|
"""
|
||||||
Check if the desired image:tag exists
|
Check if the desired image:tag exists
|
||||||
|
@ -103,11 +107,14 @@ def getVenvExe(venvPath):
|
||||||
raise FileNotFoundError(f"Python executable not found in the specified virtual environment: "+executable)
|
raise FileNotFoundError(f"Python executable not found in the specified virtual environment: "+executable)
|
||||||
return executable
|
return executable
|
||||||
|
|
||||||
|
def getVenvPath(envName):
|
||||||
|
return os.path.join(defaultCacheFolder, envName)
|
||||||
|
|
||||||
def _venvExists(envName):
|
def _venvExists(envName):
|
||||||
"""
|
"""
|
||||||
Check if the following virtual env exists
|
Check if the following virtual env exists
|
||||||
"""
|
"""
|
||||||
return os.path.isdir(os.path.join(defaultCacheFolder, envName))
|
return os.path.isdir(getVenvPath(envName))
|
||||||
|
|
||||||
def installPlugin(pluginUrl):
|
def installPlugin(pluginUrl):
|
||||||
"""
|
"""
|
||||||
|
@ -163,7 +170,11 @@ def installPlugin(pluginUrl):
|
||||||
if isLocal:
|
if isLocal:
|
||||||
os.symlink(pluginParam.nodesFolder, intallFolder)
|
os.symlink(pluginParam.nodesFolder, intallFolder)
|
||||||
if os.path.isdir(pluginParam.pipelineFolder):
|
if os.path.isdir(pluginParam.pipelineFolder):
|
||||||
os.symlink(pluginParam.pipelineFolder, os.path.join(pluginsPipelinesFolder, pluginParam.pluginName))
|
pipelineFolderLink = os.path.join(pluginsPipelinesFolder, pluginParam.pluginName)
|
||||||
|
if os.path.exists(pipelineFolderLink):
|
||||||
|
logging.warn("Plugin already installed, will overwrite")
|
||||||
|
os.unlink(pipelineFolderLink)
|
||||||
|
os.symlink(pluginParam.pipelineFolder, pipelineFolderLink)
|
||||||
else:
|
else:
|
||||||
copy_tree(pluginParam.nodesFolder, intallFolder)
|
copy_tree(pluginParam.nodesFolder, intallFolder)
|
||||||
if os.path.isdir(pluginParam.pipelineFolder):
|
if os.path.isdir(pluginParam.pipelineFolder):
|
||||||
|
@ -204,87 +215,62 @@ def uninstallPlugin(pluginUrl):
|
||||||
else:
|
else:
|
||||||
os.removedirs(pluginUrl)
|
os.removedirs(pluginUrl)
|
||||||
|
|
||||||
class PluginNode(desc.Node):
|
def isBuilt(nodeDesc):
|
||||||
"""
|
"""
|
||||||
Class to be used to make a plugin node, you need to overwrite envType and envFile
|
Check if the env needs to be build for a specific nodesc.
|
||||||
"""
|
"""
|
||||||
|
if nodeDesc.envType == EnvType.NONE:
|
||||||
@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
|
return True
|
||||||
elif cls.envType == EnvType.PIP:
|
elif nodeDesc.envType == EnvType.PIP:
|
||||||
#NOTE: could find way to check for installed packages instead of rebuilding all the time
|
#NOTE: could find way to check for installed packages instead of rebuilding all the time
|
||||||
return False
|
return False
|
||||||
elif cls.envType == EnvType.VENV:
|
elif nodeDesc.envType == EnvType.VENV:
|
||||||
return _venvExists(cls._envName)
|
return _venvExists(nodeDesc._envName)
|
||||||
elif cls.envType == EnvType.CONDA:
|
elif nodeDesc.envType == EnvType.CONDA:
|
||||||
return _condaEnvExist(cls._envName)
|
return _condaEnvExist(nodeDesc._envName)
|
||||||
elif cls.envType == EnvType.DOCKER:
|
elif nodeDesc.envType == EnvType.DOCKER:
|
||||||
return _dockerImageExists(cls._envName)
|
return _dockerImageExists(nodeDesc._envName)
|
||||||
|
|
||||||
def build(cls):
|
def build(nodeDesc):
|
||||||
"""
|
"""
|
||||||
Perform the needed steps to prepare the environement in which to run the node.
|
Perform the needed steps to prepare the environement in which to run the node.
|
||||||
"""
|
"""
|
||||||
if cls.envType == EnvType.NONE:
|
if not hasattr(nodeDesc, 'envFile'):
|
||||||
|
raise RuntimeError("The nodedesc has no env file")
|
||||||
|
returnValue = 0
|
||||||
|
if nodeDesc.envType == EnvType.NONE:
|
||||||
pass
|
pass
|
||||||
elif cls.envType == EnvType.PIP:
|
elif nodeDesc.envType == EnvType.PIP:
|
||||||
#install packages in the same python as meshroom
|
#install packages in the same python as meshroom
|
||||||
logging.info("Installing packages from "+ cls.envFile)
|
logging.info("Installing packages from "+ nodeDesc.envFile)
|
||||||
buildCommand = sys.executable+" -m pip install "+ cls.envFile
|
buildCommand = sys.executable+" -m pip install "+ nodeDesc.envFile
|
||||||
logging.info("Building with "+buildCommand+" ...")
|
logging.info("Building with "+buildCommand+" ...")
|
||||||
returnValue = os.system(buildCommand)
|
returnValue = os.system(buildCommand)
|
||||||
logging.info("Done")
|
logging.info("Done")
|
||||||
elif cls.envType == EnvType.VENV:
|
elif nodeDesc.envType == EnvType.VENV:
|
||||||
#create venv in default cache folder
|
#create venv in default cache folder
|
||||||
logging.info("Creating virtual env "+os.path.join(defaultCacheFolder, cls._envName)+" from "+cls.envFile)
|
envPath = getVenvPath(nodeDesc._envName)
|
||||||
envPath = os.path.join(defaultCacheFolder, cls._envName)
|
logging.info("Creating virtual env "+envPath+" from "+nodeDesc.envFile)
|
||||||
venv.create(envPath, with_pip=True)
|
venv.create(envPath, with_pip=True)
|
||||||
logging.info("Installing dependencies")
|
logging.info("Installing dependencies")
|
||||||
envExe = getVenvExe(envPath)
|
envExe = getVenvExe(envPath)
|
||||||
returnValue = os.system(_cleanEnvVarsRez()+envExe+" -m pip install -r "+ cls.envFile)
|
returnValue = os.system(_cleanEnvVarsRez()+envExe+" -m pip install -r "+ nodeDesc.envFile)
|
||||||
venvPythonLibFolder = os.path.join(os.path.dirname(envExe), '..', 'lib')
|
venvPythonLibFolder = os.path.join(os.path.dirname(envExe), '..', 'lib')
|
||||||
venvPythonLibFolder = [os.path.join(venvPythonLibFolder, p)
|
venvPythonLibFolder = [os.path.join(venvPythonLibFolder, p)
|
||||||
for p in os.listdir(venvPythonLibFolder) if p.startswith("python")][0]
|
for p in os.listdir(venvPythonLibFolder) if p.startswith("python")][0]
|
||||||
os.symlink(meshroomFolder,os.path.join(venvPythonLibFolder, 'site-packages', 'meshroom'))
|
os.symlink(meshroomFolder,os.path.join(venvPythonLibFolder, 'site-packages', 'meshroom'))
|
||||||
logging.info("Done")
|
logging.info("Done")
|
||||||
elif cls.envType == EnvType.CONDA:
|
elif nodeDesc.envType == EnvType.CONDA:
|
||||||
#build a conda env from a yaml file
|
#build a conda env from a yaml file
|
||||||
logging.info("Creating conda env "+cls._envName+" from "+cls.envFile)
|
logging.info("Creating conda env "+nodeDesc._envName+" from "+nodeDesc.envFile)
|
||||||
makeEnvCommand = ( _cleanEnvVarsRez()+" conda config --set channel_priority strict ; "
|
makeEnvCommand = ( _cleanEnvVarsRez()+" conda config --set channel_priority strict ; "
|
||||||
+" conda env create -v -v --name "+cls._envName
|
+" conda env create -v -v --name "+nodeDesc._envName
|
||||||
+" --file "+cls.envFile+" ")
|
+" --file "+nodeDesc.envFile+" ")
|
||||||
logging.info("Making conda env")
|
logging.info("Making conda env")
|
||||||
logging.info(makeEnvCommand)
|
logging.info(makeEnvCommand)
|
||||||
returnValue = os.system(makeEnvCommand)
|
returnValue = os.system(makeEnvCommand)
|
||||||
#find path to env's folder and add symlink to meshroom
|
#find path to env's folder and add symlink to meshroom
|
||||||
condaPythonExecudable=subprocess.check_output(_cleanEnvVarsRez()+"conda run -n "+cls._envName
|
condaPythonExecudable=subprocess.check_output(_cleanEnvVarsRez()+"conda run -n "+nodeDesc._envName
|
||||||
+" python -c \"import sys; print(sys.executable)\"",
|
+" python -c \"import sys; print(sys.executable)\"",
|
||||||
shell=True).strip().decode('UTF-8')
|
shell=True).strip().decode('UTF-8')
|
||||||
condaPythonLibFolder=os.path.join(os.path.dirname(condaPythonExecudable), '..', 'lib')
|
condaPythonLibFolder=os.path.join(os.path.dirname(condaPythonExecudable), '..', 'lib')
|
||||||
|
@ -292,28 +278,31 @@ class PluginNode(desc.Node):
|
||||||
for p in os.listdir(condaPythonLibFolder) if p.startswith("python")][0]
|
for p in os.listdir(condaPythonLibFolder) if p.startswith("python")][0]
|
||||||
os.symlink(meshroomFolder,os.path.join(condaPythonLibFolder, 'meshroom'))
|
os.symlink(meshroomFolder,os.path.join(condaPythonLibFolder, 'meshroom'))
|
||||||
logging.info("Done making conda env")
|
logging.info("Done making conda env")
|
||||||
elif cls.envType == EnvType.DOCKER:
|
elif nodeDesc.envType == EnvType.DOCKER:
|
||||||
#build docker image
|
#build docker image
|
||||||
logging.info("Creating image "+cls._envName+" from "+ cls.envFile)
|
logging.info("Creating image "+nodeDesc._envName+" from "+ nodeDesc.envFile)
|
||||||
buildCommand = "docker build -f "+cls.envFile+" -t "+cls._envName+" "+os.path.dirname(cls.envFile)
|
buildCommand = "docker build -f "+nodeDesc.envFile+" -t "+nodeDesc._envName+" "+os.path.dirname(nodeDesc.envFile)
|
||||||
logging.info("Building with "+buildCommand+" ...")
|
logging.info("Building with "+buildCommand+" ...")
|
||||||
returnValue = os.system(buildCommand)
|
returnValue = os.system(buildCommand)
|
||||||
logging.info("Done")
|
logging.info("Done")
|
||||||
|
else:
|
||||||
|
raise RuntimeError("Invalid env type")
|
||||||
if returnValue != 0:
|
if returnValue != 0:
|
||||||
raise RuntimeError("Something went wrong during build")
|
raise RuntimeError("Something went wrong during build")
|
||||||
|
|
||||||
def getCommandLine(cls, chunk):
|
def getCommandLine(chunk):
|
||||||
"""
|
"""
|
||||||
Return the command line needed to enter the environment + meshroom_compute
|
Return the command line needed to enter the environment + meshroom_compute
|
||||||
Will make meshroom available in the environment.
|
Will make meshroom available in the environment.
|
||||||
"""
|
"""
|
||||||
|
nodeDesc=chunk.node.nodeDesc
|
||||||
if chunk.node.isParallelized:
|
if chunk.node.isParallelized:
|
||||||
raise RuntimeError("Parallelisation not supported for plugin nodes")
|
raise RuntimeError("Parallelisation not supported for plugin nodes")
|
||||||
if chunk.node.graph.filepath == "":
|
if chunk.node.graph.filepath == "":
|
||||||
raise RuntimeError("The project needs to be saved to use plugin nodes")
|
raise RuntimeError("The project needs to be saved to use plugin nodes")
|
||||||
saved_graph = loadGraph(chunk.node.graph.filepath)
|
saved_graph = loadGraph(chunk.node.graph.filepath)
|
||||||
if (str(chunk.node) not in [str(f) for f in saved_graph._nodes._objects]
|
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] ):
|
or chunk.node._uid != saved_graph.findNode(str(chunk.node))._uid ):
|
||||||
raise RuntimeError("The changes needs to be saved to use plugin nodes")
|
raise RuntimeError("The changes needs to be saved to use plugin nodes")
|
||||||
|
|
||||||
cmdPrefix = ""
|
cmdPrefix = ""
|
||||||
|
@ -322,16 +311,16 @@ class PluginNode(desc.Node):
|
||||||
meshroomComputeArgs = "--node "+chunk.node.name+" \""+chunk.node.graph.filepath+"\""
|
meshroomComputeArgs = "--node "+chunk.node.name+" \""+chunk.node.graph.filepath+"\""
|
||||||
pythonsetMeshroomPath = "export PYTHONPATH="+meshroomFolder+":$PYTHONPATH;"
|
pythonsetMeshroomPath = "export PYTHONPATH="+meshroomFolder+":$PYTHONPATH;"
|
||||||
|
|
||||||
if cls.envType == EnvType.VENV:
|
if nodeDesc.envType == EnvType.VENV:
|
||||||
envPath = os.path.join(defaultCacheFolder, cls._envName)
|
envPath = getVenvPath(nodeDesc._envName)
|
||||||
envExe = getVenvExe(envPath)
|
envExe = getVenvExe(envPath)
|
||||||
#make sure meshroom in in pythonpath and that we call the right python
|
#make sure meshroom in in pythonpath and that we call the right python
|
||||||
cmdPrefix = _cleanEnvVarsRez()+pythonsetMeshroomPath+" "+envExe + " "+ meshroomCompute +" "
|
cmdPrefix = _cleanEnvVarsRez()+pythonsetMeshroomPath+" "+envExe + " "+ meshroomCompute +" "
|
||||||
elif cls.envType == EnvType.CONDA:
|
elif nodeDesc.envType == EnvType.CONDA:
|
||||||
#NOTE: system env vars are not passed to conda run, we installed it 'manually' before
|
#NOTE: system env vars are not passed to conda run, we installed it 'manually' before
|
||||||
cmdPrefix = _cleanEnvVarsRez()+" conda run --cwd "+os.path.join(meshroomFolder, "..")\
|
cmdPrefix = _cleanEnvVarsRez()+" conda run --cwd "+os.path.join(meshroomFolder, "..")\
|
||||||
+" --no-capture-output -n "+cls._envName+" "+" python "+meshroomCompute
|
+" --no-capture-output -n "+nodeDesc._envName+" "+" python "+meshroomCompute
|
||||||
elif cls.envType == EnvType.DOCKER:
|
elif nodeDesc.envType == EnvType.DOCKER:
|
||||||
#path to the selected plugin
|
#path to the selected plugin
|
||||||
classFile=inspect.getfile(chunk.node.nodeDesc.__class__)
|
classFile=inspect.getfile(chunk.node.nodeDesc.__class__)
|
||||||
pluginDir = os.path.abspath(os.path.realpath(os.path.join(os.path.dirname(classFile),"..")))
|
pluginDir = os.path.abspath(os.path.realpath(os.path.join(os.path.dirname(classFile),"..")))
|
||||||
|
@ -345,24 +334,21 @@ class PluginNode(desc.Node):
|
||||||
envCommand = " --env PYTHONPATH=/meshroomFolder --env MESHROOM_NODES_PATH=/meshroomPlugin"
|
envCommand = " --env PYTHONPATH=/meshroomFolder --env MESHROOM_NODES_PATH=/meshroomPlugin"
|
||||||
#adds the gpu arg if needed
|
#adds the gpu arg if needed
|
||||||
runtimeArg=""
|
runtimeArg=""
|
||||||
if cls.gpu != desc.Level.NONE:
|
if chunk.node.nodeDesc.gpu != desc.Level.NONE:
|
||||||
runtimeArg="--runtime=nvidia --gpus all"
|
runtimeArg="--runtime=nvidia --gpus all"
|
||||||
#compose cl
|
#compose cl
|
||||||
cmdPrefix = "docker run -it --rm "+runtimeArg+" "+mountCommand+" "+envCommand+" "+cls._envName +" \"python /meshroomBinDir/meshroom_compute "
|
cmdPrefix = "docker run -it --rm "+runtimeArg+" "+mountCommand+" "+envCommand+" "+nodeDesc._envName +" \"python /meshroomBinDir/meshroom_compute "
|
||||||
meshroomComputeArgs="--node "+chunk.node.name+" "+chunk.node.graph.filepath+"\""
|
meshroomComputeArgs="--node "+chunk.node.name+" "+chunk.node.graph.filepath+"\""
|
||||||
|
else:
|
||||||
|
raise RuntimeError("NodeType not recognised")
|
||||||
|
|
||||||
command=cmdPrefix+" "+meshroomComputeArgs
|
command=cmdPrefix+" "+meshroomComputeArgs
|
||||||
|
|
||||||
return command
|
return command
|
||||||
|
|
||||||
#class that call command line nodes in an env
|
# you may use these to explicitly define Pluginnodes
|
||||||
class PluginCommandLineNode(PluginNode, desc.CommandLineNode):
|
class PluginNode(desc.Node):
|
||||||
def buildCommandLine(self, chunk):
|
pass
|
||||||
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
|
class PluginCommandLineNode(desc.CommandLineNode):
|
||||||
#so in the case of command line using python, we have to make sur it is using the correct python
|
pass
|
||||||
if self.envType == EnvType.VENV:
|
|
||||||
envPath = os.path.join(defaultCacheFolder, self._envName)
|
|
||||||
envExe = getVenvExe(envPath)
|
|
||||||
cmd=cmd.replace("python", envExe)
|
|
||||||
return cmd
|
|
|
@ -2,7 +2,7 @@
|
||||||
{
|
{
|
||||||
"pluginName":"Meshroom Research",
|
"pluginName":"Meshroom Research",
|
||||||
"pluginUrl":"https://github.com/alicevision/MeshroomResearch/",
|
"pluginUrl":"https://github.com/alicevision/MeshroomResearch/",
|
||||||
"description":"Meshroom-Research comprises a collection of plugins for Meshroom, mostly develloped in-house at MikrosImage",
|
"description":"Meshroom-Research comprises a collection of experimental plugins for Meshroom",
|
||||||
"isCollection":true,
|
"isCollection":true,
|
||||||
"nodeTypes":["Python", "Docker", "Conda"]
|
"nodeTypes":["Python", "Docker", "Conda"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -131,6 +131,73 @@ Page {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: computeManager
|
id: computeManager
|
||||||
|
|
||||||
|
@ -525,6 +592,23 @@ Page {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
header: RowLayout {
|
header: RowLayout {
|
||||||
spacing: 0
|
spacing: 0
|
||||||
MaterialToolButton {
|
MaterialToolButton {
|
||||||
|
@ -741,6 +825,18 @@ Page {
|
||||||
ToolTip.visible: hovered
|
ToolTip.visible: hovered
|
||||||
ToolTip.text: removeImagesFromAllGroupsAction.tooltip
|
ToolTip.text: removeImagesFromAllGroupsAction.tooltip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MenuItem {
|
||||||
|
action: installPluginFromFolderAction
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.text: "Install plugin from a folder"
|
||||||
|
}
|
||||||
|
|
||||||
|
MenuItem {
|
||||||
|
action: installPluginFromURLAction
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.text: "Install plugin from a local or online url"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MenuSeparator { }
|
MenuSeparator { }
|
||||||
Action {
|
Action {
|
||||||
|
@ -1214,6 +1310,17 @@ Page {
|
||||||
var n = _reconstruction.upgradeNode(node)
|
var n = _reconstruction.upgradeNode(node)
|
||||||
_reconstruction.selectedNode = n
|
_reconstruction.selectedNode = n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onDoBuild: {
|
||||||
|
try {
|
||||||
|
_reconstruction.buildNode(node.name)
|
||||||
|
node.isNotBuilt=false
|
||||||
|
} catch (error) {
|
||||||
|
//NOTE: could do an error popup
|
||||||
|
console.log("Build error:")
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,9 @@ Item {
|
||||||
property bool readOnly: node.locked
|
property bool readOnly: node.locked
|
||||||
/// Whether the node is in compatibility mode
|
/// Whether the node is in compatibility mode
|
||||||
readonly property bool isCompatibilityNode: node ? node.hasOwnProperty("compatibilityIssue") : false
|
readonly property bool isCompatibilityNode: node ? node.hasOwnProperty("compatibilityIssue") : false
|
||||||
|
/// Whether the node is a plugin that needs to be build
|
||||||
|
readonly property bool isPlugin: node ? node.isPlugin : false
|
||||||
|
property bool isNotBuilt: node ? (!node.isBuilt) : false
|
||||||
/// Mouse related states
|
/// Mouse related states
|
||||||
property bool mainSelected: false
|
property bool mainSelected: false
|
||||||
property bool selected: false
|
property bool selected: false
|
||||||
|
@ -28,7 +31,7 @@ Item {
|
||||||
property point position: Qt.point(x, y)
|
property point position: Qt.point(x, y)
|
||||||
/// Styling
|
/// Styling
|
||||||
property color shadowColor: "#cc000000"
|
property color shadowColor: "#cc000000"
|
||||||
readonly property color defaultColor: isCompatibilityNode ? "#444" : !node.isComputable ? "#BA3D69" : activePalette.base
|
readonly property color defaultColor: isCompatibilityNode ? "#444" : (!node.isComputable ? "#BA3D69" : activePalette.base)
|
||||||
property color baseColor: defaultColor
|
property color baseColor: defaultColor
|
||||||
|
|
||||||
property point mousePosition: Qt.point(mouseArea.mouseX, mouseArea.mouseY)
|
property point mousePosition: Qt.point(mouseArea.mouseX, mouseArea.mouseY)
|
||||||
|
@ -233,6 +236,15 @@ Item {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToBuild icon for PluginNodes
|
||||||
|
Loader {
|
||||||
|
active: root.isPlugin && root.isNotBuilt
|
||||||
|
sourceComponent: ToBuildBadge {
|
||||||
|
sourceComponent: iconDelegate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Data sharing indicator
|
// Data sharing indicator
|
||||||
// Note: for an unknown reason, there are some performance issues with the UI refresh.
|
// Note: for an unknown reason, there are some performance issues with the UI refresh.
|
||||||
// Example: a node duplicated 40 times will be slow while creating another identical node
|
// Example: a node duplicated 40 times will be slow while creating another identical node
|
||||||
|
|
|
@ -18,9 +18,12 @@ Panel {
|
||||||
property bool readOnly: false
|
property bool readOnly: false
|
||||||
property bool isCompatibilityNode: node && node.compatibilityIssue !== undefined
|
property bool isCompatibilityNode: node && node.compatibilityIssue !== undefined
|
||||||
property string nodeStartDateTime: ""
|
property string nodeStartDateTime: ""
|
||||||
|
readonly property bool isPlugin: node ? node.isPlugin : false
|
||||||
|
readonly property bool isNotBuilt: node ? (!node.isBuilt) : false
|
||||||
|
|
||||||
signal attributeDoubleClicked(var mouse, var attribute)
|
signal attributeDoubleClicked(var mouse, var attribute)
|
||||||
signal upgradeRequest()
|
signal upgradeRequest()
|
||||||
|
signal doBuild()
|
||||||
|
|
||||||
title: "Node" + (node !== null ? " - <b>" + node.label + "</b>" + (node.label !== node.defaultLabel ? " (" + node.defaultLabel + ")" : "") : "")
|
title: "Node" + (node !== null ? " - <b>" + node.label + "</b>" + (node.label !== node.defaultLabel ? " (" + node.defaultLabel + ")" : "") : "")
|
||||||
icon: MaterialLabel { text: MaterialIcons.tune }
|
icon: MaterialLabel { text: MaterialIcons.tune }
|
||||||
|
@ -225,6 +228,17 @@ Panel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
active: root.isPlugin && root.isNotBuilt
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: active // for layout update
|
||||||
|
|
||||||
|
sourceComponent: ToBuildBadge {
|
||||||
|
onDoBuild: root.doBuild()
|
||||||
|
sourceComponent: bannerDelegate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loader {
|
Loader {
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
|
66
meshroom/ui/qml/GraphEditor/ToBuildBadge.qml
Normal file
66
meshroom/ui/qml/GraphEditor/ToBuildBadge.qml
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import QtQuick 2.15
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
import QtQuick.Layouts 1.11
|
||||||
|
import MaterialIcons 2.2
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
sourceComponent: iconDelegate
|
||||||
|
|
||||||
|
signal doBuild()
|
||||||
|
|
||||||
|
property Component iconDelegate: Component {
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: MaterialIcons.warning
|
||||||
|
font.family: MaterialIcons.fontFamily
|
||||||
|
font.pointSize: 12
|
||||||
|
color: "#66207F"
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
onPressed: mouse.accepted = false
|
||||||
|
ToolTip.text: "Node env needs to be built"
|
||||||
|
ToolTip.visible: containsMouse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
property Component bannerDelegate: Component {
|
||||||
|
|
||||||
|
Pane {
|
||||||
|
padding: 6
|
||||||
|
clip: true
|
||||||
|
background: Rectangle { color: "#66207F" }
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
width: parent.width
|
||||||
|
Column {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Label {
|
||||||
|
width: parent.width
|
||||||
|
elide: Label.ElideMiddle
|
||||||
|
font.bold: true
|
||||||
|
text: "Env needs to be built"
|
||||||
|
color: "white"
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
width: parent.width
|
||||||
|
elide: Label.ElideMiddle
|
||||||
|
color: "white"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
visible: (parent.width > width) ? 1 : 0
|
||||||
|
palette.window: root.color
|
||||||
|
palette.button: Qt.darker(root.color, 1.2)
|
||||||
|
palette.buttonText: "white"
|
||||||
|
text: "Build"
|
||||||
|
onClicked: doBuild()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ AttributePin 1.0 AttributePin.qml
|
||||||
AttributeEditor 1.0 AttributeEditor.qml
|
AttributeEditor 1.0 AttributeEditor.qml
|
||||||
AttributeItemDelegate 1.0 AttributeItemDelegate.qml
|
AttributeItemDelegate 1.0 AttributeItemDelegate.qml
|
||||||
CompatibilityBadge 1.0 CompatibilityBadge.qml
|
CompatibilityBadge 1.0 CompatibilityBadge.qml
|
||||||
|
ToBuildBadge 1.0 ToBuildBadge.qml
|
||||||
CompatibilityManager 1.0 CompatibilityManager.qml
|
CompatibilityManager 1.0 CompatibilityManager.qml
|
||||||
singleton GraphEditorSettings 1.0 GraphEditorSettings.qml
|
singleton GraphEditorSettings 1.0 GraphEditorSettings.qml
|
||||||
TaskManager 1.0 TaskManager.qml
|
TaskManager 1.0 TaskManager.qml
|
||||||
|
|
|
@ -132,73 +132,6 @@ ApplicationWindow {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//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
|
// Check if document has been saved
|
||||||
function ensureSaved(callback)
|
function ensureSaved(callback)
|
||||||
{
|
{
|
||||||
|
|
|
@ -586,6 +586,14 @@ class Reconstruction(UIGraph):
|
||||||
localFile = prepareUrlLocalFile(url)
|
localFile = prepareUrlLocalFile(url)
|
||||||
return installPlugin(localFile)
|
return installPlugin(localFile)
|
||||||
|
|
||||||
|
@Slot(str, result=bool)
|
||||||
|
def buildNode(self, nodeName):
|
||||||
|
print("***Building "+nodeName)
|
||||||
|
node = self._graph.node(nodeName)
|
||||||
|
from meshroom.core.plugin import isBuilt, build #lazy import to avoid circular dep
|
||||||
|
if not isBuilt(node.nodeDesc):
|
||||||
|
build(node.nodeDesc)
|
||||||
|
|
||||||
def onGraphChanged(self):
|
def onGraphChanged(self):
|
||||||
""" React to the change of the internal graph. """
|
""" React to the change of the internal graph. """
|
||||||
self._liveSfmManager.reset()
|
self._liveSfmManager.reset()
|
||||||
|
|
|
@ -6,6 +6,7 @@ from meshroom.core.graph import Graph
|
||||||
logging = logging.getLogger(__name__)
|
logging = logging.getLogger(__name__)
|
||||||
|
|
||||||
def test_pluginNodes():
|
def test_pluginNodes():
|
||||||
|
#Dont run the tests in the CI as we are unable to install plugins beforehand
|
||||||
if "CI" in os.environ:
|
if "CI" in os.environ:
|
||||||
return
|
return
|
||||||
graph = Graph('')
|
graph = Graph('')
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue