Rename and refactor io.List to io.DynamicGroup
Some checks are pending
Python Linting / Run Ruff (push) Waiting to run
Python Linting / Run Pylint (push) Waiting to run

This commit is contained in:
Talmaj Marinc 2026-06-24 22:17:01 +02:00
parent 708e09b2b2
commit abd185c0ae
2 changed files with 46 additions and 46 deletions

View File

@ -1253,15 +1253,15 @@ class DynamicSlot(ComfyTypeI):
out_dict[input_type][finalized_id] = value out_dict[input_type][finalized_id] = value
out_dict["dynamic_paths"][finalized_id] = finalize_prefix(curr_prefix, curr_prefix[-1]) out_dict["dynamic_paths"][finalized_id] = finalize_prefix(curr_prefix, curr_prefix[-1])
@comfytype(io_type="COMFY_LIST_V3") @comfytype(io_type="COMFY_DYNAMICGROUP_V3")
class List(ComfyTypeI): class DynamicGroup(ComfyTypeI):
"""A repeatable group of widget inputs (e.g. lora_name + strength stacked into N rows). """A repeatable group of widget inputs (e.g. lora_name + strength stacked into N rows).
At execution time the node receives a ``list[dict]`` where each element is a row. At execution time the node receives a ``list[dict]`` where each element is a row.
Example:: Example::
io.List.Input( io.DynamicGroup.Input(
"loras", "loras",
template=[ template=[
io.Combo.Input("lora_name", options=folder_paths.get_filename_list("loras")), io.Combo.Input("lora_name", options=folder_paths.get_filename_list("loras")),
@ -1291,25 +1291,25 @@ class List(ComfyTypeI):
): ):
super().__init__(id, display_name, optional, tooltip, lazy, extra_dict) super().__init__(id, display_name, optional, tooltip, lazy, extra_dict)
# Validate template entries: only WidgetInput subclasses, no nesting # Validate template entries: only WidgetInput subclasses, no nesting
assert len(template) > 0, "List template must have at least one field." assert len(template) > 0, "DynamicGroup template must have at least one field."
for t in template: for t in template:
assert isinstance(t, WidgetInput), ( assert isinstance(t, WidgetInput), (
f"List template field '{t.id}' must be a WidgetInput subclass " f"DynamicGroup template field '{t.id}' must be a WidgetInput subclass "
f"(Combo, Float, Int, String, Boolean, Color). Got {type(t).__name__}." f"(Combo, Float, Int, String, Boolean, Color). Got {type(t).__name__}."
) )
assert not isinstance(t, DynamicInput), ( assert not isinstance(t, DynamicInput), (
f"List template field '{t.id}' must not be a DynamicInput. " f"DynamicGroup template field '{t.id}' must not be a DynamicInput. "
"Nesting dynamic inputs inside List is not supported." "Nesting dynamic inputs inside DynamicGroup is not supported."
) )
# Enforce unique field ids within template # Enforce unique field ids within template
field_ids = [t.id for t in template] field_ids = [t.id for t in template]
assert len(field_ids) == len(set(field_ids)), ( assert len(field_ids) == len(set(field_ids)), (
f"List template field ids must be unique within a row. Got: {field_ids}" f"DynamicGroup template field ids must be unique within a row. Got: {field_ids}"
) )
assert min >= 0, "List min must be >= 0." assert min >= 0, "DynamicGroup min must be >= 0."
assert max >= 1, "List max must be >= 1." assert max >= 1, "DynamicGroup max must be >= 1."
assert max <= List._MaxRows, f"List max must be <= {List._MaxRows}." assert max <= DynamicGroup._MaxRows, f"DynamicGroup max must be <= {DynamicGroup._MaxRows}."
assert min <= max, "List min must be <= max." assert min <= max, "DynamicGroup min must be <= max."
self.template = template self.template = template
self.min = min self.min = min
self.max = max self.max = max
@ -1338,7 +1338,7 @@ class List(ComfyTypeI):
): ):
info = value[1] info = value[1]
min_rows: int = info.get("min", 0) min_rows: int = info.get("min", 0)
max_rows: int = info.get("max", List._MaxRows) max_rows: int = info.get("max", DynamicGroup._MaxRows)
template: dict[str, Any] = info.get("template", {}) template: dict[str, Any] = info.get("template", {})
# Collect all template field specs across required/optional sections # Collect all template field specs across required/optional sections
@ -1366,7 +1366,7 @@ class List(ComfyTypeI):
if present_rows > max_rows: if present_rows > max_rows:
raise ValueError( raise ValueError(
f"List input '{finalized_prefix}' received {present_rows} rows but max is {max_rows}." f"DynamicGroup input '{finalized_prefix}' received {present_rows} rows but max is {max_rows}."
) )
row_count = max(min_rows, present_rows) row_count = max(min_rows, present_rows)
@ -1522,8 +1522,8 @@ def setup_dynamic_input_funcs():
register_dynamic_input_func(DynamicCombo.io_type, DynamicCombo._expand_schema_for_dynamic) register_dynamic_input_func(DynamicCombo.io_type, DynamicCombo._expand_schema_for_dynamic)
# DynamicSlot.Input # DynamicSlot.Input
register_dynamic_input_func(DynamicSlot.io_type, DynamicSlot._expand_schema_for_dynamic) register_dynamic_input_func(DynamicSlot.io_type, DynamicSlot._expand_schema_for_dynamic)
# List.Input # DynamicGroup.Input
register_dynamic_input_func(List.io_type, List._expand_schema_for_dynamic) register_dynamic_input_func(DynamicGroup.io_type, DynamicGroup._expand_schema_for_dynamic)
if len(DYNAMIC_INPUT_LOOKUP) == 0: if len(DYNAMIC_INPUT_LOOKUP) == 0:
setup_dynamic_input_funcs() setup_dynamic_input_funcs()
@ -1963,7 +1963,7 @@ def build_nested_inputs(values: dict[str, Any], v3_data: V3Data):
values.update(result) values.update(result)
# Post-pass: convert index-keyed dicts to sorted lists for io.List fields # Post-pass: convert index-keyed dicts to sorted lists for io.DynamicGroup fields
for list_path in list_paths: for list_path in list_paths:
parts = list_path.split(".") parts = list_path.split(".")
# Navigate to the parent container, then convert the leaf # Navigate to the parent container, then convert the leaf
@ -2554,7 +2554,7 @@ __all__ = [
"DynamicCombo", "DynamicCombo",
"DynamicSlot", "DynamicSlot",
"Autogrow", "Autogrow",
"List", "DynamicGroup",
# Other classes # Other classes
"HiddenHolder", "HiddenHolder",
"Hidden", "Hidden",

View File

@ -1,4 +1,4 @@
"""Unit tests for io.List: expansion/reconstruction (0-row and N-row cases).""" """Unit tests for io.DynamicGroup: expansion/reconstruction (0-row and N-row cases)."""
import sys import sys
import types import types
import pytest import pytest
@ -10,7 +10,7 @@ if "torch" not in sys.modules:
sys.modules["torch"] = _torch_stub sys.modules["torch"] = _torch_stub
from comfy_api.latest._io import ( # noqa: E402 from comfy_api.latest._io import ( # noqa: E402
List, DynamicGroup,
Float, Float,
Int, Int,
String, String,
@ -29,12 +29,12 @@ setup_dynamic_input_funcs()
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _make_class_inputs(list_input: List.Input) -> dict: def _make_class_inputs(group_input: DynamicGroup.Input) -> dict:
"""Wrap a List.Input into the required/optional dict structure.""" """Wrap a DynamicGroup.Input into the required/optional dict structure."""
return create_input_dict_v1([list_input]) return create_input_dict_v1([group_input])
def _run(list_input: List.Input, live_values: dict) -> dict: def _run(group_input: DynamicGroup.Input, live_values: dict) -> dict:
"""End-to-end helper: expand schema + reconstruct values. """End-to-end helper: expand schema + reconstruct values.
Mirrors the production split in execution.py: Mirrors the production split in execution.py:
@ -44,7 +44,7 @@ def _run(list_input: List.Input, live_values: dict) -> dict:
The two steps are separate in production because the engine resolves The two steps are separate in production because the engine resolves
linked node outputs between them, but in tests we supply values directly. linked node outputs between them, but in tests we supply values directly.
""" """
class_inputs = _make_class_inputs(list_input) class_inputs = _make_class_inputs(group_input)
_, _, v3_data = get_finalized_class_inputs(class_inputs, live_values) _, _, v3_data = get_finalized_class_inputs(class_inputs, live_values)
return build_nested_inputs(dict(live_values), v3_data) return build_nested_inputs(dict(live_values), v3_data)
@ -53,9 +53,9 @@ def _run(list_input: List.Input, live_values: dict) -> dict:
# Schema construction # Schema construction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestListInputConstruction: class TestDynamicGroupInputConstruction:
def test_basic_construction(self): def test_basic_construction(self):
inp = List.Input( inp = DynamicGroup.Input(
"loras", "loras",
template=[ template=[
Float.Input("strength", default=1.0), Float.Input("strength", default=1.0),
@ -70,7 +70,7 @@ class TestListInputConstruction:
assert len(inp.template) == 2 assert len(inp.template) == 2
def test_get_all_includes_self_and_template(self): def test_get_all_includes_self_and_template(self):
inp = List.Input( inp = DynamicGroup.Input(
"items", "items",
template=[Float.Input("value")], template=[Float.Input("value")],
) )
@ -79,7 +79,7 @@ class TestListInputConstruction:
assert all_inputs[1].id == "value" assert all_inputs[1].id == "value"
def test_as_dict_has_template_min_max(self): def test_as_dict_has_template_min_max(self):
inp = List.Input( inp = DynamicGroup.Input(
"items", "items",
template=[Float.Input("val", default=0.5)], template=[Float.Input("val", default=0.5)],
min=1, min=1,
@ -92,32 +92,32 @@ class TestListInputConstruction:
def test_duplicate_field_ids_raises(self): def test_duplicate_field_ids_raises(self):
with pytest.raises(AssertionError): with pytest.raises(AssertionError):
List.Input( DynamicGroup.Input(
"bad", "bad",
template=[Float.Input("x"), Float.Input("x")], template=[Float.Input("x"), Float.Input("x")],
) )
def test_empty_template_raises(self): def test_empty_template_raises(self):
with pytest.raises(AssertionError): with pytest.raises(AssertionError):
List.Input("bad", template=[]) DynamicGroup.Input("bad", template=[])
def test_min_gt_max_raises(self): def test_min_gt_max_raises(self):
with pytest.raises(AssertionError): with pytest.raises(AssertionError):
List.Input("bad", template=[Float.Input("x")], min=5, max=3) DynamicGroup.Input("bad", template=[Float.Input("x")], min=5, max=3)
def test_max_exceeds_limit_raises(self): def test_max_exceeds_limit_raises(self):
with pytest.raises(AssertionError): with pytest.raises(AssertionError):
List.Input("bad", template=[Float.Input("x")], max=101) DynamicGroup.Input("bad", template=[Float.Input("x")], max=101)
def test_dynamic_input_in_template_raises(self): def test_dynamic_input_in_template_raises(self):
with pytest.raises(AssertionError): with pytest.raises(AssertionError):
List.Input( DynamicGroup.Input(
"bad", "bad",
template=[List.Input("nested", template=[Float.Input("x")])], template=[DynamicGroup.Input("nested", template=[Float.Input("x")])],
) )
def test_validate_calls_through(self): def test_validate_calls_through(self):
inp = List.Input("items", template=[Float.Input("val", min=-1.0, max=1.0)]) inp = DynamicGroup.Input("items", template=[Float.Input("val", min=-1.0, max=1.0)])
inp.validate() # should not raise inp.validate() # should not raise
@ -128,12 +128,12 @@ class TestListInputConstruction:
class TestZeroRows: class TestZeroRows:
def test_empty_live_inputs_produces_empty_list(self): def test_empty_live_inputs_produces_empty_list(self):
"""With min=0 and no live values, the result should be an empty list.""" """With min=0 and no live values, the result should be an empty list."""
inp = List.Input("loras", template=[Float.Input("strength", default=1.0)], min=0, max=10) inp = DynamicGroup.Input("loras", template=[Float.Input("strength", default=1.0)], min=0, max=10)
assert _run(inp, {}).get("loras") == [] assert _run(inp, {}).get("loras") == []
def test_min_zero_with_values(self): def test_min_zero_with_values(self):
"""min=0 but 2 rows of live data.""" """min=0 but 2 rows of live data."""
inp = List.Input("loras", template=[Float.Input("strength", default=1.0)], min=0, max=10) inp = DynamicGroup.Input("loras", template=[Float.Input("strength", default=1.0)], min=0, max=10)
result = _run(inp, {"loras.0.strength": 0.8, "loras.1.strength": 0.5}) result = _run(inp, {"loras.0.strength": 0.8, "loras.1.strength": 0.5})
assert result["loras"] == [{"strength": 0.8}, {"strength": 0.5}] assert result["loras"] == [{"strength": 0.8}, {"strength": 0.5}]
@ -145,7 +145,7 @@ class TestZeroRows:
class TestNRows: class TestNRows:
def test_two_rows_two_fields(self): def test_two_rows_two_fields(self):
"""Two rows with two fields each produce a list[dict].""" """Two rows with two fields each produce a list[dict]."""
inp = List.Input( inp = DynamicGroup.Input(
"loras", "loras",
template=[String.Input("lora_name"), Float.Input("strength", default=1.0)], template=[String.Input("lora_name"), Float.Input("strength", default=1.0)],
min=0, max=50, min=0, max=50,
@ -161,13 +161,13 @@ class TestNRows:
def test_rows_are_sorted_by_index(self): def test_rows_are_sorted_by_index(self):
"""Rows must be in ascending index order even if dict iteration is unordered.""" """Rows must be in ascending index order even if dict iteration is unordered."""
inp = List.Input("items", template=[Int.Input("v", default=0)], min=0, max=10) inp = DynamicGroup.Input("items", template=[Int.Input("v", default=0)], min=0, max=10)
result = _run(inp, {"items.0.v": 10, "items.2.v": 30, "items.1.v": 20}) result = _run(inp, {"items.0.v": 10, "items.2.v": 30, "items.1.v": 20})
assert [row["v"] for row in result["items"]] == [10, 20, 30] assert [row["v"] for row in result["items"]] == [10, 20, 30]
def test_min_rows_schema_slots(self): def test_min_rows_schema_slots(self):
"""With min=2 and no live data, 2 slots must appear in the expanded schema.""" """With min=2 and no live data, 2 slots must appear in the expanded schema."""
inp = List.Input("items", template=[Float.Input("val", default=0.0)], min=2, max=5) inp = DynamicGroup.Input("items", template=[Float.Input("val", default=0.0)], min=2, max=5)
out, _, _ = get_finalized_class_inputs(_make_class_inputs(inp), {}) out, _, _ = get_finalized_class_inputs(_make_class_inputs(inp), {})
all_slots = {**out.get("required", {}), **out.get("optional", {})} all_slots = {**out.get("required", {}), **out.get("optional", {})}
assert "items.0.val" in all_slots assert "items.0.val" in all_slots
@ -176,28 +176,28 @@ class TestNRows:
def test_min_rows_reconstructs_when_no_values(self): def test_min_rows_reconstructs_when_no_values(self):
"""min=2 with NO live values must still yield a 2-element list, """min=2 with NO live values must still yield a 2-element list,
not collapse to [] (regression: parent-path clobber).""" not collapse to [] (regression: parent-path clobber)."""
inp = List.Input("items", template=[Float.Input("val", default=0.0)], min=2, max=5) inp = DynamicGroup.Input("items", template=[Float.Input("val", default=0.0)], min=2, max=5)
result = _run(inp, {}) result = _run(inp, {})
assert len(result["items"]) == 2 assert len(result["items"]) == 2
assert all("val" in row for row in result["items"]) assert all("val" in row for row in result["items"])
def test_min_rows_reconstructs_with_partial_values(self): def test_min_rows_reconstructs_with_partial_values(self):
"""min=2 with only the first row's value present still yields 2 rows.""" """min=2 with only the first row's value present still yields 2 rows."""
inp = List.Input("items", template=[Float.Input("val", default=0.0)], min=2, max=5) inp = DynamicGroup.Input("items", template=[Float.Input("val", default=0.0)], min=2, max=5)
result = _run(inp, {"items.0.val": 0.7}) result = _run(inp, {"items.0.val": 0.7})
assert len(result["items"]) == 2 assert len(result["items"]) == 2
assert result["items"][0]["val"] == 0.7 assert result["items"][0]["val"] == 0.7
assert result["items"][1]["val"] is None assert result["items"][1]["val"] is None
def test_list_paths_in_v3_data(self): def test_list_paths_in_v3_data(self):
"""list_paths must contain the list id so build_nested_inputs knows to convert.""" """list_paths must contain the group id so build_nested_inputs knows to convert."""
inp = List.Input("things", template=[Boolean.Input("flag")], min=0, max=5) inp = DynamicGroup.Input("things", template=[Boolean.Input("flag")], min=0, max=5)
_, _, v3_data = get_finalized_class_inputs(_make_class_inputs(inp), {}) _, _, v3_data = get_finalized_class_inputs(_make_class_inputs(inp), {})
assert "things" in v3_data.get("list_paths", set()) assert "things" in v3_data.get("list_paths", set())
def test_no_leftover_flat_keys(self): def test_no_leftover_flat_keys(self):
"""Flat keys must be consumed; only the reconstructed list remains.""" """Flat keys must be consumed; only the reconstructed list remains."""
inp = List.Input("rows", template=[Float.Input("x", default=0.0)], min=0, max=5) inp = DynamicGroup.Input("rows", template=[Float.Input("x", default=0.0)], min=0, max=5)
result = _run(inp, {"rows.0.x": 1.0, "rows.1.x": 2.0}) result = _run(inp, {"rows.0.x": 1.0, "rows.1.x": 2.0})
assert "rows.0.x" not in result assert "rows.0.x" not in result
assert "rows.1.x" not in result assert "rows.1.x" not in result