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)
# 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": {},

View File

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

View File

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

View File

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