Skip to content
Draft
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
4 changes: 4 additions & 0 deletions meshroom/core/attribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,10 @@ def getDefaultValue(self):
# keyable attribute default value
if self.keyable:
return {}
# If the descriptor's default value is None and this is an input attribute with a known
# value type, use the type's default constructor value (e.g. 0 for int, "" for str).
if self._desc.value is None and self.isInput and self._desc._valueType is not None:
Comment on lines +304 to +305
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getDefaultValue() uses self._desc._valueType() when desc.value is None. For desc.ChoiceParam with exclusive=False, _valueType is the element type (e.g. str), so this produces "", which then gets validated into [""] for the attribute default. This breaks the expected empty-list default for non-exclusive ChoiceParams and can lead to incorrect CLI formatting/serialization behavior. Consider special-casing non-exclusive ChoiceParam to default to an empty list (and add a regression test).

Suggested change
# value type, use the type's default constructor value (e.g. 0 for int, "" for str).
if self._desc.value is None and self.isInput and self._desc._valueType is not None:
# value type, use the type's default constructor value (e.g. 0 for int, "" for str),
# except for non-exclusive ChoiceParam, which should default to an empty list.
if self._desc.value is None and self.isInput and self._desc._valueType is not None:
if isinstance(self._desc, desc.ChoiceParam) and not getattr(self._desc, "exclusive", True):
return []

Copilot uses AI. Check for mistakes.
return self._desc._valueType()
# 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 Down
75 changes: 59 additions & 16 deletions meshroom/core/desc/attribute.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
import ast
import os
import re
from collections.abc import Iterable
from enum import auto, Enum

from meshroom.common import BaseObject, JSValue, Property, Variant, VariantList, strtobool


def _labelFromName(name):
"""Convert a camelCase or snake_case attribute name to a 'Title Case' label.

Examples:
>>> _labelFromName('myAttributeName')
'My Attribute Name'
>>> _labelFromName('my_attribute_name')
'My Attribute Name'
>>> _labelFromName('input')
'Input'
"""
# Insert space before uppercase letter following a lowercase letter or digit (camelCase)
s = re.sub(r'([a-z\d])([A-Z])', r'\1 \2', name)
# Replace underscores with spaces (snake_case)
s = s.replace('_', ' ')
# Capitalize each word
return ' '.join(word.capitalize() for word in s.split())

class ValueTypeErrors(Enum):
NONE = auto() # No error
TYPE = auto() # Invalid type
Expand All @@ -20,8 +40,8 @@ def __init__(self, name, label, description, value, advanced, semantic, group, e
validValue=True, errorMessage="", visible=True, exposed=False):
super(Attribute, self).__init__()
self._name = name
self._label = label
self._description = description
self._label = label if label is not None else _labelFromName(name)
self._description = description if description is not None else ""
self._value = value
self._keyable = keyable
self._keyType = keyType
Expand Down Expand Up @@ -134,7 +154,7 @@ def matchDescription(self, value, strict=True):

class ListAttribute(Attribute):
""" A list of Attributes """
def __init__(self, elementDesc, name, label, description, group="allParams", advanced=False, semantic="",
def __init__(self, elementDesc, name, label=None, description=None, group="allParams", advanced=False, semantic="",
enabled=True, joinChar=" ", visible=True, exposed=False):
"""
:param elementDesc: the Attribute description of elements to store in that list
Expand Down Expand Up @@ -186,7 +206,7 @@ def matchDescription(self, value, strict=True):

class GroupAttribute(Attribute):
""" A macro Attribute composed of several Attributes """
def __init__(self, items, name, label, description, group="allParams", advanced=False, semantic="",
def __init__(self, items, name, label=None, description=None, group="allParams", advanced=False, semantic="",
enabled=True, joinChar=" ", brackets=None, visible=True, exposed=False):
"""
:param items: the description of the Attributes composing this group
Expand Down Expand Up @@ -294,8 +314,8 @@ def retrieveChildrenInvalidations(self):
class Param(Attribute):
"""
"""
def __init__(self, name, label, description, value, group, advanced, semantic, enabled,
keyable=False, keyType=None, invalidate=True, uidIgnoreValue=None,
def __init__(self, name, label=None, description=None, value=None, group="allParams", advanced=False, semantic="",
enabled=True, 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,
keyable=keyable, keyType=keyType, group=group, advanced=advanced,
Expand All @@ -307,7 +327,7 @@ def __init__(self, name, label, description, value, group, advanced, semantic, e
class File(Attribute):
"""
"""
def __init__(self, name, label, description, value, group="allParams", advanced=False, invalidate=True,
def __init__(self, name, label=None, description=None, value=None, group="allParams", advanced=False, invalidate=True,
semantic="", enabled=True, visible=True, exposed=True):
super(File, self).__init__(name=name, label=label, description=description, value=value, group=group,
advanced=advanced, enabled=enabled, invalidate=invalidate, semantic=semantic,
Expand All @@ -325,6 +345,8 @@ def validateValue(self, value):
def checkValueTypes(self):
# Some File values are functions generating a string: check whether the value is a string or if it
# is a function (but there is no way to check that the function's output is indeed a string)
if self.value is None:
return "", ValueTypeErrors.NONE
if not isinstance(self.value, str) and not callable(self.value):
return self.name, ValueTypeErrors.TYPE
return "", ValueTypeErrors.NONE
Expand All @@ -333,7 +355,7 @@ def checkValueTypes(self):
class BoolParam(Param):
"""
"""
def __init__(self, name, label, description, value, keyable=False, keyType=None,
def __init__(self, name, label=None, description=None, value=None, 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,
Expand All @@ -354,6 +376,8 @@ def validateValue(self, value):
f"value: {value}, type: {type(value)})")

def checkValueTypes(self):
if self.value is None:
return "", ValueTypeErrors.NONE
if not isinstance(self.value, bool):
return self.name, ValueTypeErrors.TYPE
return "", ValueTypeErrors.NONE
Expand All @@ -362,7 +386,7 @@ def checkValueTypes(self):
class IntParam(Param):
"""
"""
def __init__(self, name, label, description, value, range=None, keyable=False, keyType=None,
def __init__(self, name, label=None, description=None, value=None, 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
Expand All @@ -384,6 +408,8 @@ def validateValue(self, value):
f"{value}, type: {type(value)})")

def checkValueTypes(self):
if self.value is None:
return "", ValueTypeErrors.NONE
if not isinstance(self.value, int):
return self.name, ValueTypeErrors.TYPE
if (self.range and not all([isinstance(r, int) for r in self.range])):
Expand All @@ -396,7 +422,7 @@ def checkValueTypes(self):
class FloatParam(Param):
"""
"""
def __init__(self, name, label, description, value, range=None, keyable=False, keyType=None,
def __init__(self, name, label=None, description=None, value=None, 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
Expand All @@ -417,6 +443,8 @@ def validateValue(self, value):
f"{value}, type:{type(value)})")

def checkValueTypes(self):
if self.value is None:
return "", ValueTypeErrors.NONE
if not isinstance(self.value, float):
return self.name, ValueTypeErrors.TYPE
if (self.range and not all([isinstance(r, float) for r in self.range])):
Expand All @@ -429,7 +457,7 @@ def checkValueTypes(self):
class PushButtonParam(Param):
"""
"""
def __init__(self, name, label, description, group="allParams", advanced=False, enabled=True,
def __init__(self, name, label=None, description=None, group="allParams", advanced=False, enabled=True,
invalidate=True, semantic="", visible=True, exposed=False):
super(PushButtonParam, self).__init__(name=name, label=label, description=description, value=None,
group=group, advanced=advanced, enabled=enabled, invalidate=invalidate,
Expand Down Expand Up @@ -466,7 +494,7 @@ class ChoiceParam(Param):
_OVERRIDE_SERIALIZATION_KEY_VALUE = "__ChoiceParam_value__"
_OVERRIDE_SERIALIZATION_KEY_VALUES = "__ChoiceParam_values__"

def __init__(self, name: str, label: str, description: str, value, values, exclusive=True, saveValuesOverride=False,
def __init__(self, name, label=None, description=None, value=None, values=None, exclusive=True, saveValuesOverride=False,
group="allParams", joinChar=" ", advanced=False, enabled=True, invalidate=True, semantic="",
validValue=True, errorMessage="",
visible=True, exposed=False):
Comment on lines +497 to 500
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constructor now defaults values=None, making ChoiceParam instantiable without any values. This is likely accidental (the PR description only mentions label/description/value becoming optional) and can lead to runtime failures because _valueType may remain None. Consider keeping values required (no default) or raising a clear error when values is not provided.

Copilot uses AI. Check for mistakes.
Expand All @@ -482,12 +510,14 @@ def __init__(self, name: str, label: str, description: str, value, values, exclu
if self._values:
# Look at the type of the first element of the possible values
self._valueType = type(self._values[0])
elif not exclusive:
elif not exclusive and self._value is not None:
# Possible values may be defined later, so use the value to define the type.
# if non exclusive, it is a list
self._valueType = type(self._value[0])
Comment on lines 515 to 516
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In ChoiceParam.__init__, when exclusive=False and value is provided as an empty list, type(self._value[0]) will raise IndexError. Empty selections (value=[]) are a valid default for non-exclusive ChoiceParams, so this should be handled (e.g., derive the type from values when available, or fall back to None when the list is empty).

Suggested change
# if non exclusive, it is a list
self._valueType = type(self._value[0])
# if non exclusive, it is a list; use the first element when available
if self._value:
self._valueType = type(self._value[0])
else:
# Empty selection: element type cannot be inferred yet
self._valueType = None

Copilot uses AI. Check for mistakes.
else:
elif self._value is not None:
self._valueType = type(self._value)
else:
self._valueType = None

def getInstanceType(self):
# Import within the method to prevent cyclic dependencies
Expand Down Expand Up @@ -521,6 +551,10 @@ def validateValue(self, value):
return [self.conformValue(v) for v in value]

def checkValueTypes(self):
# A None value is valid and means "use the type default"
if self._value is None:
return "", ValueTypeErrors.NONE

# Check that the values have been provided as a list
if not isinstance(self._values, list):
return self.name, ValueTypeErrors.TYPE
Comment on lines 553 to 560
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChoiceParam.checkValueTypes() returns early when _value is None, which means it no longer validates that _values is a list. As a result, misconfigured descriptors (e.g. values=None or a non-list) won't be flagged by checkValueTypes() even though they can break validation/conformance later. Consider still validating _values (or explicitly allowing None with a dedicated error/behavior).

Copilot uses AI. Check for mistakes.
Expand All @@ -546,7 +580,7 @@ def checkValueTypes(self):
class StringParam(Param):
"""
"""
def __init__(self, name, label, description, value, group="allParams", advanced=False, enabled=True,
def __init__(self, name, label=None, description=None, value=None, group="allParams", advanced=False, enabled=True,
invalidate=True, semantic="", uidIgnoreValue=None, validValue=True, errorMessage="", visible=True,
exposed=False):
super(StringParam, self).__init__(name=name, label=label, description=description, value=value,
Expand All @@ -564,6 +598,8 @@ def validateValue(self, value):
return value

def checkValueTypes(self):
if self.value is None:
return "", ValueTypeErrors.NONE
if not isinstance(self.value, str):
return self.name, ValueTypeErrors.TYPE
return "", ValueTypeErrors.NONE
Expand All @@ -572,7 +608,7 @@ def checkValueTypes(self):
class ColorParam(Param):
"""
"""
def __init__(self, name, label, description, value, group="allParams", advanced=False, enabled=True,
def __init__(self, name, label=None, description=None, value=None, group="allParams", advanced=False, enabled=True,
invalidate=True, semantic="", visible=True, exposed=False):
super(ColorParam, self).__init__(name=name, label=label, description=description, value=value,
group=group, advanced=advanced, enabled=enabled, invalidate=invalidate,
Expand All @@ -587,3 +623,10 @@ def validateValue(self, value):
f"or an hexadecimal color code (param: {self.name}, value: {value}, "
f"type: {type(value)})")
return value

def checkValueTypes(self):
if self.value is None:
return "", ValueTypeErrors.NONE
if not isinstance(self.value, str):
return self.name, ValueTypeErrors.TYPE
return "", ValueTypeErrors.NONE
102 changes: 102 additions & 0 deletions tests/test_attributes.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from meshroom.core.graph import Graph
from meshroom.core import desc as coreDesc
from meshroom.core.desc.attribute import _labelFromName, StringParam, IntParam, FloatParam, BoolParam, File, ListAttribute, GroupAttribute
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ListAttribute and GroupAttribute are imported here but not used in this test module. Consider removing them to avoid unused imports and keep the test focused.

Suggested change
from meshroom.core.desc.attribute import _labelFromName, StringParam, IntParam, FloatParam, BoolParam, File, ListAttribute, GroupAttribute
from meshroom.core.desc.attribute import _labelFromName, StringParam, IntParam, FloatParam, BoolParam, File

Copilot uses AI. Check for mistakes.
from .utils import registerNodeDesc, unregisterNodeDesc
import pytest

import logging
Expand Down Expand Up @@ -101,3 +104,102 @@ def test_attribute_is2D_file_semantic(givenSemantic, expected):

# Then
assert n0.input.is2dDisplayable == expected


@pytest.mark.parametrize("name,expected_label", [
("myAttributeName", "My Attribute Name"),
("my_attribute_name", "My Attribute Name"),
("input", "Input"),
("outputMesh", "Output Mesh"),
("nbPoints", "Nb Points"),
("imageWidth", "Image Width"),
])
def test_label_from_name(name, expected_label):
"""Check that _labelFromName converts camelCase/snake_case names to title case labels."""
assert _labelFromName(name) == expected_label


def test_attribute_label_auto_generated_when_not_provided():
"""Check that label is auto-generated from name when not explicitly provided."""
attr = StringParam(name="myAttributeName")
assert attr.label == "My Attribute Name"

attr = IntParam(name="nb_points")
assert attr.label == "Nb Points"


def test_attribute_label_uses_explicit_value_when_provided():
"""Check that an explicitly provided label is used as-is."""
attr = StringParam(name="myAttributeName", label="Custom Label")
assert attr.label == "Custom Label"


def test_attribute_description_defaults_to_empty_string_when_not_provided():
"""Check that description defaults to empty string when not explicitly provided."""
attr = StringParam(name="myAttribute")
assert attr.description == ""


def test_attribute_description_uses_explicit_value_when_provided():
"""Check that an explicitly provided description is used as-is."""
attr = StringParam(name="myAttribute", description="My description.")
assert attr.description == "My description."


def test_param_default_value_is_none():
"""Check that all parameter types default to value=None."""
assert IntParam(name="x").value is None
assert FloatParam(name="x").value is None
assert BoolParam(name="x").value is None
assert StringParam(name="x").value is None
assert File(name="x").value is None


def test_output_attribute_with_none_value_is_dynamic():
"""Check that an output attribute whose descriptor value is None is marked as dynamic."""
class NodeWithDefaultOutputs(coreDesc.Node):
inputs = []
outputs = [
IntParam(name="intOut"),
FloatParam(name="floatOut"),
BoolParam(name="boolOut"),
StringParam(name="stringOut"),
File(name="fileOut"),
]

registerNodeDesc(NodeWithDefaultOutputs)
try:
g = Graph("")
node = g.addNewNode(NodeWithDefaultOutputs.__name__)
assert node.intOut.desc.isDynamicValue
assert node.floatOut.desc.isDynamicValue
assert node.boolOut.desc.isDynamicValue
assert node.stringOut.desc.isDynamicValue
assert node.fileOut.desc.isDynamicValue
finally:
unregisterNodeDesc(NodeWithDefaultOutputs)


def test_input_attribute_with_none_desc_value_uses_type_default():
"""Check that an input attribute whose descriptor value is None gets the type's default value."""
class NodeWithDefaultInputs(coreDesc.Node):
inputs = [
IntParam(name="intIn"),
FloatParam(name="floatIn"),
BoolParam(name="boolIn"),
StringParam(name="stringIn"),
File(name="fileIn"),
]
outputs = []

registerNodeDesc(NodeWithDefaultInputs)
try:
g = Graph("")
node = g.addNewNode(NodeWithDefaultInputs.__name__)
assert node.intIn.value == 0
assert node.floatIn.value == 0.0
assert node.boolIn.value is False
assert node.stringIn.value == ""
assert node.fileIn.value == ""
finally:
unregisterNodeDesc(NodeWithDefaultInputs)
Loading