From 19390c112a1da280eb83cc2fed5462a0e7a96135 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 1 Jun 2026 16:24:48 -0700 Subject: [PATCH 01/10] Add server-side TypeResolver for prompt-graph type resolution Resolves the concrete io_type of any output/input slot in a prompt by walking the graph, so API-submitted workflows (no frontend) and the execution engine agree on resolved types even when MatchType chains are involved. * New comfy_execution/type_resolver.py: TypeResolver class with output resolution (incl. MatchType template walking, cycle detection, depth cap, AnyType fallback + one-shot warning), input resolution (links and literals), is_output_list / is_input_list helpers, effective slot io_type peeling for dynamic wrappers (Autogrow -> wrapped element type, DynamicSlot -> underlying slot type), and bulk compute_live_input_types. * DynamicPrompt now lazily exposes get_type_resolver() and invalidates the resolver cache on add_ephemeral_node. * get_finalized_class_inputs / parse_class_inputs / DYNAMIC_INPUT_LOOKUP callable signature accept an optional live_input_types dict. Existing Autogrow/DynamicSlot/DynamicCombo expansions accept and ignore it; future per-type dynamic inputs use it as their discriminator. * validate_inputs and get_input_data both build live_input_types via the resolver and pass it through; validate_inputs also uses the resolver to determine received_type for linked inputs so MatchType chains in API workflows validate correctly. * validate_prompt builds one TypeResolver and shares it across all output-node validations to avoid re-walking chains. * tests-unit/execution_test/test_type_resolver.py covers V1 static return types, V1 wildcard warning behavior, MatchType resolution including first-wins, cycle termination, chain walking, input resolution, Autogrow peeling, list info, and cache invalidation. Amp-Thread-ID: https://ampcode.com/threads/T-019e8568-f382-743d-a97f-0de3ff29d501 Co-authored-by: Amp --- comfy_api/latest/_io.py | 48 ++- comfy_execution/graph.py | 15 + comfy_execution/type_resolver.py | 372 +++++++++++++++++ execution.py | 45 ++- .../execution_test/test_type_resolver.py | 377 ++++++++++++++++++ 5 files changed, 835 insertions(+), 22 deletions(-) create mode 100644 comfy_execution/type_resolver.py create mode 100644 tests-unit/execution_test/test_type_resolver.py diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index a3aa508ce..d6e92c17b 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1090,7 +1090,7 @@ class Autogrow(ComfyTypeI): self.template.validate() @staticmethod - def _expand_schema_for_dynamic(out_dict: dict[str, Any], live_inputs: dict[str, Any], value: tuple[str, dict[str, Any]], input_type: str, curr_prefix: list[str] | None): + def _expand_schema_for_dynamic(out_dict: dict[str, Any], live_inputs: dict[str, Any], value: tuple[str, dict[str, Any]], input_type: str, curr_prefix: list[str] | None, live_input_types: dict[str, str] | None = None): # NOTE: purposely do not include self in out_dict; instead use only the template inputs # need to figure out names based on template type is_names = ("names" in value[1]["template"]) @@ -1139,7 +1139,7 @@ class Autogrow(ComfyTypeI): finalized_prefix = finalize_prefix(curr_prefix) out_dict["dynamic_paths"][finalized_prefix] = finalized_prefix out_dict["dynamic_paths_default_value"][finalized_prefix] = DynamicPathsDefaultValue.EMPTY_DICT - parse_class_inputs(out_dict, live_inputs, new_dict, curr_prefix) + parse_class_inputs(out_dict, live_inputs, new_dict, curr_prefix, live_input_types) @comfytype(io_type="COMFY_DYNAMICCOMBO_V3") class DynamicCombo(ComfyTypeI): @@ -1177,7 +1177,7 @@ class DynamicCombo(ComfyTypeI): input.validate() @staticmethod - def _expand_schema_for_dynamic(out_dict: dict[str, Any], live_inputs: dict[str, Any], value: tuple[str, dict[str, Any]], input_type: str, curr_prefix: list[str] | None): + def _expand_schema_for_dynamic(out_dict: dict[str, Any], live_inputs: dict[str, Any], value: tuple[str, dict[str, Any]], input_type: str, curr_prefix: list[str] | None, live_input_types: dict[str, str] | None = None): finalized_id = finalize_prefix(curr_prefix) if finalized_id in live_inputs: key = live_inputs[finalized_id] @@ -1189,7 +1189,7 @@ class DynamicCombo(ComfyTypeI): selected_option = option break if selected_option is not None: - parse_class_inputs(out_dict, live_inputs, selected_option["inputs"], curr_prefix) + parse_class_inputs(out_dict, live_inputs, selected_option["inputs"], curr_prefix, live_input_types) # add self to inputs out_dict[input_type][finalized_id] = value out_dict["dynamic_paths"][finalized_id] = finalize_prefix(curr_prefix, curr_prefix[-1]) @@ -1232,11 +1232,11 @@ class DynamicSlot(ComfyTypeI): input.validate() @staticmethod - def _expand_schema_for_dynamic(out_dict: dict[str, Any], live_inputs: dict[str, Any], value: tuple[str, dict[str, Any]], input_type: str, curr_prefix: list[str] | None): + def _expand_schema_for_dynamic(out_dict: dict[str, Any], live_inputs: dict[str, Any], value: tuple[str, dict[str, Any]], input_type: str, curr_prefix: list[str] | None, live_input_types: dict[str, str] | None = None): finalized_id = finalize_prefix(curr_prefix) if finalized_id in live_inputs: inputs = value[1]["inputs"] - parse_class_inputs(out_dict, live_inputs, inputs, curr_prefix) + parse_class_inputs(out_dict, live_inputs, inputs, curr_prefix, live_input_types) # add self to inputs out_dict[input_type][finalized_id] = value out_dict["dynamic_paths"][finalized_id] = finalize_prefix(curr_prefix, curr_prefix[-1]) @@ -1357,11 +1357,21 @@ class Range(ComfyTypeIO): }) -DYNAMIC_INPUT_LOOKUP: dict[str, Callable[[dict[str, Any], dict[str, Any], tuple[str, dict[str, Any]], str, list[str] | None], None]] = {} -def register_dynamic_input_func(io_type: str, func: Callable[[dict[str, Any], dict[str, Any], tuple[str, dict[str, Any]], str, list[str] | None], None]): +# Signature: (out_dict, live_inputs, value, input_type, curr_prefix, live_input_types) +# live_input_types is an optional {input_id: resolved_io_type} dict produced +# by comfy_execution.type_resolver.TypeResolver. Existing dynamic-input +# implementations may ignore it; future type-discriminated dynamic inputs +# (e.g. a per-connected-type variant of DynamicCombo) use it as their +# discriminator instead of literal live_inputs values. +_DynamicInputFunc = Callable[ + [dict[str, Any], dict[str, Any], tuple[str, dict[str, Any]], str, list[str] | None, dict[str, str] | None], + None, +] +DYNAMIC_INPUT_LOOKUP: dict[str, _DynamicInputFunc] = {} +def register_dynamic_input_func(io_type: str, func: _DynamicInputFunc): DYNAMIC_INPUT_LOOKUP[io_type] = func -def get_dynamic_input_func(io_type: str) -> Callable[[dict[str, Any], dict[str, Any], tuple[str, dict[str, Any]], str, list[str] | None], None]: +def get_dynamic_input_func(io_type: str) -> _DynamicInputFunc: return DYNAMIC_INPUT_LOOKUP[io_type] def setup_dynamic_input_funcs(): @@ -1709,7 +1719,19 @@ class Schema: ) return info -def get_finalized_class_inputs(d: dict[str, Any], live_inputs: dict[str, Any], include_hidden=False) -> tuple[dict[str, Any], V3Data]: +def get_finalized_class_inputs(d: dict[str, Any], live_inputs: dict[str, Any], include_hidden=False, live_input_types: dict[str, str] | None = None) -> tuple[dict[str, Any], V3Data]: + """Expand a node's V3 schema against a concrete prompt. + + Args: + d: ``INPUT_TYPES()``-shaped dict for the node. + live_inputs: Concrete ``{input_id: value}`` map from the prompt + (values may be links ``[node_id, slot_idx]`` or literals). + include_hidden: When True, retain hidden inputs in the returned dict. + live_input_types: Optional ``{input_id: resolved_io_type}`` map + produced by ``comfy_execution.type_resolver.TypeResolver``. Future + dynamic-input strategies that branch on connected type use this as + their discriminator. Existing dynamic types ignore it. + """ out_dict = { "required": {}, "optional": {}, @@ -1719,7 +1741,7 @@ def get_finalized_class_inputs(d: dict[str, Any], live_inputs: dict[str, Any], i d = d.copy() # ignore hidden for parsing hidden = d.pop("hidden", None) - parse_class_inputs(out_dict, live_inputs, d) + parse_class_inputs(out_dict, live_inputs, d, None, live_input_types) if hidden is not None and include_hidden: out_dict["hidden"] = hidden v3_data = {} @@ -1732,7 +1754,7 @@ def get_finalized_class_inputs(d: dict[str, Any], live_inputs: dict[str, Any], i v3_data["dynamic_paths_default_value"] = dynamic_paths_default_value return out_dict, hidden, v3_data -def parse_class_inputs(out_dict: dict[str, Any], live_inputs: dict[str, Any], curr_dict: dict[str, Any], curr_prefix: list[str] | None=None) -> None: +def parse_class_inputs(out_dict: dict[str, Any], live_inputs: dict[str, Any], curr_dict: dict[str, Any], curr_prefix: list[str] | None=None, live_input_types: dict[str, str] | None = None) -> None: for input_type, inner_d in curr_dict.items(): for id, value in inner_d.items(): io_type = value[0] @@ -1740,7 +1762,7 @@ def parse_class_inputs(out_dict: dict[str, Any], live_inputs: dict[str, Any], cu # dynamic inputs need to be handled with lookup functions dynamic_input_func = get_dynamic_input_func(io_type) new_prefix = handle_prefix(curr_prefix, id) - dynamic_input_func(out_dict, live_inputs, value, input_type, new_prefix) + dynamic_input_func(out_dict, live_inputs, value, input_type, new_prefix, live_input_types) else: # non-dynamic inputs get directly transferred finalized_id = finalize_prefix(curr_prefix, id) diff --git a/comfy_execution/graph.py b/comfy_execution/graph.py index 479ee8a53..ae5b338e7 100644 --- a/comfy_execution/graph.py +++ b/comfy_execution/graph.py @@ -26,6 +26,9 @@ class DynamicPrompt: self.ephemeral_prompt = {} self.ephemeral_parents = {} self.ephemeral_display = {} + # Lazily-built type resolver, scoped to this DynamicPrompt's lifetime. + # Invalidated whenever the graph mutates via add_ephemeral_node. + self._type_resolver = None def get_node(self, node_id): if node_id in self.ephemeral_prompt: @@ -41,6 +44,18 @@ class DynamicPrompt: self.ephemeral_prompt[node_id] = node_info self.ephemeral_parents[node_id] = parent_id self.ephemeral_display[node_id] = display_id + # Conservatively invalidate the entire resolver cache. Selective + # downstream invalidation would require topological info we don't have + # here cheaply; the resolver's cache is small and easy to rebuild. + if self._type_resolver is not None: + self._type_resolver.invalidate() + + def get_type_resolver(self): + """Lazily build and return the per-prompt TypeResolver.""" + if self._type_resolver is None: + from comfy_execution.type_resolver import TypeResolver + self._type_resolver = TypeResolver(self) + return self._type_resolver def get_real_node_id(self, node_id): while node_id in self.ephemeral_parents: diff --git a/comfy_execution/type_resolver.py b/comfy_execution/type_resolver.py new file mode 100644 index 000000000..975d37097 --- /dev/null +++ b/comfy_execution/type_resolver.py @@ -0,0 +1,372 @@ +"""Server-side type resolver for prompt graphs. + +Resolves the concrete io_type of an output slot or input slot by walking the +prompt graph. Handles: + + * Static V1/V3 ``RETURN_TYPES`` (returned as-is). + * V3 ``MatchType.Output`` (resolved by walking inputs that share the same + ``template_id`` until a concrete type is found). + * Cycles and unbounded recursion (terminates at ``AnyType`` with a one-shot + warning). + * Unknown / unresolvable / wildcard outputs (fall back to ``AnyType`` with a + one-shot warning). + +The resolver works against either a raw prompt dict +(``{node_id: {"class_type": str, "inputs": dict}}``) or a +``comfy_execution.graph.DynamicPrompt`` instance. + +All resolved values are plain strings, so the resolver state is trivially +serializable across processes if needed. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from comfy_api.latest import io +from comfy_api.internal import _ComfyNodeInternal + +# Sentinel for "type is unknown / wildcard". Matches AnyType.io_type ("*"). +ANY_TYPE: str = io.AnyType.io_type + +# Hard cap on resolver recursion depth. MatchType chains should never be +# anywhere near this deep; this is a belt-and-suspenders guard against malformed +# graphs and pathological cycles. +MAX_RESOLVE_DEPTH: int = 64 + + +class TypeResolver: + """Resolves concrete io_types for a prompt graph. + + Instantiate once per prompt (or per ``DynamicPrompt``) and reuse; results + are cached. Call :py:meth:`invalidate` (or :py:meth:`invalidate_node`) when + the underlying graph mutates (e.g. when an ephemeral node is added). + """ + + def __init__(self, prompt_source: Any): + """Args: + prompt_source: Either a ``DynamicPrompt`` (anything with + ``get_node(node_id)`` / ``has_node(node_id)``) or a plain + ``dict[node_id, {"class_type", "inputs"}]``. + """ + self._source = prompt_source + self._output_cache: dict[tuple[str, int], str] = {} + self._is_output_list_cache: dict[tuple[str, int], bool] = {} + self._warned: set[tuple[str, Any, str]] = set() + + # ---- prompt access ---------------------------------------------------- + def _has_node(self, node_id: str) -> bool: + if hasattr(self._source, "has_node"): + return self._source.has_node(node_id) + return node_id in self._source + + def _get_node(self, node_id: str) -> dict[str, Any] | None: + try: + if hasattr(self._source, "get_node"): + return self._source.get_node(node_id) + return self._source[node_id] + except Exception: + return None + + @staticmethod + def _get_class_def(class_type: str): + # Local import to avoid a hard import-cycle between nodes.py and + # comfy_execution at module-load time. + import nodes + return nodes.NODE_CLASS_MAPPINGS.get(class_type) + + # ---- cache management ------------------------------------------------- + def invalidate(self) -> None: + """Clear all cached resolutions. Cheap; call after any graph mutation.""" + self._output_cache.clear() + self._is_output_list_cache.clear() + # Intentionally do NOT clear self._warned: those messages are already + # logged and re-warning would just spam the log. + + def invalidate_node(self, node_id: str) -> None: + """Clear cached entries for a single node (e.g. after node-level expand).""" + for key in [k for k in self._output_cache if k[0] == node_id]: + del self._output_cache[key] + for key in [k for k in self._is_output_list_cache if k[0] == node_id]: + del self._is_output_list_cache[key] + + # ---- output resolution ----------------------------------------------- + def resolve_output_type(self, node_id: str, slot_idx: int, + _stack: frozenset[tuple[str, int]] | None = None) -> str: + """Return the resolved io_type string of ``node_id``'s output slot. + + Falls back to ``ANY_TYPE`` on cycle, depth-overflow, unknown class, + out-of-range slot, missing node, or unresolved MatchType template. + """ + cache_key = (node_id, slot_idx) + if cache_key in self._output_cache: + return self._output_cache[cache_key] + + if _stack is None: + _stack = frozenset() + if cache_key in _stack: + self._warn(node_id, slot_idx, "cycle detected during type resolution; defaulting to AnyType") + return ANY_TYPE + if len(_stack) >= MAX_RESOLVE_DEPTH: + self._warn(node_id, slot_idx, f"exceeded MAX_RESOLVE_DEPTH={MAX_RESOLVE_DEPTH}; defaulting to AnyType") + return ANY_TYPE + next_stack = _stack | {cache_key} + + if not self._has_node(node_id): + return ANY_TYPE + + node = self._get_node(node_id) + if node is None: + return ANY_TYPE + + class_type = node.get("class_type") + class_def = self._get_class_def(class_type) if class_type is not None else None + if class_def is None: + return ANY_TYPE + + try: + return_types = class_def.RETURN_TYPES + except Exception: + return ANY_TYPE + if return_types is None or slot_idx < 0 or slot_idx >= len(return_types): + return ANY_TYPE + + declared = return_types[slot_idx] + + # V3 nodes may have MatchType outputs that need to be traced through + # the schema. V1 nodes (and V3 nodes with plain outputs) just use the + # declared RETURN_TYPES string. + resolved = declared + if isinstance(class_def, type) and issubclass(class_def, _ComfyNodeInternal): + schema = getattr(class_def, "SCHEMA", None) + if schema is None: + # Trigger schema computation. RETURN_TYPES would have done this + # already, but be defensive. + try: + schema = class_def.GET_SCHEMA() + except Exception: + schema = None + if schema is not None and slot_idx < len(schema.outputs): + out = schema.outputs[slot_idx] + if isinstance(out, io.MatchType.Output): + resolved = self._resolve_match_template( + node_id, schema, out.template.template_id, next_stack + ) + + # Treat the legacy wildcard literally as AnyType. We warn only when the + # source node's *declared* type was already wildcard, so MatchType-style + # "no upstream connected" cases (which warn elsewhere) don't double-warn. + if isinstance(resolved, str) and resolved == ANY_TYPE and declared == ANY_TYPE: + self._warn( + node_id, slot_idx, + f"node '{class_type}' output slot {slot_idx} is wildcard; defaulting to AnyType", + ) + + if not isinstance(resolved, str): + # Non-string types (e.g., legacy combos passed as list) — bail to AnyType. + self._warn(node_id, slot_idx, + f"node '{class_type}' output slot {slot_idx} has non-string return type {type(resolved).__name__}; defaulting to AnyType") + resolved = ANY_TYPE + + self._output_cache[cache_key] = resolved + return resolved + + def _resolve_match_template(self, node_id: str, schema, template_id: str, + stack: frozenset[tuple[str, int]]) -> str: + """Resolve a MatchType.Output by inspecting the node's MatchType.Inputs + with the same template_id. + + Strategy (per design decision): walk inputs in schema order, pick the + FIRST concrete (non-AnyType) resolution. If none resolve, return + AnyType with a one-shot warning. + """ + node = self._get_node(node_id) + inputs_dict = (node or {}).get("inputs", {}) or {} + any_input_seen = False + for inp in schema.inputs: + if not isinstance(inp, io.MatchType.Input): + continue + if inp.template.template_id != template_id: + continue + any_input_seen = True + val = inputs_dict.get(inp.id) + if val is None: + continue + if isinstance(val, list) and len(val) == 2 and isinstance(val[0], str): + src_node, src_slot = val[0], val[1] + t = self.resolve_output_type(src_node, src_slot, stack) + if t != ANY_TYPE: + return t + # Literal value: a MatchType slot has no concrete declared type, so + # we cannot infer anything useful here. + if not any_input_seen: + # Schema declared a template_id with no Input bearing it. This is a + # node-author bug; warn once. + self._warn(node_id, None, + f"MatchType output template '{template_id}' has no matching Input on the node; defaulting to AnyType") + else: + self._warn(node_id, None, + f"MatchType template '{template_id}' has no bound concrete upstream input; defaulting to AnyType") + return ANY_TYPE + + def is_output_list(self, node_id: str, slot_idx: int) -> bool: + """Whether the source slot is declared as a list output (``OUTPUT_IS_LIST[idx]``).""" + cache_key = (node_id, slot_idx) + if cache_key in self._is_output_list_cache: + return self._is_output_list_cache[cache_key] + result = False + node = self._get_node(node_id) + if node is not None: + class_def = self._get_class_def(node.get("class_type")) + if class_def is not None: + lst = getattr(class_def, "OUTPUT_IS_LIST", None) + if lst is not None and 0 <= slot_idx < len(lst): + result = bool(lst[slot_idx]) + self._is_output_list_cache[cache_key] = result + return result + + # ---- input resolution ------------------------------------------------ + def resolve_input_type(self, node_id: str, input_id: str) -> str: + """Resolve the io_type of the value currently bound to a node's input. + + * If the value is a link, return the resolved type of the source slot. + * If the value is a literal, return the declared slot's effective + io_type (peeling dynamic-input wrappers — e.g. an Autogrow-of-Image + slot resolves to ``IMAGE``, not ``COMFY_AUTOGROW_V3``). + * If the value is missing or the slot is unknown, return ``ANY_TYPE``. + """ + node = self._get_node(node_id) + if node is None: + return ANY_TYPE + inputs = node.get("inputs", {}) or {} + if input_id not in inputs: + return ANY_TYPE + val = inputs[input_id] + if isinstance(val, list) and len(val) == 2 and isinstance(val[0], str): + return self.resolve_output_type(val[0], val[1]) + return self.get_declared_slot_io_type(node_id, input_id) + + def is_input_list(self, node_id: str, input_id: str) -> bool: + """Whether the value bound to ``input_id`` originates from a list output.""" + node = self._get_node(node_id) + if node is None: + return False + val = (node.get("inputs", {}) or {}).get(input_id) + if isinstance(val, list) and len(val) == 2 and isinstance(val[0], str): + return self.is_output_list(val[0], val[1]) + return False + + def get_declared_slot_io_type(self, node_id: str, input_id: str) -> str: + """Return the effective declared io_type of a node's input slot. + + Peels dynamic-input wrappers so that the user-facing element type is + returned: + + * Autogrow → wrapped template input's io_type + * DynamicSlot → underlying slot's io_type + * Anything else → the slot's own io_type + * DynamicCombo / unsupported → ``ANY_TYPE`` (the combo key is itself + dynamic, not a meaningful type for consumers) + """ + node = self._get_node(node_id) + if node is None: + return ANY_TYPE + class_def = self._get_class_def(node.get("class_type")) + if class_def is None: + return ANY_TYPE + + # Prefer V3 schema (carries dynamic-input wrapper info). + if isinstance(class_def, type) and issubclass(class_def, _ComfyNodeInternal): + schema = getattr(class_def, "SCHEMA", None) + if schema is None: + try: + class_def.GET_SCHEMA() + schema = getattr(class_def, "SCHEMA", None) + except Exception: + schema = None + if schema is not None: + # First, try a top-level input id match. + for inp in schema.inputs: + if inp.id == input_id: + return self._effective_io_type(inp) + # Then a nested match (DynamicSlot / DynamicCombo prefix.child). + if "." in input_id: + top, _, _ = input_id.partition(".") + for inp in schema.inputs: + if inp.id != top: + continue + for child in inp.get_all(): + if child is inp: + continue + if child.id == input_id.split(".", 1)[1]: + return self._effective_io_type(child) + # Fall through to V1 dict for hidden inputs etc. + + # V1 fallback: look at INPUT_TYPES() dict. + try: + inputs = class_def.INPUT_TYPES() + except Exception: + return ANY_TYPE + for section in ("required", "optional"): + section_d = inputs.get(section, {}) + if input_id in section_d: + entry = section_d[input_id] + if not entry: + return ANY_TYPE + t = entry[0] + if isinstance(t, str): + return t + if isinstance(t, list): + # legacy combo declared as a list of options. + return io.Combo.io_type + return ANY_TYPE + return ANY_TYPE + + @staticmethod + def _effective_io_type(inp) -> str: + """Return the consumer-facing io_type of a (possibly dynamic) input.""" + # Autogrow wraps a template input — the element type is what matters. + if isinstance(inp, io.Autogrow.Input): + try: + return inp.template.input.get_io_type() + except Exception: + return ANY_TYPE + # DynamicSlot wraps an underlying slot input. + if isinstance(inp, io.DynamicSlot.Input): + try: + return inp.slot.get_io_type() + except Exception: + return ANY_TYPE + # DynamicCombo's "type" is a key value selector, not a connection type. + if isinstance(inp, io.DynamicCombo.Input): + return ANY_TYPE + # Everything else: trust the input's declared io_type. + try: + return inp.get_io_type() + except Exception: + return ANY_TYPE + + # ---- bulk helpers ---------------------------------------------------- + def compute_live_input_types(self, node_id: str) -> dict[str, str]: + """Build the ``{input_id: resolved_io_type}`` map for a node. + + Used by :py:func:`comfy_api.latest._io.get_finalized_class_inputs` so + future dynamic-input expansion strategies (per-type DynamicType, etc.) + can branch on what was actually connected. + """ + node = self._get_node(node_id) + if node is None: + return {} + out: dict[str, str] = {} + for input_id in (node.get("inputs", {}) or {}).keys(): + out[input_id] = self.resolve_input_type(node_id, input_id) + return out + + # ---- diagnostics ----------------------------------------------------- + def _warn(self, node_id: str, slot_idx: Any, msg: str) -> None: + key = (node_id, slot_idx, msg) + if key in self._warned: + return + self._warned.add(key) + logging.warning("TypeResolver: node=%s slot=%s %s", node_id, slot_idx, msg) diff --git a/execution.py b/execution.py index 5246d651c..f7d0924c8 100644 --- a/execution.py +++ b/execution.py @@ -83,8 +83,9 @@ class IsChangedCache: self.is_changed[node_id] = node["is_changed"] return self.is_changed[node_id] - # Intentionally do not use cached outputs here. We only want constants in IS_CHANGED - input_data_all, _, v3_data = get_input_data(node["inputs"], class_def, node_id, None) + # Intentionally do not use cached outputs here. We only want constants in IS_CHANGED. + # Pass dynprompt so the TypeResolver can resolve link types for V3 dynamic schemas. + input_data_all, _, v3_data = get_input_data(node["inputs"], class_def, node_id, None, self.dynprompt) try: is_changed = await _async_map_node_over_list(self.prompt_id, node_id, class_def, input_data_all, is_changed_name, v3_data=v3_data) is_changed = await resolve_map_node_over_list_results(is_changed) @@ -158,7 +159,15 @@ def get_input_data(inputs, class_def, unique_id, execution_list=None, dynprompt= hidden_inputs_v3 = {} valid_inputs = class_def.INPUT_TYPES() if is_v3: - valid_inputs, hidden, v3_data = _io.get_finalized_class_inputs(valid_inputs, inputs) + # Build the type-resolution map for this node so dynamic schemas can + # branch on resolved upstream types (and not only on literal values). + # When no DynamicPrompt is available (e.g. some IsChangedCache paths + # in tests), live_input_types stays None and only literal-driven + # dynamic types continue to work. + live_input_types = None + if dynprompt is not None and hasattr(dynprompt, "get_type_resolver"): + live_input_types = dynprompt.get_type_resolver().compute_live_input_types(unique_id) + valid_inputs, hidden, v3_data = _io.get_finalized_class_inputs(valid_inputs, inputs, live_input_types=live_input_types) input_data_all = {} missing_keys = {} for x in inputs: @@ -821,9 +830,19 @@ class PromptExecutor: self._notify_prompt_lifecycle("end", prompt_id) -async def validate_inputs(prompt_id, prompt, item, validated, visiting=None): +async def validate_inputs(prompt_id, prompt, item, validated, visiting=None, type_resolver=None): + """Validate inputs for a single node, recursing into upstream nodes. + + ``type_resolver`` (a ``comfy_execution.type_resolver.TypeResolver``) is + built once at the top of the recursion and reused so MatchType chains are + only walked once. It also gives V3 dynamic schemas an accurate map of + resolved upstream types for API-submitted workflows. + """ if visiting is None: visiting = [] + if type_resolver is None: + from comfy_execution.type_resolver import TypeResolver + type_resolver = TypeResolver(prompt) unique_id = item if unique_id in validated: @@ -858,7 +877,8 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None): if issubclass(obj_class, _ComfyNodeInternal): obj_class: _io._ComfyNodeBaseInternal class_inputs = obj_class.INPUT_TYPES() - class_inputs, _, v3_data = _io.get_finalized_class_inputs(class_inputs, inputs) + live_input_types = type_resolver.compute_live_input_types(unique_id) + class_inputs, _, v3_data = _io.get_finalized_class_inputs(class_inputs, inputs, live_input_types=live_input_types) validate_function_name = "validate_inputs" validate_function = first_real_override(obj_class, validate_function_name) else: @@ -909,8 +929,11 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None): o_id = val[0] o_class_type = prompt[o_id]['class_type'] - r = nodes.NODE_CLASS_MAPPINGS[o_class_type].RETURN_TYPES - received_type = r[val[1]] + # Resolve the upstream output's effective type through the + # TypeResolver. This walks MatchType/template chains, so an API + # workflow without frontend-injected type metadata still gets the + # same answer the UI does. + received_type = type_resolver.resolve_output_type(o_id, val[1]) received_types[x] = received_type if 'input_types' not in validate_function_inputs and not validate_node_input(received_type, input_type): details = f"{x}, received_type({received_type}) mismatch input_type({input_type})" @@ -930,7 +953,7 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None): try: visiting.append(unique_id) try: - r = await validate_inputs(prompt_id, prompt, o_id, validated, visiting) + r = await validate_inputs(prompt_id, prompt, o_id, validated, visiting, type_resolver) finally: visiting.pop() if r[0] is False: @@ -1155,11 +1178,15 @@ async def validate_prompt(prompt_id, prompt, partial_execution_list: Union[list[ errors = [] node_errors = {} validated = {} + # Share one TypeResolver across all output validations so MatchType chains + # are only walked once per prompt. + from comfy_execution.type_resolver import TypeResolver + type_resolver = TypeResolver(prompt) for o in outputs: valid = False reasons = [] try: - m = await validate_inputs(prompt_id, prompt, o, validated) + m = await validate_inputs(prompt_id, prompt, o, validated, None, type_resolver) valid = m[0] reasons = m[1] except Exception as ex: diff --git a/tests-unit/execution_test/test_type_resolver.py b/tests-unit/execution_test/test_type_resolver.py new file mode 100644 index 000000000..f79384cc0 --- /dev/null +++ b/tests-unit/execution_test/test_type_resolver.py @@ -0,0 +1,377 @@ +"""Unit tests for :mod:`comfy_execution.type_resolver`. + +These tests stand up a small in-memory ``NODE_CLASS_MAPPINGS`` for the test +node classes (V1 and V3) and a fake DynamicPrompt-like dict, then verify the +resolver's behaviour for: + + * Static V1 ``RETURN_TYPES`` resolution. + * V1 wildcard outputs (must yield ``AnyType`` and warn once). + * V3 ``MatchType`` chains resolved via the downstream node's bound inputs. + * ``MatchType`` with no upstream bound (fall back to ``AnyType`` + warn). + * ``MatchType`` cycles (termination at ``AnyType`` + warn, no recursion blow-up). + * Deep chains capped by ``MAX_RESOLVE_DEPTH``. + * Input-type resolution for both literal values and links. + * Effective slot io_type peeling for ``Autogrow`` (returns the wrapped type). + * ``compute_live_input_types`` produces the right shape. + * Cache invalidation. + +The tests deliberately patch ``nodes.NODE_CLASS_MAPPINGS`` so they don't need +the whole ComfyUI bootstrap. +""" + +from __future__ import annotations + +import logging +import sys +import types as _pytypes + +import pytest + + +# --------------------------------------------------------------------------- +# Lightweight V1 test node factory +# --------------------------------------------------------------------------- + +def _v1_node(return_types: tuple[str, ...], input_types_dict: dict | None = None, + output_is_list: tuple[bool, ...] | None = None): + """Build a V1 node class with the given RETURN_TYPES / INPUT_TYPES().""" + if input_types_dict is None: + input_types_dict = {"required": {}} + + class _V1: + RETURN_TYPES = return_types + if output_is_list is not None: + OUTPUT_IS_LIST = output_is_list + + @classmethod + def INPUT_TYPES(cls): + return input_types_dict + + return _V1 + + +# --------------------------------------------------------------------------- +# Fixture: install fake nodes module before importing the resolver +# --------------------------------------------------------------------------- + +@pytest.fixture +def fake_nodes_module(): + """Install a synthetic ``nodes`` module with an empty mappings dict. + + Yields the mappings dict so tests can populate it per case. Cleans up + afterwards. We also have to make sure comfy_execution.type_resolver picks + up our fake module on its local import. + """ + real_nodes = sys.modules.get("nodes") + fake = _pytypes.ModuleType("nodes") + fake.NODE_CLASS_MAPPINGS = {} + sys.modules["nodes"] = fake + try: + yield fake.NODE_CLASS_MAPPINGS + finally: + if real_nodes is not None: + sys.modules["nodes"] = real_nodes + else: + del sys.modules["nodes"] + + +@pytest.fixture +def TypeResolver(fake_nodes_module): + # Late import so it picks up our fake `nodes` module. + from comfy_execution.type_resolver import TypeResolver as TR + return TR + + +# --------------------------------------------------------------------------- +# V1 resolution +# --------------------------------------------------------------------------- + +def test_v1_static_return_types_resolves(fake_nodes_module, TypeResolver): + fake_nodes_module["AddNode"] = _v1_node(("INT",)) + prompt = {"n1": {"class_type": "AddNode", "inputs": {}}} + r = TypeResolver(prompt) + assert r.resolve_output_type("n1", 0) == "INT" + + +def test_v1_wildcard_warns_once_and_returns_any(fake_nodes_module, TypeResolver, caplog): + fake_nodes_module["WildNode"] = _v1_node(("*",)) + prompt = {"n1": {"class_type": "WildNode", "inputs": {}}} + r = TypeResolver(prompt) + with caplog.at_level(logging.WARNING, logger="root"): + assert r.resolve_output_type("n1", 0) == "*" + # second call should still return * but not produce a second warning + assert r.resolve_output_type("n1", 0) == "*" + warnings = [rec for rec in caplog.records if "TypeResolver" in rec.message] + assert len(warnings) == 1, f"expected exactly one warning, got {warnings}" + + +def test_unknown_node_returns_any(fake_nodes_module, TypeResolver): + prompt = {"n1": {"class_type": "NopeNode", "inputs": {}}} + r = TypeResolver(prompt) + assert r.resolve_output_type("n1", 0) == "*" + + +def test_out_of_range_slot_returns_any(fake_nodes_module, TypeResolver): + fake_nodes_module["AddNode"] = _v1_node(("INT",)) + prompt = {"n1": {"class_type": "AddNode", "inputs": {}}} + r = TypeResolver(prompt) + assert r.resolve_output_type("n1", 5) == "*" + + +def test_missing_node_returns_any(fake_nodes_module, TypeResolver): + fake_nodes_module["AddNode"] = _v1_node(("INT",)) + prompt = {"n1": {"class_type": "AddNode", "inputs": {}}} + r = TypeResolver(prompt) + assert r.resolve_output_type("nonexistent", 0) == "*" + + +# --------------------------------------------------------------------------- +# is_output_list / is_input_list +# --------------------------------------------------------------------------- + +def test_is_output_list(fake_nodes_module, TypeResolver): + fake_nodes_module["ListNode"] = _v1_node(("IMAGE", "MASK"), output_is_list=(True, False)) + prompt = {"n1": {"class_type": "ListNode", "inputs": {}}} + r = TypeResolver(prompt) + assert r.is_output_list("n1", 0) is True + assert r.is_output_list("n1", 1) is False + + +def test_is_input_list_follows_link(fake_nodes_module, TypeResolver): + fake_nodes_module["ListNode"] = _v1_node(("IMAGE",), output_is_list=(True,)) + fake_nodes_module["Consumer"] = _v1_node( + ("INT",), + {"required": {"img": ("IMAGE",)}}, + ) + prompt = { + "src": {"class_type": "ListNode", "inputs": {}}, + "dst": {"class_type": "Consumer", "inputs": {"img": ["src", 0]}}, + } + r = TypeResolver(prompt) + assert r.is_input_list("dst", "img") is True + + +# --------------------------------------------------------------------------- +# V3 MatchType resolution +# --------------------------------------------------------------------------- + +def _make_switch_node_class(): + """Build a V3 Switch-like node with MatchType inputs/outputs.""" + from comfy_api.latest import io + + class Switch(io.ComfyNode): + @classmethod + def define_schema(cls): + template = io.MatchType.Template("switch") + return io.Schema( + node_id="TestSwitch", + inputs=[ + io.Boolean.Input("switch"), + io.MatchType.Input("on_false", template=template, optional=True), + io.MatchType.Input("on_true", template=template, optional=True), + ], + outputs=[io.MatchType.Output(template=template)], + ) + + @classmethod + def execute(cls, switch, on_false=None, on_true=None): + return io.NodeOutput(on_true if switch else on_false) + + # Force schema computation so SCHEMA / RETURN_TYPES are populated. + Switch.GET_SCHEMA() + return Switch + + +def test_matchtype_resolves_to_upstream_concrete(fake_nodes_module, TypeResolver): + fake_nodes_module["TestSwitch"] = _make_switch_node_class() + fake_nodes_module["ImageSrc"] = _v1_node(("IMAGE",)) + prompt = { + "img": {"class_type": "ImageSrc", "inputs": {}}, + "sw": { + "class_type": "TestSwitch", + "inputs": {"switch": True, "on_true": ["img", 0]}, + }, + } + r = TypeResolver(prompt) + assert r.resolve_output_type("sw", 0) == "IMAGE" + + +def test_matchtype_first_concrete_wins(fake_nodes_module, TypeResolver): + fake_nodes_module["TestSwitch"] = _make_switch_node_class() + fake_nodes_module["ImageSrc"] = _v1_node(("IMAGE",)) + fake_nodes_module["LatentSrc"] = _v1_node(("LATENT",)) + prompt = { + "img": {"class_type": "ImageSrc", "inputs": {}}, + "lat": {"class_type": "LatentSrc", "inputs": {}}, + "sw": { + "class_type": "TestSwitch", + "inputs": { + "switch": False, + "on_false": ["img", 0], # listed first in schema → wins + "on_true": ["lat", 0], + }, + }, + } + r = TypeResolver(prompt) + assert r.resolve_output_type("sw", 0) == "IMAGE" + + +def test_matchtype_no_bound_input_returns_any(fake_nodes_module, TypeResolver, caplog): + fake_nodes_module["TestSwitch"] = _make_switch_node_class() + prompt = {"sw": {"class_type": "TestSwitch", "inputs": {"switch": True}}} + r = TypeResolver(prompt) + with caplog.at_level(logging.WARNING, logger="root"): + assert r.resolve_output_type("sw", 0) == "*" + assert any("MatchType" in rec.message for rec in caplog.records) + + +def test_matchtype_skips_wildcard_input(fake_nodes_module, TypeResolver): + """If the first matched input resolves to AnyType, the resolver tries the next.""" + fake_nodes_module["TestSwitch"] = _make_switch_node_class() + fake_nodes_module["WildNode"] = _v1_node(("*",)) + fake_nodes_module["ImageSrc"] = _v1_node(("IMAGE",)) + prompt = { + "wild": {"class_type": "WildNode", "inputs": {}}, + "img": {"class_type": "ImageSrc", "inputs": {}}, + "sw": { + "class_type": "TestSwitch", + "inputs": { + "switch": True, + "on_false": ["wild", 0], + "on_true": ["img", 0], + }, + }, + } + r = TypeResolver(prompt) + assert r.resolve_output_type("sw", 0) == "IMAGE" + + +def test_matchtype_cycle_terminates_at_any(fake_nodes_module, TypeResolver): + """Two switches that feed each other must not recurse forever.""" + fake_nodes_module["TestSwitch"] = _make_switch_node_class() + prompt = { + "a": {"class_type": "TestSwitch", "inputs": {"switch": True, "on_true": ["b", 0]}}, + "b": {"class_type": "TestSwitch", "inputs": {"switch": True, "on_true": ["a", 0]}}, + } + r = TypeResolver(prompt) + # Must not raise / recurse forever; both resolve to AnyType. + assert r.resolve_output_type("a", 0) == "*" + assert r.resolve_output_type("b", 0) == "*" + + +def test_matchtype_chain_resolves_through(fake_nodes_module, TypeResolver): + """A → B → C → IMAGE: chain must walk all the way.""" + fake_nodes_module["TestSwitch"] = _make_switch_node_class() + fake_nodes_module["ImageSrc"] = _v1_node(("IMAGE",)) + prompt = { + "src": {"class_type": "ImageSrc", "inputs": {}}, + "a": {"class_type": "TestSwitch", "inputs": {"switch": True, "on_true": ["src", 0]}}, + "b": {"class_type": "TestSwitch", "inputs": {"switch": True, "on_true": ["a", 0]}}, + "c": {"class_type": "TestSwitch", "inputs": {"switch": True, "on_true": ["b", 0]}}, + } + r = TypeResolver(prompt) + assert r.resolve_output_type("c", 0) == "IMAGE" + + +# --------------------------------------------------------------------------- +# Input resolution and effective io_type peeling +# --------------------------------------------------------------------------- + +def test_resolve_input_type_literal_uses_declared(fake_nodes_module, TypeResolver): + fake_nodes_module["Sink"] = _v1_node(("INT",), {"required": {"steps": ("INT",)}}) + prompt = {"n1": {"class_type": "Sink", "inputs": {"steps": 20}}} + r = TypeResolver(prompt) + assert r.resolve_input_type("n1", "steps") == "INT" + + +def test_resolve_input_type_link(fake_nodes_module, TypeResolver): + fake_nodes_module["Src"] = _v1_node(("LATENT",)) + fake_nodes_module["Sink"] = _v1_node(("INT",), {"required": {"x": ("*",)}}) + prompt = { + "src": {"class_type": "Src", "inputs": {}}, + "sink": {"class_type": "Sink", "inputs": {"x": ["src", 0]}}, + } + r = TypeResolver(prompt) + assert r.resolve_input_type("sink", "x") == "LATENT" + + +def test_effective_slot_type_peels_autogrow(fake_nodes_module, TypeResolver): + from comfy_api.latest import io + + class AutogrowImg(io.ComfyNode): + @classmethod + def define_schema(cls): + template = io.Autogrow.TemplatePrefix( + input=io.Image.Input("img"), + prefix="img", + min=1, + ) + return io.Schema( + node_id="AutogrowImg", + inputs=[io.Autogrow.Input("imgs", template=template)], + outputs=[io.Image.Output()], + ) + + @classmethod + def execute(cls, imgs): + return io.NodeOutput(None) + + AutogrowImg.GET_SCHEMA() + fake_nodes_module["AutogrowImg"] = AutogrowImg + prompt = {"n1": {"class_type": "AutogrowImg", "inputs": {}}} + r = TypeResolver(prompt) + # The user-facing element type, not the autogrow wrapper. + assert r.get_declared_slot_io_type("n1", "imgs") == "IMAGE" + + +# --------------------------------------------------------------------------- +# compute_live_input_types +# --------------------------------------------------------------------------- + +def test_compute_live_input_types_mixes_links_and_literals(fake_nodes_module, TypeResolver): + fake_nodes_module["Src"] = _v1_node(("MODEL",)) + fake_nodes_module["Sink"] = _v1_node( + ("INT",), + {"required": {"model": ("MODEL",), "steps": ("INT",)}}, + ) + prompt = { + "src": {"class_type": "Src", "inputs": {}}, + "sink": { + "class_type": "Sink", + "inputs": {"model": ["src", 0], "steps": 20}, + }, + } + r = TypeResolver(prompt) + assert r.compute_live_input_types("sink") == {"model": "MODEL", "steps": "INT"} + + +# --------------------------------------------------------------------------- +# Cache invalidation +# --------------------------------------------------------------------------- + +def test_invalidate_clears_cache(fake_nodes_module, TypeResolver): + fake_nodes_module["Src"] = _v1_node(("IMAGE",)) + prompt = {"n1": {"class_type": "Src", "inputs": {}}} + r = TypeResolver(prompt) + assert r.resolve_output_type("n1", 0) == "IMAGE" + # Mutate the underlying class and invalidate; the resolver must re-read. + fake_nodes_module["Src"] = _v1_node(("LATENT",)) + r.invalidate() + assert r.resolve_output_type("n1", 0) == "LATENT" + + +def test_invalidate_node_only_clears_that_node(fake_nodes_module, TypeResolver): + fake_nodes_module["SrcA"] = _v1_node(("IMAGE",)) + fake_nodes_module["SrcB"] = _v1_node(("LATENT",)) + prompt = { + "a": {"class_type": "SrcA", "inputs": {}}, + "b": {"class_type": "SrcB", "inputs": {}}, + } + r = TypeResolver(prompt) + r.resolve_output_type("a", 0) + r.resolve_output_type("b", 0) + fake_nodes_module["SrcA"] = _v1_node(("MASK",)) + r.invalidate_node("a") + assert r.resolve_output_type("a", 0) == "MASK" + # b's cached result survives even though SrcB was unchanged + assert ("b", 0) in r._output_cache From e01b335e39d1d43aed02ddd4ba9b394fd02a56a8 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 1 Jun 2026 16:30:44 -0700 Subject: [PATCH 02/10] TypeResolver: address code review (link parsing + slot_idx guard + back-compat shim) * Add _parse_link helper validating both node_id (str) and slot_idx (int, rejecting bool) so malformed API JSON (e.g. ['n1', '0']) degrades to AnyType instead of crashing with TypeError. * Add slot_idx type guards in resolve_output_type and is_output_list. * Extract _get_class_def_for_node helper to dedupe node/class lookup across resolve_output_type, is_output_list, get_declared_slot_io_type. * register_dynamic_input_func now detects 5-argument legacy callables via inspect.signature and silently wraps them; preserves backward compatibility for any custom node that registered its own dynamic input expansion against the pre-live_input_types signature. * Tests: malformed link (str slot idx, wrong arity), bad slot type directly to resolve_output_type, non-string class_type. Tests for the legacy 5-arg shim and the modern 6-arg passthrough, including callables with uninspectable signatures. Amp-Thread-ID: https://ampcode.com/threads/T-019e8568-f382-743d-a97f-0de3ff29d501 Co-authored-by: Amp --- comfy_api/latest/_io.py | 31 +++++- comfy_execution/type_resolver.py | 97 ++++++++++++------- .../test_register_dynamic_input_func.py | 79 +++++++++++++++ .../execution_test/test_type_resolver.py | 46 +++++++++ 4 files changed, 218 insertions(+), 35 deletions(-) create mode 100644 tests-unit/comfy_api_test/test_register_dynamic_input_func.py diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index d6e92c17b..a7228acac 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1369,7 +1369,36 @@ _DynamicInputFunc = Callable[ ] DYNAMIC_INPUT_LOOKUP: dict[str, _DynamicInputFunc] = {} def register_dynamic_input_func(io_type: str, func: _DynamicInputFunc): - DYNAMIC_INPUT_LOOKUP[io_type] = func + """Register a dynamic-input expansion callback. + + Accepts both the current 6-argument form and the legacy 5-argument form + (without ``live_input_types``). Legacy callables are silently wrapped so + custom nodes that registered against the older signature continue to work. + """ + try: + sig = inspect.signature(func) + param_count = sum( + 1 for p in sig.parameters.values() + if p.kind in (inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.VAR_POSITIONAL) + ) + # 5 = legacy signature (no live_input_types). If the callable has + # *args we can't be certain, so treat it as new-style. + has_varargs = any(p.kind is inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values()) + is_legacy = (param_count == 5 and not has_varargs) + except (TypeError, ValueError): + # Builtins / C-implemented callables without introspectable signatures + # are assumed to be new-style. + is_legacy = False + + if is_legacy: + _legacy = func + def _adapter(out_dict, live_inputs, value, input_type, curr_prefix, live_input_types=None): + _legacy(out_dict, live_inputs, value, input_type, curr_prefix) + DYNAMIC_INPUT_LOOKUP[io_type] = _adapter + else: + DYNAMIC_INPUT_LOOKUP[io_type] = func def get_dynamic_input_func(io_type: str) -> _DynamicInputFunc: return DYNAMIC_INPUT_LOOKUP[io_type] diff --git a/comfy_execution/type_resolver.py b/comfy_execution/type_resolver.py index 975d37097..d5d21d7a9 100644 --- a/comfy_execution/type_resolver.py +++ b/comfy_execution/type_resolver.py @@ -27,6 +27,25 @@ from typing import Any from comfy_api.latest import io from comfy_api.internal import _ComfyNodeInternal + +def _parse_link(val: Any) -> tuple[str, int] | None: + """Return (src_node_id, src_slot_idx) if ``val`` is a well-formed link. + + A link in the prompt schema is a length-2 list/tuple ``[node_id, slot_idx]`` + where ``node_id`` is a string and ``slot_idx`` is a non-negative int. + Anything else (including ``[node_id, "0"]`` from malformed API JSON) returns + ``None`` so callers can fall back to AnyType instead of crashing. + """ + if not isinstance(val, (list, tuple)) or len(val) != 2: + return None + src_node, src_slot = val[0], val[1] + if not isinstance(src_node, str): + return None + # bool is a subclass of int — reject it to avoid treating True/False as slot 1/0. + if isinstance(src_slot, bool) or not isinstance(src_slot, int): + return None + return src_node, src_slot + # Sentinel for "type is unknown / wildcard". Matches AnyType.io_type ("*"). ANY_TYPE: str = io.AnyType.io_type @@ -76,6 +95,18 @@ class TypeResolver: import nodes return nodes.NODE_CLASS_MAPPINGS.get(class_type) + def _get_class_def_for_node(self, node_id: str): + """Return (node_dict, class_def) for ``node_id``, or ``(None, None)``.""" + if not self._has_node(node_id): + return None, None + node = self._get_node(node_id) + if node is None: + return None, None + class_type = node.get("class_type") + if not isinstance(class_type, str): + return node, None + return node, self._get_class_def(class_type) + # ---- cache management ------------------------------------------------- def invalidate(self) -> None: """Clear all cached resolutions. Cheap; call after any graph mutation.""" @@ -97,8 +128,15 @@ class TypeResolver: """Return the resolved io_type string of ``node_id``'s output slot. Falls back to ``ANY_TYPE`` on cycle, depth-overflow, unknown class, - out-of-range slot, missing node, or unresolved MatchType template. + out-of-range slot, missing node, malformed link, or unresolved + MatchType template. """ + # Guard against malformed callers passing non-int slot indices (e.g. + # API JSON that sent a string). Falling back to AnyType is safer than + # raising TypeError mid-validation. + if isinstance(slot_idx, bool) or not isinstance(slot_idx, int): + return ANY_TYPE + cache_key = (node_id, slot_idx) if cache_key in self._output_cache: return self._output_cache[cache_key] @@ -113,17 +151,10 @@ class TypeResolver: return ANY_TYPE next_stack = _stack | {cache_key} - if not self._has_node(node_id): - return ANY_TYPE - - node = self._get_node(node_id) - if node is None: - return ANY_TYPE - - class_type = node.get("class_type") - class_def = self._get_class_def(class_type) if class_type is not None else None + node, class_def = self._get_class_def_for_node(node_id) if class_def is None: return ANY_TYPE + class_type = node.get("class_type") try: return_types = class_def.RETURN_TYPES @@ -193,13 +224,13 @@ class TypeResolver: val = inputs_dict.get(inp.id) if val is None: continue - if isinstance(val, list) and len(val) == 2 and isinstance(val[0], str): - src_node, src_slot = val[0], val[1] - t = self.resolve_output_type(src_node, src_slot, stack) + link = _parse_link(val) + if link is not None: + t = self.resolve_output_type(link[0], link[1], stack) if t != ANY_TYPE: return t - # Literal value: a MatchType slot has no concrete declared type, so - # we cannot infer anything useful here. + # Literal value (or malformed link): a MatchType slot has no + # concrete declared type, so we cannot infer anything useful here. if not any_input_seen: # Schema declared a template_id with no Input bearing it. This is a # node-author bug; warn once. @@ -212,17 +243,17 @@ class TypeResolver: def is_output_list(self, node_id: str, slot_idx: int) -> bool: """Whether the source slot is declared as a list output (``OUTPUT_IS_LIST[idx]``).""" + if isinstance(slot_idx, bool) or not isinstance(slot_idx, int): + return False cache_key = (node_id, slot_idx) if cache_key in self._is_output_list_cache: return self._is_output_list_cache[cache_key] result = False - node = self._get_node(node_id) - if node is not None: - class_def = self._get_class_def(node.get("class_type")) - if class_def is not None: - lst = getattr(class_def, "OUTPUT_IS_LIST", None) - if lst is not None and 0 <= slot_idx < len(lst): - result = bool(lst[slot_idx]) + _, class_def = self._get_class_def_for_node(node_id) + if class_def is not None: + lst = getattr(class_def, "OUTPUT_IS_LIST", None) + if lst is not None and 0 <= slot_idx < len(lst): + result = bool(lst[slot_idx]) self._is_output_list_cache[cache_key] = result return result @@ -234,7 +265,8 @@ class TypeResolver: * If the value is a literal, return the declared slot's effective io_type (peeling dynamic-input wrappers — e.g. an Autogrow-of-Image slot resolves to ``IMAGE``, not ``COMFY_AUTOGROW_V3``). - * If the value is missing or the slot is unknown, return ``ANY_TYPE``. + * If the value is missing, malformed, or the slot is unknown, return + ``ANY_TYPE``. """ node = self._get_node(node_id) if node is None: @@ -242,9 +274,9 @@ class TypeResolver: inputs = node.get("inputs", {}) or {} if input_id not in inputs: return ANY_TYPE - val = inputs[input_id] - if isinstance(val, list) and len(val) == 2 and isinstance(val[0], str): - return self.resolve_output_type(val[0], val[1]) + link = _parse_link(inputs[input_id]) + if link is not None: + return self.resolve_output_type(link[0], link[1]) return self.get_declared_slot_io_type(node_id, input_id) def is_input_list(self, node_id: str, input_id: str) -> bool: @@ -252,10 +284,10 @@ class TypeResolver: node = self._get_node(node_id) if node is None: return False - val = (node.get("inputs", {}) or {}).get(input_id) - if isinstance(val, list) and len(val) == 2 and isinstance(val[0], str): - return self.is_output_list(val[0], val[1]) - return False + link = _parse_link((node.get("inputs", {}) or {}).get(input_id)) + if link is None: + return False + return self.is_output_list(link[0], link[1]) def get_declared_slot_io_type(self, node_id: str, input_id: str) -> str: """Return the effective declared io_type of a node's input slot. @@ -269,10 +301,7 @@ class TypeResolver: * DynamicCombo / unsupported → ``ANY_TYPE`` (the combo key is itself dynamic, not a meaningful type for consumers) """ - node = self._get_node(node_id) - if node is None: - return ANY_TYPE - class_def = self._get_class_def(node.get("class_type")) + _, class_def = self._get_class_def_for_node(node_id) if class_def is None: return ANY_TYPE diff --git a/tests-unit/comfy_api_test/test_register_dynamic_input_func.py b/tests-unit/comfy_api_test/test_register_dynamic_input_func.py new file mode 100644 index 000000000..afabf9fe7 --- /dev/null +++ b/tests-unit/comfy_api_test/test_register_dynamic_input_func.py @@ -0,0 +1,79 @@ +"""Backward-compat tests for ``register_dynamic_input_func``. + +When ``live_input_types`` was added as a sixth argument to the dynamic-input +expansion callback, third-party custom nodes that registered against the +original 5-argument signature would otherwise crash with ``TypeError`` the +first time their input was expanded. ``register_dynamic_input_func`` wraps +such legacy callables transparently. +""" + +from __future__ import annotations + +from comfy_api.latest import _io + + +def test_legacy_5arg_callback_is_wrapped_transparently(): + received = {} + + def legacy(out_dict, live_inputs, value, input_type, curr_prefix): + received["args"] = (out_dict, live_inputs, value, input_type, curr_prefix) + + io_type = "TEST_LEGACY_5ARG_V3" + try: + _io.register_dynamic_input_func(io_type, legacy) + fn = _io.get_dynamic_input_func(io_type) + + # Caller invokes with 6 arguments (current signature). The shim must + # strip the trailing live_input_types argument before delegating. + fn({"required": {}}, {"a": 1}, ("X", {}), "required", ["p"], {"a": "INT"}) + + assert received["args"] == ( + {"required": {}}, {"a": 1}, ("X", {}), "required", ["p"] + ) + finally: + _io.DYNAMIC_INPUT_LOOKUP.pop(io_type, None) + + +def test_new_6arg_callback_passes_live_input_types_through(): + received = {} + + def modern(out_dict, live_inputs, value, input_type, curr_prefix, live_input_types=None): + received["live_input_types"] = live_input_types + + io_type = "TEST_MODERN_6ARG_V3" + try: + _io.register_dynamic_input_func(io_type, modern) + fn = _io.get_dynamic_input_func(io_type) + fn({}, {}, ("X", {}), "required", None, {"foo": "IMAGE"}) + assert received["live_input_types"] == {"foo": "IMAGE"} + finally: + _io.DYNAMIC_INPUT_LOOKUP.pop(io_type, None) + + +def test_callable_with_uninspectable_signature_assumed_modern(): + """``functools.partial`` and C builtins may have no introspectable signature. + + The shim must not blow up; falling back to the new signature is the safe + choice (we get a clean TypeError if the callable really is too old, which + is no worse than the pre-shim behavior). + """ + calls = [] + + class _CallableObj: + # Lambdas / objects with __call__ are introspectable; partial with + # opaque builtins are not. Simulate the latter by raising in __signature__. + def __call__(self, *args, **kwargs): + calls.append((args, kwargs)) + + @property + def __signature__(self): + raise ValueError("uninspectable") + + io_type = "TEST_UNINSPECTABLE_V3" + try: + _io.register_dynamic_input_func(io_type, _CallableObj()) + fn = _io.get_dynamic_input_func(io_type) + fn({}, {}, ("X", {}), "required", None, {"x": "INT"}) + assert calls and len(calls[0][0]) == 6 + finally: + _io.DYNAMIC_INPUT_LOOKUP.pop(io_type, None) diff --git a/tests-unit/execution_test/test_type_resolver.py b/tests-unit/execution_test/test_type_resolver.py index f79384cc0..e89874ca0 100644 --- a/tests-unit/execution_test/test_type_resolver.py +++ b/tests-unit/execution_test/test_type_resolver.py @@ -360,6 +360,52 @@ def test_invalidate_clears_cache(fake_nodes_module, TypeResolver): assert r.resolve_output_type("n1", 0) == "LATENT" +# --------------------------------------------------------------------------- +# Malformed input robustness +# --------------------------------------------------------------------------- + +def test_malformed_link_does_not_crash(fake_nodes_module, TypeResolver): + """A link with a non-int slot index must not raise; resolver returns AnyType.""" + fake_nodes_module["Src"] = _v1_node(("IMAGE",)) + fake_nodes_module["Sink"] = _v1_node(("INT",), {"required": {"x": ("*",)}}) + prompt = { + "src": {"class_type": "Src", "inputs": {}}, + # slot index sent as a string (common API JSON mistake) + "sink": {"class_type": "Sink", "inputs": {"x": ["src", "0"]}}, + } + r = TypeResolver(prompt) + # Falls back to declared slot type (still "*"), no exception. + assert r.resolve_input_type("sink", "x") == "*" + + +def test_malformed_link_wrong_arity_does_not_crash(fake_nodes_module, TypeResolver): + fake_nodes_module["Src"] = _v1_node(("IMAGE",)) + fake_nodes_module["Sink"] = _v1_node(("INT",), {"required": {"x": ("*",)}}) + prompt = { + "src": {"class_type": "Src", "inputs": {}}, + "sink": {"class_type": "Sink", "inputs": {"x": ["src"]}}, # arity 1 + } + r = TypeResolver(prompt) + assert r.resolve_input_type("sink", "x") == "*" + + +def test_direct_resolve_output_type_with_bad_slot_idx_returns_any(fake_nodes_module, TypeResolver): + fake_nodes_module["Src"] = _v1_node(("IMAGE",)) + prompt = {"src": {"class_type": "Src", "inputs": {}}} + r = TypeResolver(prompt) + # type-wise these should be unreachable through normal validation but the + # resolver must still degrade gracefully. + assert r.resolve_output_type("src", "0") == "*" + assert r.resolve_output_type("src", True) == "*" # bool is a subclass of int + assert r.is_output_list("src", "0") is False + + +def test_non_string_class_type_returns_any(fake_nodes_module, TypeResolver): + prompt = {"n1": {"class_type": 42, "inputs": {}}} + r = TypeResolver(prompt) + assert r.resolve_output_type("n1", 0) == "*" + + def test_invalidate_node_only_clears_that_node(fake_nodes_module, TypeResolver): fake_nodes_module["SrcA"] = _v1_node(("IMAGE",)) fake_nodes_module["SrcB"] = _v1_node(("LATENT",)) From 15f55f1b243ebaacc2dddac3e4e8be5bc546217b Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 1 Jun 2026 16:43:08 -0700 Subject: [PATCH 03/10] Drop register_dynamic_input_func legacy-arity shim register_dynamic_input_func is a private helper (underscore module, not in __all__, not re-exported). Its only callers are the three core setup_dynamic_input_funcs() registrations, all of which were updated together. No third party can be relying on the old 5-arg signature, so the inspect.signature shim and accompanying backward-compat tests are over-engineered. Amp-Thread-ID: https://ampcode.com/threads/T-019e8568-f382-743d-a97f-0de3ff29d501 Co-authored-by: Amp --- comfy_api/latest/_io.py | 31 +------- .../test_register_dynamic_input_func.py | 79 ------------------- 2 files changed, 1 insertion(+), 109 deletions(-) delete mode 100644 tests-unit/comfy_api_test/test_register_dynamic_input_func.py diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index a7228acac..d6e92c17b 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1369,36 +1369,7 @@ _DynamicInputFunc = Callable[ ] DYNAMIC_INPUT_LOOKUP: dict[str, _DynamicInputFunc] = {} def register_dynamic_input_func(io_type: str, func: _DynamicInputFunc): - """Register a dynamic-input expansion callback. - - Accepts both the current 6-argument form and the legacy 5-argument form - (without ``live_input_types``). Legacy callables are silently wrapped so - custom nodes that registered against the older signature continue to work. - """ - try: - sig = inspect.signature(func) - param_count = sum( - 1 for p in sig.parameters.values() - if p.kind in (inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - inspect.Parameter.VAR_POSITIONAL) - ) - # 5 = legacy signature (no live_input_types). If the callable has - # *args we can't be certain, so treat it as new-style. - has_varargs = any(p.kind is inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values()) - is_legacy = (param_count == 5 and not has_varargs) - except (TypeError, ValueError): - # Builtins / C-implemented callables without introspectable signatures - # are assumed to be new-style. - is_legacy = False - - if is_legacy: - _legacy = func - def _adapter(out_dict, live_inputs, value, input_type, curr_prefix, live_input_types=None): - _legacy(out_dict, live_inputs, value, input_type, curr_prefix) - DYNAMIC_INPUT_LOOKUP[io_type] = _adapter - else: - DYNAMIC_INPUT_LOOKUP[io_type] = func + DYNAMIC_INPUT_LOOKUP[io_type] = func def get_dynamic_input_func(io_type: str) -> _DynamicInputFunc: return DYNAMIC_INPUT_LOOKUP[io_type] diff --git a/tests-unit/comfy_api_test/test_register_dynamic_input_func.py b/tests-unit/comfy_api_test/test_register_dynamic_input_func.py deleted file mode 100644 index afabf9fe7..000000000 --- a/tests-unit/comfy_api_test/test_register_dynamic_input_func.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Backward-compat tests for ``register_dynamic_input_func``. - -When ``live_input_types`` was added as a sixth argument to the dynamic-input -expansion callback, third-party custom nodes that registered against the -original 5-argument signature would otherwise crash with ``TypeError`` the -first time their input was expanded. ``register_dynamic_input_func`` wraps -such legacy callables transparently. -""" - -from __future__ import annotations - -from comfy_api.latest import _io - - -def test_legacy_5arg_callback_is_wrapped_transparently(): - received = {} - - def legacy(out_dict, live_inputs, value, input_type, curr_prefix): - received["args"] = (out_dict, live_inputs, value, input_type, curr_prefix) - - io_type = "TEST_LEGACY_5ARG_V3" - try: - _io.register_dynamic_input_func(io_type, legacy) - fn = _io.get_dynamic_input_func(io_type) - - # Caller invokes with 6 arguments (current signature). The shim must - # strip the trailing live_input_types argument before delegating. - fn({"required": {}}, {"a": 1}, ("X", {}), "required", ["p"], {"a": "INT"}) - - assert received["args"] == ( - {"required": {}}, {"a": 1}, ("X", {}), "required", ["p"] - ) - finally: - _io.DYNAMIC_INPUT_LOOKUP.pop(io_type, None) - - -def test_new_6arg_callback_passes_live_input_types_through(): - received = {} - - def modern(out_dict, live_inputs, value, input_type, curr_prefix, live_input_types=None): - received["live_input_types"] = live_input_types - - io_type = "TEST_MODERN_6ARG_V3" - try: - _io.register_dynamic_input_func(io_type, modern) - fn = _io.get_dynamic_input_func(io_type) - fn({}, {}, ("X", {}), "required", None, {"foo": "IMAGE"}) - assert received["live_input_types"] == {"foo": "IMAGE"} - finally: - _io.DYNAMIC_INPUT_LOOKUP.pop(io_type, None) - - -def test_callable_with_uninspectable_signature_assumed_modern(): - """``functools.partial`` and C builtins may have no introspectable signature. - - The shim must not blow up; falling back to the new signature is the safe - choice (we get a clean TypeError if the callable really is too old, which - is no worse than the pre-shim behavior). - """ - calls = [] - - class _CallableObj: - # Lambdas / objects with __call__ are introspectable; partial with - # opaque builtins are not. Simulate the latter by raising in __signature__. - def __call__(self, *args, **kwargs): - calls.append((args, kwargs)) - - @property - def __signature__(self): - raise ValueError("uninspectable") - - io_type = "TEST_UNINSPECTABLE_V3" - try: - _io.register_dynamic_input_func(io_type, _CallableObj()) - fn = _io.get_dynamic_input_func(io_type) - fn({}, {}, ("X", {}), "required", None, {"x": "INT"}) - assert calls and len(calls[0][0]) == 6 - finally: - _io.DYNAMIC_INPUT_LOOKUP.pop(io_type, None) From 004ac8820b3726972eef07aed5de04742c03edb3 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 1 Jun 2026 16:54:05 -0700 Subject: [PATCH 04/10] TypeResolver: trim verbose comments and docstrings Amp-Thread-ID: https://ampcode.com/threads/T-019e8568-f382-743d-a97f-0de3ff29d501 Co-authored-by: Amp --- comfy_api/latest/_io.py | 20 ++----- comfy_execution/graph.py | 9 +-- comfy_execution/type_resolver.py | 99 ++++++++++---------------------- execution.py | 22 ++----- 4 files changed, 45 insertions(+), 105 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index d6e92c17b..98d19d7e0 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1357,12 +1357,9 @@ class Range(ComfyTypeIO): }) -# Signature: (out_dict, live_inputs, value, input_type, curr_prefix, live_input_types) -# live_input_types is an optional {input_id: resolved_io_type} dict produced -# by comfy_execution.type_resolver.TypeResolver. Existing dynamic-input -# implementations may ignore it; future type-discriminated dynamic inputs -# (e.g. a per-connected-type variant of DynamicCombo) use it as their -# discriminator instead of literal live_inputs values. +# Signature: (out_dict, live_inputs, value, input_type, curr_prefix, live_input_types). +# live_input_types is {input_id: resolved_io_type} from TypeResolver; existing +# expanders ignore it, future type-discriminated ones use it as discriminator. _DynamicInputFunc = Callable[ [dict[str, Any], dict[str, Any], tuple[str, dict[str, Any]], str, list[str] | None, dict[str, str] | None], None, @@ -1722,15 +1719,8 @@ class Schema: def get_finalized_class_inputs(d: dict[str, Any], live_inputs: dict[str, Any], include_hidden=False, live_input_types: dict[str, str] | None = None) -> tuple[dict[str, Any], V3Data]: """Expand a node's V3 schema against a concrete prompt. - Args: - d: ``INPUT_TYPES()``-shaped dict for the node. - live_inputs: Concrete ``{input_id: value}`` map from the prompt - (values may be links ``[node_id, slot_idx]`` or literals). - include_hidden: When True, retain hidden inputs in the returned dict. - live_input_types: Optional ``{input_id: resolved_io_type}`` map - produced by ``comfy_execution.type_resolver.TypeResolver``. Future - dynamic-input strategies that branch on connected type use this as - their discriminator. Existing dynamic types ignore it. + ``live_input_types`` is an optional ``{input_id: resolved_io_type}`` map + (from ``TypeResolver``) used by future type-discriminated dynamic inputs. """ out_dict = { "required": {}, diff --git a/comfy_execution/graph.py b/comfy_execution/graph.py index ae5b338e7..eb9ac79fa 100644 --- a/comfy_execution/graph.py +++ b/comfy_execution/graph.py @@ -26,9 +26,7 @@ class DynamicPrompt: self.ephemeral_prompt = {} self.ephemeral_parents = {} self.ephemeral_display = {} - # Lazily-built type resolver, scoped to this DynamicPrompt's lifetime. - # Invalidated whenever the graph mutates via add_ephemeral_node. - self._type_resolver = None + self._type_resolver = None # lazy; invalidated by add_ephemeral_node def get_node(self, node_id): if node_id in self.ephemeral_prompt: @@ -44,9 +42,8 @@ class DynamicPrompt: self.ephemeral_prompt[node_id] = node_info self.ephemeral_parents[node_id] = parent_id self.ephemeral_display[node_id] = display_id - # Conservatively invalidate the entire resolver cache. Selective - # downstream invalidation would require topological info we don't have - # here cheaply; the resolver's cache is small and easy to rebuild. + # Selective downstream invalidation would need topological info; the + # cache is small, so just wipe it. if self._type_resolver is not None: self._type_resolver.invalidate() diff --git a/comfy_execution/type_resolver.py b/comfy_execution/type_resolver.py index d5d21d7a9..d22eee1ea 100644 --- a/comfy_execution/type_resolver.py +++ b/comfy_execution/type_resolver.py @@ -1,22 +1,12 @@ """Server-side type resolver for prompt graphs. -Resolves the concrete io_type of an output slot or input slot by walking the -prompt graph. Handles: +Resolves the concrete io_type of any output/input slot by walking the prompt +graph. Handles V1/V3 ``RETURN_TYPES``, V3 ``MatchType`` template chains, and +falls back to ``AnyType`` (with a one-shot warning) on cycles, depth overflow, +or unresolvable wildcards. - * Static V1/V3 ``RETURN_TYPES`` (returned as-is). - * V3 ``MatchType.Output`` (resolved by walking inputs that share the same - ``template_id`` until a concrete type is found). - * Cycles and unbounded recursion (terminates at ``AnyType`` with a one-shot - warning). - * Unknown / unresolvable / wildcard outputs (fall back to ``AnyType`` with a - one-shot warning). - -The resolver works against either a raw prompt dict -(``{node_id: {"class_type": str, "inputs": dict}}``) or a -``comfy_execution.graph.DynamicPrompt`` instance. - -All resolved values are plain strings, so the resolver state is trivially -serializable across processes if needed. +Works against either a raw prompt dict or a ``DynamicPrompt``. All resolved +values are strings, so resolver state is cross-process serializable. """ from __future__ import annotations @@ -29,30 +19,23 @@ from comfy_api.internal import _ComfyNodeInternal def _parse_link(val: Any) -> tuple[str, int] | None: - """Return (src_node_id, src_slot_idx) if ``val`` is a well-formed link. + """Return ``(src_node_id, src_slot_idx)`` if ``val`` is a well-formed link, else ``None``. - A link in the prompt schema is a length-2 list/tuple ``[node_id, slot_idx]`` - where ``node_id`` is a string and ``slot_idx`` is a non-negative int. - Anything else (including ``[node_id, "0"]`` from malformed API JSON) returns - ``None`` so callers can fall back to AnyType instead of crashing. + A link is ``[node_id: str, slot_idx: int]``. Malformed shapes return ``None`` + so callers can fall back to AnyType rather than raise. """ if not isinstance(val, (list, tuple)) or len(val) != 2: return None src_node, src_slot = val[0], val[1] if not isinstance(src_node, str): return None - # bool is a subclass of int — reject it to avoid treating True/False as slot 1/0. + # bool is a subclass of int — reject so True/False aren't read as slot 1/0. if isinstance(src_slot, bool) or not isinstance(src_slot, int): return None return src_node, src_slot -# Sentinel for "type is unknown / wildcard". Matches AnyType.io_type ("*"). ANY_TYPE: str = io.AnyType.io_type - -# Hard cap on resolver recursion depth. MatchType chains should never be -# anywhere near this deep; this is a belt-and-suspenders guard against malformed -# graphs and pathological cycles. -MAX_RESOLVE_DEPTH: int = 64 +MAX_RESOLVE_DEPTH: int = 64 # belt-and-suspenders cap; real MatchType chains stay tiny class TypeResolver: @@ -90,8 +73,7 @@ class TypeResolver: @staticmethod def _get_class_def(class_type: str): - # Local import to avoid a hard import-cycle between nodes.py and - # comfy_execution at module-load time. + # Local import: nodes <-> comfy_execution would cycle at import time. import nodes return nodes.NODE_CLASS_MAPPINGS.get(class_type) @@ -112,8 +94,7 @@ class TypeResolver: """Clear all cached resolutions. Cheap; call after any graph mutation.""" self._output_cache.clear() self._is_output_list_cache.clear() - # Intentionally do NOT clear self._warned: those messages are already - # logged and re-warning would just spam the log. + # Keep self._warned: re-emitting already-logged warnings would just spam. def invalidate_node(self, node_id: str) -> None: """Clear cached entries for a single node (e.g. after node-level expand).""" @@ -131,9 +112,7 @@ class TypeResolver: out-of-range slot, missing node, malformed link, or unresolved MatchType template. """ - # Guard against malformed callers passing non-int slot indices (e.g. - # API JSON that sent a string). Falling back to AnyType is safer than - # raising TypeError mid-validation. + # Degrade gracefully on non-int slot_idx (e.g. malformed API JSON). if isinstance(slot_idx, bool) or not isinstance(slot_idx, int): return ANY_TYPE @@ -165,15 +144,13 @@ class TypeResolver: declared = return_types[slot_idx] - # V3 nodes may have MatchType outputs that need to be traced through - # the schema. V1 nodes (and V3 nodes with plain outputs) just use the - # declared RETURN_TYPES string. + # Only V3 schemas carry MatchType template info; V1 RETURN_TYPES are + # always concrete strings. resolved = declared if isinstance(class_def, type) and issubclass(class_def, _ComfyNodeInternal): schema = getattr(class_def, "SCHEMA", None) if schema is None: - # Trigger schema computation. RETURN_TYPES would have done this - # already, but be defensive. + # RETURN_TYPES access above usually populates SCHEMA — be defensive. try: schema = class_def.GET_SCHEMA() except Exception: @@ -185,9 +162,8 @@ class TypeResolver: node_id, schema, out.template.template_id, next_stack ) - # Treat the legacy wildcard literally as AnyType. We warn only when the - # source node's *declared* type was already wildcard, so MatchType-style - # "no upstream connected" cases (which warn elsewhere) don't double-warn. + # Warn only for V1 wildcards declared as "*"; unresolved MatchType + # templates warn separately in _resolve_match_template, avoiding double-warns. if isinstance(resolved, str) and resolved == ANY_TYPE and declared == ANY_TYPE: self._warn( node_id, slot_idx, @@ -195,7 +171,7 @@ class TypeResolver: ) if not isinstance(resolved, str): - # Non-string types (e.g., legacy combos passed as list) — bail to AnyType. + # e.g. legacy combo declared as a list of options. self._warn(node_id, slot_idx, f"node '{class_type}' output slot {slot_idx} has non-string return type {type(resolved).__name__}; defaulting to AnyType") resolved = ANY_TYPE @@ -205,13 +181,7 @@ class TypeResolver: def _resolve_match_template(self, node_id: str, schema, template_id: str, stack: frozenset[tuple[str, int]]) -> str: - """Resolve a MatchType.Output by inspecting the node's MatchType.Inputs - with the same template_id. - - Strategy (per design decision): walk inputs in schema order, pick the - FIRST concrete (non-AnyType) resolution. If none resolve, return - AnyType with a one-shot warning. - """ + """Walk MatchType.Inputs sharing ``template_id``; return first concrete resolution or ``ANY_TYPE``.""" node = self._get_node(node_id) inputs_dict = (node or {}).get("inputs", {}) or {} any_input_seen = False @@ -229,11 +199,9 @@ class TypeResolver: t = self.resolve_output_type(link[0], link[1], stack) if t != ANY_TYPE: return t - # Literal value (or malformed link): a MatchType slot has no - # concrete declared type, so we cannot infer anything useful here. + # Literal or malformed link: MatchType slots have no declared concrete type. if not any_input_seen: - # Schema declared a template_id with no Input bearing it. This is a - # node-author bug; warn once. + # Node-author bug: output template has no matching Input. self._warn(node_id, None, f"MatchType output template '{template_id}' has no matching Input on the node; defaulting to AnyType") else: @@ -315,11 +283,11 @@ class TypeResolver: except Exception: schema = None if schema is not None: - # First, try a top-level input id match. + # Top-level input id. for inp in schema.inputs: if inp.id == input_id: return self._effective_io_type(inp) - # Then a nested match (DynamicSlot / DynamicCombo prefix.child). + # Nested (DynamicSlot / DynamicCombo `parent.child`). if "." in input_id: top, _, _ = input_id.partition(".") for inp in schema.inputs: @@ -330,9 +298,8 @@ class TypeResolver: continue if child.id == input_id.split(".", 1)[1]: return self._effective_io_type(child) - # Fall through to V1 dict for hidden inputs etc. + # Fall through to V1 dict (hidden inputs, etc.). - # V1 fallback: look at INPUT_TYPES() dict. try: inputs = class_def.INPUT_TYPES() except Exception: @@ -346,8 +313,7 @@ class TypeResolver: t = entry[0] if isinstance(t, str): return t - if isinstance(t, list): - # legacy combo declared as a list of options. + if isinstance(t, list): # legacy combo declared as list of options return io.Combo.io_type return ANY_TYPE return ANY_TYPE @@ -355,22 +321,20 @@ class TypeResolver: @staticmethod def _effective_io_type(inp) -> str: """Return the consumer-facing io_type of a (possibly dynamic) input.""" - # Autogrow wraps a template input — the element type is what matters. + # Autogrow / DynamicSlot wrap a real element type; that's what consumers care about. if isinstance(inp, io.Autogrow.Input): try: return inp.template.input.get_io_type() except Exception: return ANY_TYPE - # DynamicSlot wraps an underlying slot input. if isinstance(inp, io.DynamicSlot.Input): try: return inp.slot.get_io_type() except Exception: return ANY_TYPE - # DynamicCombo's "type" is a key value selector, not a connection type. + # DynamicCombo's "type" is a key selector, not a connection type. if isinstance(inp, io.DynamicCombo.Input): return ANY_TYPE - # Everything else: trust the input's declared io_type. try: return inp.get_io_type() except Exception: @@ -380,9 +344,8 @@ class TypeResolver: def compute_live_input_types(self, node_id: str) -> dict[str, str]: """Build the ``{input_id: resolved_io_type}`` map for a node. - Used by :py:func:`comfy_api.latest._io.get_finalized_class_inputs` so - future dynamic-input expansion strategies (per-type DynamicType, etc.) - can branch on what was actually connected. + Consumed by ``_io.get_finalized_class_inputs`` so future per-type + dynamic-input expansion can branch on what was actually connected. """ node = self._get_node(node_id) if node is None: diff --git a/execution.py b/execution.py index f7d0924c8..cd58f538e 100644 --- a/execution.py +++ b/execution.py @@ -84,7 +84,6 @@ class IsChangedCache: return self.is_changed[node_id] # Intentionally do not use cached outputs here. We only want constants in IS_CHANGED. - # Pass dynprompt so the TypeResolver can resolve link types for V3 dynamic schemas. input_data_all, _, v3_data = get_input_data(node["inputs"], class_def, node_id, None, self.dynprompt) try: is_changed = await _async_map_node_over_list(self.prompt_id, node_id, class_def, input_data_all, is_changed_name, v3_data=v3_data) @@ -159,11 +158,7 @@ def get_input_data(inputs, class_def, unique_id, execution_list=None, dynprompt= hidden_inputs_v3 = {} valid_inputs = class_def.INPUT_TYPES() if is_v3: - # Build the type-resolution map for this node so dynamic schemas can - # branch on resolved upstream types (and not only on literal values). - # When no DynamicPrompt is available (e.g. some IsChangedCache paths - # in tests), live_input_types stays None and only literal-driven - # dynamic types continue to work. + # Let dynamic schemas branch on resolved upstream types, not just literal values. live_input_types = None if dynprompt is not None and hasattr(dynprompt, "get_type_resolver"): live_input_types = dynprompt.get_type_resolver().compute_live_input_types(unique_id) @@ -833,10 +828,8 @@ class PromptExecutor: async def validate_inputs(prompt_id, prompt, item, validated, visiting=None, type_resolver=None): """Validate inputs for a single node, recursing into upstream nodes. - ``type_resolver`` (a ``comfy_execution.type_resolver.TypeResolver``) is - built once at the top of the recursion and reused so MatchType chains are - only walked once. It also gives V3 dynamic schemas an accurate map of - resolved upstream types for API-submitted workflows. + ``type_resolver`` is built once at the top of recursion and shared so + MatchType chains are only walked once per prompt. """ if visiting is None: visiting = [] @@ -929,10 +922,8 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None, typ o_id = val[0] o_class_type = prompt[o_id]['class_type'] - # Resolve the upstream output's effective type through the - # TypeResolver. This walks MatchType/template chains, so an API - # workflow without frontend-injected type metadata still gets the - # same answer the UI does. + # Walks MatchType/template chains so API workflows without + # frontend-injected type metadata get the same answer as the UI. received_type = type_resolver.resolve_output_type(o_id, val[1]) received_types[x] = received_type if 'input_types' not in validate_function_inputs and not validate_node_input(received_type, input_type): @@ -1178,8 +1169,7 @@ async def validate_prompt(prompt_id, prompt, partial_execution_list: Union[list[ errors = [] node_errors = {} validated = {} - # Share one TypeResolver across all output validations so MatchType chains - # are only walked once per prompt. + # Shared across output validations so MatchType chains walk only once. from comfy_execution.type_resolver import TypeResolver type_resolver = TypeResolver(prompt) for o in outputs: From 0e4a15b7fb5ce5b979f5a51c412ca1842673ac3a Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 1 Jun 2026 17:08:50 -0700 Subject: [PATCH 05/10] TypeResolver: import internal _io so isinstance chain sees DynamicSlot The public `comfy_api.latest.io` re-export does not include DynamicSlot, so the isinstance chain in _effective_io_type raised AttributeError the first time it ran against any V3 input that wasn't an Autogrow. End-to-end test caught this with a MatchType chain feeding a probe node. Amp-Thread-ID: https://ampcode.com/threads/T-019e8568-f382-743d-a97f-0de3ff29d501 Co-authored-by: Amp --- comfy_execution/type_resolver.py | 2 +- .../execution_test/test_type_resolver.py | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/comfy_execution/type_resolver.py b/comfy_execution/type_resolver.py index d22eee1ea..96df4fc2a 100644 --- a/comfy_execution/type_resolver.py +++ b/comfy_execution/type_resolver.py @@ -14,7 +14,7 @@ from __future__ import annotations import logging from typing import Any -from comfy_api.latest import io +from comfy_api.latest import _io as io from comfy_api.internal import _ComfyNodeInternal diff --git a/tests-unit/execution_test/test_type_resolver.py b/tests-unit/execution_test/test_type_resolver.py index e89874ca0..6d23223d0 100644 --- a/tests-unit/execution_test/test_type_resolver.py +++ b/tests-unit/execution_test/test_type_resolver.py @@ -328,6 +328,36 @@ def test_effective_slot_type_peels_autogrow(fake_nodes_module, TypeResolver): # compute_live_input_types # --------------------------------------------------------------------------- +def test_effective_slot_type_on_v3_plain_input(fake_nodes_module, TypeResolver): + """V3 input that is neither Autogrow/DynamicSlot/DynamicCombo must still resolve. + + Regression test: importing ``io`` from the public re-export skipped + ``DynamicSlot``, so an ``isinstance`` chain in ``_effective_io_type`` raised + ``AttributeError`` the first time it ran against a plain V3 input. + """ + from comfy_api.latest import io + + class BoolSink(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="BoolSink", + inputs=[io.Boolean.Input("flag")], + outputs=[io.Boolean.Output()], + ) + + @classmethod + def execute(cls, flag): + return io.NodeOutput(flag) + + BoolSink.GET_SCHEMA() + fake_nodes_module["BoolSink"] = BoolSink + prompt = {"n": {"class_type": "BoolSink", "inputs": {"flag": True}}} + r = TypeResolver(prompt) + assert r.get_declared_slot_io_type("n", "flag") == "BOOLEAN" + assert r.compute_live_input_types("n") == {"flag": "BOOLEAN"} + + def test_compute_live_input_types_mixes_links_and_literals(fake_nodes_module, TypeResolver): fake_nodes_module["Src"] = _v1_node(("MODEL",)) fake_nodes_module["Sink"] = _v1_node( From d91c1d8d484ebab79284c956e34e10493051a366 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 1 Jun 2026 17:56:32 -0700 Subject: [PATCH 06/10] DynamicSlot: typed option dispatch driven by TypeResolver Replaces the old connected/unconnected fixed-child DynamicSlot with a type-keyed option list. Each Option declares a 'when' condition (None, io.AnyType, a single ComfyType, a list, or a MultiType.Input) and the child inputs revealed when that condition matches the slot's resolved upstream type. Selection happens at schema-finalization time using live_input_types computed by TypeResolver, so API-only workflows (no frontend) get the same expansion the UI would. - _io.py: redesign DynamicSlot.Input / Option; auto-derive slotType as the union of all non-None when sets; expose it via as_dict so the frontend knows what types are accepted; the class io_type stays COMFY_DYNAMICSLOT_V3 as the parse-time dispatch tag. - type_resolver.py: return the auto-derived _slot_io_type for DynamicSlot.Input; document the AnyType (*) limitation. - execution.py: validate links into a DynamicSlot against slotType, not the dispatch tag COMFY_DYNAMICSLOT_V3. - tests: new test_dynamic_slot.py + regression coverage in test_type_resolver.py. Amp-Thread-ID: https://ampcode.com/threads/T-019e8568-f382-743d-a97f-0de3ff29d501 Co-authored-by: Amp --- comfy_api/latest/_io.py | 172 +++++++++-- comfy_execution/type_resolver.py | 13 +- execution.py | 9 +- .../comfy_api_test/test_dynamic_slot.py | 266 ++++++++++++++++++ .../execution_test/test_type_resolver.py | 29 ++ 5 files changed, 455 insertions(+), 34 deletions(-) create mode 100644 tests-unit/comfy_api_test/test_dynamic_slot.py diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 98d19d7e0..6eaf7850a 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1196,50 +1196,164 @@ class DynamicCombo(ComfyTypeI): @comfytype(io_type="COMFY_DYNAMICSLOT_V3") class DynamicSlot(ComfyTypeI): + """A slot whose revealed inputs depend on the type connected upstream. + + Options dispatch on the type resolved by + :py:class:`comfy_execution.type_resolver.TypeResolver`: + + * ``Option(when=None, ...)`` — nothing connected to the slot. + * ``Option(when=io.AnyType, ...)`` — link present, upstream resolves to ``"*"`` + (e.g. Reroute, generic If/Else, V1 nodes that declare AnyType outputs). + * ``Option(when=io.Image, ...)`` — upstream resolves to ``IMAGE``. + * ``Option(when=[io.Image, io.Mask], ...)`` — share children across types. + * ``Option(when=io.MultiType.Input(...), ...)`` — same as the list form. + + On a connected slot the first option whose ``when`` set intersects the + resolved type set wins; on an unconnected slot the first ``when=None`` + option wins. No implicit "match anything I didn't enumerate" fallback — + declare ``when=io.AnyType`` if you want it. + + Known limitation: when an upstream node declares its output as ``AnyType`` + (Reroute, generic forwarders, many V1 utilities) the resolver can only + report ``"*"`` — it cannot introspect the runtime value to recover a more + specific type. Such links will always select the ``when=io.AnyType`` + branch (or no branch), never a concrete-type branch. + """ Type = dict[str, Any] - class Input(DynamicInput): - def __init__(self, slot: Input, inputs: list[Input], - display_name: str=None, tooltip: str=None, lazy: bool=None, extra_dict=None): - assert(not isinstance(slot, DynamicInput)) - self.slot = copy.copy(slot) - self.slot.display_name = slot.display_name if slot.display_name is not None else display_name - optional = True - self.slot.tooltip = slot.tooltip if slot.tooltip is not None else tooltip - self.slot.lazy = slot.lazy if slot.lazy is not None else lazy - self.slot.extra_dict = slot.extra_dict if slot.extra_dict is not None else extra_dict - super().__init__(slot.id, self.slot.display_name, optional, self.slot.tooltip, self.slot.lazy, self.slot.extra_dict) + class Option: + """One branch of inputs revealed when the slot's resolved type matches ``when``. + + ``when`` accepts: + * ``None`` — no link present + * ``io.AnyType`` — upstream resolved type is literally ``"*"`` + * a single ComfyType class (e.g. ``io.Image``) + * a list of ComfyType classes (shared branch across multiple types) + * a ``MultiType.Input`` instance (parsed via its ``.io_types``) + """ + def __init__(self, when: Any, inputs: list[Input]): + self.when = when self.inputs = inputs - self.force_input = None - # force widget inputs to have no widgets, otherwise this would be awkward - if isinstance(self.slot, WidgetInput): - self.force_input = True - self.slot.force_input = True + self._when_types = self._normalize_when(when) + + @staticmethod + def _normalize_when(when: Any) -> frozenset[str] | None: + """Normalize ``when`` to a ``frozenset[str]`` of io_types, or ``None`` for the unconnected case.""" + if when is None: + return None + if isinstance(when, type) and issubclass(when, _ComfyType): + return frozenset([when.io_type]) + if isinstance(when, MultiType.Input): + return frozenset(t.io_type for t in when.io_types) + if isinstance(when, Iterable) and not isinstance(when, str): + types: list[str] = [] + for t in when: + if not (isinstance(t, type) and issubclass(t, _ComfyType)): + raise ValueError( + f"DynamicSlot.Option: list entries must be ComfyType classes, got {t!r}" + ) + types.append(t.io_type) + if not types: + raise ValueError("DynamicSlot.Option: when=[] is not allowed; use when=None instead") + return frozenset(types) + raise ValueError( + "DynamicSlot.Option: when must be None, a ComfyType class, a list of ComfyType classes, " + f"or a MultiType.Input; got {when!r}" + ) + + def as_dict(self): + return { + "when": None if self._when_types is None else sorted(self._when_types), + "inputs": create_input_dict_v1(self.inputs), + } + + 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): + if not options: + raise ValueError("DynamicSlot.Input: at least one Option is required") + super().__init__(id, display_name, True, tooltip, lazy, extra_dict) + self.options = options + # Auto-derive the slot's declared connection type as the union of + # every non-None option's `when` set. Order is preserved per option, + # then deduplicated, so authors control the displayed precedence. + connected_types: list[str] = [] + for opt in options: + if opt._when_types is None: + continue + for t in opt._when_types: + if t not in connected_types: + connected_types.append(t) + if not connected_types: + raise ValueError( + "DynamicSlot.Input: at least one Option must have a non-None `when`; " + "a slot with only a `when=None` option can never be connected" + ) + self._slot_io_type = ",".join(connected_types) + + # NOTE: do NOT override get_io_type — parse_class_inputs uses the class + # io_type (COMFY_DYNAMICSLOT_V3) to dispatch into the dynamic expander. + # The auto-derived connection type is published via the `slotType` field + # in as_dict() so the frontend knows what links are accepted. def get_all(self) -> list[Input]: - return [self.slot] + self.inputs + seen_ids: set[str] = set() + out: list[Input] = [] + for opt in self.options: + for inp in opt.inputs: + if inp.id in seen_ids: + continue + seen_ids.add(inp.id) + out.append(inp) + return out def as_dict(self): return super().as_dict() | prune_dict({ - "slotType": str(self.slot.get_io_type()), - "inputs": create_input_dict_v1(self.inputs), - "forceInput": self.force_input, + "slotType": self._slot_io_type, + "options": [o.as_dict() for o in self.options], }) def validate(self): - self.slot.validate() - for input in self.inputs: - input.validate() + for opt in self.options: + for inp in opt.inputs: + inp.validate() + + @staticmethod + def _select_option(options: list[dict[str, Any]], live_input_types: dict[str, str] | None, + finalized_id: str, has_link: bool) -> dict[str, Any] | None: + """Pick the first option whose ``when`` matches the slot's current state. + + Matching is set intersection against the resolved type string split on + commas (so MultiType outputs like ``"IMAGE,MASK"`` work naturally). + """ + if not has_link: + for opt in options: + if opt["when"] is None: + return opt + return None + resolved = (live_input_types or {}).get(finalized_id, "*") + resolved_set = set(t.strip() for t in resolved.split(",")) + for opt in options: + when = opt["when"] + if when is None: + continue + if resolved_set & set(when): + return opt + return None @staticmethod def _expand_schema_for_dynamic(out_dict: dict[str, Any], live_inputs: dict[str, Any], value: tuple[str, dict[str, Any]], input_type: str, curr_prefix: list[str] | None, live_input_types: dict[str, str] | None = None): finalized_id = finalize_prefix(curr_prefix) - if finalized_id in live_inputs: - inputs = value[1]["inputs"] - parse_class_inputs(out_dict, live_inputs, inputs, curr_prefix, live_input_types) - # add self to inputs - out_dict[input_type][finalized_id] = value - out_dict["dynamic_paths"][finalized_id] = finalize_prefix(curr_prefix, curr_prefix[-1]) + options: list[dict[str, Any]] = value[1].get("options", []) + has_link = finalized_id in live_inputs and live_inputs[finalized_id] is not None + selected = DynamicSlot._select_option(options, live_input_types, finalized_id, has_link) + if selected is not None: + parse_class_inputs(out_dict, live_inputs, selected["inputs"], curr_prefix, live_input_types) + # Always advertise the slot itself so the connector renders even when no + # option matched (e.g. resolved type wasn't enumerated and there's no + # AnyType option). Unmatched cases just expand no children. + out_dict[input_type][finalized_id] = value + out_dict["dynamic_paths"][finalized_id] = finalize_prefix(curr_prefix, curr_prefix[-1]) @comfytype(io_type="IMAGECOMPARE") class ImageCompare(ComfyTypeI): diff --git a/comfy_execution/type_resolver.py b/comfy_execution/type_resolver.py index 96df4fc2a..31c3b3059 100644 --- a/comfy_execution/type_resolver.py +++ b/comfy_execution/type_resolver.py @@ -7,6 +7,13 @@ or unresolvable wildcards. Works against either a raw prompt dict or a ``DynamicPrompt``. All resolved values are strings, so resolver state is cross-process serializable. + +Known limitation: when an upstream node declares its output as ``AnyType`` +(``"*"``) — Reroute, generic If/Else, many V1 utility nodes — the resolver +returns ``"*"``. It has no way to introspect the runtime value to recover a +more specific type. Downstream consumers (e.g. :py:class:`DynamicSlot`) will +treat such links as AnyType and select their ``AnyType`` branch (or none), +not a concrete-type branch. """ from __future__ import annotations @@ -328,10 +335,8 @@ class TypeResolver: except Exception: return ANY_TYPE if isinstance(inp, io.DynamicSlot.Input): - try: - return inp.slot.get_io_type() - except Exception: - return ANY_TYPE + # Auto-derived slot type — comma-joined union of all option `when` types. + return getattr(inp, "_slot_io_type", ANY_TYPE) # DynamicCombo's "type" is a key selector, not a connection type. if isinstance(inp, io.DynamicCombo.Input): return ANY_TYPE diff --git a/execution.py b/execution.py index cd58f538e..93e0f185e 100644 --- a/execution.py +++ b/execution.py @@ -926,7 +926,14 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None, typ # frontend-injected type metadata get the same answer as the UI. received_type = type_resolver.resolve_output_type(o_id, val[1]) received_types[x] = received_type - if 'input_types' not in validate_function_inputs and not validate_node_input(received_type, input_type): + # DynamicSlot publishes its accepted connection types via the + # `slotType` field (auto-derived union of option `when` types). + # The declared input_type ("COMFY_DYNAMICSLOT_V3") is just the + # dispatch tag; validate against slotType instead. + effective_input_type = input_type + if input_type == _io.DynamicSlot.io_type and isinstance(extra_info, dict): + effective_input_type = extra_info.get("slotType", input_type) + if 'input_types' not in validate_function_inputs and not validate_node_input(received_type, effective_input_type): details = f"{x}, received_type({received_type}) mismatch input_type({input_type})" error = { "type": "return_type_mismatch", diff --git a/tests-unit/comfy_api_test/test_dynamic_slot.py b/tests-unit/comfy_api_test/test_dynamic_slot.py new file mode 100644 index 000000000..ef0f520c2 --- /dev/null +++ b/tests-unit/comfy_api_test/test_dynamic_slot.py @@ -0,0 +1,266 @@ +"""Unit tests for the redesigned ``DynamicSlot`` with type-keyed options.""" + +import pytest + +from comfy_api.latest import _io as io + + +def _opt(when, ids=None): + """Build an Option whose inputs are placeholder String widgets named after ids.""" + ids = ids or [] + inputs = [io.String.Input(name) for name in ids] + return io.DynamicSlot.Option(when=when, inputs=inputs) + + +# --------------------------------------------------------------------------- +# Option.when normalization +# --------------------------------------------------------------------------- + +def test_option_when_none(): + o = _opt(None, ["a"]) + assert o._when_types is None + assert o.as_dict()["when"] is None + + +def test_option_when_single_type(): + o = _opt(io.Image) + assert o._when_types == frozenset({"IMAGE"}) + assert o.as_dict()["when"] == ["IMAGE"] + + +def test_option_when_anytype(): + o = _opt(io.AnyType) + assert o._when_types == frozenset({"*"}) + assert o.as_dict()["when"] == ["*"] + + +def test_option_when_list(): + o = _opt([io.Image, io.Mask]) + assert o._when_types == frozenset({"IMAGE", "MASK"}) + # list form sorted for stable serialization + assert o.as_dict()["when"] == ["IMAGE", "MASK"] + + +def test_option_when_multitype_input(): + mt = io.MultiType.Input("x", types=[io.Image, io.Latent]) + o = _opt(mt) + assert o._when_types == frozenset({"IMAGE", "LATENT"}) + + +def test_option_when_empty_list_rejected(): + with pytest.raises(ValueError, match="when=\\[\\]"): + io.DynamicSlot.Option(when=[], inputs=[]) + + +def test_option_when_garbage_rejected(): + with pytest.raises(ValueError, match="when must be"): + io.DynamicSlot.Option(when="IMAGE", inputs=[]) + + +def test_option_when_list_with_non_comfytype_rejected(): + with pytest.raises(ValueError, match="list entries"): + io.DynamicSlot.Option(when=[io.Image, "MASK"], inputs=[]) + + +# --------------------------------------------------------------------------- +# DynamicSlot.Input construction and serialization +# --------------------------------------------------------------------------- + +def test_input_requires_at_least_one_option(): + with pytest.raises(ValueError, match="at least one Option"): + io.DynamicSlot.Input("x", options=[]) + + +def test_input_requires_non_none_option(): + with pytest.raises(ValueError, match="non-None `when`"): + io.DynamicSlot.Input("x", options=[_opt(None, ["a"])]) + + +def test_input_auto_derives_slot_type(): + inp = io.DynamicSlot.Input("x", options=[ + _opt(io.Image, ["a"]), + _opt(io.Mask, ["b"]), + _opt(None, ["c"]), + ]) + # Declared order preserved across non-None options; None contributes nothing. + # Note: get_io_type() intentionally still returns the dynamic class io_type + # (COMFY_DYNAMICSLOT_V3) so parse_class_inputs dispatches into the expander. + # The auto-derived slot type is exposed via the `slotType` field of as_dict() + # and via the private `_slot_io_type` attribute (used by the type resolver). + assert inp._slot_io_type == "IMAGE,MASK" + d = inp.as_dict() + assert d["slotType"] == "IMAGE,MASK" + assert len(d["options"]) == 3 + + +def test_input_includes_anytype_in_slot_type(): + inp = io.DynamicSlot.Input("x", options=[ + _opt(io.Image, ["a"]), + _opt(io.AnyType, ["b"]), + ]) + assert inp._slot_io_type == "IMAGE,*" + + +def test_input_get_all_dedups_inputs_by_id(): + inp = io.DynamicSlot.Input("x", options=[ + _opt(io.Image, ["shared", "image_only"]), + _opt(io.Mask, ["shared", "mask_only"]), + ]) + ids = [i.id for i in inp.get_all()] + assert ids == ["shared", "image_only", "mask_only"] + + +# --------------------------------------------------------------------------- +# Option selection +# --------------------------------------------------------------------------- + +def _select(options, live_input_types, has_link, finalized_id="x"): + """Convenience wrapper that runs the dispatch through the dict form (post-as_dict).""" + serialized = [o.as_dict() for o in options] + return io.DynamicSlot._select_option( + serialized, live_input_types, finalized_id, has_link + ) + + +def test_select_unconnected_picks_none_option(): + options = [_opt(io.Image, ["img_widgets"]), _opt(None, ["empty_widgets"])] + sel = _select(options, {}, has_link=False) + assert sel is not None + assert sel["when"] is None + + +def test_select_unconnected_with_no_none_option_returns_none(): + options = [_opt(io.Image, ["x"])] + assert _select(options, {}, has_link=False) is None + + +def test_select_concrete_type_match(): + options = [ + _opt(io.Image, ["a"]), + _opt(io.Mask, ["b"]), + _opt(io.AnyType, ["c"]), + ] + sel = _select(options, {"x": "MASK"}, has_link=True) + assert sel["when"] == ["MASK"] + + +def test_select_anytype_matches_wildcard_resolved(): + options = [_opt(io.Image, ["a"]), _opt(io.AnyType, ["c"])] + sel = _select(options, {"x": "*"}, has_link=True) + assert sel["when"] == ["*"] + + +def test_select_anytype_does_not_match_concrete(): + options = [_opt(io.AnyType, ["c"])] + # MASK isn't in any option's set; AnyType only matches "*". No expansion. + assert _select(options, {"x": "MASK"}, has_link=True) is None + + +def test_select_first_match_wins(): + options = [ + _opt([io.Image, io.Mask], ["both"]), + _opt(io.Image, ["image_only"]), + ] + # Resolved IMAGE matches both; first option wins. + sel = _select(options, {"x": "IMAGE"}, has_link=True) + assert sel["inputs"] + # The "both" option's first input is named "both" + first_input_id = next(iter(sel["inputs"]["required"].keys())) + assert first_input_id == "both" + + +def test_select_multitype_upstream_intersects_option_set(): + """When upstream declares MultiType like 'IMAGE,MASK', any option that + intersects with that set matches (first wins).""" + options = [ + _opt(io.Latent, ["latent_only"]), + _opt(io.Mask, ["mask_only"]), + ] + sel = _select(options, {"x": "IMAGE,MASK"}, has_link=True) + assert sel["when"] == ["MASK"] + + +def test_select_missing_resolved_falls_through_to_anytype(): + """If live_input_types lacks an entry for this slot but a link exists, + we treat it as '*' (resolver default for unresolvable links).""" + options = [_opt(io.Image, ["a"]), _opt(io.AnyType, ["c"])] + sel = _select(options, {}, has_link=True) + assert sel["when"] == ["*"] + + +# --------------------------------------------------------------------------- +# End-to-end expansion via _expand_schema_for_dynamic +# --------------------------------------------------------------------------- + +def test_expand_unconnected_path(): + """An unconnected slot with a `when=None` option expands that option's children.""" + inp = io.DynamicSlot.Input("x", options=[ + _opt(io.Image, ["image_widget"]), + _opt(None, ["empty_widget"]), + ]) + d = inp.as_dict() + value = (io.DynamicSlot.io_type, d) + out_dict = { + "required": {}, "optional": {}, "hidden": {}, + "dynamic_paths": {}, "dynamic_paths_default_value": {}, + } + io.DynamicSlot._expand_schema_for_dynamic( + out_dict=out_dict, + live_inputs={}, # no entry for "x" → unconnected + value=value, + input_type="optional", + curr_prefix=["x"], + live_input_types=None, + ) + # The slot itself is always advertised in the caller's bucket. + assert "x" in out_dict["optional"] + # Children land in their own buckets (required by default) with + # parent-prefixed ids. + assert "x.empty_widget" in out_dict["required"] + assert "x.image_widget" not in out_dict["required"] + + +def test_expand_typed_path(): + """A connected slot expands the matching type's children.""" + inp = io.DynamicSlot.Input("x", options=[ + _opt(io.Image, ["image_widget"]), + _opt(io.Mask, ["mask_widget"]), + ]) + d = inp.as_dict() + value = (io.DynamicSlot.io_type, d) + out_dict = { + "required": {}, "optional": {}, "hidden": {}, + "dynamic_paths": {}, "dynamic_paths_default_value": {}, + } + io.DynamicSlot._expand_schema_for_dynamic( + out_dict=out_dict, + live_inputs={"x": ["src_node", 0]}, # link present + value=value, + input_type="optional", + curr_prefix=["x"], + live_input_types={"x": "MASK"}, + ) + assert "x" in out_dict["optional"] + assert "x.mask_widget" in out_dict["required"] + assert "x.image_widget" not in out_dict["required"] + + +def test_expand_unmatched_concrete_still_advertises_slot(): + """Resolved type not in any option → no children, but the slot itself stays.""" + inp = io.DynamicSlot.Input("x", options=[_opt(io.Image, ["image_widget"])]) + d = inp.as_dict() + value = (io.DynamicSlot.io_type, d) + out_dict = { + "required": {}, "optional": {}, "hidden": {}, + "dynamic_paths": {}, "dynamic_paths_default_value": {}, + } + io.DynamicSlot._expand_schema_for_dynamic( + out_dict=out_dict, + live_inputs={"x": ["src_node", 0]}, + value=value, + input_type="optional", + curr_prefix=["x"], + live_input_types={"x": "LATENT"}, + ) + assert "x" in out_dict["optional"] + assert "x.image_widget" not in out_dict["required"] diff --git a/tests-unit/execution_test/test_type_resolver.py b/tests-unit/execution_test/test_type_resolver.py index 6d23223d0..48ddc4c20 100644 --- a/tests-unit/execution_test/test_type_resolver.py +++ b/tests-unit/execution_test/test_type_resolver.py @@ -358,6 +358,35 @@ def test_effective_slot_type_on_v3_plain_input(fake_nodes_module, TypeResolver): assert r.compute_live_input_types("n") == {"flag": "BOOLEAN"} +def test_effective_slot_type_peels_dynamic_slot(fake_nodes_module, TypeResolver): + """A DynamicSlot input reports its auto-derived slotType (union of `when` types).""" + from comfy_api.latest import _io as io + + class DSNode(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="DSNode", + inputs=[ + io.DynamicSlot.Input("slot", options=[ + io.DynamicSlot.Option(when=io.Image, inputs=[]), + io.DynamicSlot.Option(when=io.Latent, inputs=[]), + ]), + ], + outputs=[io.String.Output()], + ) + + @classmethod + def execute(cls, **kwargs): + return io.NodeOutput("") + + DSNode.GET_SCHEMA() + fake_nodes_module["DSNode"] = DSNode + prompt = {"n": {"class_type": "DSNode", "inputs": {}}} + r = TypeResolver(prompt) + assert r.get_declared_slot_io_type("n", "slot") == "IMAGE,LATENT" + + def test_compute_live_input_types_mixes_links_and_literals(fake_nodes_module, TypeResolver): fake_nodes_module["Src"] = _v1_node(("MODEL",)) fake_nodes_module["Sink"] = _v1_node( From 16dd7d115c8efad5f547400f989373d3335757d3 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 1 Jun 2026 18:34:30 -0700 Subject: [PATCH 07/10] DynamicSlot: address code review Bug fixes: - execution.py validate_inputs() now calls _select_option for DynamicSlot links instead of validating against slotType. Old code accepted any concrete upstream type whenever AnyType was enumerated (slotType contained '*', which validate_node_input treats as accept-anything). Verified end-to-end: LATENT into an IMAGE+AnyType slot is now rejected. - Thread live_input_types through get_input_data so custom V3 validate_inputs() sees the same DynamicSlot branch that finalization picked, instead of re-finalizing without resolver context. - DynamicSlot.Option._when_types is now an ordered tuple (preserves author declaration order); _slot_io_type/slotType ordering was previously nondeterministic via frozenset iteration. Design: - DynamicSlot.Input.get_all() now returns [self] + children, matching Autogrow / DynamicCombo so consumers like PriceBadge work uniformly. - Enforce per-option type uniqueness in DynamicSlot.Input: each io_type may appear in at most one option's 'when', and at most one option may declare when=None. Removes the ambiguous first-match-on-overlap case for single concrete types; ordering still matters when upstream is a multi-type union. - Reject non-Option entries in options=[...] explicitly. Polish: - Trim verbose DynamicSlot docstrings and inline comments. Amp-Thread-ID: https://ampcode.com/threads/T-019e8568-f382-743d-a97f-0de3ff29d501 Co-authored-by: Amp --- comfy_api/latest/_io.py | 101 ++++++++++-------- execution.py | 28 +++-- .../comfy_api_test/test_dynamic_slot.py | 93 ++++++++++++---- 3 files changed, 145 insertions(+), 77 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 6eaf7850a..a736863fd 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1198,26 +1198,19 @@ class DynamicCombo(ComfyTypeI): class DynamicSlot(ComfyTypeI): """A slot whose revealed inputs depend on the type connected upstream. - Options dispatch on the type resolved by - :py:class:`comfy_execution.type_resolver.TypeResolver`: + Each ``Option`` declares a ``when`` condition; the first option whose + condition matches the slot's resolved upstream type (or whose + ``when=None`` matches an empty slot) decides which child inputs are + exposed. - * ``Option(when=None, ...)`` — nothing connected to the slot. - * ``Option(when=io.AnyType, ...)`` — link present, upstream resolves to ``"*"`` - (e.g. Reroute, generic If/Else, V1 nodes that declare AnyType outputs). - * ``Option(when=io.Image, ...)`` — upstream resolves to ``IMAGE``. - * ``Option(when=[io.Image, io.Mask], ...)`` — share children across types. - * ``Option(when=io.MultiType.Input(...), ...)`` — same as the list form. + Each concrete type may appear in at most one option's ``when``, so the + matching branch is unambiguous. The unconnected case (``when=None``) is + its own bucket and may also appear at most once. - On a connected slot the first option whose ``when`` set intersects the - resolved type set wins; on an unconnected slot the first ``when=None`` - option wins. No implicit "match anything I didn't enumerate" fallback — - declare ``when=io.AnyType`` if you want it. - - Known limitation: when an upstream node declares its output as ``AnyType`` - (Reroute, generic forwarders, many V1 utilities) the resolver can only - report ``"*"`` — it cannot introspect the runtime value to recover a more - specific type. Such links will always select the ``when=io.AnyType`` - branch (or no branch), never a concrete-type branch. + The AnyType limitation documented in + :py:mod:`comfy_execution.type_resolver` applies: an upstream output + declared as ``AnyType`` resolves to ``"*"`` and will only match a + ``when=io.AnyType`` option, never a concrete-type one. """ Type = dict[str, Any] @@ -1234,17 +1227,22 @@ class DynamicSlot(ComfyTypeI): def __init__(self, when: Any, inputs: list[Input]): self.when = when self.inputs = inputs + # ``_when_types`` is the ordered tuple of io_types (deterministic); + # ``_when_set`` is the same content as a set for fast matching. self._when_types = self._normalize_when(when) + self._when_set: frozenset[str] | None = ( + None if self._when_types is None else frozenset(self._when_types) + ) @staticmethod - def _normalize_when(when: Any) -> frozenset[str] | None: - """Normalize ``when`` to a ``frozenset[str]`` of io_types, or ``None`` for the unconnected case.""" + def _normalize_when(when: Any) -> tuple[str, ...] | None: + """Normalize ``when`` to an ordered, deduplicated tuple of io_types, or ``None`` for the unconnected case.""" if when is None: return None if isinstance(when, type) and issubclass(when, _ComfyType): - return frozenset([when.io_type]) + return (when.io_type,) if isinstance(when, MultiType.Input): - return frozenset(t.io_type for t in when.io_types) + return tuple(dict.fromkeys(t.io_type for t in when.io_types)) if isinstance(when, Iterable) and not isinstance(when, str): types: list[str] = [] for t in when: @@ -1252,10 +1250,11 @@ class DynamicSlot(ComfyTypeI): raise ValueError( f"DynamicSlot.Option: list entries must be ComfyType classes, got {t!r}" ) - types.append(t.io_type) + if t.io_type not in types: + types.append(t.io_type) if not types: raise ValueError("DynamicSlot.Option: when=[] is not allowed; use when=None instead") - return frozenset(types) + return tuple(types) raise ValueError( "DynamicSlot.Option: when must be None, a ComfyType class, a list of ComfyType classes, " f"or a MultiType.Input; got {when!r}" @@ -1263,7 +1262,7 @@ class DynamicSlot(ComfyTypeI): def as_dict(self): return { - "when": None if self._when_types is None else sorted(self._when_types), + "when": None if self._when_types is None else list(self._when_types), "inputs": create_input_dict_v1(self.inputs), } @@ -1272,18 +1271,34 @@ class DynamicSlot(ComfyTypeI): display_name: str=None, 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: + if not isinstance(opt, DynamicSlot.Option): + raise ValueError( + f"DynamicSlot.Input: options must be DynamicSlot.Option instances, got {opt!r}" + ) super().__init__(id, display_name, True, tooltip, lazy, extra_dict) self.options = options - # Auto-derive the slot's declared connection type as the union of - # every non-None option's `when` set. Order is preserved per option, - # then deduplicated, so authors control the displayed precedence. + # Enforce uniqueness: each io_type (and the unconnected case) may + # appear in at most one option's ``when``. Also derive the slot's + # declared connection type as the ordered union of every non-None + # option's ``when`` set so authors control displayed precedence. + seen_types: set[str] = set() + seen_none = False connected_types: list[str] = [] for opt in options: if opt._when_types is None: + if seen_none: + raise ValueError("DynamicSlot.Input: only one Option may declare when=None") + seen_none = True continue for t in opt._when_types: - if t not in connected_types: - connected_types.append(t) + if t in seen_types: + raise ValueError( + f"DynamicSlot.Input: type {t!r} appears in more than one Option's `when`; " + "each type must be claimed by exactly one option" + ) + seen_types.add(t) + connected_types.append(t) if not connected_types: raise ValueError( "DynamicSlot.Input: at least one Option must have a non-None `when`; " @@ -1291,21 +1306,19 @@ class DynamicSlot(ComfyTypeI): ) self._slot_io_type = ",".join(connected_types) - # NOTE: do NOT override get_io_type — parse_class_inputs uses the class - # io_type (COMFY_DYNAMICSLOT_V3) to dispatch into the dynamic expander. - # The auto-derived connection type is published via the `slotType` field - # in as_dict() so the frontend knows what links are accepted. + # parse_class_inputs dispatches on the class io_type (COMFY_DYNAMICSLOT_V3), + # so get_all/get_io_type must not be overridden; slotType is published via as_dict. def get_all(self) -> list[Input]: seen_ids: set[str] = set() - out: list[Input] = [] + children: list[Input] = [] for opt in self.options: for inp in opt.inputs: if inp.id in seen_ids: continue seen_ids.add(inp.id) - out.append(inp) - return out + children.append(inp) + return [self] + children def as_dict(self): return super().as_dict() | prune_dict({ @@ -1321,10 +1334,13 @@ class DynamicSlot(ComfyTypeI): @staticmethod def _select_option(options: list[dict[str, Any]], live_input_types: dict[str, str] | None, finalized_id: str, has_link: bool) -> dict[str, Any] | None: - """Pick the first option whose ``when`` matches the slot's current state. + """Pick the first option whose ``when`` matches the slot's state. - Matching is set intersection against the resolved type string split on - commas (so MultiType outputs like ``"IMAGE,MASK"`` work naturally). + Connected: pick the first option whose ``when`` set intersects the + comma-split resolved type. Unconnected: pick the first ``when=None``. + With per-option type uniqueness, at most one connected option can match + any single concrete type; ordering only matters when upstream declares + a multi-type union (e.g. ``"IMAGE,MASK"``). """ if not has_link: for opt in options: @@ -1332,7 +1348,7 @@ class DynamicSlot(ComfyTypeI): return opt return None resolved = (live_input_types or {}).get(finalized_id, "*") - resolved_set = set(t.strip() for t in resolved.split(",")) + resolved_set = {t.strip() for t in resolved.split(",")} for opt in options: when = opt["when"] if when is None: @@ -1350,8 +1366,7 @@ class DynamicSlot(ComfyTypeI): if selected is not None: parse_class_inputs(out_dict, live_inputs, selected["inputs"], curr_prefix, live_input_types) # Always advertise the slot itself so the connector renders even when no - # option matched (e.g. resolved type wasn't enumerated and there's no - # AnyType option). Unmatched cases just expand no children. + # option matched (unmatched concrete + no AnyType option). out_dict[input_type][finalized_id] = value out_dict["dynamic_paths"][finalized_id] = finalize_prefix(curr_prefix, curr_prefix[-1]) diff --git a/execution.py b/execution.py index 93e0f185e..b8727473b 100644 --- a/execution.py +++ b/execution.py @@ -152,15 +152,14 @@ class CacheSet: SENSITIVE_EXTRA_DATA_KEYS = ("auth_token_comfy_org", "api_key_comfy_org") -def get_input_data(inputs, class_def, unique_id, execution_list=None, dynprompt=None, extra_data={}): +def get_input_data(inputs, class_def, unique_id, execution_list=None, dynprompt=None, extra_data={}, live_input_types=None): is_v3 = issubclass(class_def, _ComfyNodeInternal) v3_data: io.V3Data = {} hidden_inputs_v3 = {} valid_inputs = class_def.INPUT_TYPES() if is_v3: # Let dynamic schemas branch on resolved upstream types, not just literal values. - live_input_types = None - if dynprompt is not None and hasattr(dynprompt, "get_type_resolver"): + if live_input_types is None and dynprompt is not None and hasattr(dynprompt, "get_type_resolver"): live_input_types = dynprompt.get_type_resolver().compute_live_input_types(unique_id) valid_inputs, hidden, v3_data = _io.get_finalized_class_inputs(valid_inputs, inputs, live_input_types=live_input_types) input_data_all = {} @@ -867,6 +866,7 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None, typ v3_data = None validate_function_inputs = [] validate_has_kwargs = False + live_input_types = None if issubclass(obj_class, _ComfyNodeInternal): obj_class: _io._ComfyNodeBaseInternal class_inputs = obj_class.INPUT_TYPES() @@ -926,14 +926,16 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None, typ # frontend-injected type metadata get the same answer as the UI. received_type = type_resolver.resolve_output_type(o_id, val[1]) received_types[x] = received_type - # DynamicSlot publishes its accepted connection types via the - # `slotType` field (auto-derived union of option `when` types). - # The declared input_type ("COMFY_DYNAMICSLOT_V3") is just the - # dispatch tag; validate against slotType instead. - effective_input_type = input_type + # DynamicSlot's declared input_type is just the dispatch tag + # (COMFY_DYNAMICSLOT_V3); a link is valid iff some Option would + # actually claim the resolved upstream type. if input_type == _io.DynamicSlot.io_type and isinstance(extra_info, dict): - effective_input_type = extra_info.get("slotType", input_type) - if 'input_types' not in validate_function_inputs and not validate_node_input(received_type, effective_input_type): + link_valid = _io.DynamicSlot._select_option( + extra_info.get("options", []), {x: received_type}, x, has_link=True + ) is not None + else: + link_valid = validate_node_input(received_type, input_type) + if 'input_types' not in validate_function_inputs and not link_valid: details = f"{x}, received_type({received_type}) mismatch input_type({input_type})" error = { "type": "return_type_mismatch", @@ -1079,7 +1081,11 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None, typ continue if len(validate_function_inputs) > 0 or validate_has_kwargs: - input_data_all, _, v3_data = get_input_data(inputs, obj_class, unique_id) + # Reuse the precomputed live_input_types so a custom validate_inputs() + # sees the same DynamicSlot branch that finalization picked above. + input_data_all, _, v3_data = get_input_data( + inputs, obj_class, unique_id, live_input_types=live_input_types + ) input_filtered = {} for x in input_data_all: if x in validate_function_inputs or validate_has_kwargs: diff --git a/tests-unit/comfy_api_test/test_dynamic_slot.py b/tests-unit/comfy_api_test/test_dynamic_slot.py index ef0f520c2..c4444d745 100644 --- a/tests-unit/comfy_api_test/test_dynamic_slot.py +++ b/tests-unit/comfy_api_test/test_dynamic_slot.py @@ -19,32 +19,39 @@ def _opt(when, ids=None): def test_option_when_none(): o = _opt(None, ["a"]) assert o._when_types is None + assert o._when_set is None assert o.as_dict()["when"] is None def test_option_when_single_type(): o = _opt(io.Image) - assert o._when_types == frozenset({"IMAGE"}) + assert o._when_types == ("IMAGE",) + assert o._when_set == frozenset({"IMAGE"}) assert o.as_dict()["when"] == ["IMAGE"] def test_option_when_anytype(): o = _opt(io.AnyType) - assert o._when_types == frozenset({"*"}) + assert o._when_types == ("*",) assert o.as_dict()["when"] == ["*"] -def test_option_when_list(): - o = _opt([io.Image, io.Mask]) - assert o._when_types == frozenset({"IMAGE", "MASK"}) - # list form sorted for stable serialization - assert o.as_dict()["when"] == ["IMAGE", "MASK"] +def test_option_when_list_preserves_order(): + """Declaration order is preserved in both the tuple and the serialized form.""" + o = _opt([io.Mask, io.Image]) + assert o._when_types == ("MASK", "IMAGE") + assert o.as_dict()["when"] == ["MASK", "IMAGE"] + + +def test_option_when_list_dedups_within_option(): + o = _opt([io.Image, io.Image, io.Mask]) + assert o._when_types == ("IMAGE", "MASK") def test_option_when_multitype_input(): mt = io.MultiType.Input("x", types=[io.Image, io.Latent]) o = _opt(mt) - assert o._when_types == frozenset({"IMAGE", "LATENT"}) + assert o._when_types == ("IMAGE", "LATENT") def test_option_when_empty_list_rejected(): @@ -83,10 +90,6 @@ def test_input_auto_derives_slot_type(): _opt(None, ["c"]), ]) # Declared order preserved across non-None options; None contributes nothing. - # Note: get_io_type() intentionally still returns the dynamic class io_type - # (COMFY_DYNAMICSLOT_V3) so parse_class_inputs dispatches into the expander. - # The auto-derived slot type is exposed via the `slotType` field of as_dict() - # and via the private `_slot_io_type` attribute (used by the type resolver). assert inp._slot_io_type == "IMAGE,MASK" d = inp.as_dict() assert d["slotType"] == "IMAGE,MASK" @@ -101,13 +104,45 @@ def test_input_includes_anytype_in_slot_type(): assert inp._slot_io_type == "IMAGE,*" -def test_input_get_all_dedups_inputs_by_id(): +def test_input_rejects_duplicate_type_across_options(): + with pytest.raises(ValueError, match="appears in more than one"): + io.DynamicSlot.Input("x", options=[ + _opt(io.Image, ["a"]), + _opt([io.Image, io.Mask], ["b"]), + ]) + + +def test_input_rejects_duplicate_anytype_across_options(): + with pytest.raises(ValueError, match="appears in more than one"): + io.DynamicSlot.Input("x", options=[ + _opt(io.AnyType, ["a"]), + _opt(io.AnyType, ["b"]), + ]) + + +def test_input_rejects_duplicate_when_none(): + with pytest.raises(ValueError, match="only one Option may declare when=None"): + io.DynamicSlot.Input("x", options=[ + _opt(io.Image, ["a"]), + _opt(None, ["b"]), + _opt(None, ["c"]), + ]) + + +def test_input_rejects_non_option_entry(): + with pytest.raises(ValueError, match="must be DynamicSlot.Option instances"): + io.DynamicSlot.Input("x", options=[_opt(io.Image, ["a"]), "not an option"]) + + +def test_input_get_all_prepends_self_and_dedups_children(): inp = io.DynamicSlot.Input("x", options=[ _opt(io.Image, ["shared", "image_only"]), _opt(io.Mask, ["shared", "mask_only"]), ]) - ids = [i.id for i in inp.get_all()] - assert ids == ["shared", "image_only", "mask_only"] + items = inp.get_all() + # Convention shared with Autogrow / DynamicCombo: parent first, then children. + assert items[0] is inp + assert [i.id for i in items[1:]] == ["shared", "image_only", "mask_only"] # --------------------------------------------------------------------------- @@ -156,17 +191,29 @@ def test_select_anytype_does_not_match_concrete(): assert _select(options, {"x": "MASK"}, has_link=True) is None -def test_select_first_match_wins(): +def test_select_anytype_branch_does_not_swallow_unenumerated_concrete(): + """Regression: a slot exposing IMAGE + AnyType must reject LATENT upstream + instead of expanding the AnyType branch. validate_inputs relies on this + to compute link validity (slotType="IMAGE,*" alone would over-accept).""" + options = [_opt(io.Image, ["image_widget"]), _opt(io.AnyType, ["any_widget"])] + assert _select(options, {"x": "LATENT"}, has_link=True) is None + # Sanity: IMAGE still matches the IMAGE branch and "*" still matches AnyType. + assert _select(options, {"x": "IMAGE"}, has_link=True)["when"] == ["IMAGE"] + assert _select(options, {"x": "*"}, has_link=True)["when"] == ["*"] + + +def test_select_first_match_wins_on_union_upstream(): + """Ordering only matters when upstream declares a multi-type union; with + per-option type uniqueness, single concrete types can never match two + options.""" options = [ - _opt([io.Image, io.Mask], ["both"]), - _opt(io.Image, ["image_only"]), + _opt([io.Image, io.Mask], ["image_or_mask"]), + _opt(io.Latent, ["latent_only"]), ] - # Resolved IMAGE matches both; first option wins. - sel = _select(options, {"x": "IMAGE"}, has_link=True) - assert sel["inputs"] - # The "both" option's first input is named "both" + # Upstream union "IMAGE,LATENT" intersects both options; first option wins. + sel = _select(options, {"x": "IMAGE,LATENT"}, has_link=True) first_input_id = next(iter(sel["inputs"]["required"].keys())) - assert first_input_id == "both" + assert first_input_id == "image_or_mask" def test_select_multitype_upstream_intersects_option_set(): From 346ee898cb156f12be4c84684214b3ac22a25fb7 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 1 Jun 2026 19:18:10 -0700 Subject: [PATCH 08/10] DynamicSlot: forbid AnyType inside list/MultiType when Grouping AnyType with concrete types conflates the known-type case with the unresolvable-wildcard case under one branch and muddies slotType. AnyType must stand alone as when=io.AnyType so the two states stay distinct in both dispatch and the serialized schema. Amp-Thread-ID: https://ampcode.com/threads/T-019e8568-f382-743d-a97f-0de3ff29d501 Co-authored-by: Amp --- comfy_api/latest/_io.py | 13 ++++++++++++- tests-unit/comfy_api_test/test_dynamic_slot.py | 13 +++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index a736863fd..4a80ed138 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1242,7 +1242,13 @@ class DynamicSlot(ComfyTypeI): if isinstance(when, type) and issubclass(when, _ComfyType): return (when.io_type,) if isinstance(when, MultiType.Input): - return tuple(dict.fromkeys(t.io_type for t in when.io_types)) + result = tuple(dict.fromkeys(t.io_type for t in when.io_types)) + if "*" in result and len(result) > 1: + raise ValueError( + "DynamicSlot.Option: AnyType cannot be grouped with concrete types; " + "use a separate Option(when=io.AnyType, ...) instead" + ) + return result if isinstance(when, Iterable) and not isinstance(when, str): types: list[str] = [] for t in when: @@ -1254,6 +1260,11 @@ class DynamicSlot(ComfyTypeI): types.append(t.io_type) if not types: raise ValueError("DynamicSlot.Option: when=[] is not allowed; use when=None instead") + if "*" in types and len(types) > 1: + raise ValueError( + "DynamicSlot.Option: AnyType cannot be grouped with concrete types; " + "use a separate Option(when=io.AnyType, ...) instead" + ) return tuple(types) raise ValueError( "DynamicSlot.Option: when must be None, a ComfyType class, a list of ComfyType classes, " diff --git a/tests-unit/comfy_api_test/test_dynamic_slot.py b/tests-unit/comfy_api_test/test_dynamic_slot.py index c4444d745..b2e810116 100644 --- a/tests-unit/comfy_api_test/test_dynamic_slot.py +++ b/tests-unit/comfy_api_test/test_dynamic_slot.py @@ -69,6 +69,19 @@ def test_option_when_list_with_non_comfytype_rejected(): io.DynamicSlot.Option(when=[io.Image, "MASK"], inputs=[]) +def test_option_when_list_with_anytype_rejected(): + """AnyType must stand alone — it represents the unresolvable-wildcard + state, not a concrete type that can share a branch with concrete types.""" + with pytest.raises(ValueError, match="AnyType cannot be grouped"): + io.DynamicSlot.Option(when=[io.Image, io.AnyType], inputs=[]) + + +def test_option_when_multitype_with_anytype_rejected(): + mt = io.MultiType.Input("x", types=[io.Image, io.AnyType]) + with pytest.raises(ValueError, match="AnyType cannot be grouped"): + io.DynamicSlot.Option(when=mt, inputs=[]) + + # --------------------------------------------------------------------------- # DynamicSlot.Input construction and serialization # --------------------------------------------------------------------------- From 8cac12afa8f038e137891ab4f2c724d96e69c8fb Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 1 Jun 2026 19:28:15 -0700 Subject: [PATCH 09/10] 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 --- comfy_api/latest/_io.py | 13 ++++++++-- .../comfy_api_test/test_dynamic_slot.py | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 4a80ed138..e9908f2b8 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -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): diff --git a/tests-unit/comfy_api_test/test_dynamic_slot.py b/tests-unit/comfy_api_test/test_dynamic_slot.py index b2e810116..ef2850e99 100644 --- a/tests-unit/comfy_api_test/test_dynamic_slot.py +++ b/tests-unit/comfy_api_test/test_dynamic_slot.py @@ -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"]), From cc3e2abd7f7b5e380f4999bf176cc182258a6bcc Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Mon, 1 Jun 2026 19:33:34 -0700 Subject: [PATCH 10/10] Remove unused o_class_type assignment (ruff F841) Amp-Thread-ID: https://ampcode.com/threads/T-019e8568-f382-743d-a97f-0de3ff29d501 Co-authored-by: Amp --- execution.py | 1 - 1 file changed, 1 deletion(-) diff --git a/execution.py b/execution.py index b8727473b..160d0118b 100644 --- a/execution.py +++ b/execution.py @@ -921,7 +921,6 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None, typ continue o_id = val[0] - o_class_type = prompt[o_id]['class_type'] # Walks MatchType/template chains so API workflows without # frontend-injected type metadata get the same answer as the UI. received_type = type_resolver.resolve_output_type(o_id, val[1])