[core] Introduce DynamicChoiceParam attribute

Add a new ChoiceParam-like type that supports the override and
serialization of the list of possible values.
This commit is contained in:
Yann Lanthony 2025-01-29 12:45:15 +01:00
parent d51dba12ad
commit a8c54a40db
4 changed files with 215 additions and 3 deletions

View file

@ -362,7 +362,10 @@ class Attribute(BaseObject):
If it is a list with one empty string element, it will returns 2 quotes.
'''
# ChoiceParam with multiple values should be combined
if isinstance(self.attributeDesc, desc.ChoiceParam) and not self.attributeDesc.exclusive:
if (
isinstance(self.attributeDesc, (desc.ChoiceParam, desc.DynamicChoiceParam))
and not self.attributeDesc.exclusive
):
# Ensure value is a list as expected
assert (isinstance(self.value, Sequence) and not isinstance(self.value, str))
v = self.attributeDesc.joinChar.join(self.getEvalValue())
@ -370,7 +373,10 @@ class Attribute(BaseObject):
return '"{}"'.format(v)
return v
# String, File, single value Choice are based on strings and should includes quotes to deal with spaces
if withQuotes and isinstance(self.attributeDesc, (desc.StringParam, desc.File, desc.ChoiceParam)):
if withQuotes and isinstance(
self.attributeDesc,
(desc.StringParam, desc.File, desc.ChoiceParam, desc.DynamicChoiceParam),
):
return '"{}"'.format(self.getEvalValue())
return str(self.getEvalValue())
@ -797,3 +803,42 @@ class GroupAttribute(Attribute):
# Override value property
value = Property(Variant, Attribute._get_value, _set_value, notify=Attribute.valueChanged)
isDefault = Property(bool, _isDefault, notify=Attribute.valueChanged)
class DynamicChoiceParam(GroupAttribute):
def __init__(self, node, attributeDesc, isOutput, root=None, parent=None):
super().__init__(node, attributeDesc, isOutput, root, parent)
# Granularity (and performance) could be improved by using the 'valueChanged' signals of sub-attributes.
# But as there are situations where:
# * the whole GroupAttribute is 'changed' (eg: connection/disconnection)
# * the sub-attributes are re-created (eg: resetToDefaultValue)
# it is simpler to use the GroupAttribute's 'valueChanged' signal as the main trigger for updates.
self.valueChanged.connect(self.choiceValueChanged)
self.valueChanged.connect(self.choiceValuesChanged)
def _get_value(self):
if self.isLink:
return super()._get_value()
return self.choiceValue.value
def _set_value(self, value):
if isinstance(value, dict) or Attribute.isLinkExpression(value):
super()._set_value(value)
else:
self.choiceValue.value = value
def getValues(self):
if self.isLink:
return self.linkParam.getValues()
return self.choiceValues.getExportValue() or self.desc.values
def setValues(self, values):
self.choiceValues.value = values
def getValueStr(self, withQuotes=True):
return Attribute.getValueStr(self, withQuotes)
choiceValueChanged = Signal()
value = Property(Variant, _get_value, _set_value, notify=choiceValueChanged)
choiceValuesChanged = Signal()
values = Property(Variant, getValues, setValues, notify=choiceValuesChanged)

View file

@ -3,6 +3,7 @@ from .attribute import (
BoolParam,
ChoiceParam,
ColorParam,
DynamicChoiceParam,
File,
FloatParam,
GroupAttribute,
@ -33,6 +34,7 @@ __all__ = [
"BoolParam",
"ChoiceParam",
"ColorParam",
"DynamicChoiceParam",
"File",
"FloatParam",
"GroupAttribute",

View file

@ -2,7 +2,8 @@ import ast
import distutils.util
import os
import types
from collections.abc import Iterable
from collections.abc import Iterable, Sequence
from typing import Union
from meshroom.common import BaseObject, JSValue, Property, Variant, VariantList
@ -526,3 +527,100 @@ class ColorParam(Param):
'color code (param: {}, value: {}, type: {})'.format(self.name, value, type(value)))
return value
class DynamicChoiceParam(GroupAttribute):
"""
Attribute supporting a single or multiple values, providing a list of predefined options that can be
modified at runtime and serialized.
"""
_PYTHON_BUILTIN_TO_PARAM_TYPE = {
str: StringParam,
int: IntParam,
}
def __init__(
self,
name: str,
label: str,
description: str,
value: Union[str, int, Sequence[Union[str, int]]],
values: Union[Sequence[str], Sequence[int]],
exclusive: bool=True,
group: str="allParams",
joinChar: str=" ",
advanced: bool=False,
enabled: bool=True,
invalidate: bool=True,
semantic: str="",
validValue: bool=True,
errorMessage: str="",
visible: bool=True,
exposed: bool=False,
):
# DynamicChoiceParam is a composed of:
# - a child ChoiceParam to hold the attribute value and as a backend to expose a ChoiceParam-compliant API
# - a child ListAttribute to hold the list of possible values
self._valueParam = ChoiceParam(
name="choiceValue",
label="Value",
description="",
value=value,
# Initialize the list of possible values to pass description validation.
values=values,
exclusive=exclusive,
group="",
joinChar=joinChar,
advanced=advanced,
enabled=enabled,
invalidate=invalidate,
semantic=semantic,
validValue=validValue,
errorMessage=errorMessage,
visible=visible,
exposed=exposed,
)
valueType: type = self._valueParam._valueType
paramType = DynamicChoiceParam._PYTHON_BUILTIN_TO_PARAM_TYPE[valueType]
self._valuesParam = ListAttribute(
name="choiceValues",
label="Values",
elementDesc=paramType(
name="choiceEntry",
label="Choice entry",
description="A possible choice value",
invalidate=False,
value=valueType(),
),
description="List of possible choice values",
group="",
advanced=True,
visible=False,
exposed=False,
)
self._valuesParam._value = values
super().__init__(
name=name,
label=label,
description=description,
group=group,
groupDesc=[self._valueParam, self._valuesParam],
advanced=advanced,
semantic=semantic,
enabled=enabled,
visible=visible,
exposed=exposed,
)
def getInstanceType(self):
from meshroom.core.attribute import DynamicChoiceParam
return DynamicChoiceParam
values = Property(VariantList, lambda self: self._valuesParam._value, constant=True)
exclusive = Property(bool, lambda self: self._valueParam.exclusive, constant=True)
joinChar = Property(str, lambda self: self._valueParam.joinChar, constant=True)

View file

@ -15,6 +15,19 @@ class NodeWithStaticChoiceParam(desc.Node):
),
]
class NodeWithDynamicChoiceParam(desc.Node):
inputs = [
desc.DynamicChoiceParam(
name="dynChoice",
label="Dynamic Choice",
description="A dynamic choice parameter",
value="A",
values=["A", "B", "C"],
exclusive=True,
exposed=True,
),
]
class TestStaticChoiceParam:
@classmethod
@ -47,3 +60,57 @@ class TestStaticChoiceParam:
loadedNode = loadedGraph.node(node.name)
assert loadedNode.choice.value == "CustomValue"
class TestDynamicChoiceParam:
@classmethod
def setup_class(cls):
registerNodeType(NodeWithDynamicChoiceParam)
@classmethod
def teardown_class(cls):
unregisterNodeType(NodeWithDynamicChoiceParam)
def test_resetDefaultValues(self, graphSavedOnDisk):
graph: Graph = graphSavedOnDisk
node = graph.addNewNode(NodeWithDynamicChoiceParam.__name__)
node.dynChoice.values = ["D", "E", "F"]
node.dynChoice.value = "D"
node.dynChoice.resetToDefaultValue()
assert node.dynChoice.values == ["A", "B", "C"]
assert node.dynChoice.value == "A"
def test_customValueIsSerialized(self, graphSavedOnDisk):
graph: Graph = graphSavedOnDisk
node = graph.addNewNode(NodeWithDynamicChoiceParam.__name__)
node.dynChoice.value = "CustomValue"
graph.save()
loadedGraph = loadGraph(graph.filepath)
loadedNode = loadedGraph.node(node.name)
assert loadedNode.dynChoice.value == "CustomValue"
def test_customValuesAreSerialized(self, graphSavedOnDisk):
graph: Graph = graphSavedOnDisk
node = graph.addNewNode(NodeWithDynamicChoiceParam.__name__)
node.dynChoice.values = ["D", "E", "F"]
graph.save()
loadedGraph = loadGraph(graph.filepath)
loadedNode = loadedGraph.node(node.name)
assert loadedNode.dynChoice.values == ["D", "E", "F"]
def test_duplicateNodeWithGroupAttributeDerivedAttribute(self):
graph = Graph("")
node = graph.addNewNode(NodeWithDynamicChoiceParam.__name__)
node.dynChoice.values = ["D", "E", "F"]
node.dynChoice.value = "G"
duplicates = graph.duplicateNodes([node])
duplicate = duplicates[node][0]
assert duplicate.dynChoice.value == node.dynChoice.value
assert duplicate.dynChoice.values == node.dynChoice.values