TypeResolver: trim verbose comments and docstrings

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:54:05 -07:00
parent 15f55f1b24
commit 004ac8820b
4 changed files with 45 additions and 105 deletions

View File

@ -1357,12 +1357,9 @@ class Range(ComfyTypeIO):
}) })
# Signature: (out_dict, live_inputs, value, input_type, curr_prefix, live_input_types) # 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 # live_input_types is {input_id: resolved_io_type} from TypeResolver; existing
# by comfy_execution.type_resolver.TypeResolver. Existing dynamic-input # expanders ignore it, future type-discriminated ones use it as discriminator.
# 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[ _DynamicInputFunc = Callable[
[dict[str, Any], dict[str, Any], tuple[str, dict[str, Any]], str, list[str] | None, dict[str, str] | None], [dict[str, Any], dict[str, Any], tuple[str, dict[str, Any]], str, list[str] | None, dict[str, str] | None],
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]: 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. """Expand a node's V3 schema against a concrete prompt.
Args: ``live_input_types`` is an optional ``{input_id: resolved_io_type}`` map
d: ``INPUT_TYPES()``-shaped dict for the node. (from ``TypeResolver``) used by future type-discriminated dynamic inputs.
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 = { out_dict = {
"required": {}, "required": {},

View File

@ -26,9 +26,7 @@ class DynamicPrompt:
self.ephemeral_prompt = {} self.ephemeral_prompt = {}
self.ephemeral_parents = {} self.ephemeral_parents = {}
self.ephemeral_display = {} self.ephemeral_display = {}
# Lazily-built type resolver, scoped to this DynamicPrompt's lifetime. self._type_resolver = None # lazy; invalidated by add_ephemeral_node
# Invalidated whenever the graph mutates via add_ephemeral_node.
self._type_resolver = None
def get_node(self, node_id): def get_node(self, node_id):
if node_id in self.ephemeral_prompt: if node_id in self.ephemeral_prompt:
@ -44,9 +42,8 @@ class DynamicPrompt:
self.ephemeral_prompt[node_id] = node_info self.ephemeral_prompt[node_id] = node_info
self.ephemeral_parents[node_id] = parent_id self.ephemeral_parents[node_id] = parent_id
self.ephemeral_display[node_id] = display_id self.ephemeral_display[node_id] = display_id
# Conservatively invalidate the entire resolver cache. Selective # Selective downstream invalidation would need topological info; the
# downstream invalidation would require topological info we don't have # cache is small, so just wipe it.
# here cheaply; the resolver's cache is small and easy to rebuild.
if self._type_resolver is not None: if self._type_resolver is not None:
self._type_resolver.invalidate() self._type_resolver.invalidate()

View File

@ -1,22 +1,12 @@
"""Server-side type resolver for prompt graphs. """Server-side type resolver for prompt graphs.
Resolves the concrete io_type of an output slot or input slot by walking the Resolves the concrete io_type of any output/input slot by walking the prompt
prompt graph. Handles: 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). Works against either a raw prompt dict or a ``DynamicPrompt``. All resolved
* V3 ``MatchType.Output`` (resolved by walking inputs that share the same values are strings, so resolver state is cross-process serializable.
``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 from __future__ import annotations
@ -29,30 +19,23 @@ from comfy_api.internal import _ComfyNodeInternal
def _parse_link(val: Any) -> tuple[str, int] | None: 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]`` A link is ``[node_id: str, slot_idx: int]``. Malformed shapes return ``None``
where ``node_id`` is a string and ``slot_idx`` is a non-negative int. so callers can fall back to AnyType rather than raise.
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: if not isinstance(val, (list, tuple)) or len(val) != 2:
return None return None
src_node, src_slot = val[0], val[1] src_node, src_slot = val[0], val[1]
if not isinstance(src_node, str): if not isinstance(src_node, str):
return None 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): if isinstance(src_slot, bool) or not isinstance(src_slot, int):
return None return None
return src_node, src_slot return src_node, src_slot
# Sentinel for "type is unknown / wildcard". Matches AnyType.io_type ("*").
ANY_TYPE: str = io.AnyType.io_type ANY_TYPE: str = io.AnyType.io_type
MAX_RESOLVE_DEPTH: int = 64 # belt-and-suspenders cap; real MatchType chains stay tiny
# 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: class TypeResolver:
@ -90,8 +73,7 @@ class TypeResolver:
@staticmethod @staticmethod
def _get_class_def(class_type: str): def _get_class_def(class_type: str):
# Local import to avoid a hard import-cycle between nodes.py and # Local import: nodes <-> comfy_execution would cycle at import time.
# comfy_execution at module-load time.
import nodes import nodes
return nodes.NODE_CLASS_MAPPINGS.get(class_type) return nodes.NODE_CLASS_MAPPINGS.get(class_type)
@ -112,8 +94,7 @@ class TypeResolver:
"""Clear all cached resolutions. Cheap; call after any graph mutation.""" """Clear all cached resolutions. Cheap; call after any graph mutation."""
self._output_cache.clear() self._output_cache.clear()
self._is_output_list_cache.clear() self._is_output_list_cache.clear()
# Intentionally do NOT clear self._warned: those messages are already # Keep self._warned: re-emitting already-logged warnings would just spam.
# logged and re-warning would just spam the log.
def invalidate_node(self, node_id: str) -> None: def invalidate_node(self, node_id: str) -> None:
"""Clear cached entries for a single node (e.g. after node-level expand).""" """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 out-of-range slot, missing node, malformed link, or unresolved
MatchType template. MatchType template.
""" """
# Guard against malformed callers passing non-int slot indices (e.g. # Degrade gracefully on non-int slot_idx (e.g. malformed API JSON).
# 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): if isinstance(slot_idx, bool) or not isinstance(slot_idx, int):
return ANY_TYPE return ANY_TYPE
@ -165,15 +144,13 @@ class TypeResolver:
declared = return_types[slot_idx] declared = return_types[slot_idx]
# V3 nodes may have MatchType outputs that need to be traced through # Only V3 schemas carry MatchType template info; V1 RETURN_TYPES are
# the schema. V1 nodes (and V3 nodes with plain outputs) just use the # always concrete strings.
# declared RETURN_TYPES string.
resolved = declared resolved = declared
if isinstance(class_def, type) and issubclass(class_def, _ComfyNodeInternal): if isinstance(class_def, type) and issubclass(class_def, _ComfyNodeInternal):
schema = getattr(class_def, "SCHEMA", None) schema = getattr(class_def, "SCHEMA", None)
if schema is None: if schema is None:
# Trigger schema computation. RETURN_TYPES would have done this # RETURN_TYPES access above usually populates SCHEMA — be defensive.
# already, but be defensive.
try: try:
schema = class_def.GET_SCHEMA() schema = class_def.GET_SCHEMA()
except Exception: except Exception:
@ -185,9 +162,8 @@ class TypeResolver:
node_id, schema, out.template.template_id, next_stack node_id, schema, out.template.template_id, next_stack
) )
# Treat the legacy wildcard literally as AnyType. We warn only when the # Warn only for V1 wildcards declared as "*"; unresolved MatchType
# source node's *declared* type was already wildcard, so MatchType-style # templates warn separately in _resolve_match_template, avoiding double-warns.
# "no upstream connected" cases (which warn elsewhere) don't double-warn.
if isinstance(resolved, str) and resolved == ANY_TYPE and declared == ANY_TYPE: if isinstance(resolved, str) and resolved == ANY_TYPE and declared == ANY_TYPE:
self._warn( self._warn(
node_id, slot_idx, node_id, slot_idx,
@ -195,7 +171,7 @@ class TypeResolver:
) )
if not isinstance(resolved, str): 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, 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") f"node '{class_type}' output slot {slot_idx} has non-string return type {type(resolved).__name__}; defaulting to AnyType")
resolved = ANY_TYPE resolved = ANY_TYPE
@ -205,13 +181,7 @@ class TypeResolver:
def _resolve_match_template(self, node_id: str, schema, template_id: str, def _resolve_match_template(self, node_id: str, schema, template_id: str,
stack: frozenset[tuple[str, int]]) -> str: stack: frozenset[tuple[str, int]]) -> str:
"""Resolve a MatchType.Output by inspecting the node's MatchType.Inputs """Walk MatchType.Inputs sharing ``template_id``; return first concrete resolution or ``ANY_TYPE``."""
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) node = self._get_node(node_id)
inputs_dict = (node or {}).get("inputs", {}) or {} inputs_dict = (node or {}).get("inputs", {}) or {}
any_input_seen = False any_input_seen = False
@ -229,11 +199,9 @@ class TypeResolver:
t = self.resolve_output_type(link[0], link[1], stack) t = self.resolve_output_type(link[0], link[1], stack)
if t != ANY_TYPE: if t != ANY_TYPE:
return t return t
# Literal value (or malformed link): a MatchType slot has no # Literal or malformed link: MatchType slots have no declared concrete type.
# concrete declared type, so we cannot infer anything useful here.
if not any_input_seen: if not any_input_seen:
# Schema declared a template_id with no Input bearing it. This is a # Node-author bug: output template has no matching Input.
# node-author bug; warn once.
self._warn(node_id, None, self._warn(node_id, None,
f"MatchType output template '{template_id}' has no matching Input on the node; defaulting to AnyType") f"MatchType output template '{template_id}' has no matching Input on the node; defaulting to AnyType")
else: else:
@ -315,11 +283,11 @@ class TypeResolver:
except Exception: except Exception:
schema = None schema = None
if schema is not None: if schema is not None:
# First, try a top-level input id match. # Top-level input id.
for inp in schema.inputs: for inp in schema.inputs:
if inp.id == input_id: if inp.id == input_id:
return self._effective_io_type(inp) return self._effective_io_type(inp)
# Then a nested match (DynamicSlot / DynamicCombo prefix.child). # Nested (DynamicSlot / DynamicCombo `parent.child`).
if "." in input_id: if "." in input_id:
top, _, _ = input_id.partition(".") top, _, _ = input_id.partition(".")
for inp in schema.inputs: for inp in schema.inputs:
@ -330,9 +298,8 @@ class TypeResolver:
continue continue
if child.id == input_id.split(".", 1)[1]: if child.id == input_id.split(".", 1)[1]:
return self._effective_io_type(child) 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: try:
inputs = class_def.INPUT_TYPES() inputs = class_def.INPUT_TYPES()
except Exception: except Exception:
@ -346,8 +313,7 @@ class TypeResolver:
t = entry[0] t = entry[0]
if isinstance(t, str): if isinstance(t, str):
return t return t
if isinstance(t, list): if isinstance(t, list): # legacy combo declared as list of options
# legacy combo declared as a list of options.
return io.Combo.io_type return io.Combo.io_type
return ANY_TYPE return ANY_TYPE
return ANY_TYPE return ANY_TYPE
@ -355,22 +321,20 @@ class TypeResolver:
@staticmethod @staticmethod
def _effective_io_type(inp) -> str: def _effective_io_type(inp) -> str:
"""Return the consumer-facing io_type of a (possibly dynamic) input.""" """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): if isinstance(inp, io.Autogrow.Input):
try: try:
return inp.template.input.get_io_type() return inp.template.input.get_io_type()
except Exception: except Exception:
return ANY_TYPE return ANY_TYPE
# DynamicSlot wraps an underlying slot input.
if isinstance(inp, io.DynamicSlot.Input): if isinstance(inp, io.DynamicSlot.Input):
try: try:
return inp.slot.get_io_type() return inp.slot.get_io_type()
except Exception: except Exception:
return ANY_TYPE 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): if isinstance(inp, io.DynamicCombo.Input):
return ANY_TYPE return ANY_TYPE
# Everything else: trust the input's declared io_type.
try: try:
return inp.get_io_type() return inp.get_io_type()
except Exception: except Exception:
@ -380,9 +344,8 @@ class TypeResolver:
def compute_live_input_types(self, node_id: str) -> dict[str, str]: def compute_live_input_types(self, node_id: str) -> dict[str, str]:
"""Build the ``{input_id: resolved_io_type}`` map for a node. """Build the ``{input_id: resolved_io_type}`` map for a node.
Used by :py:func:`comfy_api.latest._io.get_finalized_class_inputs` so Consumed by ``_io.get_finalized_class_inputs`` so future per-type
future dynamic-input expansion strategies (per-type DynamicType, etc.) dynamic-input expansion can branch on what was actually connected.
can branch on what was actually connected.
""" """
node = self._get_node(node_id) node = self._get_node(node_id)
if node is None: if node is None:

View File

@ -84,7 +84,6 @@ class IsChangedCache:
return self.is_changed[node_id] return self.is_changed[node_id]
# Intentionally do not use cached outputs here. We only want constants in IS_CHANGED. # 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) input_data_all, _, v3_data = get_input_data(node["inputs"], class_def, node_id, None, self.dynprompt)
try: 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 _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 = {} hidden_inputs_v3 = {}
valid_inputs = class_def.INPUT_TYPES() valid_inputs = class_def.INPUT_TYPES()
if is_v3: if is_v3:
# Build the type-resolution map for this node so dynamic schemas can # Let dynamic schemas branch on resolved upstream types, not just literal values.
# 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 live_input_types = None
if dynprompt is not None and hasattr(dynprompt, "get_type_resolver"): if dynprompt is not None and hasattr(dynprompt, "get_type_resolver"):
live_input_types = dynprompt.get_type_resolver().compute_live_input_types(unique_id) 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): async def validate_inputs(prompt_id, prompt, item, validated, visiting=None, type_resolver=None):
"""Validate inputs for a single node, recursing into upstream nodes. """Validate inputs for a single node, recursing into upstream nodes.
``type_resolver`` (a ``comfy_execution.type_resolver.TypeResolver``) is ``type_resolver`` is built once at the top of recursion and shared so
built once at the top of the recursion and reused so MatchType chains are MatchType chains are only walked once per prompt.
only walked once. It also gives V3 dynamic schemas an accurate map of
resolved upstream types for API-submitted workflows.
""" """
if visiting is None: if visiting is None:
visiting = [] visiting = []
@ -929,10 +922,8 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None, typ
o_id = val[0] o_id = val[0]
o_class_type = prompt[o_id]['class_type'] o_class_type = prompt[o_id]['class_type']
# Resolve the upstream output's effective type through the # Walks MatchType/template chains so API workflows without
# TypeResolver. This walks MatchType/template chains, so an API # frontend-injected type metadata get the same answer as the UI.
# 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_type = type_resolver.resolve_output_type(o_id, val[1])
received_types[x] = received_type received_types[x] = received_type
if 'input_types' not in validate_function_inputs and not validate_node_input(received_type, input_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 = [] errors = []
node_errors = {} node_errors = {}
validated = {} validated = {}
# Share one TypeResolver across all output validations so MatchType chains # Shared across output validations so MatchType chains walk only once.
# are only walked once per prompt.
from comfy_execution.type_resolver import TypeResolver from comfy_execution.type_resolver import TypeResolver
type_resolver = TypeResolver(prompt) type_resolver = TypeResolver(prompt)
for o in outputs: for o in outputs: