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 <amp@ampcode.com>
This commit is contained in:
Jedrzej Kosinski 2026-06-01 16:24:48 -07:00
parent 06b710aa68
commit 19390c112a
5 changed files with 835 additions and 22 deletions

View File

@ -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)

View File

@ -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:

View File

@ -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)

View File

@ -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:

View File

@ -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