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["dynamic_paths"][finalized_id] = finalize_prefix(curr_prefix, curr_prefix[-1])
@comfytype(io_type="COMFY_LIST_V3")
class List(ComfyTypeI):
@comfytype(io_type="COMFY_DYNAMICGROUP_V3")
class DynamicGroup(ComfyTypeI):
"""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.
Example::
io.List.Input(
io.DynamicGroup.Input(
"loras",
template=[
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)
# 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:
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__}."
)
assert not isinstance(t, DynamicInput), (
f"List template field '{t.id}' must not be a DynamicInput. "
"Nesting dynamic inputs inside List is not supported."
f"DynamicGroup template field '{t.id}' must not be a DynamicInput. "
"Nesting dynamic inputs inside DynamicGroup is not supported."
)
# Enforce unique field ids within template
field_ids = [t.id for t in template]
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 max >= 1, "List max must be >= 1."
assert max <= List._MaxRows, f"List max must be <= {List._MaxRows}."
assert min <= max, "List min must be <= max."
assert min >= 0, "DynamicGroup min must be >= 0."
assert max >= 1, "DynamicGroup max must be >= 1."
assert max <= DynamicGroup._MaxRows, f"DynamicGroup max must be <= {DynamicGroup._MaxRows}."
assert min <= max, "DynamicGroup min must be <= max."
self.template = template
self.min = min
self.max = max
@ -1338,7 +1338,7 @@ class List(ComfyTypeI):
):
info = value[1]
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", {})
# Collect all template field specs across required/optional sections
@ -1366,7 +1366,7 @@ class List(ComfyTypeI):
if present_rows > max_rows:
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)
@ -1522,8 +1522,8 @@ def setup_dynamic_input_funcs():
register_dynamic_input_func(DynamicCombo.io_type, DynamicCombo._expand_schema_for_dynamic)
# DynamicSlot.Input
register_dynamic_input_func(DynamicSlot.io_type, DynamicSlot._expand_schema_for_dynamic)
# List.Input
register_dynamic_input_func(List.io_type, List._expand_schema_for_dynamic)
# DynamicGroup.Input
register_dynamic_input_func(DynamicGroup.io_type, DynamicGroup._expand_schema_for_dynamic)
if len(DYNAMIC_INPUT_LOOKUP) == 0:
setup_dynamic_input_funcs()
@ -1963,7 +1963,7 @@ def build_nested_inputs(values: dict[str, Any], v3_data: V3Data):
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:
parts = list_path.split(".")
# Navigate to the parent container, then convert the leaf
@ -2554,7 +2554,7 @@ __all__ = [
"DynamicCombo",
"DynamicSlot",
"Autogrow",
"List",
"DynamicGroup",
# Other classes
"HiddenHolder",
"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 types
import pytest
@ -10,7 +10,7 @@ if "torch" not in sys.modules:
sys.modules["torch"] = _torch_stub
from comfy_api.latest._io import ( # noqa: E402
List,
DynamicGroup,
Float,
Int,
String,
@ -29,12 +29,12 @@ setup_dynamic_input_funcs()
# Helpers
# ---------------------------------------------------------------------------
def _make_class_inputs(list_input: List.Input) -> dict:
"""Wrap a List.Input into the required/optional dict structure."""
return create_input_dict_v1([list_input])
def _make_class_inputs(group_input: DynamicGroup.Input) -> dict:
"""Wrap a DynamicGroup.Input into the required/optional dict structure."""
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.
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
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)
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
# ---------------------------------------------------------------------------
class TestListInputConstruction:
class TestDynamicGroupInputConstruction:
def test_basic_construction(self):
inp = List.Input(
inp = DynamicGroup.Input(
"loras",
template=[
Float.Input("strength", default=1.0),
@ -70,7 +70,7 @@ class TestListInputConstruction:
assert len(inp.template) == 2
def test_get_all_includes_self_and_template(self):
inp = List.Input(
inp = DynamicGroup.Input(
"items",
template=[Float.Input("value")],
)
@ -79,7 +79,7 @@ class TestListInputConstruction:
assert all_inputs[1].id == "value"
def test_as_dict_has_template_min_max(self):
inp = List.Input(
inp = DynamicGroup.Input(
"items",
template=[Float.Input("val", default=0.5)],
min=1,
@ -92,32 +92,32 @@ class TestListInputConstruction:
def test_duplicate_field_ids_raises(self):
with pytest.raises(AssertionError):
List.Input(
DynamicGroup.Input(
"bad",
template=[Float.Input("x"), Float.Input("x")],
)
def test_empty_template_raises(self):
with pytest.raises(AssertionError):
List.Input("bad", template=[])
DynamicGroup.Input("bad", template=[])
def test_min_gt_max_raises(self):
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):
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):
with pytest.raises(AssertionError):
List.Input(
DynamicGroup.Input(
"bad",
template=[List.Input("nested", template=[Float.Input("x")])],
template=[DynamicGroup.Input("nested", template=[Float.Input("x")])],
)
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
@ -128,12 +128,12 @@ class TestListInputConstruction:
class TestZeroRows:
def test_empty_live_inputs_produces_empty_list(self):
"""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") == []
def test_min_zero_with_values(self):
"""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})
assert result["loras"] == [{"strength": 0.8}, {"strength": 0.5}]
@ -145,7 +145,7 @@ class TestZeroRows:
class TestNRows:
def test_two_rows_two_fields(self):
"""Two rows with two fields each produce a list[dict]."""
inp = List.Input(
inp = DynamicGroup.Input(
"loras",
template=[String.Input("lora_name"), Float.Input("strength", default=1.0)],
min=0, max=50,
@ -161,13 +161,13 @@ class TestNRows:
def test_rows_are_sorted_by_index(self):
"""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})
assert [row["v"] for row in result["items"]] == [10, 20, 30]
def test_min_rows_schema_slots(self):
"""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), {})
all_slots = {**out.get("required", {}), **out.get("optional", {})}
assert "items.0.val" in all_slots
@ -176,28 +176,28 @@ class TestNRows:
def test_min_rows_reconstructs_when_no_values(self):
"""min=2 with NO live values must still yield a 2-element list,
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, {})
assert len(result["items"]) == 2
assert all("val" in row for row in result["items"])
def test_min_rows_reconstructs_with_partial_values(self):
"""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})
assert len(result["items"]) == 2
assert result["items"][0]["val"] == 0.7
assert result["items"][1]["val"] is None
def test_list_paths_in_v3_data(self):
"""list_paths must contain the list id so build_nested_inputs knows to convert."""
inp = List.Input("things", template=[Boolean.Input("flag")], min=0, max=5)
"""list_paths must contain the group id so build_nested_inputs knows to convert."""
inp = DynamicGroup.Input("things", template=[Boolean.Input("flag")], min=0, max=5)
_, _, v3_data = get_finalized_class_inputs(_make_class_inputs(inp), {})
assert "things" in v3_data.get("list_paths", set())
def test_no_leftover_flat_keys(self):
"""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})
assert "rows.0.x" not in result
assert "rows.1.x" not in result