DynamicSlot: support required slots and always forceInput

- Add `optional` kwarg to DynamicSlot.Input (default True). When False,
  declaring a when=None Option is rejected because the unconnected branch
  is unreachable.
- Always publish `forceInput=True` on the slot itself. slotType may
  include widget-capable types (INT/STRING/etc.) but a DynamicSlot is
  meant to look like a connection point, never a widget.

Amp-Thread-ID: https://ampcode.com/threads/T-019e8568-f382-743d-a97f-0de3ff29d501
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Jedrzej Kosinski 2026-06-01 19:28:15 -07:00
parent 346ee898cb
commit 8cac12afa8
2 changed files with 35 additions and 2 deletions

View File

@ -1279,7 +1279,7 @@ class DynamicSlot(ComfyTypeI):
class Input(DynamicInput):
def __init__(self, id: str, options: list[DynamicSlot.Option],
display_name: str=None, tooltip: str=None, lazy: bool=None, extra_dict=None):
display_name: str=None, optional: bool=True, tooltip: str=None, lazy: bool=None, extra_dict=None):
if not options:
raise ValueError("DynamicSlot.Input: at least one Option is required")
for opt in options:
@ -1287,7 +1287,7 @@ class DynamicSlot(ComfyTypeI):
raise ValueError(
f"DynamicSlot.Input: options must be DynamicSlot.Option instances, got {opt!r}"
)
super().__init__(id, display_name, True, tooltip, lazy, extra_dict)
super().__init__(id, display_name, optional, tooltip, lazy, extra_dict)
self.options = options
# Enforce uniqueness: each io_type (and the unconnected case) may
# appear in at most one option's ``when``. Also derive the slot's
@ -1315,6 +1315,12 @@ class DynamicSlot(ComfyTypeI):
"DynamicSlot.Input: at least one Option must have a non-None `when`; "
"a slot with only a `when=None` option can never be connected"
)
# A required slot demands a link, so the when=None branch is unreachable.
if not optional and seen_none:
raise ValueError(
"DynamicSlot.Input: optional=False forbids when=None options; "
"the unconnected branch is unreachable when a link is required"
)
self._slot_io_type = ",".join(connected_types)
# parse_class_inputs dispatches on the class io_type (COMFY_DYNAMICSLOT_V3),
@ -1335,6 +1341,9 @@ class DynamicSlot(ComfyTypeI):
return super().as_dict() | prune_dict({
"slotType": self._slot_io_type,
"options": [o.as_dict() for o in self.options],
# Always render as a connector — slotType may include widget-capable
# types (INT/STRING/etc.) but a DynamicSlot is a connection point.
"forceInput": True,
})
def validate(self):

View File

@ -147,6 +147,30 @@ def test_input_rejects_non_option_entry():
io.DynamicSlot.Input("x", options=[_opt(io.Image, ["a"]), "not an option"])
def test_input_defaults_to_optional_and_always_force_input():
"""The slot is always rendered as a connector, never as a widget, even
when slotType includes widget-capable types like INT/STRING."""
inp = io.DynamicSlot.Input("x", options=[_opt(io.Int, ["n"])])
d = inp.as_dict()
assert d["forceInput"] is True
# default optional=True → slot lives in optional bucket via DynamicInput
assert inp.optional is True
def test_input_required_slot_allowed_without_when_none():
inp = io.DynamicSlot.Input("x", optional=False, options=[_opt(io.Image, ["a"])])
assert inp.optional is False
def test_input_required_slot_rejects_when_none_option():
with pytest.raises(ValueError, match="optional=False forbids when=None"):
io.DynamicSlot.Input(
"x",
optional=False,
options=[_opt(io.Image, ["a"]), _opt(None, ["b"])],
)
def test_input_get_all_prepends_self_and_dedups_children():
inp = io.DynamicSlot.Input("x", options=[
_opt(io.Image, ["shared", "image_only"]),