Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9b517f7
[core] desc: Add `keyable` and `keyType` properties
gregoire-dl Sep 8, 2025
2b115bb
[core] desc: Add `validateKeyValues` method
gregoire-dl Sep 8, 2025
a42fcab
[core] Add `keyValues` class
gregoire-dl Sep 8, 2025
33f8967
[core] attribute: Add keyable behavior support
gregoire-dl Sep 8, 2025
54416c9
[core] node: Handle keyable attribute cases
gregoire-dl Sep 8, 2025
dcd4350
[ui] commands: Add `ResetAttributeKeyValuesCommand`
gregoire-dl Sep 8, 2025
b1b916f
[ui] commands: Add `AddAttributeKeyValueCommand`
gregoire-dl Sep 8, 2025
a84aa4e
[ui] commands: Add `RemoveAttributeKeyCommand`
gregoire-dl Sep 8, 2025
52cad42
[ui] graph: Handle keyable attribute case in `resetAttribute` method
gregoire-dl Sep 8, 2025
248873b
[ui] graph: Add `addAttributeKeyValue` method
gregoire-dl Sep 8, 2025
0a8b835
[ui] graph: Add `addAttributeKeyDefaultValue` method
gregoire-dl Sep 8, 2025
11be3c6
[ui] graph: Add `removeAttributeKey` method
gregoire-dl Sep 8, 2025
ea04062
[qml] AttributeItemDelegate: Handle keyable attribute cases
gregoire-dl Sep 16, 2025
bb3a9f0
[tests] Add new test `attributeKeyValues`
gregoire-dl Sep 8, 2025
1abc6d6
[core] attribute: Fix keyable attribute default value
gregoire-dl Sep 26, 2025
1c84111
[ui] graph: Remove useless command to reset keyable attribute
gregoire-dl Sep 26, 2025
53724bc
[ui] commands: Fix wrong `oldValue` for keyable attribute
gregoire-dl Sep 26, 2025
91493cb
[core] keyVakues: Specify parent object for `DictModel` and `KeyValue…
gregoire-dl Sep 26, 2025
2587bc9
[core] keyValues: Convert key to string in `getSerializedValues`
gregoire-dl Sep 26, 2025
ad44db6
[tests] test_attributeKeyValues: Fix spaces
gregoire-dl Sep 26, 2025
9149d0a
[qml] AttributeItemDelegate: Lock keyable attribute when there is no …
gregoire-dl Sep 29, 2025
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
69 changes: 65 additions & 4 deletions meshroom/core/attribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from string import Template
from meshroom.common import BaseObject, Property, Variant, Signal, ListModel, DictModel, Slot
from meshroom.core import desc, hashValue

from meshroom.core.keyValues import KeyValues
from typing import TYPE_CHECKING

if TYPE_CHECKING:
Expand Down Expand Up @@ -77,6 +77,7 @@ def __init__(self, node, attributeDesc: desc.Attribute, isOutput: bool, root=Non
self._invalidate = False if self._isOutput else attributeDesc.invalidate
self._invalidationValue = "" # invalidation value for output attributes
self._value = None
self._keyValues = None # list of pairs (key, value) for keyable attribute
self._initValue()

def _getFullName(self) -> str:
Expand Down Expand Up @@ -119,7 +120,12 @@ def _initValue(self):
Initialize the attribute value.
Called in the attribute factory for each attributes.
"""
if self._desc._valueType is not None:
if self._desc.keyable:
# Keyable attribute, initialize keyValues from attribute description
self._keyValues = KeyValues(self._desc)
# Send signal and updates if keyValues changed
self._keyValues.pairsChanged.connect(self._onKeyValuesChanged)
elif self._desc._valueType is not None:
self._value = self._desc._valueType()

def _getEvalValue(self):
Expand All @@ -144,6 +150,8 @@ def _getValue(self):
"""
Return the value of the attribute or the linked attribute value.
"""
if self.keyable:
raise RuntimeError(f"Cannot get value of {self._getFullName()}, the attribute is keyable.")
if self.isLink:
return self._getInputLink().value
return self._value
Expand All @@ -157,6 +165,14 @@ def _setValue(self, value):
if isinstance(value, Attribute) or Attribute.isLinkExpression(value):
# if we set a link to another attribute
self._value = value
if self.keyable:
self._keyValues.reset()
elif self.keyable and isinstance(value, dict):
# keyable attribute initialize from a dict
self.keyValues.resetFromDict(value)
elif self.keyable:
# keyable attribute but value is not a dict
raise RuntimeError(f"Cannot set value of {self._getFullName()}, the attribute is keyable.")
elif callable(value):
# evaluate the function
self._value = value(self)
Expand All @@ -178,6 +194,16 @@ def _setValue(self, value):
self.requestNodeUpdate()
self.valueChanged.emit()

def _getKeyValues(self):
"""
Return the per-key values object of the attribute or of the linked attribute.
"""
if not self.keyable:
raise RuntimeError(f"Cannot get keyValues of {self._getFullName()}, the attribute is not keyable.")
if self.isLink:
return self._getInputLink().keyValues
return self._keyValues

def _applyExpr(self):
"""
For string parameters with an expression (when loaded from file),
Expand Down Expand Up @@ -209,7 +235,11 @@ def resetToDefaultValue(self):
"""
Reset the attribute to its default value.
"""
self._setValue(copy.copy(self.getDefaultValue()))
if self.keyable:
self._value = None
self._keyValues.reset()
else:
self._setValue(copy.copy(self.getDefaultValue()))

def getDefaultValue(self):
"""
Expand All @@ -222,6 +252,9 @@ def getDefaultValue(self):
if not self.node.isCompatibilityNode:
logging.warning(f"Failed to evaluate 'defaultValue' (node lambda) for attribute '{self.fullName}': {e}")
return None
# keyable attribute default value
if self.keyable:
return {}
# Need to force a copy, for the case where the value is a list
# (avoid reference to the desc value)
return copy.copy(self._desc.value)
Expand All @@ -232,6 +265,8 @@ def getSerializedValue(self):
"""
if self.isLink:
return self._getInputLink().asLinkExpr()
if self.keyable:
return self._keyValues.getSerializedValues()
if self.isOutput and self._desc.isExpression:
return self.getDefaultValue()
return self.value
Expand All @@ -247,6 +282,9 @@ def getValueStr(self, withQuotes=True) -> str:
If it is an empty list, it will returns a really empty string.
If it is a list with one empty string element, it will returns 2 quotes.
"""
# Keyable attribute, for now return the list of pairs as a JSON sting
if self.keyable:
return self._keyValues.getJson()
# ChoiceParam with multiple values should be combined
if isinstance(self._desc, desc.ChoiceParam) and not self._desc.exclusive:
# Ensure value is a list as expected
Expand All @@ -273,6 +311,12 @@ def upgradeValue(self, exportedValue):
"""
self._setValue(exportedValue)

def _isDefault(self):
if self.keyable:
return len(self._keyValues.pairs) == 0
else:
return self._getValue() == self.getDefaultValue()

def _isValid(self):
"""
Check attribute description validValue:
Expand Down Expand Up @@ -328,6 +372,8 @@ def uid(self) -> str:
if self.isLink:
linkRootAttribute = self._getInputLink(recursive=True)
return linkRootAttribute.uid()
if self.keyable:
return self._keyValues.uid()
if isinstance(self._value, (list, tuple, set,)):
# non-exclusive choice param
# hash of sorted values hashed
Expand Down Expand Up @@ -420,6 +466,17 @@ def _hasAnyOutputLinks(self) -> bool:

# Slots

@Slot()
def _onKeyValuesChanged(self):
"""
For keyable attribute, when the list or pairs (key, value) is modified this method should be called.
Emit Attribute.valueChanged and update node / graph like _setValue().
"""
if self.isInput:
self.requestGraphUpdate()
self.requestNodeUpdate()
self.valueChanged.emit()

@Slot()
def _onValueChanged(self):
self.node._onAttributeChanged(self)
Expand Down Expand Up @@ -464,9 +521,13 @@ def matchText(self, text: str) -> bool:
valueChanged = Signal()
value = Property(Variant, _getValue, _setValue, notify=valueChanged)
evalValue = Property(Variant, _getEvalValue, notify=valueChanged)
# Whether the attribute can have a distinct value per key.
keyable = Property(bool, lambda self: self._desc.keyable, constant=True)
# The list of pairs (key, value) of the attribute.
keyValues = Property(Variant, _getKeyValues, notify=valueChanged)

# Whether the attribute value is the default value.
isDefault = Property(bool, lambda self: self.value == self.getDefaultValue(), notify=valueChanged)
isDefault = Property(bool, _isDefault, notify=valueChanged)
# Whether the attribute value is valid.
isValid = Property(bool, _isValid, notify=valueChanged)
# Whether the attribute value is displayable in 2d.
Expand Down
69 changes: 50 additions & 19 deletions meshroom/core/desc/attribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ class Attribute(BaseObject):
"""
"""

def __init__(self, name, label, description, value, advanced, semantic, group, enabled, invalidate=True,
uidIgnoreValue=None, validValue=True, errorMessage="", visible=True, exposed=False):
def __init__(self, name, label, description, value, advanced, semantic, group, enabled,
keyable=False, keyType=None, invalidate=True, uidIgnoreValue=None,
validValue=True, errorMessage="", visible=True, exposed=False):
super(Attribute, self).__init__()
self._name = name
self._label = label
self._description = description
self._value = value
self._keyable = keyable
self._keyType = keyType
self._group = group
self._advanced = advanced
self._enabled = enabled
Expand Down Expand Up @@ -46,6 +49,15 @@ def validateValue(self, value):
"""
raise NotImplementedError("Attribute.validateValue is an abstract function that should be "
"implemented in the derived class.")

def validateKeyValues(self, keyValues):
""" Return validated/conformed 'keyValues'.
Raises:
ValueError: if a value does not have the proper type
"""
return isinstance(keyValues, dict) and \
all(isinstance(k, str) and self.validateValue(v) for k,v in keyValues.items())

def checkValueTypes(self):
""" Returns the attribute's name if the default value's type is invalid or if the range's type (when available)
Expand All @@ -65,7 +77,10 @@ def matchDescription(self, value, strict=True):
strict: strict test for the match (for instance, regarding a group with some parameter changes)
"""
try:
self.validateValue(value)
if self._keyable:
self.validateKeyValues(value)
else:
self.validateValue(value)
except ValueError:
return False
return True
Expand All @@ -82,6 +97,14 @@ def matchDescription(self, value, strict=True):
# The default value of the attribute's descriptor is None, so it is not an input value,
# but an output value that is computed during the Node's process execution.
isDynamicValue = Property(bool, lambda self: self._isDynamicValue, constant=True)
# keyable:
# Whether the attribute can have a distinct value per key.
# By default, atribute value is not keyable.
keyable = Property(bool, lambda self: self._keyable, constant=True)
# keyType:
# The type of key corresponding to the attribute value.
# This property only makes sense for keyable attributes.
keyType = Property(str, lambda self: self._keyType, constant=True)
group = Property(str, lambda self: self._group, constant=True)
advanced = Property(bool, lambda self: self._advanced, constant=True)
enabled = Property(Variant, lambda self: self._enabled, constant=True)
Expand Down Expand Up @@ -265,11 +288,13 @@ def retrieveChildrenInvalidations(self):
class Param(Attribute):
"""
"""
def __init__(self, name, label, description, value, group, advanced, semantic, enabled, invalidate=True,
uidIgnoreValue=None, validValue=True, errorMessage="", visible=True, exposed=False):
def __init__(self, name, label, description, value, group, advanced, semantic, enabled,
keyable=False, keyType=None, invalidate=True, uidIgnoreValue=None,
validValue=True, errorMessage="", visible=True, exposed=False):
super(Param, self).__init__(name=name, label=label, description=description, value=value,
group=group, advanced=advanced, enabled=enabled, invalidate=invalidate,
semantic=semantic, uidIgnoreValue=uidIgnoreValue, validValue=validValue,
keyable=keyable, keyType=keyType, group=group, advanced=advanced,
enabled=enabled, invalidate=invalidate, semantic=semantic,
uidIgnoreValue=uidIgnoreValue, validValue=validValue,
errorMessage=errorMessage, visible=visible, exposed=exposed)


Expand Down Expand Up @@ -302,11 +327,13 @@ def checkValueTypes(self):
class BoolParam(Param):
"""
"""
def __init__(self, name, label, description, value, group="allParams", advanced=False, enabled=True,
invalidate=True, semantic="", visible=True, exposed=False):
def __init__(self, name, label, description, value, keyable=False, keyType=None,
group="allParams", advanced=False, enabled=True, invalidate=True,
semantic="", visible=True, exposed=False):
super(BoolParam, self).__init__(name=name, label=label, description=description, value=value,
group=group, advanced=advanced, enabled=enabled, invalidate=invalidate,
semantic=semantic, visible=visible, exposed=exposed)
keyable=keyable, keyType=keyType, group=group, advanced=advanced,
enabled=enabled, invalidate=invalidate, semantic=semantic,
visible=visible, exposed=exposed)
self._valueType = bool

def validateValue(self, value):
Expand All @@ -330,12 +357,14 @@ def checkValueTypes(self):
class IntParam(Param):
"""
"""
def __init__(self, name, label, description, value, range=None, group="allParams", advanced=False, enabled=True,
invalidate=True, semantic="", validValue=True, errorMessage="", visible=True, exposed=False):
def __init__(self, name, label, description, value, range=None, keyable=False, keyType=None,
group="allParams", advanced=False, enabled=True, invalidate=True, semantic="",
validValue=True, errorMessage="", visible=True, exposed=False):
self._range = range
super(IntParam, self).__init__(name=name, label=label, description=description, value=value,
group=group, advanced=advanced, enabled=enabled, invalidate=invalidate,
semantic=semantic, validValue=validValue, errorMessage=errorMessage,
keyable=keyable, keyType=keyType, group=group, advanced=advanced,
enabled=enabled, invalidate=invalidate, semantic=semantic,
validValue=validValue, errorMessage=errorMessage,
visible=visible, exposed=exposed)
self._valueType = int

Expand All @@ -360,12 +389,14 @@ def checkValueTypes(self):
class FloatParam(Param):
"""
"""
def __init__(self, name, label, description, value, range=None, group="allParams", advanced=False, enabled=True,
invalidate=True, semantic="", validValue=True, errorMessage="", visible=True, exposed=False):
def __init__(self, name, label, description, value, range=None, keyable=False, keyType=None,
group="allParams", advanced=False, enabled=True, invalidate=True, semantic="",
validValue=True, errorMessage="", visible=True, exposed=False):
self._range = range
super(FloatParam, self).__init__(name=name, label=label, description=description, value=value,
group=group, advanced=advanced, enabled=enabled, invalidate=invalidate,
semantic=semantic, validValue=validValue, errorMessage=errorMessage,
keyable=keyable, keyType=keyType, group=group, advanced=advanced,
enabled=enabled, invalidate=invalidate, semantic=semantic,
validValue=validValue, errorMessage=errorMessage,
visible=visible, exposed=exposed)
self._valueType = float

Expand Down
Loading
Loading