[core] Refactor nodeFactory function

Rewrite `nodeFactory` to reduce cognitive complexity,
while preserving the current behavior.
This commit is contained in:
Yann Lanthony 2025-02-06 16:46:04 +01:00
parent 75db9dc16c
commit c883c53397
3 changed files with 175 additions and 93 deletions

View file

@ -339,7 +339,7 @@ class Graph(BaseObject):
if isTemplate and not publishOutputs and nodeData["nodeType"] == "Publish": if isTemplate and not publishOutputs and nodeData["nodeType"] == "Publish":
continue continue
n = nodeFactory(nodeData, nodeName, template=isTemplate) n = nodeFactory(nodeData, nodeName, inTemplate=isTemplate)
# Add node to the graph with raw attributes values # Add node to the graph with raw attributes values
self._addNode(n, nodeName) self._addNode(n, nodeName)
@ -392,14 +392,14 @@ class Graph(BaseObject):
# Different UIDs, remove the existing node from the graph and replace it with a CompatibilityNode # Different UIDs, remove the existing node from the graph and replace it with a CompatibilityNode
logging.debug("UID conflict detected for {}".format(nodeName)) logging.debug("UID conflict detected for {}".format(nodeName))
self.removeNode(nodeName) self.removeNode(nodeName)
n = nodeFactory(nodeData, nodeName, template=False, uidConflict=True) n = nodeFactory(nodeData, nodeName, expectedUid=graphUid)
self._addNode(n, nodeName) self._addNode(n, nodeName)
else: else:
# f connecting nodes have UID conflicts and are removed/re-added to the graph, some edges may be lost: # f connecting nodes have UID conflicts and are removed/re-added to the graph, some edges may be lost:
# the links will be erroneously updated, and any further resolution will fail. # the links will be erroneously updated, and any further resolution will fail.
# Recreating the entire graph as it was ensures that all edges will be correctly preserved. # Recreating the entire graph as it was ensures that all edges will be correctly preserved.
self.removeNode(nodeName) self.removeNode(nodeName)
n = nodeFactory(nodeData, nodeName, template=False, uidConflict=False) n = nodeFactory(nodeData, nodeName)
self._addNode(n, nodeName) self._addNode(n, nodeName)
def updateImportedProject(self, data): def updateImportedProject(self, data):

View file

@ -1,116 +1,197 @@
import logging import logging
from typing import Any, Iterable, Optional, Union
import meshroom.core import meshroom.core
from meshroom.core import Version, desc from meshroom.core import Version, desc
from meshroom.core.node import CompatibilityIssue, CompatibilityNode, Node, Position from meshroom.core.node import CompatibilityIssue, CompatibilityNode, Node, Position
def nodeFactory(nodeDict, name=None, template=False, uidConflict=False): def nodeFactory(
nodeData: dict,
name: Optional[str] = None,
inTemplate: bool = False,
expectedUid: Optional[str] = None,
) -> Union[Node, CompatibilityNode]:
""" """
Create a node instance by deserializing the given node data. Create a node instance by deserializing the given node data.
If the serialized data matches the corresponding node type description, a Node instance is created. If the serialized data matches the corresponding node type description, a Node instance is created.
If any compatibility issue occurs, a NodeCompatibility instance is created instead. If any compatibility issue occurs, a NodeCompatibility instance is created instead.
Args: Args:
nodeDict (dict): the serialization of the node nodeDict: The serialized Node data.
name (str): (optional) the node's name name: (optional) The node's name.
template (bool): (optional) true if the node is part of a template, false otherwise inTemplate: (optional) True if the node is created as part of a graph template.
uidConflict (bool): (optional) true if a UID conflict has been detected externally on that node expectedUid: (optional) The expected UID of the node within the context of a Graph.
Returns: Returns:
BaseNode: the created node The created Node instance.
""" """
nodeType = nodeDict["nodeType"] return _NodeCreator(nodeData, name, inTemplate, expectedUid).create()
# Retro-compatibility: inputs were previously saved as "attributes"
if "inputs" not in nodeDict and "attributes" in nodeDict:
nodeDict["inputs"] = nodeDict["attributes"]
del nodeDict["attributes"]
# Get node inputs/outputs class _NodeCreator:
inputs = nodeDict.get("inputs", {})
internalInputs = nodeDict.get("internalInputs", {})
outputs = nodeDict.get("outputs", {})
version = nodeDict.get("version", None)
internalFolder = nodeDict.get("internalFolder", None)
position = Position(*nodeDict.get("position", []))
uid = nodeDict.get("uid", None)
compatibilityIssue = None def __init__(
self,
nodeData: dict,
name: Optional[str] = None,
inTemplate: bool = False,
expectedUid: Optional[str] = None,
):
self.nodeData = nodeData
self.name = name
self.inTemplate = inTemplate
self.expectedUid = expectedUid
nodeDesc = None self._normalizeNodeData()
try:
nodeDesc = meshroom.core.nodesDesc[nodeType]
except KeyError:
# Unknown node type
compatibilityIssue = CompatibilityIssue.UnknownNodeType
# Unknown node type should take precedence over UID conflict, as it cannot be resolved self.nodeType = self.nodeData["nodeType"]
if uidConflict and nodeDesc: self.inputs = self.nodeData.get("inputs", {})
compatibilityIssue = CompatibilityIssue.UidConflict self.internalInputs = self.nodeData.get("internalInputs", {})
self.outputs = self.nodeData.get("outputs", {})
self.version = self.nodeData.get("version", None)
self.internalFolder = self.nodeData.get("internalFolder")
self.position = Position(*self.nodeData.get("position", []))
self.uid = self.nodeData.get("uid", None)
self.nodeDesc = meshroom.core.nodesDesc.get(self.nodeType, None)
if nodeDesc and not uidConflict: # if uidConflict, there is no need to look for another compatibility issue def create(self) -> Union[Node, CompatibilityNode]:
# Compare serialized node version with current node version compatibilityIssue = self._checkCompatibilityIssues()
currentNodeVersion = meshroom.core.nodeVersion(nodeDesc) if compatibilityIssue:
# If both versions are available, check for incompatibility in major version node = self._createCompatibilityNode(compatibilityIssue)
if version and currentNodeVersion and Version(version).major != Version(currentNodeVersion).major: node = self._tryUpgradeCompatibilityNode(node)
compatibilityIssue = CompatibilityIssue.VersionConflict
# In other cases, check attributes compatibility between serialized node and its description
else: else:
# Check that the node has the exact same set of inputs/outputs as its description, except node = self._createNode()
# if the node is described in a template file, in which only non-default parameters are saved; return node
# 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 isinstance(attr, desc.PushButtonParam)]) != sorted(inputs.keys()) or
sorted([attr.name for attr in nodeDesc.outputs if not attr.isDynamicValue]) !=
sorted(outputs.keys())):
compatibilityIssue = CompatibilityIssue.DescriptionConflict
# Check whether there are any internal attributes that are invalidating in the node description: if there def _normalizeNodeData(self):
# are, then check that these internal attributes are part of nodeDict; if they are not, a compatibility """Consistency fixes for backward compatibility with older serialized data."""
# issue must be raised to warn the user, as this will automatically change the node's UID # Inputs were previously saved as "attributes".
if not template: if "inputs" not in self.nodeData and "attributes" in self.nodeData:
invalidatingIntInputs = [] self.nodeData["inputs"] = self.nodeData["attributes"]
for attr in nodeDesc.internalInputs: del self.nodeData["attributes"]
if attr.invalidate:
invalidatingIntInputs.append(attr.name)
for attr in invalidatingIntInputs:
if attr not in internalInputs.keys():
compatibilityIssue = CompatibilityIssue.DescriptionConflict
break
# Verify that all inputs match their descriptions def _checkCompatibilityIssues(self) -> Optional[CompatibilityIssue]:
for attrName, value in inputs.items(): if self.nodeDesc is None:
if not CompatibilityNode.attributeDescFromName(nodeDesc.inputs, attrName, value): return CompatibilityIssue.UnknownNodeType
compatibilityIssue = CompatibilityIssue.DescriptionConflict
break
# Verify that all internal inputs match their description
for attrName, value in internalInputs.items():
if not CompatibilityNode.attributeDescFromName(nodeDesc.internalInputs, attrName, value):
compatibilityIssue = CompatibilityIssue.DescriptionConflict
break
# Verify that all outputs match their descriptions
for attrName, value in outputs.items():
if not CompatibilityNode.attributeDescFromName(nodeDesc.outputs, attrName, value):
compatibilityIssue = CompatibilityIssue.DescriptionConflict
break
if compatibilityIssue is None: if not self._checkUidCompatibility():
node = Node(nodeType, position, uid=uid, **inputs, **internalInputs, **outputs) return CompatibilityIssue.UidConflict
else:
logging.debug("Compatibility issue detected for node '{}': {}".format(name, compatibilityIssue.name)) if not self._checkVersionCompatibility():
node = CompatibilityNode(nodeType, nodeDict, position, compatibilityIssue) return CompatibilityIssue.VersionConflict
# Retro-compatibility: no internal folder saved
# can't spawn meaningful CompatibilityNode with precomputed outputs if not self._checkDescriptionCompatibility():
# => automatically try to perform node upgrade return CompatibilityIssue.DescriptionConflict
if not internalFolder and nodeDesc:
logging.warning("No serialized output data: performing automatic upgrade on '{}'".format(name)) return None
node = node.upgrade()
# If the node comes from a template file and there is a conflict, it should be upgraded anyway unless it is def _checkUidCompatibility(self) -> bool:
# an "unknown node type" conflict (in which case the upgrade would fail) return self.expectedUid is None or self.expectedUid == self.uid
elif template and compatibilityIssue is not CompatibilityIssue.UnknownNodeType:
node = node.upgrade() def _checkVersionCompatibility(self) -> bool:
# Special case: a node with a version set to None indicates
# that it has been created from the current version of the node type.
nodeCreatedFromCurrentVersion = self.version is None
if nodeCreatedFromCurrentVersion:
return True
nodeTypeCurrentVersion = meshroom.core.nodeVersion(self.nodeDesc, "0.0")
return Version(self.version).major == Version(nodeTypeCurrentVersion).major
def _checkDescriptionCompatibility(self) -> bool:
# Only perform strict attribute name matching for non-template graphs,
# since only non-default-value input attributes are serialized in templates.
if not self.inTemplate:
if not self._checkAttributesNamesMatchDescription():
return False
return self._checkAttributesAreCompatibleWithDescription()
def _checkAttributesNamesMatchDescription(self) -> bool:
return (
self._checkInputAttributesNames()
and self._checkOutputAttributesNames()
and self._checkInternalAttributesNames()
)
def _checkAttributesAreCompatibleWithDescription(self) -> bool:
return (
self._checkAttributesCompatibility(self.nodeDesc.inputs, self.inputs)
and self._checkAttributesCompatibility(self.nodeDesc.internalInputs, self.internalInputs)
and self._checkAttributesCompatibility(self.nodeDesc.outputs, self.outputs)
)
def _checkInputAttributesNames(self) -> bool:
def serializedInput(attr: desc.Attribute) -> bool:
"""Filter that excludes not-serialized desc input attributes."""
if isinstance(attr, desc.PushButtonParam):
# PushButtonParam are not serialized has they do not hold a value.
return False
return True
refAttributes = filter(serializedInput, self.nodeDesc.inputs)
return self._checkAttributesNamesStrictlyMatch(refAttributes, self.inputs)
def _checkOutputAttributesNames(self) -> bool:
def serializedOutput(attr: desc.Attribute) -> bool:
"""Filter that excludes not-serialized desc output attributes."""
if attr.isDynamicValue:
# Dynamic outputs values are not serialized with the node,
# as their value is written in the computed output data.
return False
return True
refAttributes = filter(serializedOutput, self.nodeDesc.outputs)
return self._checkAttributesNamesStrictlyMatch(refAttributes, self.outputs)
def _checkInternalAttributesNames(self) -> bool:
invalidatingDescAttributes = [attr.name for attr in self.nodeDesc.internalInputs if attr.invalidate]
return all(attr in self.internalInputs.keys() for attr in invalidatingDescAttributes)
def _checkAttributesNamesStrictlyMatch(
self, descAttributes: Iterable[desc.Attribute], attributesDict: dict[str, Any]
) -> bool:
refNames = sorted([attr.name for attr in descAttributes])
attrNames = sorted(attributesDict.keys())
return refNames == attrNames
def _checkAttributesCompatibility(
self, descAttributes: list[desc.Attribute], attributesDict: dict[str, Any]
) -> bool:
return all(
CompatibilityNode.attributeDescFromName(descAttributes, attrName, value) is not None
for attrName, value in attributesDict.items()
)
def _createNode(self) -> Node:
logging.info(f"Creating node '{self.name}'")
return Node(
self.nodeType,
position=self.position,
uid=self.uid,
**self.inputs,
**self.internalInputs,
**self.outputs,
)
def _createCompatibilityNode(self, compatibilityIssue) -> CompatibilityNode:
logging.warning(f"Compatibility issue detected for node '{self.name}': {compatibilityIssue.name}")
return CompatibilityNode(
self.nodeType, self.nodeData, position=self.position, issue=compatibilityIssue
)
def _tryUpgradeCompatibilityNode(self, node: CompatibilityNode) -> Union[Node, CompatibilityNode]:
"""Handle possible upgrades of CompatibilityNodes, when no computed data is associated to the Node."""
if node.issue == CompatibilityIssue.UnknownNodeType:
return node
# Nodes in templates are not meant to hold computation data.
if self.inTemplate:
logging.warning(f"Compatibility issue in template: performing automatic upgrade on '{self.name}'")
return node.upgrade()
# Backward compatibility: "internalFolder" was not serialized.
if not self.internalFolder:
logging.warning(f"No serialized output data: performing automatic upgrade on '{self.name}'")
return node return node

View file

@ -432,11 +432,12 @@ class UpgradeNodeCommand(GraphCommand):
def undoImpl(self): def undoImpl(self):
# delete upgraded node # delete upgraded node
expectedUid = self.graph.node(self.nodeName)._uid
self.graph.removeNode(self.nodeName) self.graph.removeNode(self.nodeName)
# recreate compatibility node # recreate compatibility node
with GraphModification(self.graph): with GraphModification(self.graph):
# We come back from an upgrade, so we enforce uidConflict=True as there was a uid conflict before # We come back from an upgrade, so we enforce uidConflict=True as there was a uid conflict before
node = nodeFactory(self.nodeDict, name=self.nodeName, uidConflict=True) node = nodeFactory(self.nodeDict, name=self.nodeName, expectedUid=expectedUid)
self.graph.addNode(node, self.nodeName) self.graph.addNode(node, self.nodeName)
# recreate out edges # recreate out edges
for dstAttr, srcAttr in self.outEdges.items(): for dstAttr, srcAttr in self.outEdges.items():