Meshroom/meshroom/core/nodeFactory.py
Yann Lanthony 87fbcee06d [core][graphIO] Improve node type version handling
* Deserialization: Replace the logic that defaulted the node type version to "0.0" when unspecified,
and assume that unspecified version on a node is targetting current node type version.
* Serialization: Only serialize node type versions for which a version info is available.

* Test suites:
  * Add helper context manager to manually override the version of a given node type.
  * Add new unit tests to cover version conflicts handling is various scenarios.
2025-02-06 16:46:05 +01:00

201 lines
8.2 KiB
Python

import logging
from typing import Any, Iterable, Optional, Union
import meshroom.core
from meshroom.core import Version, desc
from meshroom.core.node import CompatibilityIssue, CompatibilityNode, Node, Position
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.
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.
Args:
nodeData: The serialized Node data.
name: The node's name.
inTemplate: True if the node is created as part of a graph template.
expectedUid: The expected UID of the node within the context of a Graph.
Returns:
The created Node instance.
"""
return _NodeCreator(nodeData, name, inTemplate, expectedUid).create()
class _NodeCreator:
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
self._normalizeNodeData()
self.nodeType = self.nodeData["nodeType"]
self.inputs = self.nodeData.get("inputs", {})
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)
def create(self) -> Union[Node, CompatibilityNode]:
compatibilityIssue = self._checkCompatibilityIssues()
if compatibilityIssue:
node = self._createCompatibilityNode(compatibilityIssue)
node = self._tryUpgradeCompatibilityNode(node)
else:
node = self._createNode()
return node
def _normalizeNodeData(self):
"""Consistency fixes for backward compatibility with older serialized data."""
# Inputs were previously saved as "attributes".
if "inputs" not in self.nodeData and "attributes" in self.nodeData:
self.nodeData["inputs"] = self.nodeData["attributes"]
del self.nodeData["attributes"]
def _checkCompatibilityIssues(self) -> Optional[CompatibilityIssue]:
if self.nodeDesc is None:
return CompatibilityIssue.UnknownNodeType
if not self._checkUidCompatibility():
return CompatibilityIssue.UidConflict
if not self._checkVersionCompatibility():
return CompatibilityIssue.VersionConflict
if not self._checkDescriptionCompatibility():
return CompatibilityIssue.DescriptionConflict
return None
def _checkUidCompatibility(self) -> bool:
return self.expectedUid is None or self.expectedUid == self.uid
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)
# If the node type has not current version information, assume compatibility.
if nodeTypeCurrentVersion is None:
return True
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.upgrade()
return node