Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Discard attribute changed callbacks during graph loading #2598

Merged
merged 5 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions meshroom/core/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ class GraphException(MeshroomException):
pass


class GraphCompatibilityError(GraphException):
"""
Raised when node compatibility issues occur when loading a graph.

Args:
filepath: The path to the file that caused the error.
issues: A dictionnary of node names and their respective compatibility issues.
"""
def __init__(self, filepath, issues: dict[str, str]) -> None:
self.filepath = filepath
self.issues = issues
msg = f"Compatibility issues found when loading {self.filepath}: {self.issues}"
super().__init__(msg)


class UnknownNodeTypeError(GraphException):
"""
Raised when asked to create a unknown node type.
Expand Down
33 changes: 31 additions & 2 deletions meshroom/core/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from meshroom.common import BaseObject, DictModel, Slot, Signal, Property
from meshroom.core import Version
from meshroom.core.attribute import Attribute, ListAttribute, GroupAttribute
from meshroom.core.exception import StopGraphVisit, StopBranchVisit
from meshroom.core.exception import GraphCompatibilityError, StopGraphVisit, StopBranchVisit
from meshroom.core.node import nodeFactory, Status, Node, CompatibilityNode

# Replace default encoder to support Enums
Expand Down Expand Up @@ -214,6 +214,7 @@
def __init__(self, name, parent=None):
super(Graph, self).__init__(parent)
self.name = name
self._loading = False
self._updateEnabled = True
self._updateRequested = False
self.dirtyTopology = False
Expand Down Expand Up @@ -246,6 +247,11 @@
""" Get loaded file supported features based on its version. """
return Graph.IO.getFeaturesForVersion(self.header.get(Graph.IO.Keys.FileVersion, "0.0"))

@property
def isLoading(self):
""" Return True if the graph is currently being loaded. """
return self._loading

@Slot(str)
def load(self, filepath, setupProjectFile=True, importProject=False, publishOutputs=False):
"""
Expand All @@ -259,97 +265,104 @@
of opened.
publishOutputs: True if "Publish" nodes from templates should not be ignored.
"""
self._loading = True
try:
self._load(filepath, setupProjectFile, importProject, publishOutputs)
finally:
self._loading = False

def _load(self, filepath, setupProjectFile, importProject, publishOutputs):
if not importProject:
self.clear()
with open(filepath) as jsonFile:
fileData = json.load(jsonFile)

self.header = fileData.get(Graph.IO.Keys.Header, {})

fileVersion = self.header.get(Graph.IO.Keys.FileVersion, "0.0")
# Retro-compatibility for all project files with the previous UID format
if Version(fileVersion) < Version("2.0"):
# For internal folders, all "{uid0}" keys should be replaced with "{uid}"
updatedFileData = json.dumps(fileData).replace("{uid0}", "{uid}")

# For fileVersion < 2.0, the nodes' UID is stored as:
# "uids": {"0": "hashvalue"}
# These should be identified and replaced with:
# "uid": "hashvalue"
uidPattern = re.compile(r'"uids": \{"0":.*?\}')
uidOccurrences = uidPattern.findall(updatedFileData)
for occ in uidOccurrences:
uid = occ.split("\"")[-2] # UID is second to last element
newUidStr = r'"uid": "{}"'.format(uid)
updatedFileData = updatedFileData.replace(occ, newUidStr)
fileData = json.loads(updatedFileData)

# Older versions of Meshroom files only contained the serialized nodes
graphData = fileData.get(Graph.IO.Keys.Graph, fileData)

if importProject:
self._importedNodes.clear()
graphData = self.updateImportedProject(graphData)

if not isinstance(graphData, dict):
raise RuntimeError('loadGraph error: Graph is not a dict. File: {}'.format(filepath))

nodesVersions = self.header.get(Graph.IO.Keys.NodesVersions, {})

self._fileDateVersion = os.path.getmtime(filepath)

# Check whether the file was saved as a template in minimal mode
isTemplate = self.header.get("template", False)

with GraphModification(self):
# iterate over nodes sorted by suffix index in their names
for nodeName, nodeData in sorted(graphData.items(), key=lambda x: self.getNodeIndexFromName(x[0])):
if not isinstance(nodeData, dict):
raise RuntimeError('loadGraph error: Node is not a dict. File: {}'.format(filepath))

# retrieve version from
# 1. nodeData: node saved from a CompatibilityNode
# 2. nodesVersion in file header: node saved from a Node
# 3. fallback to no version "0.0": retro-compatibility
if "version" not in nodeData:
nodeData["version"] = nodesVersions.get(nodeData["nodeType"], "0.0")

# if the node is a "Publish" node and comes from a template file, it should be ignored
# unless publishOutputs is True
if isTemplate and not publishOutputs and nodeData["nodeType"] == "Publish":
continue

n = nodeFactory(nodeData, nodeName, template=isTemplate)

# Add node to the graph with raw attributes values
self._addNode(n, nodeName)

if importProject:
self._importedNodes.add(n)

# Create graph edges by resolving attributes expressions
self._applyExpr()

if setupProjectFile:
# Update filepath related members
# Note: needs to be done at the end as it will trigger an updateInternals.
self._setFilepath(filepath)
elif not isTemplate:
# If no filepath is being set but the graph is not a template, trigger an updateInternals either way.
self.updateInternals()

# By this point, the graph has been fully loaded and an updateInternals has been triggered, so all the
# nodes' links have been resolved and their UID computations are all complete.
# It is now possible to check whether the UIDs stored in the graph file for each node correspond to the ones
# that were computed.
if not isTemplate: # UIDs are not stored in templates
self._evaluateUidConflicts(graphData)
try:
self._applyExpr()
except Exception as e:
logging.warning(e)

return True

Check notice on line 365 in meshroom/core/graph.py

View check run for this annotation

codefactor.io / CodeFactor

meshroom/core/graph.py#L274-L365

Complex Method

def _evaluateUidConflicts(self, data):
"""
Expand Down Expand Up @@ -1633,11 +1646,27 @@
canComputeLeaves = Property(bool, lambda self: self._canComputeLeaves, notify=canComputeLeavesChanged)


def loadGraph(filepath):
def loadGraph(filepath, strictCompatibility: bool = False) -> Graph:
"""
Load a Graph from a Meshroom Graph (.mg) file.

Args:
filepath: The path to the Meshroom Graph file.
strictCompatibility: If True, raise a GraphCompatibilityError if the loaded Graph has node compatibility issues.

Returns:
Graph: The loaded Graph instance.

Raises:
GraphCompatibilityError: If the Graph has node compatibility issues and `strictCompatibility` is True.
"""
graph = Graph("")
graph.load(filepath)

compatibilityIssues = len(graph.compatibilityNodes) > 0
if compatibilityIssues and strictCompatibility:
raise GraphCompatibilityError(filepath, {n.name: str(n.issue) for n in graph.compatibilityNodes})

graph.update()
return graph

Expand Down
4 changes: 4 additions & 0 deletions meshroom/core/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -964,6 +964,10 @@ def _onAttributeChanged(self, attr: Attribute):
if attr.value is None:
# Discard dynamic values depending on the graph processing.
return

if self.graph and self.graph.isLoading:
# Do not trigger attribute callbacks during the graph loading.
return

callback = self._getAttributeChangedCallback(attr)

Expand Down
23 changes: 22 additions & 1 deletion tests/test_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import meshroom.core
from meshroom.core import desc, registerNodeType, unregisterNodeType
from meshroom.core.exception import NodeUpgradeError
from meshroom.core.exception import GraphCompatibilityError, NodeUpgradeError
from meshroom.core.graph import Graph, loadGraph
from meshroom.core.node import CompatibilityNode, CompatibilityIssue, Node

Expand Down Expand Up @@ -395,3 +395,24 @@ def test_conformUpgrade():

unregisterNodeType(SampleNodeV5)
unregisterNodeType(SampleNodeV6)


class TestGraphLoadingWithStrictCompatibility:

def test_failsOnNodeDescriptionCompatibilityIssue(self, graphSavedOnDisk):
registerNodeType(SampleNodeV1)
registerNodeType(SampleNodeV2)

graph: Graph = graphSavedOnDisk
graph.addNewNode(SampleNodeV1.__name__)
graph.save()

# Replace saved node description by V2
meshroom.core.nodesDesc[SampleNodeV1.__name__] = SampleNodeV2

with pytest.raises(GraphCompatibilityError):
loadGraph(graph.filepath, strictCompatibility=True)

unregisterNodeType(SampleNodeV1)
unregisterNodeType(SampleNodeV2)

28 changes: 26 additions & 2 deletions tests/test_nodeAttributeChangedCallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ def test_loadingGraphDoesNotTriggerCallback(self, graphSavedOnDisk):
node.affectedInput.value = 2
graph.save()

loadedGraph = loadGraph(graph.filepath)
loadedGraph = loadGraph(graph.filepath, strictCompatibility=True)
loadedNode = loadedGraph.node(node.name)
assert loadedNode
assert loadedNode.affectedInput.value == 2
Expand All @@ -170,11 +170,13 @@ def test_loadingGraphDoesNotTriggerCallbackForConnectedAttributes(

graph.addEdge(nodeA.input, nodeB.input)
nodeA.input.value = 5
assert nodeB.affectedInput.value == nodeB.input.value * 2

nodeB.affectedInput.value = 2

graph.save()

loadedGraph = loadGraph(graph.filepath)
loadedGraph = loadGraph(graph.filepath, strictCompatibility=True)
loadedNodeB = loadedGraph.node(nodeB.name)
assert loadedNodeB
assert loadedNodeB.affectedInput.value == 2
Expand Down Expand Up @@ -407,3 +409,25 @@ def test_clearingDynamicOutputValueDoesNotTriggerDownstreamAttributeChangedCallb
nodeA.clearData()
assert nodeA.output.value == nodeB.input.value is None
assert nodeB.affectedInput.value == expectedPreClearValue

def test_loadingGraphWithComputedDynamicOutputValueDoesNotTriggerDownstreamAttributeChangedCallback(
self, graphSavedOnDisk
):
graph: Graph = graphSavedOnDisk
nodeA = graph.addNewNode(NodeWithDynamicOutputValue.__name__)
nodeB = graph.addNewNode(NodeWithAttributeChangedCallback.__name__)

nodeA.input.value = 10
graph.addEdge(nodeA.output, nodeB.input)
executeGraph(graph)

assert nodeA.output.value == nodeB.input.value == 20
assert nodeB.affectedInput.value == 0

graph.save()

loadGraph(graph.filepath, strictCompatibility=True)

assert nodeB.affectedInput.value == 0


Loading